diff --git a/docs/screenshots/ui-refresh-desktop-dark.png b/docs/screenshots/ui-refresh-desktop-dark.png new file mode 100644 index 0000000..9afbe21 Binary files /dev/null and b/docs/screenshots/ui-refresh-desktop-dark.png differ diff --git a/docs/screenshots/ui-refresh-desktop-light.png b/docs/screenshots/ui-refresh-desktop-light.png new file mode 100644 index 0000000..18f4152 Binary files /dev/null and b/docs/screenshots/ui-refresh-desktop-light.png differ diff --git a/docs/screenshots/ui-refresh-phone-dark.png b/docs/screenshots/ui-refresh-phone-dark.png new file mode 100644 index 0000000..ecaa138 Binary files /dev/null and b/docs/screenshots/ui-refresh-phone-dark.png differ diff --git a/docs/screenshots/ui-refresh-phone-light.png b/docs/screenshots/ui-refresh-phone-light.png new file mode 100644 index 0000000..07be9f7 Binary files /dev/null and b/docs/screenshots/ui-refresh-phone-light.png differ diff --git a/frontend/e2e/hearth.spec.ts b/frontend/e2e/hearth.spec.ts index ae89845..49e3f64 100644 --- a/frontend/e2e/hearth.spec.ts +++ b/frontend/e2e/hearth.spec.ts @@ -525,8 +525,22 @@ async function floorplanViewBox(page: Page): Promise { } async function openFloorplanFinder(page: Page) { - const openButton = page.getByRole('button', { name: 'Open find' }); - if (await openButton.count()) await openButton.click(); + await expect(page.getByRole('searchbox', { name: /Room, point, panel/ })).toBeVisible(); +} + +async function openEditMap(page: Page) { + const tools = page.locator('.edit-map-tools'); + if (await tools.getAttribute('open') === null) await tools.getByText('Edit map', { exact: true }).click(); +} + +async function expandFloorplanDetails(page: Page) { + const toggle = page.getByRole('button', { name: 'Expand details' }); + if (await toggle.count()) await toggle.click(); +} + +async function collapseFloorplanDetails(page: Page) { + const toggle = page.getByRole('button', { name: 'Collapse details' }); + if (await toggle.count()) await toggle.click(); } async function findFloorplanBackground(page: Page): Promise<{ x: number; y: number }> { @@ -933,6 +947,7 @@ test('saves the location shown by the latest point preview', async ({ page }) => const state = await mockApi(page); await page.goto('/'); + await openEditMap(page); await page.getByRole('button', { name: 'Add point' }).click(); await clickFloorplan(page, 0.55, 0.65); @@ -969,6 +984,7 @@ test('zoom and fit keep point placement in floor coordinates', async ({ page }) await page.getByRole('button', { name: 'Fit', exact: true }).click(); await page.getByRole('button', { name: 'Zoom in' }).click(); + await openEditMap(page); await page.getByRole('button', { name: 'Add point' }).click(); await clickFloorplanCoordinate(page, 15, 5); await expect(page.getByRole('spinbutton', { name: 'X:' })).toHaveValue('15'); @@ -1078,6 +1094,7 @@ test('finds upper-floor points and keeps breaker points highlighted across floor await expect(upperPointResult).toContainText('upper'); await upperPointResult.getByRole('button').click(); await expect(page.getByRole('combobox', { name: 'Floor:' })).toHaveValue('upper'); + await expandFloorplanDetails(page); await expect(page.getByRole('region', { name: 'Selected point' })).toContainText('Upper smoke alarm'); await expect(page.locator('[data-point-id="2"]')).toHaveAttribute('aria-pressed', 'true'); @@ -1085,20 +1102,22 @@ test('finds upper-floor points and keeps breaker points highlighted across floor const breakerResult = page.locator('[data-find-type="circuit"][data-find-id="1"]'); await expect(breakerResult).toContainText('main, upper'); await breakerResult.getByRole('button').click(); + await expandFloorplanDetails(page); const breakerDetails = page.getByRole('region', { name: 'Selected breaker' }); await expect(breakerDetails).toContainText('2 mapped points across 2 floors'); await expect(breakerDetails).toContainText('North wall outlet'); await expect(breakerDetails).toContainText('Upper smoke alarm'); - await expect(page.locator('[data-point-id="2"] + .point-symbol')).toHaveAttribute('stroke', '#f97316'); + await expect(page.locator('[data-point-id="2"] + .point-symbol')).toHaveAttribute('stroke', 'var(--selection)'); await page.getByRole('combobox', { name: 'Floor:' }).selectOption('main'); - await expect(page.locator('[data-point-id="1"] + .point-symbol')).toHaveAttribute('stroke', '#f97316'); + await expect(page.locator('[data-point-id="1"] + .point-symbol')).toHaveAttribute('stroke', 'var(--selection)'); + await expandFloorplanDetails(page); await expect(breakerDetails).toContainText('Upper smoke alarm'); - await page.getByRole('button', { name: 'Close find' }).click(); await expect(page.getByLabel('Floorplan map for main')).toBeVisible(); const roomOnMap = page.getByRole('button', { name: 'Room: Garage' }); await roomOnMap.focus(); await page.keyboard.press('Enter'); + await expandFloorplanDetails(page); await expect(page.getByRole('region', { name: 'Selected room' })).toContainText('Garage'); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); }); @@ -1119,7 +1138,8 @@ test('distinguishes matching rooms and panels and explains empty and no-match st await openFloorplanFinder(page); const search = page.getByRole('searchbox', { name: 'Room, point, panel, breaker, or verified description' }); - await expect(page.getByText('Type to search saved rooms, mapped points, panels, and breakers.')).toBeVisible(); + await expect(search).toHaveAttribute('placeholder', 'Search rooms, points, panels, or breakers'); + await expect(page.getByText('Type to search saved rooms, mapped points, panels, and breakers.')).toHaveText(/Type to search/); await search.focus(); await page.keyboard.type('Garage'); const mainRoomResult = page.locator('[data-find-type="room"][data-find-id="1"] button'); @@ -1142,13 +1162,13 @@ test('distinguishes matching rooms and panels and explains empty and no-match st await page.keyboard.press('Tab'); await expect(panelResult).toBeFocused(); await page.keyboard.press('Enter'); + await expandFloorplanDetails(page); await expect(page.getByRole('region', { name: 'Selected panel' })).toContainText('Location: Garage'); await expect(page.getByRole('button', { name: 'Room: Garage' })).toHaveAttribute('aria-pressed', 'true'); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); await search.fill('no saved item has this name'); await expect(page.getByText(/No matches\. Try a room name/)).toBeVisible(); - await page.getByRole('button', { name: 'Close find' }).click(); await expect(page.getByLabel('Floorplan map for main')).toBeVisible(); }); @@ -1184,6 +1204,7 @@ test('asks before finding away from a room draft and saves or discards only on c await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/'); + await openEditMap(page); await page.getByRole('button', { name: 'Add room', exact: true }).click(); await openFloorplanFinder(page); const roomForm = page.getByRole('form', { name: 'Add room' }); @@ -1201,9 +1222,12 @@ test('asks before finding away from a room draft and saves or discards only on c await upperRoomResult.click(); await confirmation.getByRole('button', { name: 'Discard room and find' }).click(); await expect(page.getByRole('combobox', { name: 'Floor:' })).toHaveValue('upper'); + await expandFloorplanDetails(page); await expect(page.getByRole('region', { name: 'Selected room' })).toContainText('Upper room'); expect(state.createdRooms).toEqual([]); + await collapseFloorplanDetails(page); + await openEditMap(page); await page.getByRole('button', { name: 'Add room', exact: true }).click(); await openFloorplanFinder(page); await page.getByRole('form', { name: 'Add room' }).getByRole('textbox', { name: 'Room name' }).fill('Saved draft'); @@ -1213,7 +1237,7 @@ test('asks before finding away from a room draft and saves or discards only on c .getByRole('button', { name: 'Save room and find' }).click(); await expect.poll(() => state.createdRooms).toHaveLength(1); await expect(page.getByRole('combobox', { name: 'Floor:' })).toHaveValue('main'); - await expect(page.getByRole('region', { name: 'Selected room' })).toContainText('Garage'); + await expect(page.locator('.floorplan-sidebar-summary')).toContainText('Garage'); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); }); @@ -1227,6 +1251,7 @@ test('keeps a room draft when saving it before find fails', async ({ page }) => }; const state = await mockApi(page, { rooms: [room, upperRoom], failRoomSave: true }); await page.goto('/'); + await openEditMap(page); await page.getByRole('button', { name: 'Add room', exact: true }).click(); await openFloorplanFinder(page); await page.getByRole('form', { name: 'Add room' }).getByRole('textbox', { name: 'Room name' }).fill('Unsaved room'); @@ -1287,6 +1312,7 @@ test('explains how to choose a location while adding a point', async ({ page }) await mockApi(page); await page.goto('/'); + await openEditMap(page); await page.getByRole('button', { name: 'Add point' }).click(); await expect( @@ -1370,13 +1396,16 @@ test('offers a named chooser when point hit areas overlap at 390px', async ({ pa await expect(chooser.getByRole('button', { name: /Nearby outlet.*Garage/ })).toBeVisible(); expect(state.pointUpdates).toEqual([]); await chooser.getByRole('button', { name: /North wall outlet.*Garage/ }).click(); + await expandFloorplanDetails(page); await expect(page.getByRole('region', { name: 'Selected point' })).toContainText('North wall outlet'); await expect(chooser).not.toBeVisible(); + await page.getByRole('button', { name: 'Collapse details' }).click(); await page.locator('.floorplan-svg').scrollIntoViewIfNeeded(); await clickFloorplanCoordinate(page, 12.1, 2.1); await expect(chooser).toBeVisible(); await clickFloorplanCoordinate(page, 18, 8); await expect(chooser).not.toBeVisible(); + await expandFloorplanDetails(page); await expect(page.getByRole('region', { name: 'Selected room' })).toContainText('Garage'); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); }); @@ -1434,11 +1463,89 @@ test('keeps invalid and failed point moves recoverable, with tap and numeric fal await page.getByRole('button', { name: 'Move point', exact: true }).click(); await clickFloorplanCoordinate(page, 16, 3); await page.getByRole('button', { name: 'Rooms', exact: true }).click(); + await page.getByRole('button', { name: 'Discard and continue', exact: true }).click(); await page.getByRole('button', { name: 'Floorplan', exact: true }).click(); await expect(marker).toHaveAttribute('cx', '12'); expect(state.pointUpdates).toEqual([]); }); +test('guards a collapsed room draft before leaving the floorplan', async ({ page }) => { + const state = await mockApi(page, { failRoomSave: true }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + await openEditMap(page); + await page.getByRole('button', { name: 'Add room', exact: true }).click(); + const form = page.getByRole('form', { name: 'Add room' }); + await form.getByRole('textbox', { name: 'Room name' }).fill('Protected draft'); + await collapseFloorplanDetails(page); + + const roomsNav = page.getByRole('button', { name: 'Rooms', exact: true }); + await roomsNav.click(); + const confirmation = page.getByRole('alertdialog', { name: 'Leave this draft?' }); + await expect(confirmation).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(confirmation).not.toBeVisible(); + await expect(page).toHaveURL(/#floorplan$/); + await expect(page.getByRole('button', { name: 'Expand details' })).toBeVisible(); + await expect(roomsNav).toBeFocused(); + await expandFloorplanDetails(page); + await expect(form.getByRole('textbox', { name: 'Room name' })).toHaveValue('Protected draft'); + await collapseFloorplanDetails(page); + + await roomsNav.click(); + await confirmation.getByRole('button', { name: 'Save and continue' }).click(); + await expect(page.getByText(/Failed to create room.*Room could not be saved/)).toBeVisible(); + await expect(confirmation).toBeVisible(); + await expect(page).toHaveURL(/#floorplan$/); + await confirmation.getByRole('button', { name: 'Stay' }).click(); + await expandFloorplanDetails(page); + await expect(form.getByRole('textbox', { name: 'Room name' })).toHaveValue('Protected draft'); + expect(state.createdRooms).toEqual([]); + + await collapseFloorplanDetails(page); + await roomsNav.click(); + await confirmation.getByRole('button', { name: 'Discard and continue' }).click(); + await expect(page).toHaveURL(/#rooms$/); + expect(state.createdRooms).toEqual([]); +}); + +test('guards a point draft before changing floors and saves it once', async ({ page }) => { + const upperRoom = { + ...room, + id: 2, + name: 'Upper room', + floor: 'upper', + polygon: [[0, 0], [10, 0], [10, 10], [0, 10]], + }; + const state = await mockApi(page, { rooms: [room, upperRoom] }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + await page.getByRole('button', { name: 'outlet: North wall outlet' }).click(); + await expandFloorplanDetails(page); + await page.getByRole('button', { name: 'Edit point', exact: true }).click(); + const label = page.getByRole('textbox', { name: 'Label:' }); + await label.fill('Guarded outlet'); + await collapseFloorplanDetails(page); + + const floorSelect = page.getByRole('combobox', { name: 'Floor:', exact: true }); + await floorSelect.selectOption('upper'); + const confirmation = page.getByRole('alertdialog', { name: 'Leave this draft?' }); + await confirmation.getByRole('button', { name: 'Stay' }).click(); + await expect(floorSelect).toHaveValue('main'); + await expect(page.getByRole('button', { name: 'Expand details' })).toBeVisible(); + await expect(floorSelect).toBeFocused(); + await expandFloorplanDetails(page); + await expect(label).toHaveValue('Guarded outlet'); + await collapseFloorplanDetails(page); + expect(state.pointUpdates).toEqual([]); + + await floorSelect.selectOption('upper'); + await confirmation.getByRole('button', { name: 'Save and continue' }).click(); + await expect.poll(() => state.pointUpdates).toHaveLength(1); + expect(state.pointUpdates[0]).toMatchObject({ label: 'Guarded outlet' }); + await expect(floorSelect).toHaveValue('upper'); +}); + test('drags a point by touch at 390px without horizontal overflow', async ({ browser }) => { const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); const page = await context.newPage(); @@ -1446,7 +1553,9 @@ test('drags a point by touch at 390px without horizontal overflow', async ({ bro await page.goto('http://127.0.0.1:4173'); const marker = page.getByRole('button', { name: 'outlet: North wall outlet' }); await marker.click(); + await expandFloorplanDetails(page); await page.getByRole('button', { name: 'Move point', exact: true }).click(); + await page.getByRole('button', { name: 'Collapse details' }).click(); await marker.scrollIntoViewIfNeeded(); const start = await floorplanScreenPoint(page, 12, 2); const end = await floorplanScreenPoint(page, 16, 3); @@ -1460,8 +1569,10 @@ test('drags a point by touch at 390px without horizontal overflow', async ({ bro await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); await expect(marker).toHaveAttribute('cx', '16'); await expect(marker).toHaveAttribute('cy', '3'); + await expect(page.getByRole('button', { name: 'Expand details' })).toBeVisible(); expect(state.pointUpdates).toEqual([]); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + await expandFloorplanDetails(page); await page.getByRole('button', { name: 'Save point' }).click(); await expect.poll(() => state.pointUpdates).toHaveLength(1); await context.close(); @@ -1470,6 +1581,7 @@ test('drags a point by touch at 390px without horizontal overflow', async ({ bro test('captures and undoes points while preserving circuit-walk defaults', async ({ page }) => { const state = await mockApi(page); await page.goto('/'); + await openEditMap(page); await page.getByRole('button', { name: 'Walk circuit' }).click(); await page.getByRole('combobox', { name: 'Circuit:' }).selectOption('2'); await page.getByLabel('Kind:').fill('switch'); @@ -1513,13 +1625,17 @@ for (const width of [1440, 390]) { const { x, y, width } = e.getBoundingClientRect(); return { x, y, width }; })); - expect(boxes[0].y).toBe(boxes[1].y); - expect(boxes[2].y).toBe(boxes[3].y); - expect(boxes[0].x).toBe(boxes[2].x); - expect(boxes[1].x).toBe(boxes[3].x); + expect(new Set(boxes.map(({ y }) => y)).size).toBe(1); expect(boxes[0].width).toBe(boxes[1].width); + const nav = await page.getByRole('navigation', { name: 'Main sections' }).boundingBox(); + expect(nav!.y + nav!.height).toBe(844); + } else { + const nav = await page.getByRole('navigation', { name: 'Main sections' }).boundingBox(); + expect(nav!.y).toBeLessThan(50); } await page.keyboard.press('Tab'); + await expect(page.getByRole('link', { name: 'Hearth home' })).toBeFocused(); + await page.keyboard.press('Tab'); await expect(buttons.nth(0)).toBeFocused(); await page.keyboard.press('Tab'); await expect(buttons.nth(1)).toBeFocused(); @@ -1528,9 +1644,7 @@ for (const width of [1440, 390]) { await expect(page).toHaveURL(/#rooms$/); await expect(buttons.nth(1)).toHaveAttribute('aria-current', 'page'); await expect(buttons.nth(1)).toBeFocused(); - expect(await buttons.nth(1).evaluate((e) => - getComputedStyle(e).outlineColor === getComputedStyle(document.body).color, - )).toBe(true); + expect(await buttons.nth(1).evaluate((e) => getComputedStyle(e).outlineStyle)).toBe('solid'); await page.keyboard.press('Tab'); await page.keyboard.press('Space'); await expect(page).toHaveURL(/#panels$/); @@ -1713,38 +1827,55 @@ for (const layout of ['small', 'multiple']) { await page.goto('/'); const marker = page.getByRole('button', { name: 'outlet: North wall outlet' }); await marker.click(); + const panel = page.getByRole('complementary', { name: 'Floorplan details panel' }); + const toggle = page.getByRole('button', { name: 'Expand details' }); + await expect(toggle).toBeFocused(); + await expect(panel.locator('.floorplan-sidebar-summary')).toContainText('North wall outlet'); + await expect(panel.locator('.floorplan-sidebar-summary')).toContainText('Main panel — breaker 1'); + await expect(panel).not.toHaveAttribute('aria-modal'); + await toggle.click(); + const [panelBox, navBox] = await Promise.all([ + panel.boundingBox(), + page.getByRole('navigation', { name: 'Main sections' }).boundingBox(), + ]); + expect(panelBox!.height).toBeLessThanOrEqual(844 * 0.65 + 1); + expect(panelBox!.y + panelBox!.height).toBeLessThanOrEqual(navBox!.y + 1); const answer = page.getByText('Circuit: Main panel — breaker 1', { exact: true }); - await expect(answer).toBeInViewport({ ratio: 1 }); - const details = page.getByRole('region', { name: 'Selected point' }); - await expect(details).toBeFocused(); + await expect(answer).toBeInViewport({ ratio: 0.85 }); await page.getByRole('button', { name: 'Back to map', exact: true }).click(); await expect(marker).toBeFocused(); await expect(marker).toBeInViewport({ ratio: 1 }); const target = await marker.boundingBox(); await page.mouse.click(target!.x + target!.width / 2 + 18, target!.y + target!.height / 2); - await expect(details).toBeFocused(); + await expandFloorplanDetails(page); await page.getByRole('button', { name: 'Back to map', exact: true }).click(); await page.keyboard.press('Enter'); - await expect(details).toBeFocused(); - await expect(answer).toBeInViewport({ ratio: 1 }); + await expandFloorplanDetails(page); + await expect(answer).toBeInViewport({ ratio: 0.85 }); const map = await page.locator('.floorplan-main .floorplan-svg').boundingBox(); - expect(map!.height).toBeLessThanOrEqual(362); + expect(map!.height).toBeLessThanOrEqual(382); const symbol = await page.locator('.point-symbol').first().boundingBox(); expect(symbol!.width).toBeLessThanOrEqual(24); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); await page.getByRole('button', { name: 'Edit point', exact: true }).click(); + const savePoint = page.getByRole('button', { name: 'Save point', exact: true }); + await savePoint.scrollIntoViewIfNeeded(); + await expect(savePoint).toBeInViewport(); await page.getByRole('textbox', { name: 'Label:' }).fill('Discard this'); await page.getByRole('button', { name: 'Cancel', exact: true }).click(); expect(state.updatedPoint).toBeNull(); - await expect(answer).toBeInViewport({ ratio: 1 }); + await expandFloorplanDetails(page); await page.getByRole('button', { name: 'Move point', exact: true }).click(); await expect(marker).toBeFocused(); await expect(marker).toBeInViewport({ ratio: 1 }); + await expandFloorplanDetails(page); await page.getByRole('button', { name: 'Cancel', exact: true }).click(); expect(state.updatedPoint).toBeNull(); - await expect(details).toBeFocused(); + await expandFloorplanDetails(page); await page.getByRole('button', { name: 'Back to map', exact: true }).click(); + await openEditMap(page); await page.getByRole('button', { name: 'Walk circuit', exact: true }).click(); + await expandFloorplanDetails(page); await expect(page.getByRole('heading', { name: 'Circuit walk', exact: true })).toBeInViewport(); await page.getByRole('button', { name: 'Finish walk', exact: true }).click(); expect(state.createdPoints).toEqual([]); @@ -1771,6 +1902,7 @@ test('makes the floorplan controls keyboard-operable and named', async ({ page } await page.goto('/'); await expect(page.getByRole('heading', { level: 1, name: 'Hearth' })).toBeVisible(); await expect(page.getByRole('heading', { level: 2, name: 'Floorplan' })).toBeVisible(); + await openEditMap(page); const addPoint = page.getByRole('button', { name: 'Add point' }); const walkButton = page.getByRole('button', { name: 'Walk circuit' }); @@ -1795,7 +1927,7 @@ test('makes the floorplan controls keyboard-operable and named', async ({ page } await page.keyboard.press('Tab'); await expect(pointButton).toBeFocused(); await page.keyboard.press('Enter'); - await expect(page.getByRole('heading', { level: 3, name: 'outlet' })).toBeVisible(); + await expect(page.getByRole('heading', { level: 3, name: 'North wall outlet' })).toBeVisible(); const circuitButton = page.getByRole('button', { name: /Breaker 1 — Garage/ }); await circuitButton.focus(); @@ -1808,6 +1940,7 @@ test('keeps point placement unavailable until a room exists', async ({ page }) = await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/'); + await openEditMap(page); await expect(page.getByRole('button', { name: 'Add point' })).toBeDisabled(); await expect(page.getByText('Add a room before placing points on the floorplan.')).toBeVisible(); @@ -1843,14 +1976,14 @@ test('shows panel status and opens mapped breakers on the floorplan', async ({ p const mappedBox = await mappedBreaker.boundingBox(); const unmappedBox = await unmappedBreaker.boundingBox(); - expect(unmappedBox?.height).toBeGreaterThan(mappedBox?.height ?? 0); + expect(unmappedBox?.height).toBeGreaterThanOrEqual(mappedBox?.height ?? 0); await mappedBreaker.getByRole('button', { name: 'View breaker 1 on floorplan' }).click(); await expect(page.getByRole('heading', { level: 2, name: 'Floorplan' })).toBeVisible(); await expect(page.getByRole('button', { name: /Breaker 1 — Garage/ })).toHaveClass(/selected/); await expect(page.locator('[data-point-id="1"] + .point-symbol')).toHaveAttribute( 'stroke', - '#f97316', + 'var(--selection)', ); }); @@ -1862,12 +1995,14 @@ for (const width of [1440, 390]) { const directory = page.getByRole('region', { name: 'Workshop subpanel breaker directory' }); await directory.getByRole('button', { name: 'Map breaker 1', exact: true }).click(); await expect(page).toHaveURL(/#floorplan$/); + if (width === 390) await expandFloorplanDetails(page); const controls = page.locator('.walk-controls'); await expect(controls.getByRole('combobox', { name: 'Circuit:', exact: true })).toHaveValue('3'); await expect(controls.locator('option:checked')).toHaveText('Workshop subpanel — breaker 1'); await expect(page.getByRole('combobox', { name: 'Floor:', exact: true })).toHaveValue('main'); await expect(controls.getByRole('heading', { name: 'Circuit walk' })).toBeInViewport(); + if (width === 390) await page.getByRole('button', { name: 'Collapse details' }).click(); await clickFloorplan(page, 0.6, 0.4); await page.getByLabel('Label:', { exact: true }).fill('Discard this draft'); await page.getByRole('button', { name: 'Cancel', exact: true }).click(); @@ -1885,11 +2020,13 @@ for (const width of [1440, 390]) { await page.goForward(); await expect(page).toHaveURL(/#panels$/); await directory.getByRole('button', { name: 'Map breaker 1', exact: true }).click(); + if (width === 390) await collapseFloorplanDetails(page); await clickFloorplan(page, 0.6, 0.4); await page.getByLabel('Label:', { exact: true }).fill('Bench outlet'); await page.locator('.point-form').getByRole('button', { name: 'Add point', exact: true }).click(); await expect(page.getByText('1 point added this walk.')).toBeVisible(); expect(state.createdPoints).toEqual([expect.objectContaining({ circuit_id: 3, room_id: 1, label: 'Bench outlet' })]); + if (width === 390) await collapseFloorplanDetails(page); await clickFloorplan(page, 0.5, 0.3); await page.getByLabel('Label:', { exact: true }).fill('Unfinished point'); await controls.getByRole('button', { name: 'Finish walk', exact: true }).click(); @@ -1903,7 +2040,11 @@ for (const width of [1440, 390]) { await expect(directory.getByText('1 mapped point', { exact: true })).toBeVisible(); await directory.getByRole('button', { name: 'View breaker 1 on floorplan', exact: true }).click(); await expect(controls).not.toBeVisible(); - await expect(page.getByRole('button', { name: /Breaker 1 — Workshop/ })).toHaveClass(/selected/); + if (width === 390) { + await expect(page.locator('.floorplan-sidebar-summary')).toContainText('Workshop subpanel · Breaker 1'); + } else { + await expect(page.getByRole('button', { name: /Breaker 1 — Workshop/ })).toHaveClass(/selected/); + } await page.goBack(); await page.goForward(); await expect(controls).not.toBeVisible(); @@ -1919,6 +2060,7 @@ for (const width of [1440, 390]) { await expect(page.getByText('Add a room before mapping Workshop subpanel — breaker 1.')).toBeVisible(); await expect(page.getByText('Then return to this breaker and choose Map breaker.')).toBeVisible(); await expect(page.locator('.walk-controls')).not.toBeVisible(); + await openEditMap(page); await page.getByRole('button', { name: 'Add room', exact: true }).click(); await expect(page).toHaveURL(/#floorplan$/); await expect(page.getByRole('form', { name: 'Add room' })).toBeVisible(); @@ -2123,6 +2265,7 @@ test('creates, previews, positions, and cancels rooms on the phone floorplan', a await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/'); + await openEditMap(page); await page.getByRole('button', { name: 'Add room', exact: true }).click(); const canceledDraft = page.getByRole('form', { name: 'Add room' }); const roomMap = page.locator('.room-authoring .floorplan-svg'); @@ -2140,11 +2283,14 @@ test('creates, previews, positions, and cancels rooms on the phone floorplan', a expect((await control.boundingBox())!.height).toBeGreaterThanOrEqual(44); } await canceledDraft.getByRole('textbox', { name: 'Room name' }).fill('Discarded room'); + await collapseFloorplanDetails(page); await clickFloorplan(page, 0.7, 0.5); await expect(page.locator('.draft-room-polygon')).toBeVisible(); + await expandFloorplanDetails(page); await canceledDraft.getByRole('button', { name: 'Cancel' }).click(); expect(state.createdRooms).toEqual([]); + await collapseFloorplanDetails(page); await page.getByRole('button', { name: 'Add room', exact: true }).click(); let draft = page.getByRole('form', { name: 'Add room' }); await draft.getByRole('textbox', { name: 'Room name' }).fill('Kitchen'); @@ -2153,6 +2299,7 @@ test('creates, previews, positions, and cancels rooms on the phone floorplan', a await draft.getByRole('button', { name: 'Save room' }).click(); await expect.poll(() => state.createdRooms).toHaveLength(1); + await collapseFloorplanDetails(page); await page.getByRole('button', { name: 'Add room', exact: true }).click(); draft = page.getByRole('form', { name: 'Add room' }); await draft.getByRole('textbox', { name: 'Room name' }).fill('Living room'); @@ -2163,7 +2310,9 @@ test('creates, previews, positions, and cancels rooms on the phone floorplan', a await expect(page.locator('.room-polygon')).toHaveCount(1); await expect(page.locator('.draft-room-polygon')).toBeVisible(); + await collapseFloorplanDetails(page); await clickFloorplan(page, 0.85, 0.5); + await expandFloorplanDetails(page); await draft.getByRole('button', { name: 'Save room' }).click(); await expect.poll(() => state.createdRooms).toHaveLength(2); const kitchenMaxX = Math.max(...(state.createdRooms[0].polygon as number[][]).map(([x]) => x)); @@ -2175,6 +2324,7 @@ test('creates, previews, positions, and cancels rooms on the phone floorplan', a test('draws a rectangle on an empty floor and saves exact corrected dimensions', async ({ page }) => { const state = await mockApi(page, { rooms: [], points: [] }); await page.goto('/'); + await openEditMap(page); await page.getByRole('button', { name: 'Add room', exact: true }).click(); let form = page.getByRole('form', { name: 'Add room' }); const start = await floorplanScreenPoint(page, 2, 2); @@ -2219,6 +2369,7 @@ test('draws beside existing rooms with touch at 390px', async ({ browser }) => { const page = await context.newPage(); const state = await mockApi(page, { rooms: [room, adjacentRoom] }); await page.goto('http://127.0.0.1:4173'); + await openEditMap(page); await page.getByRole('button', { name: 'Add room', exact: true }).click(); const form = page.getByRole('form', { name: 'Add room' }); await form.getByRole('textbox', { name: 'Room name' }).fill('Sunroom'); @@ -2306,6 +2457,7 @@ test('keeps invalid and failed resize drafts recoverable at 390px', async ({ pag await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/'); await page.locator('g[aria-label="Room: Garage"]').click(); + await expandFloorplanDetails(page); await page.getByRole('button', { name: 'Resize room on map' }).click(); const form = page.getByRole('form', { name: 'Edit room' }); await form.getByRole('spinbutton', { name: 'Room length in feet' }).fill('0'); @@ -2459,24 +2611,27 @@ test('touch drag keeps a room draft recoverable after a failed save at 390px', a const state = await mockApi(page, { rooms: [room, adjacentRoom], failRoomSave: true }); await page.goto('http://127.0.0.1:4173'); await page.locator('g[aria-label="Room: Garage"]').click(); + await expandFloorplanDetails(page); await page.getByRole('button', { name: 'Edit room on map' }).click(); const draft = page.getByRole('form', { name: 'Edit room' }); await draft.getByText('Fine position (optional)').click(); const outline = page.getByRole('button', { name: 'Move Garage draft' }); await expect(page.locator('.room-polygon')).toHaveCount(2); + await collapseFloorplanDetails(page); await outline.scrollIntoViewIfNeeded(); - const box = await outline.boundingBox(); - if (!box) throw new Error('Room draft is not visible'); - const x = box.x + box.width / 2; - const y = box.y + box.height / 2; + const start = await floorplanScreenPoint(page, 15, 5); + const end = await floorplanScreenPoint(page, 18, 7); + expect(await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.getAttribute('class'), start)) + .toContain('draft-room-polygon'); const cdp = await context.newCDPSession(page); - await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ id: 1, x, y }] }); - await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ id: 1, x: x + 50, y: y + 30 }] }); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ id: 1, x: start.x, y: start.y }] }); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ id: 1, x: end.x, y: end.y }] }); await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); - const position = draft.getByRole('spinbutton', { name: 'Room X position in feet' }); + const position = page.locator('.room-draft-form input[aria-label="Room X position in feet"]'); await expect.poll(async () => Number(await position.inputValue())).toBeGreaterThan(10); expect(state.updatedRoom).toBeNull(); + await expandFloorplanDetails(page); await draft.getByRole('button', { name: 'Save room' }).click(); await expect(page.getByText(/Failed to save room.*Room could not be saved/)).toBeVisible(); await expect(position).not.toHaveValue('10'); @@ -2490,6 +2645,7 @@ test('touch drag keeps a room draft recoverable after a failed save at 390px', a test('retains a room draft after save failure', async ({ page }) => { const state = await mockApi(page, { rooms: [], points: [], failRoomSave: true }); await page.goto('/'); + await openEditMap(page); await page.getByRole('button', { name: 'Add room', exact: true }).click(); const form = page.getByRole('form', { name: 'Add room' }); await form.getByRole('textbox', { name: 'Room name' }).fill('Kitchen'); @@ -2507,6 +2663,7 @@ test('does not offer a duplicate create retry when refresh fails after save', as failRoomRefreshAfterSave: true, }); await page.goto('/'); + await openEditMap(page); await page.getByRole('button', { name: 'Add room', exact: true }).click(); const form = page.getByRole('form', { name: 'Add room' }); await form.getByRole('textbox', { name: 'Room name' }).fill('Kitchen'); @@ -2558,13 +2715,14 @@ test('keeps circuit-walk controls reachable over the phone floorplan', async ({ const box = await page.locator('.floorplan-svg').boundingBox(); expect(box?.width).toBeGreaterThan(330); + await openEditMap(page); await page.getByRole('button', { name: 'Walk circuit' }).click(); const walkSidebar = page.locator('.walk-sidebar'); await expect(walkSidebar).toHaveCSS('position', 'fixed'); const walkBox = await walkSidebar.boundingBox(); expect(walkBox?.x).toBeGreaterThanOrEqual(0); expect((walkBox?.x ?? 0) + (walkBox?.width ?? 0)).toBeLessThanOrEqual(390); - await expect(page.getByRole('button', { name: 'Finish walk' })).toBeVisible(); + await expect(walkSidebar.locator('.floorplan-sidebar-summary')).toContainText('Circuit walk'); await clickFloorplan(page, 0.48, 0.4); const addPoint = page.locator('.point-form').getByRole('button', { name: 'Add point' }); @@ -2647,13 +2805,16 @@ for (const colorScheme of ['light', 'dark'] as const) { const marker = page.getByRole('button', {name: 'outlet: North wall outlet'}); await marker.focus(); await page.keyboard.press('Enter'); + await expandFloorplanDetails(page); await expect(page.getByText('Circuit: Main panel — breaker 1', {exact: true})).toBeVisible(); const first = page.getByRole('button', {name: /Breaker 1 — Garage/}); const second = page.getByRole('button', {name: 'Breaker 2', exact: true}); await second.click(); + await expandFloorplanDetails(page); await expect(second).toHaveClass(/selected/); await expect(first).not.toHaveClass(/selected/); await first.click(); + await expandFloorplanDetails(page); await expect(first).toHaveClass(/selected/); await expect(second).not.toHaveClass(/selected/); await first.scrollIntoViewIfNeeded(); @@ -2703,7 +2864,11 @@ for (const width of [1280, 390]) { await expect(roomForm.getByRole('combobox', { name: 'Room floor' })).toHaveValue('Loft'); await roomForm.getByRole('textbox', { name: 'Room name' }).fill('Loft storage'); await roomForm.getByRole('button', { name: 'Save room', exact: true }).click(); - await expect(page.getByRole('region', { name: 'Selected room' })).toContainText('Loft storage'); + if (width === 390) { + await expect(page.locator('.floorplan-sidebar-summary')).toContainText('Loft storage'); + } else { + await expect(page.getByRole('region', { name: 'Selected room' })).toContainText('Loft storage'); + } expect(state.createdRooms[0].floor).toBe('Loft'); await page.locator('.floor-manager summary').click(); await page.getByRole('button', { name: 'Remove floor', exact: true }).click(); @@ -2717,6 +2882,9 @@ for (const width of [1280, 390]) { await page.setViewportSize({ width, height: 844 }); await page.goto('/'); await page.getByRole('button', { name: 'outlet: North wall outlet' }).click(); + if (width === 390) { + await expect(page.locator('.floorplan-sidebar-summary')).toContainText('North wall outlet'); + } const marker = page.locator('[data-point-id="1"]'); const position = await marker.evaluate((element) => [element.getAttribute('cx'), element.getAttribute('cy')]); await page.locator('.floor-manager summary').click(); @@ -2729,6 +2897,7 @@ for (const width of [1280, 390]) { await form.getByLabel('Rename floor').fill('Ground'); await form.getByRole('button', { name: 'Save floor' }).click(); await expect(page.getByRole('combobox', { name: 'Floor:', exact: true })).toHaveValue('Ground'); + if (width === 390) await expandFloorplanDetails(page); await expect(page.getByRole('region', { name: 'Selected point' })).toContainText('North wall outlet'); await expect(marker).toHaveAttribute('aria-pressed', 'true'); expect(await marker.evaluate((element) => [element.getAttribute('cx'), element.getAttribute('cy')])).toEqual(position); @@ -2771,6 +2940,7 @@ test('floor lifecycle handles blank and duplicate drafts, keyboard creation, and page.once('dialog', (dialog) => dialog.accept()); await page.getByRole('button', { name: 'Remove floor', exact: true }).click(); await expect(page.getByText('Create a floor to start a floorplan.')).toBeVisible(); + await openEditMap(page); await expect(page.getByRole('button', { name: 'Add room', exact: true })).toBeDisabled(); await page.locator('.floor-manager').getByRole('button', { name: 'Add floor', exact: true }).click(); await form.getByLabel('New floor name').fill('Basement'); @@ -2796,7 +2966,9 @@ test('floor lifecycle keeps failed rename and create drafts recoverable', async await expect(page.getByText(/Floor save unavailable/)).toBeVisible(); await expect(form.getByRole('textbox')).toHaveValue('Ground'); await expect(page.getByRole('combobox', { name: 'Floor:', exact: true })).toHaveValue('main'); + await expandFloorplanDetails(page); await expect(page.getByRole('region', { name: 'Selected point' })).toContainText('North wall outlet'); + await collapseFloorplanDetails(page); await form.getByRole('button', { name: 'Cancel', exact: true }).click(); } }); @@ -2809,6 +2981,7 @@ test('floor lifecycle keeps a floor selected after failed removal', async ({ pag await page.getByRole('button', { name: 'Remove floor', exact: true }).click(); await expect(page.getByText(/Floor could not be removed/)).toBeVisible(); await expect(page.getByRole('combobox', { name: 'Floor:', exact: true })).toHaveValue('main'); + await openEditMap(page); await expect(page.getByRole('button', { name: 'Add room', exact: true })).toBeEnabled(); }); @@ -2816,6 +2989,7 @@ test('floor lifecycle prevents another edit or find from redirecting a floor dra await mockApi(page, { rooms: [room, { ...room, id: 2, name: 'Upper room', floor: 'upper' }] }); await page.goto('/'); await page.getByRole('button', { name: 'outlet: North wall outlet' }).click(); + await expandFloorplanDetails(page); await page.locator('.floor-manager summary').click(); await page.getByRole('button', { name: 'Rename floor', exact: true }).click(); const form = page.locator('.floor-management-form'); diff --git a/frontend/e2e/measured-geometry.spec.ts b/frontend/e2e/measured-geometry.spec.ts index f78813e..bff6d78 100644 --- a/frontend/e2e/measured-geometry.spec.ts +++ b/frontend/e2e/measured-geometry.spec.ts @@ -51,6 +51,8 @@ async function openEditor(page: Page, id = 1) { await page.goto('/'); await page.locator(`[data-room-id="${id}"]`).focus(); await page.keyboard.press('Enter'); + const expand = page.getByRole('button', { name: 'Expand details' }); + if (await expand.count()) await expand.click(); await page.getByRole('button', { name: 'Open geometry editor' }).click(); await expect(page.getByRole('heading', { name: 'Edit geometry for Measured room' })).toBeVisible(); } @@ -72,7 +74,7 @@ test('measured editor preserves exact source, selection, floor and viewport at 3 expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); await page.getByRole('button', { name: 'Save room', exact: true }).focus(); await page.keyboard.press('Enter'); - await expect(page.getByRole('region', { name: 'Selected room' })).toBeVisible(); + await expect(page.locator('.floorplan-sidebar-summary')).toContainText('Measured room'); expect(state.patches).toHaveLength(1); expect(Object.keys(state.patches[0]).sort()).toEqual(['measurement_source', 'polygon']); expect(state.patches[0].measurement_source).toEqual({ ...source, start: { ...source.start, x: 3.025 } }); @@ -80,6 +82,7 @@ test('measured editor preserves exact source, selection, floor and viewport at 3 await expect(page.locator('[data-room-id="1"]')).toBeFocused(); await page.reload(); await page.locator('[data-room-id="1"]').press('Enter'); + await page.getByRole('button', { name: 'Expand details' }).click(); await page.getByRole('button', { name: 'Open geometry editor' }).click(); await expect(page.getByRole('spinbutton', { name: 'X (ft):', exact: true })).toHaveValue('3.025'); }); diff --git a/frontend/index.html b/frontend/index.html index 0fca6f0..e314af4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,8 @@ - frontend + + Hearth
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 6893eb1..ea3422a 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1 +1,5 @@ - \ No newline at end of file + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css index 5db11da..bf4608f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,18 +1,121 @@ .app { max-width: 1200px; margin: 0 auto; - padding: 1rem; + padding: 0 1.25rem 2rem; +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: 3.75rem; + border-bottom: 1px solid var(--border); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 0.5rem; + min-height: 44px; + color: var(--text); + text-decoration: none; +} + +.brand > span { + display: grid; + width: 2rem; + height: 2rem; + place-items: center; + border-radius: 0.6rem; + background: var(--primary); + color: var(--primary-text); + font-size: 1.35rem; } .app h1 { - margin-top: 0; + margin: 0; + font-size: 1.2rem; + letter-spacing: -0.02em; +} + +.app h2 { + margin: 1.25rem 0 0.75rem; + font-size: clamp(1.4rem, 3vw, 1.8rem); + letter-spacing: -0.025em; } .tabs { display: flex; flex-wrap: wrap; - gap: 0.5rem; - margin-bottom: 1rem; + gap: 0.25rem; + margin: 0; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button, +input, +select, +textarea, +summary { + border-radius: 0.5rem; +} + +button { + border: 1px solid var(--border); + background: var(--surface-raised); + color: var(--text); +} + +button:hover:not(:disabled) { + border-color: var(--primary); +} + +button:disabled { + cursor: default; + opacity: 0.55; +} + +button, +input:not([type='radio']):not([type='checkbox']), +select { + box-sizing: border-box; + min-height: 44px; + max-width: 100%; + padding: 0.5rem 0.65rem; +} + +button[type='submit'], +.room-builder-actions button:first-child, +.floorplan-find-confirmation .form-actions button:first-child { + border-color: var(--primary); + background: var(--primary); + color: var(--primary-text); +} + +:is(button, input, select, textarea, summary, a):focus-visible { + outline: 3px solid var(--selection); + outline-offset: 2px; +} + +input, +select, +textarea { + border: 1px solid var(--border); + background: var(--surface-raised); + color: var(--text); +} + +input::placeholder, +textarea::placeholder { + color: var(--text-muted); + opacity: 1; } .tabs button, @@ -37,20 +140,21 @@ .floorplan-svg:focus-visible, .room-editor :is(button, input, select):focus-visible, .floorplan-layout :is(button, input, select, summary):focus-visible { - outline: 2px solid CanvasText; + outline: 3px solid var(--selection); outline-offset: 2px; } .tabs button.active, .heading-buttons button.active { font-weight: bold; - background: CanvasText; - color: Canvas; + border-color: var(--primary); + background: var(--primary); + color: var(--primary-text); } .floorplan-layout { display: flex; - gap: 1.5rem; + gap: 1rem; } .floorplan-main { @@ -60,10 +164,11 @@ .floorplan-finder { min-width: 0; - margin-bottom: 0.75rem; - padding: 0.5rem; - border: 1px solid #999; - border-radius: 4px; + margin-bottom: 0.625rem; + padding: 0.625rem; + border: 1px solid var(--border); + border-radius: 0.75rem; + background: var(--surface); } .floorplan-finder-heading { @@ -75,12 +180,16 @@ } .floorplan-finder-heading h3 { - margin: 0; + margin: 0 0 0.25rem; + font-size: 0.875rem; } .floorplan-finder label { - display: block; - margin-top: 0.5rem; + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); } .floorplan-finder input { @@ -88,11 +197,14 @@ width: 100%; max-width: 100%; font: inherit; - padding: 0.5rem; + min-height: 44px; + padding: 0.5rem 0.75rem; } .floorplan-find-status { - margin: 0.5rem 0 0; + margin: 0.35rem 0 0; + color: var(--text-muted); + font-size: 0.825rem; overflow-wrap: anywhere; } @@ -105,7 +217,7 @@ } .floorplan-find-results li + li { - border-top: 1px solid #999; + border-top: 1px solid var(--border); } .floorplan-find-results button { @@ -123,17 +235,21 @@ .floorplan-find-kind { font-size: 0.8rem; font-weight: bold; + color: var(--selection); + letter-spacing: 0.03em; + text-transform: uppercase; } .floorplan-find-confirmation { margin-bottom: 0.75rem; padding: 0.75rem; - border: 2px solid #b45309; - border-radius: 4px; + border: 2px solid var(--selection); + border-radius: 0.75rem; + background: var(--selection-soft); } .floorplan-find-confirmation:focus { - outline: 2px solid CanvasText; + outline: 3px solid var(--selection); outline-offset: 2px; } @@ -155,7 +271,7 @@ .floorplan-toolbar { display: flex; flex-wrap: wrap; - gap: 1rem; + gap: 0.5rem 1rem; align-items: center; margin-bottom: 0.5rem; } @@ -167,17 +283,27 @@ margin-top: 0.5rem; } -.floor-manager { +.floor-manager, +.edit-map-tools { max-width: 100%; } -.floor-manager summary { +.floor-manager summary, +.edit-map-tools summary { display: flex; align-items: center; min-height: 44px; + padding: 0 0.25rem; cursor: pointer; } +.edit-map-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding-top: 0.5rem; +} + .floor-management-form { display: flex; flex-wrap: wrap; @@ -192,23 +318,38 @@ } .floorplan-navigation { + position: absolute; + z-index: 2; + top: 0.75rem; + right: 0.75rem; display: flex; - flex-wrap: wrap; - gap: 0.5rem; + gap: 0.25rem; align-items: center; - margin-bottom: 0.5rem; + padding: 0.25rem; + border: 1px solid var(--border); + border-radius: 0.75rem; + background: color-mix(in srgb, var(--surface-raised) 92%, transparent); + box-shadow: 0 4px 16px rgb(53 42 30 / 12%); } .floorplan-navigation p { - flex: 1 1 16rem; - margin: 0; - font-size: 0.875rem; + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); +} + +.floorplan-canvas { + position: relative; } .floorplan-svg { width: 100%; - height: 70vh; - border: 1px solid #999; + height: min(64vh, 640px); + border: 1px solid var(--border); + border-radius: 0.875rem; + background: var(--surface-raised); touch-action: none; cursor: grab; } @@ -223,13 +364,13 @@ } .room-polygon { - fill: #f3f4f6; - stroke: #333; + fill: var(--surface-muted); + stroke: var(--text); stroke-width: 0.3; } .selected-room { - stroke: #f97316; + stroke: var(--selection); stroke-width: 0.6; } @@ -238,13 +379,13 @@ } .floorplan-svg g[role='button']:focus-visible .room-polygon { - stroke: #111; + stroke: var(--primary); stroke-width: 0.7; } .draft-room-polygon { - fill: rgb(249 115 22 / 25%); - stroke: #f97316; + fill: color-mix(in srgb, var(--selection) 25%, transparent); + stroke: var(--selection); } .draft-room-polygon[role='button'] { @@ -256,7 +397,7 @@ } .draft-room-polygon[role='button']:focus-visible { - stroke: #111; + stroke: var(--primary); } .room-draft-summary { @@ -265,8 +406,8 @@ } .room-resize-handle-visible { - fill: #f97316; - stroke: #fff; + fill: var(--selection); + stroke: var(--surface-raised); stroke-width: 2; vector-effect: non-scaling-stroke; } @@ -276,17 +417,17 @@ } .room-resize-handle:focus-visible .room-resize-handle-visible { - stroke: #111; + stroke: var(--primary); stroke-width: 3; } .anchor-room { - stroke: #f97316; + stroke: var(--selection); stroke-width: 0.6; } .room-label { - fill: #333; + fill: var(--text); pointer-events: none; } @@ -295,7 +436,7 @@ } .point-marker:focus-visible + .point-symbol { - stroke: #111; + stroke: var(--primary); stroke-width: 3; } @@ -317,7 +458,7 @@ } .point-details:focus-visible { - outline: 2px solid CanvasText; + outline: 3px solid var(--selection); outline-offset: 2px; } @@ -326,11 +467,29 @@ flex-shrink: 0; } +.floorplan-sidebar-summary { + display: none; +} + +.details-answer { + margin: -0.25rem 0 0.75rem; + color: var(--primary); + font-size: 1.05rem; + font-weight: 700; +} + +.details-actions { + padding-top: 0.75rem; + margin-top: 0.75rem; + border-top: 1px solid var(--border); +} + .info-card { - border: 1px solid #999; - border-radius: 4px; + border: 1px solid var(--border); + border-radius: 0.75rem; padding: 0.75rem; margin-bottom: 1rem; + background: var(--surface); } .info-card label { @@ -358,8 +517,8 @@ .placement-instruction { padding: 0.5rem; - border-left: 4px solid #f97316; - background: light-dark(#fff7ed, #431407); + border-left: 4px solid var(--selection); + background: var(--selection-soft); } .form-actions { @@ -396,7 +555,7 @@ .circuit-list button.selected { font-weight: bold; - color: light-dark(#b45309, #fdba74); + color: var(--selection); } .stacked-form label { @@ -412,10 +571,11 @@ } .panel-card { - border: 1px solid #999; + border: 1px solid var(--border); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; + background: var(--surface); } .panel-card-header { @@ -457,8 +617,8 @@ gap: 0.5rem 1rem; padding: 0.5rem 0; margin: 0.75rem 0; - border-top: 1px solid #999; - border-bottom: 1px solid #999; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); } .breaker-directory { @@ -469,7 +629,7 @@ .breaker-slot { display: grid; grid-template-columns: minmax(5.5rem, 7rem) minmax(0, 1fr) minmax(10rem, auto); - border: 1px solid #777; + border: 1px solid var(--border); border-radius: 6px; overflow: hidden; } @@ -485,8 +645,8 @@ justify-content: center; min-height: 5rem; padding: 0.5rem; - border-right: 1px solid #777; - background: color-mix(in srgb, CanvasText 8%, Canvas); + border-right: 1px solid var(--border); + background: var(--surface-muted); text-align: center; } @@ -520,11 +680,11 @@ .status-badge.mapped, .status-badge.verified { - color: #047857; + color: var(--success); } .status-badge.warning { - color: #b45309; + color: var(--selection); } .breaker-actions { @@ -537,9 +697,9 @@ .editor-form { padding: 0.75rem; - border: 1px solid #777; + border: 1px solid var(--border); border-radius: 6px; - background: color-mix(in srgb, CanvasText 4%, Canvas); + background: var(--surface); } .field-grid { @@ -572,7 +732,7 @@ .circuit-add { padding-top: 0.75rem; margin-top: 0.75rem; - border-top: 1px solid #999; + border-top: 1px solid var(--border); } .panel-add { @@ -589,7 +749,7 @@ } .error { - color: #dc2626; + color: var(--danger); } .room-builder { @@ -687,7 +847,7 @@ align-items: center; padding-bottom: 1rem; margin-bottom: 1rem; - border-bottom: 1px solid #999; + border-bottom: 1px solid var(--border); } .wall-row label { @@ -722,7 +882,7 @@ .draft-room-outline { fill: none; - stroke: #f97316; + stroke: var(--selection); stroke-width: 0.4; } @@ -742,8 +902,9 @@ .maintenance-card { padding: 0.75rem; - border: 1px solid #777; + border: 1px solid var(--border); border-radius: 8px; + background: var(--surface); } .maintenance-card-header { @@ -758,16 +919,16 @@ } .maintenance-overdue { - color: #b91c1c; + color: var(--danger); } .maintenance-due-today, .maintenance-upcoming { - color: #b45309; + color: var(--selection); } .maintenance-completed { - color: #047857; + color: var(--success); } .maintenance-meta { @@ -816,9 +977,41 @@ } @media (max-width: 700px) { + .app { + padding: 0 0.75rem calc(5rem + env(safe-area-inset-bottom)); + } + + .app-header { + min-height: 3.25rem; + } + .tabs { + position: fixed; + z-index: 30; + right: 0; + bottom: 0; + left: 0; display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0; + padding: 0.35rem 0.25rem calc(0.35rem + env(safe-area-inset-bottom)); + border-top: 1px solid var(--border); + background: var(--surface-raised); + box-shadow: 0 -5px 20px rgb(53 42 30 / 14%); + } + + .tabs button { + min-width: 0; + padding: 0.45rem 0.2rem; + border-color: transparent; + background: transparent; + font-size: 0.72rem; + line-height: 1.15; + } + + .tabs button.active { + border-color: var(--primary); + background: var(--primary); } .room-editor table { @@ -826,6 +1019,40 @@ overflow-wrap: anywhere; } + .floorplan-finder { + padding: 0.5rem; + } + + .floorplan-finder-heading { + display: none; + } + + .floorplan-find-status { + font-size: 0.75rem; + } + + #floorplan-find-query:placeholder-shown + .floorplan-find-status { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + } + + .floorplan-toolbar { + gap: 0.25rem 0.75rem; + } + + .floorplan-toolbar > label { + display: flex; + align-items: center; + gap: 0.35rem; + } + + .floorplan-toolbar > label select { + width: min(8rem, 34vw); + } + .wall-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -851,8 +1078,86 @@ flex-direction: column; } + .floorplan-layout { + padding-bottom: 5.5rem; + } + .floorplan-sidebar { - width: 100%; + position: fixed; + z-index: 20; + right: 0.5rem; + bottom: calc(3.85rem + env(safe-area-inset-bottom)); + left: 0.5rem; + width: auto; + max-height: 65dvh; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + box-sizing: border-box; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 1rem; + background: var(--surface-raised); + box-shadow: var(--shadow); + } + + .floorplan-sidebar-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + box-sizing: border-box; + min-height: 4rem; + padding: 0.5rem 0.625rem 0.5rem 0.875rem; + } + + .floorplan-sidebar-summary > div { + min-width: 0; + } + + .floorplan-sidebar-summary span, + .floorplan-sidebar-summary strong, + .floorplan-sidebar-summary small { + display: block; + } + + .floorplan-sidebar-summary span { + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .floorplan-sidebar-summary strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .floorplan-sidebar-summary small { + color: var(--primary); + font-size: 0.8rem; + font-weight: 700; + line-height: 1.2; + } + + .floorplan-sidebar-summary button { + flex: 0 0 auto; + } + + .floorplan-sidebar:not(.expanded) .floorplan-sidebar-body { + display: none; + } + + .floorplan-sidebar-body { + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding: 0 0.75rem calc(0.75rem + env(safe-area-inset-bottom)); + } + + .floorplan-sidebar-body > :last-child { + margin-bottom: 0; } .room-authoring .floorplan-toolbar { @@ -878,8 +1183,8 @@ } .room-authoring .floorplan-main .floorplan-svg { - min-height: 180px; - height: 21svh; + min-height: 220px; + height: 32svh; } .room-authoring { @@ -930,7 +1235,7 @@ .floorplan-main .floorplan-svg { min-height: 240px; - height: min(42svh, 360px); + height: min(45svh, 380px); } .back-to-map { @@ -943,24 +1248,8 @@ font: inherit; } - .floorplan-layout.walking { - padding-bottom: 12rem; - } - .walk-sidebar { - position: fixed; - z-index: 10; - right: 1rem; - bottom: 1rem; - left: 1rem; - width: auto; - max-height: 45vh; - overflow-y: auto; - padding: 0.75rem; - border: 1px solid #999; - border-radius: 8px; - background: Canvas; - box-shadow: 0 4px 18px rgb(0 0 0 / 25%); + max-height: 65dvh; } .walk-sidebar .info-card { @@ -987,7 +1276,7 @@ grid-column: 1 / -1; flex-direction: row; flex-wrap: wrap; - border-top: 1px solid #777; + border-top: 1px solid var(--border); } .breaker-edit-form { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index abab74d..f28a790 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,12 +31,15 @@ function App() { const onHashChange = () => { const nextTab = tabFromLocation(); if (nextTab === tab) return; - const proceed = () => { setTab(nextTab); window.history.replaceState(null, '', `#${nextTab}`); }; + const proceed = () => { + setTab(nextTab); + window.history.replaceState(null, '', `#${nextTab}`); + if (nextTab !== 'floorplan') setFloorplanTarget(null); + if (nextTab !== 'rooms') setRoomInitialFloor(null); + }; window.history.replaceState(null, '', `#${tab}`); if (navigationGuard.current) navigationGuard.current(proceed); else proceed(); - if (nextTab !== 'floorplan') setFloorplanTarget(null); - if (nextTab !== 'rooms') setRoomInitialFloor(null); }; window.addEventListener('hashchange', onHashChange); return () => window.removeEventListener('hashchange', onHashChange); @@ -58,9 +61,12 @@ function App() { return (
-
-

Hearth

-
- {open && ( -
+
setQuery(event.target.value)} autoComplete="off" @@ -186,8 +163,7 @@ export function FloorplanFinder({ ))} )} -
- )} +
); } diff --git a/frontend/src/components/FloorplanView.tsx b/frontend/src/components/FloorplanView.tsx index be1506c..aa7b144 100644 --- a/frontend/src/components/FloorplanView.tsx +++ b/frontend/src/components/FloorplanView.tsx @@ -319,8 +319,11 @@ export function FloorplanView({ target: FloorplanFindTarget; floor: string; } | null>(null); + const [detailsExpanded, setDetailsExpanded] = useState(false); + const [leaveConfirmationOpen, setLeaveConfirmationOpen] = useState(false); const svgRef = useRef(null); const pointDetailsRef = useRef(null); + const detailsToggleRef = useRef(null); const pointChoicesRef = useRef(null); const findConfirmationRef = useRef(null); const pointerPositions = useRef(new Map()); @@ -331,11 +334,17 @@ export function FloorplanView({ const roomPlacement = useRef<{ pointerId: number; startClientX: number; startClientY: number } | null>(null); const pointPlacement = useRef<{ pointerId: number; startClientX: number; startClientY: number } | null>(null); const suppressMapClick = useRef(false); + const hadDraftPoint = useRef(false); + const leaveContinuation = useRef<(() => void) | null>(null); + const leaveFocus = useRef(null); + const leaveWasExpanded = useRef(false); + const findWasExpanded = useRef(false); + const leaveConfirmationRef = useRef(null); function revealPointDetails() { if (!window.matchMedia('(max-width: 700px)').matches) return; - pointDetailsRef.current?.focus({ preventScroll: true }); - pointDetailsRef.current?.scrollIntoView({ block: 'start' }); + setDetailsExpanded(false); + requestAnimationFrame(() => detailsToggleRef.current?.focus({ preventScroll: true })); } useEffect(() => { @@ -348,11 +357,13 @@ export function FloorplanView({ }, [pointChoiceIds]); function returnToPoint(pointId: number | null) { - const marker = svgRef.current?.querySelector( - `[data-point-id="${pointId}"]`, - ); - marker?.focus({ preventScroll: true }); - marker?.scrollIntoView({ block: 'center' }); + setDetailsExpanded(false); + requestAnimationFrame(() => { + const marker = svgRef.current?.querySelector( + `[data-point-id="${pointId}"]`, + ); + marker?.focus({ preventScroll: true }); + }); } const floors = allFloors.map((item) => item.name); @@ -893,6 +904,7 @@ export function FloorplanView({ setRoomDraft(null); setMeasuredRoom(null); setWallPreview(null); + setDetailsExpanded(false); } function finishWalk() { @@ -901,6 +913,46 @@ export function FloorplanView({ finishInteraction(); } + function requestDraftLeave(proceed: () => void, focusTarget?: HTMLElement | null) { + if (saving) return; + if (!roomDraft && !draftPoint) { + proceed(); + return; + } + leaveContinuation.current = proceed; + leaveFocus.current = focusTarget ?? document.activeElement as HTMLElement | null; + leaveWasExpanded.current = detailsExpanded; + setPendingFindTarget(null); + setDetailsExpanded(false); + setLeaveConfirmationOpen(true); + } + + function stayWithDraft() { + setLeaveConfirmationOpen(false); + leaveContinuation.current = null; + setDetailsExpanded(leaveWasExpanded.current); + requestAnimationFrame(() => leaveFocus.current?.focus()); + } + + async function saveAndLeaveDraft() { + const saved = roomDraft ? await saveRoomDraft() : await saveDraft(); + if (!saved) return; + if (mode === 'walk') finishWalk(); + const proceed = leaveContinuation.current; + leaveContinuation.current = null; + setLeaveConfirmationOpen(false); + proceed?.(); + } + + function discardAndLeaveDraft() { + const proceed = leaveContinuation.current; + leaveContinuation.current = null; + setLeaveConfirmationOpen(false); + if (mode === 'walk') finishWalk(); + else finishInteraction(); + proceed?.(); + } + function startAdd() { if (plan.rooms.length === 0) return; if (mode === 'add') { @@ -962,6 +1014,8 @@ export function FloorplanView({ } function showFindTarget(target: FloorplanFindTarget) { + const selectionFromDetails = document.activeElement?.closest('.floorplan-sidebar-body'); + setDetailsExpanded(false); setPointChoiceIds(null); setSelectedPointId(null); setSelectedRoomId(null); @@ -1007,9 +1061,13 @@ export function FloorplanView({ } else { setPendingFindFocus(null); } + if (selectionFromDetails && window.matchMedia('(max-width: 700px)').matches) { + requestAnimationFrame(() => detailsToggleRef.current?.focus({ preventScroll: true })); + } } function chooseFindTarget(target: FloorplanFindTarget) { + if (saving) return; if (floorBusy) { setError(floorAction ? 'Save or cancel the floor draft before finding another item.' @@ -1018,6 +1076,8 @@ export function FloorplanView({ } if (measuredRoom) { wallEditor.current?.requestLeave(() => { cancelMeasured(); showFindTarget(target); }); return; } if (roomDraft || draftPoint) { + findWasExpanded.current = detailsExpanded; + setDetailsExpanded(false); setPendingFindTarget(target); return; } @@ -1049,13 +1109,12 @@ export function FloorplanView({ const point = plan.circuit_points.find((candidate) => candidate.id === selectedPointId); if (!point) return; const { id: _id, ...draft } = point; + setDetailsExpanded(true); setDraftPoint(draft); setMode(move ? 'move' : 'edit'); } - function handleFloorChange(nextFloor: string) { - if (nextFloor === floor) return; - if (measuredRoom) { wallEditor.current?.requestLeave(() => { cancelMeasured(); setSelectedRoomId(null); setViewport(null); setFloor(nextFloor); }); return; } + function changeFloor(nextFloor: string) { if (mode === 'walk') setWalkCreatedIds([]); finishInteraction(); setSelectedPointId(null); @@ -1064,6 +1123,12 @@ export function FloorplanView({ setFloor(nextFloor); } + function handleFloorChange(nextFloor: string, focusTarget?: HTMLElement | null) { + if (nextFloor === floor) return; + if (measuredRoom) { wallEditor.current?.requestLeave(() => { cancelMeasured(); setSelectedRoomId(null); setViewport(null); setFloor(nextFloor); }); return; } + requestDraftLeave(() => changeFloor(nextFloor), focusTarget); + } + function beginFloorAction(action: 'create' | 'rename') { setFloorNameDraft(action === 'rename' ? floor : ''); setFloorAction(action); @@ -1136,6 +1201,7 @@ export function FloorplanView({ function startRoomCreate() { if (!currentFloor) return; + setDetailsExpanded(true); const roomFloor = floor; const [x, y] = suggestedRectangleOrigin( allRooms.filter((room) => room.floor === roomFloor), @@ -1161,6 +1227,7 @@ export function FloorplanView({ function selectRoom(room: Room) { if (mode !== 'idle') return; + setDetailsExpanded(false); setPointChoiceIds(null); setSelectedRoomId(room.id); setSelectedPointId(null); @@ -1178,6 +1245,7 @@ export function FloorplanView({ } function startMeasuredEdit(room: Room) { + setDetailsExpanded(true); setViewport(visibleViewport); setMeasuredRoom(room); setWallPreview(null); @@ -1199,6 +1267,7 @@ export function FloorplanView({ function startRoomEdit(room: Room, resize = false) { const rectangle = editableRectangle(room); if (!rectangle) return; + setDetailsExpanded(true); setRoomDraft({ roomId: room.id, name: room.name, @@ -1282,6 +1351,7 @@ export function FloorplanView({ setRoomDraft(null); setResizingRoom(false); setMode('idle'); + setDetailsExpanded(false); setError(null); try { @@ -1434,6 +1504,31 @@ export function FloorplanView({ } } + useEffect(() => { + if (!navigationGuard || measuredRoom || (!roomDraft && !draftPoint)) return; + navigationGuard.current = (proceed) => requestDraftLeave(proceed); + return () => { + if (!measuredRoom) navigationGuard.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [detailsExpanded, draftPoint, measuredRoom, navigationGuard, roomDraft, saving]); + + useEffect(() => { + if (!roomDraft && !draftPoint) return; + const warnBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault(); + window.addEventListener('beforeunload', warnBeforeUnload); + return () => window.removeEventListener('beforeunload', warnBeforeUnload); + }, [draftPoint, roomDraft]); + + useEffect(() => { + if (draftPoint && !hadDraftPoint.current) setDetailsExpanded(true); + hadDraftPoint.current = draftPoint !== null; + }, [draftPoint]); + + useEffect(() => { + if (leaveConfirmationOpen) leaveConfirmationRef.current?.focus(); + }, [leaveConfirmationOpen]); + const selectedPoint = plan.circuit_points.find((p) => p.id === selectedPointId) ?? null; const selectedRoom = allRooms.find((room) => room.id === selectedRoomId) ?? null; const selectedPanel = panels.find((panel) => panel.id === selectedPanelId) ?? null; @@ -1462,6 +1557,28 @@ export function FloorplanView({ ...displayedPoints.filter((point) => point.id === selectedPointId), ]; const markerRadius = Math.max(bounds.width, bounds.height) * 0.018; + const detailsTitle = measuredRoom + ? `Editing ${measuredRoom.name}` + : roomDraft + ? `${roomDraft.roomId == null ? 'Adding' : 'Editing'} ${roomDraft.name || 'room'}` + : draftPoint + ? `${activeEdit ? 'Editing' : mode === 'walk' ? 'Walking' : 'Adding'} ${draftPoint.label?.trim() || draftPoint.kind}` + : mode === 'walk' + ? `Circuit walk · ${walkCircuitId === '' ? 'Choose a breaker' : circuitLabel(walkCircuitId)}` + : selectedPoint + ? selectedPoint.label?.trim() || selectedPoint.kind + : selectedCircuit + ? `${selectedCircuitPanel?.name ?? 'Unknown panel'} · Breaker ${selectedCircuit.breaker_label}` + : selectedPanel + ? selectedPanel.name + : selectedRoom + ? selectedRoom.name + : 'Browse circuits'; + const detailsContext = selectedPoint && !draftPoint + ? circuitLabel(selectedPoint.circuit_id) + : selectedRoom && !roomDraft + ? selectedRoom.floor + : null; return (
{ @@ -1475,6 +1592,37 @@ export function FloorplanView({ >
{error &&

{error}

} + {leaveConfirmationOpen && ( +
{ + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + stayWithDraft(); + } + }} + > +

Leave this draft?

+

+ {roomDraft + ? 'Save the room draft, discard it, or stay and keep editing.' + : mode === 'walk' + ? 'Earlier points in this walk are already saved. Save or discard only the current point draft, or stay.' + : 'Save the point draft, discard it, or stay and keep editing.'} +

+
+ + + +
+
+ )} { if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); setPendingFindTarget(null); + setDetailsExpanded(findWasExpanded.current); document.getElementById('floorplan-find-query')?.focus(); } }} @@ -1517,6 +1668,7 @@ export function FloorplanView({ type="button" onClick={() => { setPendingFindTarget(null); + setDetailsExpanded(findWasExpanded.current); document.getElementById('floorplan-find-query')?.focus(); }} > @@ -1530,8 +1682,8 @@ export function FloorplanView({ Floor:{' '}