From 2feb79ed257b71cfcafa961d69aa611c7bdd6a37 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Sat, 26 Sep 2026 23:48:03 -0700 Subject: [PATCH 1/7] feat(web): refine page layouts and typography controls Improve drive calendar and charging history density, streamline appearance settings, expand typography presets, and unify dashboard layout creation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/features/catalogue-settings.md | 6 + docs/features/dashboard.md | 2 +- internal/api/settings/font_validation_test.go | 26 +++++ internal/api/settings/handler.go | 18 ++- .../appearance-consolidation.smoke.spec.ts | 37 +++++++ web/e2e/charging-history-layout.smoke.spec.ts | 32 ++++++ web/e2e/dashboard-layouts.smoke.spec.ts | 52 +++++++-- web/e2e/drive-calendar-layout.smoke.spec.ts | 49 +++++++++ web/e2e/mockApi.ts | 35 ++++++ web/e2e/typography-studio.smoke.spec.ts | 41 +++++++ web/index.html | 17 ++- web/src/components/ui/FontProvider.test.tsx | 13 +++ web/src/components/ui/FontProvider.tsx | 47 +++++++- web/src/components/ui/ThemePicker.test.tsx | 22 +++- web/src/components/ui/ThemePicker.tsx | 52 ++++++--- .../drive-calendar/DriveCalendarHeatmap.tsx | 4 +- .../pages/DriveCalendarPage.test.tsx | 21 +++- .../analytics/pages/DriveCalendarPage.tsx | 24 ++-- .../pages/TeslaChargingHistoryPage.tsx | 31 +++--- .../TeslaChargingHistoryPage.test.tsx | 15 +++ .../dashboard/components/TemplateGallery.tsx | 9 +- .../components/WidgetPicker.test.tsx | 29 ++--- .../dashboard/components/WidgetPicker.tsx | 41 ------- .../__tests__/TemplateGallery.test.tsx | 18 +-- .../hooks/useDashboardLayout.test.ts | 35 ++++++ .../dashboard/hooks/useDashboardLayout.ts | 26 ++++- .../dashboard/pages/DashboardPage.tsx | 22 ++-- .../components/AppearanceSettings.test.tsx | 34 ++++++ .../components/AppearanceSettings.tsx | 28 +++-- .../settings/components/FontFamilyPicker.tsx | 104 ++++++++++++++++++ .../components/TypographySettings.test.tsx | 76 +++++++++++-- .../components/TypographySettings.tsx | 93 ++++++---------- .../components/TypographySpecimen.tsx | 53 +++++++++ .../settings/components/fontChoices.ts | 54 +++++++++ .../features/settings/pages/SettingsPage.tsx | 16 +-- web/src/i18n/en.json | 78 ++++++++++++- web/src/i18n/en/locale-dashboard.json | 75 ++++++++++++- web/src/i18n/en/locale-detail-settings.json | 64 ++++++++++- web/src/i18n/en/locale-detail-theme.json | 3 + web/src/i18n/en/shell.json | 3 +- web/src/i18n/en/usage-manifest.json | 6 +- 41 files changed, 1153 insertions(+), 258 deletions(-) create mode 100644 internal/api/settings/font_validation_test.go create mode 100644 web/e2e/appearance-consolidation.smoke.spec.ts create mode 100644 web/e2e/charging-history-layout.smoke.spec.ts create mode 100644 web/e2e/drive-calendar-layout.smoke.spec.ts create mode 100644 web/e2e/typography-studio.smoke.spec.ts create mode 100644 web/src/features/settings/components/FontFamilyPicker.tsx create mode 100644 web/src/features/settings/components/TypographySpecimen.tsx create mode 100644 web/src/features/settings/components/fontChoices.ts diff --git a/docs/features/catalogue-settings.md b/docs/features/catalogue-settings.md index b5379df5f1..810d31da6e 100644 --- a/docs/features/catalogue-settings.md +++ b/docs/features/catalogue-settings.md @@ -15,6 +15,12 @@ and `/settings#appearance` continue to work. Switching categories preserves unsaved form edits and does not save automatically. Each section retains its own save or instant-apply behavior. +**Fonts & readability** at `/settings#typography` offers a searchable UI and +monospace font library, live font samples, and a text field for trying your own +copy. Font selection, reading presets, scale, line height, letter spacing, and +heading weight apply across the app immediately. Selected fonts are persisted +with the rest of the settings and restored before the first page paint. + Reset controls live in **Reset & recovery**, separate from everyday preferences. The overview retains the current-preference summary, export link, guided-tour launcher, and setup-checklist restart. diff --git a/docs/features/dashboard.md b/docs/features/dashboard.md index c37388222d..8b712375e9 100644 --- a/docs/features/dashboard.md +++ b/docs/features/dashboard.md @@ -42,7 +42,7 @@ Widgets are self-rendering: each one declares which signals it depends on, and i ## Multiple dashboards -Use **Layout packs** in the dashboard header to preview and install a ready-made dashboard without replacing your current one, or choose **Blank Dashboard** to start with no widgets. The **Personal workspace** controls let you switch, rename, duplicate, and create dashboards; **Customize** reveals the drag handles and always-visible **Arrange**, settings, and remove controls. **Auto Arrange** packs widgets into rows without resetting their chosen widths or heights. Each dashboard has its own widgets and layout. Common patterns: +Use **New Layout** in the dashboard header or the Personal workspace strip to create a dashboard without replacing your current one. Choose **Blank Dashboard** to start with no widgets, or preview a populated template such as Operations Desk, Battery Care, Winter Ready, Daily Commuter, or Fleet Manager. The template creates its widgets and responsive grid positions together; use **Add Widget** under **Customize** only to add individual widgets to the current layout. The **Personal workspace** controls let you switch, rename, and duplicate layouts; **Customize** reveals the drag handles and always-visible **Arrange**, settings, and remove controls. **Auto Arrange** packs widgets into rows without resetting their chosen widths or heights. Each dashboard has its own widgets and layout. Common patterns: - **Daily** — Vehicle Hero, Battery Level, Quick Actions, Recent Alerts - **Long trip** — Live Drive, Charger Map, Range Bar, Tire Pressure, Energy Flow diff --git a/internal/api/settings/font_validation_test.go b/internal/api/settings/font_validation_test.go new file mode 100644 index 0000000000..351c8b3ec1 --- /dev/null +++ b/internal/api/settings/font_validation_test.go @@ -0,0 +1,26 @@ +package settings + +import "testing" + +func TestFontPresetsAcceptedBySettings(t *testing.T) { + for _, id := range []string{ + "inter", "system", "roboto", "source", "plex", "atkinson", "nunito", + "dm-sans", "manrope", "outfit", "poppins", "work-sans", "public-sans", + "lato", "open-sans", "noto-sans", "custom", + } { + if !validFontFamily[id] { + t.Errorf("UI font %q must be accepted by settings", id) + } + } + for _, id := range []string{ + "jetbrains", "fira", "plex-mono", "source-code-pro", "roboto-mono", + "inconsolata", "space-mono", "ubuntu-mono", "system", "custom", + } { + if !validFontMono[id] { + t.Errorf("monospace font %q must be accepted by settings", id) + } + } + if validFontFamily["unknown"] || validFontMono["unknown"] { + t.Fatal("unrecognized font IDs must be rejected") + } +} diff --git a/internal/api/settings/handler.go b/internal/api/settings/handler.go index 9c61f1a260..beecd4e922 100644 --- a/internal/api/settings/handler.go +++ b/internal/api/settings/handler.go @@ -29,6 +29,18 @@ type SettingsHandler struct { telemetryHandler any } +var validFontFamily = map[string]bool{ + "inter": true, "system": true, "roboto": true, "source": true, "plex": true, "atkinson": true, + "nunito": true, "dm-sans": true, "manrope": true, "outfit": true, "poppins": true, + "work-sans": true, "public-sans": true, "lato": true, "open-sans": true, "noto-sans": true, "custom": true, +} + +var validFontMono = map[string]bool{ + "jetbrains": true, "fira": true, "plex-mono": true, "source-code-pro": true, + "roboto-mono": true, "inconsolata": true, "space-mono": true, "ubuntu-mono": true, + "system": true, "custom": true, +} + type endpointControlsStore interface { GetEndpointControls(context.Context) (settingsmodel.LegacyPollingConfig, error) UpsertEndpointControls(context.Context, settingsmodel.LegacyPollingConfig) error @@ -130,14 +142,12 @@ func (h *SettingsHandler) Update(w http.ResponseWriter, r *http.Request) { // ── Typography validation (Typography Unit 0) ── // Mirror the union types + slider bounds in FontProvider.tsx. Empty values // are tolerated (a partial round-trip keeps the stored default). - validFontFamily := map[string]bool{"inter": true, "system": true, "roboto": true, "source": true, "plex": true, "atkinson": true, "custom": true} if s.FontFamily != "" && !validFontFamily[s.FontFamily] { - httpx.WriteError(w, http.StatusBadRequest, "font_family must be one of inter/system/roboto/source/plex/atkinson/custom") + httpx.WriteError(w, http.StatusBadRequest, "font_family must be a supported UI font") return } - validFontMono := map[string]bool{"jetbrains": true, "fira": true, "plex-mono": true, "system": true, "custom": true} if s.FontMono != "" && !validFontMono[s.FontMono] { - httpx.WriteError(w, http.StatusBadRequest, "font_mono must be one of jetbrains/fira/plex-mono/system/custom") + httpx.WriteError(w, http.StatusBadRequest, "font_mono must be a supported monospace font") return } if len(s.FontCustomSans) > 200 || len(s.FontCustomMono) > 200 { diff --git a/web/e2e/appearance-consolidation.smoke.spec.ts b/web/e2e/appearance-consolidation.smoke.spec.ts new file mode 100644 index 0000000000..4e324b1fe0 --- /dev/null +++ b/web/e2e/appearance-consolidation.smoke.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test' +import { assertMockApiComplete, installApiMocks, seedBrowserState, waitForHarnessReady } from './mockApi' +import { expectNoHorizontalOverflow } from './qualityAssertions' + +for (const width of [390, 1920]) { + for (const theme of ['dark', 'light'] as const) { + test(`appearance keeps everyday controls concise at ${width}px in ${theme}`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 900 }) + await seedBrowserState(page, theme, '/settings') + const api = await installApiMocks(page, 'populated', theme) + await page.goto('/settings#appearance', { waitUntil: 'domcontentloaded' }) + await waitForHarnessReady(page, api) + + const appearance = page.locator('#appearance') + await expect(appearance).toBeVisible() + await expect(appearance.getByText('Display Mode', { exact: true })).toBeVisible() + await expect(page.getByText('Information density', { exact: true })).toBeVisible() + const browse = appearance.getByRole('button', { name: /Browse all \d+ modes/ }) + await expect(browse).toHaveAttribute('aria-expanded', 'false') + await expect(appearance.getByRole('searchbox', { name: 'Search display modes…' })).toHaveCount(0) + const more = page.getByRole('button', { name: 'More appearance options' }) + await expect(more).toHaveAttribute('aria-expanded', 'false') + await expect(page.getByRole('radiogroup', { name: 'Chart palette' })).toHaveCount(0) + await expectNoHorizontalOverflow(page) + await page.screenshot({ path: testInfo.outputPath(`appearance-${theme}-${width}.png`) }) + + await browse.click() + await expect(appearance.getByRole('searchbox', { name: 'Search display modes…' })).toBeVisible() + await appearance.getByRole('button', { name: 'Show essential modes' }).click() + await more.click() + await expect(page.getByRole('radiogroup', { name: 'Chart palette' })).toBeVisible() + await expect(page.getByTestId('statusbar-toggle-enabled')).toBeVisible() + await expectNoHorizontalOverflow(page) + await assertMockApiComplete(page, api) + }) + } +} diff --git a/web/e2e/charging-history-layout.smoke.spec.ts b/web/e2e/charging-history-layout.smoke.spec.ts new file mode 100644 index 0000000000..e066564b6c --- /dev/null +++ b/web/e2e/charging-history-layout.smoke.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test' +import { assertMockApiComplete, installApiMocks, seedBrowserState, waitForHarnessReady } from './mockApi' +import { expectNoHorizontalOverflow } from './qualityAssertions' + +for (const width of [1200, 1920]) { + test(`charging history uses space below spending at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }) + await seedBrowserState(page, 'dark', '/tesla-charging-history') + const api = await installApiMocks(page, 'populated', 'dark') + await page.goto('/tesla-charging-history', { waitUntil: 'domcontentloaded' }) + await waitForHarnessReady(page, api) + + const panel = (title: string) => page.getByText(title, { exact: true }).first() + .locator('xpath=ancestor::*[@data-print-card][1]') + const [spending, locations, radar, sessions] = await Promise.all( + ['Monthly Spending', 'Top Locations', 'Price Radar', 'Charging Sessions'] + .map(async title => panel(title).boundingBox()), + ) + for (const box of [spending, locations, radar, sessions]) expect(box).not.toBeNull() + if (width === 1920) { + expect(sessions!.x).toBeCloseTo(spending!.x, 0) + expect(radar!.x).toBeGreaterThan(sessions!.x + sessions!.width) + expect(Math.abs(sessions!.y - radar!.y)).toBeLessThan(2) + expect(locations!.x).toBeCloseTo(radar!.x, 0) + } else { + expect(sessions!.y).toBeGreaterThan(radar!.y + radar!.height) + expect(sessions!.width).toBeGreaterThan(spending!.width * 0.9) + } + await expectNoHorizontalOverflow(page) + await assertMockApiComplete(page, api) + }) +} diff --git a/web/e2e/dashboard-layouts.smoke.spec.ts b/web/e2e/dashboard-layouts.smoke.spec.ts index 6cfce3c856..555efe804f 100644 --- a/web/e2e/dashboard-layouts.smoke.spec.ts +++ b/web/e2e/dashboard-layouts.smoke.spec.ts @@ -3,7 +3,7 @@ import { assertMockApiComplete, expectThemeApplied, fulfillApiMock, installApiMo for (const theme of ['dark', 'light'] as const) { for (const width of [390, 1440]) { - test(`layout packs stay readable and blank dashboards survive reopening at ${width}px in ${theme} mode`, async ({ page }, testInfo) => { + test(`new layout gallery stays readable and blank dashboards survive reopening at ${width}px in ${theme} mode`, async ({ page }, testInfo) => { await page.setViewportSize({ width, height: 900 }) await seedBrowserState(page, theme, '/') const mockApi = await installApiMocks(page, 'populated', theme) @@ -11,8 +11,8 @@ for (const theme of ['dark', 'light'] as const) { await waitForHarnessReady(page, mockApi) await expectThemeApplied(page, theme) - await page.getByRole('button', { name: 'Layout packs' }).first().click() - const gallery = page.getByRole('dialog', { name: 'Layout packs' }) + await page.getByRole('button', { name: 'New Layout' }).first().click() + const gallery = page.getByRole('dialog', { name: 'Create a layout' }) await expect(gallery).toBeVisible() await expect(gallery.getByRole('heading', { name: 'Default' })).toBeVisible() const preview = gallery.getByTestId('mini-grid-preview').first() @@ -62,16 +62,16 @@ for (const theme of ['dark', 'light'] as const) { } } -test('installs a layout pack and restores its widgets in a new browser', async ({ page, browser }) => { +test('creates a populated layout and restores its widgets in a new browser', async ({ page, browser }) => { await seedBrowserState(page, 'dark', '/') const mockApi = await installApiMocks(page) await page.goto('/', { waitUntil: 'domcontentloaded' }) - await page.getByRole('button', { name: 'Layout packs' }).first().click() - const gallery = page.getByRole('dialog', { name: 'Layout packs' }) + await page.getByRole('button', { name: 'New Layout' }).first().click() + const gallery = page.getByRole('dialog', { name: 'Create a layout' }) await gallery.getByRole('button', { name: /Daily Commuter/ }).click() await page.getByRole('dialog', { name: 'Template Preview' }) - .getByRole('button', { name: 'Install as new dashboard' }).click() + .getByRole('button', { name: 'Create layout with these widgets' }).click() await expect(page.getByRole('button', { name: 'Switch dashboard layout' })).toContainText('Daily Commuter') const saved = await page.evaluate(() => ({ @@ -111,6 +111,44 @@ test('installs a layout pack and restores its widgets in a new browser', async ( } }) +test('mobile New Layout creates populated Operations Desk without mixing templates into Add Widget', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await seedBrowserState(page, 'dark', '/') + const mockApi = await installApiMocks(page) + await page.route('**/api/v1/analytics/fleet?**', (route) => + fulfillApiMock(route, mockApi, { + status: 200, + contentType: 'application/json', + body: JSON.stringify({ total_vehicles: 0, total_drives: 0, total_distance_m: 0 }), + }), + ) + await page.goto('/', { waitUntil: 'domcontentloaded' }) + await waitForHarnessReady(page, mockApi) + + await page.getByRole('button', { name: 'New Layout' }).first().click() + const gallery = page.getByRole('dialog', { name: 'Create a layout' }) + await expect(gallery.getByRole('button', { name: /Blank Dashboard/ })).toBeVisible() + await gallery.getByRole('button', { name: /Operations Desk/ }).click() + await expect(page.getByRole('dialog', { name: 'Template Preview' })).toContainText('Fleet Posture') + await page.getByRole('button', { name: 'Create layout with these widgets' }).click() + await expect(page.getByRole('button', { name: 'Switch dashboard layout' })).toContainText('Operations Desk') + const widgets = await page.evaluate(() => { + const dashboards = JSON.parse(localStorage.getItem('teslasync-dashboards') ?? '[]') as + Array<{ id: string; widgets: Array<{ widgetId: string }>; layouts: Record }> + return dashboards.find((d) => d.id === localStorage.getItem('teslasync-active-dashboard')) + }) + expect(widgets?.widgets.map((widget) => widget.widgetId)).toContain('fleet-posture') + expect(widgets?.layouts.xs).toHaveLength(widgets?.widgets.length) + + await page.getByRole('button', { name: 'Customize' }).click() + await page.getByRole('button', { name: 'Add Widget' }).click() + const picker = page.getByRole('dialog', { name: 'Add Widget' }) + await expect(picker.getByText('Layout Presets')).toHaveCount(0) + await expect(picker.getByRole('textbox', { name: 'Search widgets' })).toBeVisible() + await waitForHarnessReady(page, mockApi) + await assertMockApiComplete(page, mockApi) +}) + for (const width of [390, 1440]) { test(`new dashboards show fleet posture in the ${width}px widget grid without a fixed brief`, async ({ page }, testInfo) => { await page.setViewportSize({ width, height: 900 }) diff --git a/web/e2e/drive-calendar-layout.smoke.spec.ts b/web/e2e/drive-calendar-layout.smoke.spec.ts new file mode 100644 index 0000000000..a383c7cf43 --- /dev/null +++ b/web/e2e/drive-calendar-layout.smoke.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from '@playwright/test'; +import { assertMockApiComplete, installApiMocks, seedBrowserState, waitForHarnessReady } from './mockApi'; +import { expectNoHorizontalOverflow } from './qualityAssertions'; + +for (const [width, range, compact] of [ + [1920, 'from=2026-07-01&to=2026-09-13', true], + [1920, 'from=2025-01-01&to=2025-12-31', false], + [390, 'from=2026-07-01&to=2026-09-13', true], +] as const) { + test(`drive calendar activity fits ${width}px with ${compact ? 'short' : 'year-long'} period`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await seedBrowserState(page, 'light', '/drive-calendar'); + const mockApi = await installApiMocks(page, 'populated', 'light'); + await page.goto(`/drive-calendar?${range}`, { waitUntil: 'domcontentloaded' }); + await waitForHarnessReady(page, mockApi); + + const activity = page.getByRole('region', { name: 'Driving activity' }); + const [heatmap, monthly, weekday] = await Promise.all( + [0, 1, 2].map((index) => activity.locator('[data-print-card]').nth(index).boundingBox()), + ); + const bounds = await activity.boundingBox(); + expect(heatmap).not.toBeNull(); + expect(monthly).not.toBeNull(); + expect(weekday).not.toBeNull(); + expect(bounds).not.toBeNull(); + if (compact) { + const dayCells = activity.getByRole('img', { name: /Daily driving heatmap/ }).locator('[title]'); + expect(await dayCells.count()).toBeGreaterThan(0); + expect(await dayCells.evaluateAll(cells => cells.some(cell => /[1-9]\d* drives/.test(cell.getAttribute('title') ?? '')))) + .toBe(true); + } + + if (width === 1920 && compact) { + expect(heatmap!.width).toBeLessThan(bounds!.width * 0.4); + expect(monthly!.x).toBeGreaterThan(heatmap!.x + heatmap!.width); + expect(weekday!.x).toBeGreaterThan(monthly!.x + monthly!.width); + expect(Math.abs(heatmap!.y - monthly!.y)).toBeLessThan(2); + expect(Math.abs(heatmap!.y - weekday!.y)).toBeLessThan(2); + } else if (width === 1920) { + expect(heatmap!.width).toBeGreaterThan(bounds!.width * 0.9); + expect(monthly!.y).toBeGreaterThan(heatmap!.y + heatmap!.height); + } else { + expect(monthly!.y).toBeGreaterThan(heatmap!.y + heatmap!.height); + expect(weekday!.y).toBeGreaterThan(monthly!.y + monthly!.height); + } + await expectNoHorizontalOverflow(page); + await assertMockApiComplete(page, mockApi); + }); +} diff --git a/web/e2e/mockApi.ts b/web/e2e/mockApi.ts index f22f60a953..96b620d5fc 100644 --- a/web/e2e/mockApi.ts +++ b/web/e2e/mockApi.ts @@ -136,6 +136,22 @@ const charging = { live: false, }; +const chargingHistorySites = ['Fremont', 'Gilroy', 'Bakersfield', 'Kettleman City']; +const chargingHistoryEntries = chargingHistorySites.map((site, index) => ({ + id: index + 1, + session_id: index + 100, + vin: vehicle.vin, + site_location_name: `Supercharger - ${site}`, + charge_start_datetime: `2026-08-${25 - index}T10:00:00Z`, + charge_stop_datetime: `2026-08-${25 - index}T11:00:00Z`, + usage_wh: 40000, + total_due: 12 + index, + currency_code: 'USD', + rate_base: 0.3, + pricing_type: 'kWh', + invoice_content_id: null, +})); + const repairCase = { id: 301, fingerprint: 'mock-drive-101', kind: 'drive', session_id: 101, related_session_id: null, vehicle_id: 7, rule: 'missing_end_boundary', @@ -565,6 +581,25 @@ export function resolveApiFixture( : { ...charging, started_at: scenario === 'stale' ? STALE : charging.started_at }; return matched(listFor(scenario, value)); } + if (path.startsWith('/tesla/charging/history/sites')) { + return matched({ + sites: scenario === 'empty' ? [] : chargingHistorySites.map((site, index) => ({ + site: `Supercharger - ${site}`, + visits: 2, + last_visit: '2026-08-25', + avg_per_kwh: 0.3 + index * 0.05, + })), + unpriced_count: 0, + }); + } + if (path.startsWith('/tesla/charging/history')) { + return matched({ + entries: scenario === 'empty' ? [] : chargingHistoryEntries, + summary: scenario === 'empty' + ? { total_sessions: 0, total_wh: 0, total_spend: 0, avg_cost_per_kwh: 0 } + : { total_sessions: 4, total_wh: 160000, total_spend: 54, avg_cost_per_kwh: 0.34 }, + }); + } if (path.startsWith('/analytics/fsd')) { return matched(fsdInsightsFixture(scenario)); } diff --git a/web/e2e/typography-studio.smoke.spec.ts b/web/e2e/typography-studio.smoke.spec.ts new file mode 100644 index 0000000000..ed896a364c --- /dev/null +++ b/web/e2e/typography-studio.smoke.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from '@playwright/test' +import { assertMockApiComplete, installApiMocks, seedBrowserState, waitForHarnessReady } from './mockApi' +import { expectNoHorizontalOverflow } from './qualityAssertions' + +for (const width of [390, 1920]) { + for (const theme of ['dark', 'light'] as const) { + test(`typography studio previews and applies fonts at ${width}px in ${theme}`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 900 }) + await seedBrowserState(page, theme, '/settings') + const api = await installApiMocks(page, 'populated', theme) + await page.goto('/settings#typography', { waitUntil: 'domcontentloaded' }) + await waitForHarnessReady(page, api) + + const preview = page.getByRole('group', { name: 'Typography preview' }) + await expect(preview).toBeVisible() + await page.getByRole('textbox', { name: 'Try your own text' }).fill('Road trips & range') + await expect(preview.locator('p').filter({ hasText: 'Road trips & range' })).toBeVisible() + + const sans = page.getByRole('group', { name: 'UI font' }) + await sans.getByRole('searchbox', { name: 'Search UI font' }).fill('noto') + await expect(sans.getByRole('button')).toHaveCount(1) + await sans.getByRole('button', { name: /Noto Sans/ }).click() + await expect(page.locator('html')).toHaveCSS('--font-sans', /Noto Sans/) + + const mono = page.getByRole('group', { name: 'Monospace font' }) + await mono.getByRole('searchbox', { name: 'Search Monospace font' }).fill('inconsolata') + await expect(mono.getByRole('button')).toHaveCount(1) + await mono.getByRole('button', { name: /Inconsolata/ }).click() + await expect(page.locator('html')).toHaveCSS('--font-mono', /Inconsolata/) + + await page.getByRole('button', { name: 'High legibility' }).click() + await expect(page.locator('html')).toHaveCSS('--font-scale', '1.05') + await expect(sans.getByRole('searchbox', { name: 'Search UI font' })).toHaveValue('') + await expect(sans.getByRole('button', { name: /Atkinson Hyperlegible/ })) + .toHaveAttribute('aria-pressed', 'true') + await expectNoHorizontalOverflow(page) + await page.screenshot({ path: testInfo.outputPath(`typography-${theme}-${width}.png`) }) + await assertMockApiComplete(page, api) + }) + } +} diff --git a/web/index.html b/web/index.html index 5661a9e635..46d07213f9 100644 --- a/web/index.html +++ b/web/index.html @@ -19,12 +19,27 @@ roboto: "'Roboto', system-ui, -apple-system, sans-serif", source: "'Source Sans 3', system-ui, -apple-system, sans-serif", plex: "'IBM Plex Sans', system-ui, -apple-system, sans-serif", - atkinson: "'Atkinson Hyperlegible', system-ui, -apple-system, sans-serif" + atkinson: "'Atkinson Hyperlegible', system-ui, -apple-system, sans-serif", + nunito: "'Nunito', system-ui, -apple-system, sans-serif", + "dm-sans": "'DM Sans', system-ui, -apple-system, sans-serif", + manrope: "'Manrope', system-ui, -apple-system, sans-serif", + outfit: "'Outfit', system-ui, -apple-system, sans-serif", + poppins: "'Poppins', system-ui, -apple-system, sans-serif", + "work-sans": "'Work Sans', system-ui, -apple-system, sans-serif", + "public-sans": "'Public Sans', system-ui, -apple-system, sans-serif", + lato: "'Lato', system-ui, -apple-system, sans-serif", + "open-sans": "'Open Sans', system-ui, -apple-system, sans-serif", + "noto-sans": "'Noto Sans', system-ui, -apple-system, sans-serif" }; var MONO = { jetbrains: "'JetBrains Mono', 'Fira Code', ui-monospace, monospace", fira: "'Fira Code', ui-monospace, monospace", "plex-mono": "'IBM Plex Mono', ui-monospace, monospace", + "source-code-pro": "'Source Code Pro', ui-monospace, monospace", + "roboto-mono": "'Roboto Mono', ui-monospace, monospace", + inconsolata: "'Inconsolata', ui-monospace, monospace", + "space-mono": "'Space Mono', ui-monospace, monospace", + "ubuntu-mono": "'Ubuntu Mono', ui-monospace, monospace", system: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" }; var sansId = ls.getItem('teslasync-font-family'); diff --git a/web/src/components/ui/FontProvider.test.tsx b/web/src/components/ui/FontProvider.test.tsx index 5d8336b136..e3eef746c8 100644 --- a/web/src/components/ui/FontProvider.test.tsx +++ b/web/src/components/ui/FontProvider.test.tsx @@ -181,6 +181,17 @@ describe('applyFontCSS', () => { }), ).toBeNull() }) + + it('provides a real family stylesheet for every curated non-system preset', () => { + for (const sans of SANS_FAMILY_IDS.filter(id => id !== 'custom' && id !== 'system')) { + expect(resolveSansStack(sans, '')).toContain('sans-serif') + expect(fontStylesheetHref({ ...DEFAULT_FONT_PREFS, sans, mono: 'system' })).toContain('family=') + } + for (const mono of MONO_FAMILY_IDS.filter(id => id !== 'custom' && id !== 'system')) { + expect(resolveMonoStack(mono, '')).toContain('monospace') + expect(fontStylesheetHref({ ...DEFAULT_FONT_PREFS, sans: 'system', mono })).toContain('family=') + } + }) }) it('resolves a custom sans stack into --font-sans', () => { @@ -261,6 +272,8 @@ describe('exported constants + presets', () => { expect(SANS_FAMILY_IDS).toContain('custom') expect(MONO_FAMILY_IDS).toContain('jetbrains') expect(MONO_FAMILY_IDS).toContain('custom') + expect(SANS_FAMILY_IDS).toHaveLength(17) + expect(MONO_FAMILY_IDS).toHaveLength(10) }) it('exposes a sane scale range and slider presets', () => { diff --git a/web/src/components/ui/FontProvider.tsx b/web/src/components/ui/FontProvider.tsx index 92496991b5..bfdceaff5c 100644 --- a/web/src/components/ui/FontProvider.tsx +++ b/web/src/components/ui/FontProvider.tsx @@ -29,9 +29,12 @@ import { TOPICS } from '@/lib/broadcastTopics' // ───────────────────────────────────────────────────────────────────────────── /** Curated UI font presets plus a free-text custom stack. */ -export type FontFamilyId = 'inter' | 'system' | 'roboto' | 'source' | 'plex' | 'atkinson' | 'custom' +export type FontFamilyId = 'inter' | 'system' | 'roboto' | 'source' | 'plex' | 'atkinson' + | 'nunito' | 'dm-sans' | 'manrope' | 'outfit' | 'poppins' | 'work-sans' + | 'public-sans' | 'lato' | 'open-sans' | 'noto-sans' | 'custom' /** Curated monospace presets plus a free-text custom stack. */ -export type MonoFamilyId = 'jetbrains' | 'fira' | 'plex-mono' | 'system' | 'custom' +export type MonoFamilyId = 'jetbrains' | 'fira' | 'plex-mono' | 'source-code-pro' + | 'roboto-mono' | 'inconsolata' | 'space-mono' | 'ubuntu-mono' | 'system' | 'custom' export interface FontPrefs { sans: FontFamilyId @@ -58,12 +61,27 @@ export const FONT_SANS_STACKS: Record, string> = source: "'Source Sans 3', system-ui, -apple-system, sans-serif", plex: "'IBM Plex Sans', system-ui, -apple-system, sans-serif", atkinson: "'Atkinson Hyperlegible', system-ui, -apple-system, sans-serif", + nunito: "'Nunito', system-ui, -apple-system, sans-serif", + 'dm-sans': "'DM Sans', system-ui, -apple-system, sans-serif", + manrope: "'Manrope', system-ui, -apple-system, sans-serif", + outfit: "'Outfit', system-ui, -apple-system, sans-serif", + poppins: "'Poppins', system-ui, -apple-system, sans-serif", + 'work-sans': "'Work Sans', system-ui, -apple-system, sans-serif", + 'public-sans': "'Public Sans', system-ui, -apple-system, sans-serif", + lato: "'Lato', system-ui, -apple-system, sans-serif", + 'open-sans': "'Open Sans', system-ui, -apple-system, sans-serif", + 'noto-sans': "'Noto Sans', system-ui, -apple-system, sans-serif", } export const FONT_MONO_STACKS: Record, string> = { jetbrains: "'JetBrains Mono', 'Fira Code', ui-monospace, monospace", fira: "'Fira Code', ui-monospace, monospace", 'plex-mono': "'IBM Plex Mono', ui-monospace, monospace", + 'source-code-pro': "'Source Code Pro', ui-monospace, monospace", + 'roboto-mono': "'Roboto Mono', ui-monospace, monospace", + inconsolata: "'Inconsolata', ui-monospace, monospace", + 'space-mono': "'Space Mono', ui-monospace, monospace", + 'ubuntu-mono': "'Ubuntu Mono', ui-monospace, monospace", system: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', } @@ -73,12 +91,27 @@ const GOOGLE_SANS_QUERIES: Partial> = { source: 'Source+Sans+3:wght@400;500;600;700', plex: 'IBM+Plex+Sans:wght@400;500;600;700', atkinson: 'Atkinson+Hyperlegible:wght@400;700', + nunito: 'Nunito:wght@400;500;600;700;800', + 'dm-sans': 'DM+Sans:wght@400;500;600;700;800', + manrope: 'Manrope:wght@400;500;600;700;800', + outfit: 'Outfit:wght@400;500;600;700;800', + poppins: 'Poppins:wght@400;500;600;700;800', + 'work-sans': 'Work+Sans:wght@400;500;600;700;800', + 'public-sans': 'Public+Sans:wght@400;500;600;700;800', + lato: 'Lato:wght@400;700', + 'open-sans': 'Open+Sans:wght@400;500;600;700;800', + 'noto-sans': 'Noto+Sans:wght@400;500;600;700;800', } const GOOGLE_MONO_QUERIES: Partial> = { jetbrains: 'JetBrains+Mono:wght@400;500;600', fira: 'Fira+Code:wght@400;500;600', 'plex-mono': 'IBM+Plex+Mono:wght@400;500;600', + 'source-code-pro': 'Source+Code+Pro:wght@400;500;600;700', + 'roboto-mono': 'Roboto+Mono:wght@400;500;600;700', + inconsolata: 'Inconsolata:wght@400;500;600;700', + 'space-mono': 'Space+Mono:wght@400;700', + 'ubuntu-mono': 'Ubuntu+Mono:wght@400;700', } const ACTIVE_FONT_STYLESHEET_ID = 'teslasync-active-fonts' @@ -87,8 +120,14 @@ const ACTIVE_FONT_STYLESHEET_ID = 'teslasync-active-fonts' const CUSTOM_SANS_FALLBACK = 'system-ui, -apple-system, sans-serif' const CUSTOM_MONO_FALLBACK = 'ui-monospace, monospace' -export const SANS_FAMILY_IDS: FontFamilyId[] = ['inter', 'system', 'roboto', 'source', 'plex', 'atkinson', 'custom'] -export const MONO_FAMILY_IDS: MonoFamilyId[] = ['jetbrains', 'fira', 'plex-mono', 'system', 'custom'] +export const SANS_FAMILY_IDS: FontFamilyId[] = [ + 'inter', 'system', 'roboto', 'source', 'plex', 'atkinson', 'nunito', 'dm-sans', + 'manrope', 'outfit', 'poppins', 'work-sans', 'public-sans', 'lato', 'open-sans', 'noto-sans', 'custom', +] +export const MONO_FAMILY_IDS: MonoFamilyId[] = [ + 'jetbrains', 'fira', 'plex-mono', 'source-code-pro', 'roboto-mono', + 'inconsolata', 'space-mono', 'ubuntu-mono', 'system', 'custom', +] // Text-scale slider bounds. Kept above the WCAG-AA body-size floor at 0.85×. export const FONT_SCALE_MIN = 0.85 diff --git a/web/src/components/ui/ThemePicker.test.tsx b/web/src/components/ui/ThemePicker.test.tsx index 36b09cd440..2fe8d3d899 100644 --- a/web/src/components/ui/ThemePicker.test.tsx +++ b/web/src/components/ui/ThemePicker.test.tsx @@ -198,12 +198,13 @@ beforeEach(() => { }) describe('ThemePicker — display-mode section', () => { - it('renders the grouped modes, the live count, and per-category totals', () => { + it('shows core modes first and offers the full library on demand', () => { renderPicker() expect(screen.getByText('Display Mode')).toBeInTheDocument() - expect(screen.getByText('4 modes')).toBeInTheDocument() - // Core has two modes; the uncategorised one lands in the appended "Other". + expect(screen.getByRole('button', { name: 'Browse all 4 modes' })).toHaveAttribute('aria-expanded', 'false') expect(screen.getByText('Core').textContent).toContain('(2)') + expect(screen.queryByRole('button', { name: 'Dracula' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Browse all 4 modes' })) expect(screen.getByText('Other').textContent).toContain('(1)') expect(screen.getByRole('button', { name: 'Dark' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Plain' })).toBeInTheDocument() @@ -211,6 +212,7 @@ describe('ThemePicker — display-mode section', () => { it('orders categories Core → Editor → Other via the real modeCategoryOrder', () => { renderPicker() + fireEvent.click(screen.getByRole('button', { name: 'Browse all 4 modes' })) const dark = screen.getByRole('button', { name: 'Dark' }) const dracula = screen.getByRole('button', { name: 'Dracula' }) const plain = screen.getByRole('button', { name: 'Plain' }) @@ -234,11 +236,22 @@ describe('ThemePicker — display-mode section', () => { expect(screen.getByRole('button', { name: 'Light', pressed: true })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Dark', pressed: false })).toBeInTheDocument() }) + + it('keeps a selected non-core mode visible when the catalog is collapsed', () => { + renderPicker({ initialModeId: 'dracula' }) + expect(screen.getByRole('button', { name: 'Dracula', pressed: true })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Plain' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Browse all 4 modes' })) + expect(screen.getByRole('button', { name: 'Plain' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Show essential modes' })) + expect(screen.getByRole('button', { name: 'Dracula', pressed: true })).toBeInTheDocument() + }) }) describe('ThemePicker — mode search', () => { it('filters modes by name and hides the non-matching categories', () => { renderPicker() + fireEvent.click(screen.getByRole('button', { name: 'Browse all 4 modes' })) fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'drac' } }) expect(screen.getByRole('button', { name: 'Dracula' })).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Dark' })).toBeNull() @@ -249,6 +262,7 @@ describe('ThemePicker — mode search', () => { it('also matches on the category label, not just the mode name', () => { renderPicker() + fireEvent.click(screen.getByRole('button', { name: 'Browse all 4 modes' })) fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'core' } }) expect(screen.getByRole('button', { name: 'Dark' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Light' })).toBeInTheDocument() @@ -257,6 +271,7 @@ describe('ThemePicker — mode search', () => { it('shows the empty state when nothing matches', () => { renderPicker() + fireEvent.click(screen.getByRole('button', { name: 'Browse all 4 modes' })) fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'zzzzz' } }) expect(screen.getByText('No display modes match your search.')).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Dark' })).toBeNull() @@ -363,6 +378,7 @@ describe('ThemePicker — prop toggles', () => { describe('ThemePicker — accessibility', () => { it('gives the search and colour inputs real accessible names', () => { renderPicker({ initialThemeId: 'custom' }) + fireEvent.click(screen.getByRole('button', { name: 'Browse all 4 modes' })) expect( screen.getByRole('searchbox', { name: 'Search display modes…' }), ).toBeInTheDocument() diff --git a/web/src/components/ui/ThemePicker.tsx b/web/src/components/ui/ThemePicker.tsx index 4466fe5a3f..81c1f0a424 100644 --- a/web/src/components/ui/ThemePicker.tsx +++ b/web/src/components/ui/ThemePicker.tsx @@ -12,8 +12,8 @@ import { cn } from '@/lib/cn' * * Single source of truth for the theme + mode + custom-colour UI. Used in * three places: - * 1. The full Appearance settings page (`AppearanceSettings.tsx`) — all - * sections enabled. + * 1. The Appearance settings page (`AppearanceSettings.tsx`) — core modes + * first, with the full catalog available on demand. * 2. The top-bar quick-switcher popover (Layout.tsx) — `compact` and * `showCustom={false}` to keep the popover small. * 3. The first-run dashboard banner — opens the same popover. @@ -46,6 +46,8 @@ const modeIcons: Record = { nord: , } +const essentialModes = new Set(['dark', 'light', 'auto']) + /** Fallback glyph for the 130+ generated presets that have no dedicated icon. */ function schemeIcon(scheme: 'dark' | 'light'): ReactNode { return scheme === 'light' ? : @@ -65,11 +67,15 @@ export function ThemePicker({ const [customPrimary, setCustomPrimary] = useState(() => localStorage.getItem('teslasync-custom-primary') || '#00b4d8') const [customAccent, setCustomAccent] = useState(() => localStorage.getItem('teslasync-custom-accent') || '#e63946') const [modeQuery, setModeQuery] = useState('') + const [showAllModes, setShowAllModes] = useState(false) const modeCount = Object.keys(allModes).length + const essentialModeCount = Object.values(allModes).filter(m => essentialModes.has(m.id)).length const groupedModes = useMemo(() => { const q = modeQuery.trim().toLowerCase() const list = (Object.values(allModes) as ModeTheme[]).filter( + m => showAllModes || essentialModes.has(m.id) || m.id === modeId, + ).filter( m => !q || (m.name ?? '').toLowerCase().includes(q) || (m.category ?? '').toLowerCase().includes(q), ) const byCat = new Map() @@ -85,7 +91,7 @@ export function ThemePicker({ const ib = order.indexOf(b[0]) return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib) }) - }, [allModes, modeQuery]) + }, [allModes, modeId, modeQuery, showAllModes]) const handleTheme = (id: ThemeId, name: string) => { setTheme(id) @@ -104,7 +110,7 @@ export function ThemePicker({ onChange?.('custom') } - const modeGridCols = compact ? 'grid-cols-2' : 'grid-cols-2 sm:grid-cols-4' + const modeGridCols = compact ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4' const themeGridCols = compact ? 'grid-cols-2 sm:grid-cols-3' : 'grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6' @@ -118,18 +124,17 @@ export function ThemePicker({

{t('theme.displayMode', 'Display Mode')}

- - {t('theme.modeCount', '{{count}} modes', { count: modeCount })} - - setModeQuery(e.target.value)} - placeholder={t('theme.searchModes', 'Search display modes…')} - aria-label={t('theme.searchModes', 'Search display modes…')} - className="mb-3" - /> + {showAllModes && ( + setModeQuery(e.target.value)} + placeholder={t('theme.searchModes', 'Search display modes…')} + aria-label={t('theme.searchModes', 'Search display modes…')} + className="mb-3" + /> + )}
{groupedModes.length === 0 && (

@@ -166,7 +171,7 @@ export function ThemePicker({ {modeIcons[m.id] ?? schemeIcon(m.colorScheme)}

-

{m.name}

+

{m.name}

{[m.bg, m.surface1, m.surface2, m.surface3].map((c, i) => (
))}
+ {modeCount > essentialModeCount && ( + + )}
)} diff --git a/web/src/features/analytics/components/drive-calendar/DriveCalendarHeatmap.tsx b/web/src/features/analytics/components/drive-calendar/DriveCalendarHeatmap.tsx index e1cf9f065b..322658d328 100644 --- a/web/src/features/analytics/components/drive-calendar/DriveCalendarHeatmap.tsx +++ b/web/src/features/analytics/components/drive-calendar/DriveCalendarHeatmap.tsx @@ -22,11 +22,13 @@ const LEVEL_CLASSES = [ interface DriveCalendarHeatmapProps extends DriveCalendarSectionState { calendar: DriveCalendar; + className?: string; } /** Responsive Sunday-first activity grid with month and weekday context. */ export function DriveCalendarHeatmap({ calendar, + className, isLoading, error, onRetry, @@ -36,7 +38,7 @@ export function DriveCalendarHeatmap({ const weekdayLabels = getWeekdayLabels(t); return ( - +
)}
- - + - {/* 2b — Price radar: cheapest visited sites by realized $/kWh. */} - -
-
- -
-
-
+ + + - {/* 3 — Detail band: full-width sessions table with search + bulk export. */} - + {/* Sessions use the space below the chart at wide widths. */} + {t('tesla_charging.sessions', 'Charging Sessions')} @@ -637,6 +633,7 @@ export default function TeslaChargingHistoryPage() { )} + ); } diff --git a/web/src/features/charging/pages/__tests__/TeslaChargingHistoryPage.test.tsx b/web/src/features/charging/pages/__tests__/TeslaChargingHistoryPage.test.tsx index b8afefb4a8..db8257981c 100644 --- a/web/src/features/charging/pages/__tests__/TeslaChargingHistoryPage.test.tsx +++ b/web/src/features/charging/pages/__tests__/TeslaChargingHistoryPage.test.tsx @@ -350,6 +350,21 @@ describe('TeslaChargingHistoryPage — happy path', () => { expect(screen.getByText('Charging Sessions')).toBeInTheDocument(); }); + it('fills the wide-screen space below spending with sessions alongside Price Radar', async () => { + installRequest(); + renderPage(); + await screen.findByText('Charging Sessions'); + + const spending = screen.getByText('Monthly Spending').closest('[class*="2xl:col-span-2"]'); + const sessions = screen.getByText('Charging Sessions').closest('[class*="2xl:col-span-2"]'); + const locations = screen.getByText('Top Locations').closest('[class*="2xl:col-start-3"]'); + expect(spending).toHaveClass('2xl:col-span-2'); + expect(sessions).toHaveClass('2xl:row-start-2'); + expect(locations).toHaveClass('2xl:row-start-1'); + expect(spending?.parentElement).toBe(sessions?.parentElement); + expect(sessions?.parentElement).toBe(locations?.parentElement); + }); + it('derives the KPI band from the server summary + entry list', async () => { installRequest(); renderPage(); diff --git a/web/src/features/dashboard/components/TemplateGallery.tsx b/web/src/features/dashboard/components/TemplateGallery.tsx index 863b862ddb..357f2106d3 100644 --- a/web/src/features/dashboard/components/TemplateGallery.tsx +++ b/web/src/features/dashboard/components/TemplateGallery.tsx @@ -21,6 +21,9 @@ const TEMPLATE_DESCRIPTIONS: Record = performance: { key: 'templates.performance.desc', fallback: 'Track driving performance, efficiency, and vehicle health' }, kiosk_wall: { key: 'templates.kioskWall.desc', fallback: 'Clean layout designed for always-on screens and kiosk mode' }, minimal: { key: 'templates.minimal.desc', fallback: 'Just the essentials — battery, charging, climate, and navigation' }, + battery_care: { key: 'templates.batteryCare.desc', fallback: 'Follow battery health, range, charging history, and energy use together' }, + operations: { key: 'templates.operations.desc', fallback: 'Fleet posture, alerts, recent drives, and system health for daily triage' }, + winter_ready: { key: 'templates.winterReady.desc', fallback: 'Check climate, tire pressure, weather, and range before setting off' }, }; /* ─── Template Detail View ─── */ @@ -80,7 +83,7 @@ function TemplateDetail({ - {t('templates.apply', 'Install as new dashboard')} + {t('templates.apply', 'Create layout with these widgets')}
@@ -211,7 +214,7 @@ export function TemplateGallery({ open, onClose, onApply }: TemplateGalleryProps title={ selectedTemplate ? t('templates.detail', 'Template Preview') - : t('templates.title', 'Layout packs') + : t('templates.title', 'Create a layout') } size="lg" className="sm:max-h-[80vh]" @@ -250,7 +253,7 @@ export function TemplateGallery({ open, onClose, onApply }: TemplateGalleryProps {t('templates.blank', 'Blank Dashboard')}

- {t('templates.blank.desc', 'Start from scratch and add widgets manually')} + {t('templates.blankDescription', 'Start from scratch and add widgets manually')}

diff --git a/web/src/features/dashboard/components/WidgetPicker.test.tsx b/web/src/features/dashboard/components/WidgetPicker.test.tsx index 30c79ee312..332950d9ef 100644 --- a/web/src/features/dashboard/components/WidgetPicker.test.tsx +++ b/web/src/features/dashboard/components/WidgetPicker.test.tsx @@ -2,17 +2,17 @@ * WidgetPicker tests. * * WidgetPicker is the slide-in drawer for adding widgets to a dashboard. It is - * a controlled component over the widget registry + layout presets, so the + * a controlled component over the widget registry, so the * tests exercise its full behaviour contract rather than a smoke render: * * - Visibility: renders nothing when closed, a labelled dialog when open. * - Search: filters the flat list, shows a result-count header, an empty * state for no matches, and matches by name/description/category. - * - Category pills: narrow the grouped view and hide the presets section. + * - Category pills: narrow the grouped view. * - Add semantics: a single click adds one widget; "Add all" batches the * addable (non-active) widgets; Enter adds the sole search result; * Ctrl/Cmd+Enter adds-and-closes; already-added widgets are disabled and - * inert; presets fire onApplyPreset + onClose. + * inert. * - Feedback: the footer summarises the session count with a Done action and * a screen-reader live region announces each add. * - Recently-added: persisted ids surface a section (excluding active ones), @@ -61,18 +61,16 @@ type Props = React.ComponentProps; function renderPicker(overrides: Partial = {}) { const onClose = vi.fn(); const onAddWidgets = vi.fn(); - const onApplyPreset = vi.fn(); const utils = render( , ); - return { ...utils, onClose, onAddWidgets, onApplyPreset }; + return { ...utils, onClose, onAddWidgets }; } /** The clickable widget card whose accessible name contains `desc`. */ @@ -92,7 +90,7 @@ describe('WidgetPicker', () => { expect(screen.queryByRole('textbox')).toBeNull(); }); - it('renders a labelled dialog, search field, category tabs and presets when open', () => { + it('renders only widgets in the labelled catalogue, not layout templates', () => { renderPicker(); expect(screen.getByRole('dialog', { name: 'Add Widget' })).toBeInTheDocument(); @@ -106,11 +104,12 @@ describe('WidgetPicker', () => { screen.getByRole('button', { name: 'Battery & Range', pressed: false }), ).toHaveClass('rounded-shape-sm', 'border-[var(--control-border)]'); - // The whole registry is available and the preset section is present. + // Layout creation lives in its own gallery, not among the widgets. expect( screen.getByText(new RegExp(`${WIDGET_REGISTRY.length} widgets available`)), ).toBeInTheDocument(); - expect(screen.getByText('Layout Presets')).toBeInTheDocument(); + expect(screen.queryByText('Layout Presets')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Daily Commuter/i })).not.toBeInTheDocument(); expect(widgetCard(BATTERY_GAUGE_DESC)).toBeInTheDocument(); }); @@ -141,7 +140,7 @@ describe('WidgetPicker', () => { expect(onAddWidgets).not.toHaveBeenCalled(); }); - it('filtering by category narrows the list and hides the presets section', () => { + it('filtering by category narrows the list', () => { renderPicker(); fireEvent.click( screen.getByRole('button', { name: 'Battery & Range', pressed: false }), @@ -152,7 +151,6 @@ describe('WidgetPicker', () => { expect( screen.queryByRole('button', { name: new RegExp(VEHICLE_HERO_DESC, 'i') }), ).toBeNull(); - // Presets only render on the unfiltered "All" view. expect(screen.queryByText('Layout Presets')).toBeNull(); }); @@ -170,7 +168,6 @@ describe('WidgetPicker', () => { expect( screen.queryByRole('button', { name: new RegExp(BATTERY_GAUGE_DESC, 'i') }), ).toBeNull(); - // Presets are hidden while searching. expect(screen.queryByText('Layout Presets')).toBeNull(); }); @@ -231,14 +228,6 @@ describe('WidgetPicker', () => { expect(ids).not.toContain('range-estimate'); }); - it('applies a layout preset and closes the drawer', () => { - const { onApplyPreset, onClose } = renderPicker(); - fireEvent.click(screen.getByRole('button', { name: /Daily Commuter/i })); - - expect(onApplyPreset).toHaveBeenCalledWith('commuter'); - expect(onClose).toHaveBeenCalledTimes(1); - }); - it('Ctrl+Enter on a widget card adds it and closes the drawer', () => { const { onAddWidgets, onClose } = renderPicker(); fireEvent.keyDown(widgetCard(BATTERY_GAUGE_DESC), { key: 'Enter', ctrlKey: true }); diff --git a/web/src/features/dashboard/components/WidgetPicker.tsx b/web/src/features/dashboard/components/WidgetPicker.tsx index 4636f6afb5..6fabec89b5 100644 --- a/web/src/features/dashboard/components/WidgetPicker.tsx +++ b/web/src/features/dashboard/components/WidgetPicker.tsx @@ -13,7 +13,6 @@ import { cn } from '@/lib/cn'; import { Drawer, Badge, Button as UiButton, Input as UiInput } from '@/components/ui'; import { VisuallyHidden } from '@/components/a11y'; import { WIDGET_REGISTRY } from '../widgets/registry'; -import { DASHBOARD_PRESETS } from '../hooks/useDashboardLayout'; import type { SavedDashboard, WidgetCategory, WidgetDef } from '../widgets/types'; import { MiniGridPreview } from './MiniGridPreview'; @@ -96,7 +95,6 @@ interface WidgetPickerProps { open: boolean; onClose: () => void; onAddWidgets: (widgetIds: string[]) => void; - onApplyPreset: (presetId: string) => void; activeWidgetIds: string[]; } @@ -104,7 +102,6 @@ export function WidgetPicker({ open, onClose, onAddWidgets, - onApplyPreset, activeWidgetIds, }: WidgetPickerProps) { const { t } = useTranslation('dashboard'); @@ -474,44 +471,6 @@ export function WidgetPicker({ )} - {/* Layout Presets — hide when searching or filtering by category */} - {!query && categoryFilter === 'all' && ( - <> -
-

- {t('dashboard.presets', 'Layout Presets')} -

-
- {DASHBOARD_PRESETS.map((preset) => ( - { - onApplyPreset(preset.id); - onClose(); - }} - className={cn( - 'h-auto w-full flex-col items-stretch justify-start gap-0 rounded-xl border p-3 text-left transition-all', - 'bg-white/[0.03] border-white/[0.06]', - 'hover:bg-white/[0.06] hover:border-white/[0.12] cursor-pointer', - )} - > - - {preset.name} - -

- {preset.widgets.length} {t('dashboard.widgets', 'widgets')} -

-
- ))} -
-
-
- - )} - {/* Widgets — flat list when searching, grouped by category otherwise */} {query ? ( filteredWidgets.length > 0 ? ( diff --git a/web/src/features/dashboard/components/__tests__/TemplateGallery.test.tsx b/web/src/features/dashboard/components/__tests__/TemplateGallery.test.tsx index 170c4a8c2c..704a70b569 100644 --- a/web/src/features/dashboard/components/__tests__/TemplateGallery.test.tsx +++ b/web/src/features/dashboard/components/__tests__/TemplateGallery.test.tsx @@ -129,14 +129,14 @@ describe('TemplateGallery', () => { it('renders no dialog when open=false', () => { renderGallery({ open: false }); expect(screen.queryByRole('dialog')).toBeNull(); - expect(screen.queryByRole('heading', { name: 'Layout packs' })).toBeNull(); + expect(screen.queryByRole('heading', { name: 'Create a layout' })).toBeNull(); }); it('renders the grid: dialog title, the blank option, and every preset card', () => { renderGallery(); expect(screen.getByRole('dialog')).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'Layout packs', level: 2 })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Create a layout', level: 2 })).toBeInTheDocument(); // Blank + all four fixtures each render a titled card. expect(screen.getByRole('heading', { name: 'Blank Dashboard', level: 4 })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Default', level: 4 })).toBeInTheDocument(); @@ -186,7 +186,7 @@ describe('TemplateGallery', () => { // The modal heading flips and the detail scaffold appears. expect(screen.getByRole('heading', { name: 'Template Preview', level: 2 })).toBeInTheDocument(); expect(screen.getByText('8 widgets')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Install as new dashboard' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create layout with these widgets' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument(); // Real widget names from the registry are listed. expect(screen.getByText('Vehicle Card')).toBeInTheDocument(); @@ -201,7 +201,7 @@ describe('TemplateGallery', () => { fireEvent.click(cardFor('Minimal')); expect(screen.getByText('4 widgets')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Install as new dashboard' })); + fireEvent.click(screen.getByRole('button', { name: 'Create layout with these widgets' })); expect(onApply).toHaveBeenCalledTimes(1); expect(onApply).toHaveBeenCalledWith('minimal'); @@ -213,12 +213,12 @@ describe('TemplateGallery', () => { const { onApply } = renderGallery(); fireEvent.click(cardFor('Minimal')); - expect(screen.getByRole('button', { name: 'Install as new dashboard' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create layout with these widgets' })).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Back' })); expect(screen.getByRole('heading', { name: 'Blank Dashboard', level: 4 })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Install as new dashboard' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Create layout with these widgets' })).toBeNull(); expect(onApply).not.toHaveBeenCalled(); }); @@ -249,7 +249,7 @@ describe('TemplateGallery', () => { // Drill into a preset, then simulate the parent closing via open=false only // (bypassing onClose — e.g. an Apply handler that flips its own state). fireEvent.click(cardFor('Default')); - expect(screen.getByRole('button', { name: 'Install as new dashboard' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create layout with these widgets' })).toBeInTheDocument(); rerender(); expect(screen.queryByRole('dialog')).toBeNull(); @@ -257,7 +257,7 @@ describe('TemplateGallery', () => { rerender(); // The stale detail must NOT persist — the grid is shown instead. expect(screen.getByRole('heading', { name: 'Blank Dashboard', level: 4 })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Install as new dashboard' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Create layout with these widgets' })).toBeNull(); }); it('null-safely renders a malformed preset that is missing its widgets array', () => { @@ -271,6 +271,6 @@ describe('TemplateGallery', () => { // Drilling in also survives the empty widget list. fireEvent.click(brokenCard); expect(screen.getByText('0 widgets')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Install as new dashboard' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create layout with these widgets' })).toBeInTheDocument(); }); }); diff --git a/web/src/features/dashboard/hooks/useDashboardLayout.test.ts b/web/src/features/dashboard/hooks/useDashboardLayout.test.ts index 5302a07612..b182c9db75 100644 --- a/web/src/features/dashboard/hooks/useDashboardLayout.test.ts +++ b/web/src/features/dashboard/hooks/useDashboardLayout.test.ts @@ -184,6 +184,38 @@ describe('DASHBOARD_PRESETS', () => { expect(registryIds.has(w.widgetId)).toBe(true); } expect(Object.keys(preset.layouts).sort()).toEqual([...BREAKPOINTS].sort()); + for (const [breakpoint, columns] of Object.entries(GRID_COLS)) { + const layout = preset.layouts[breakpoint]; + expect(layout).toHaveLength(preset.widgets.length); + expect(new Set(layout.map((item) => item.i)).size).toBe(layout.length); + for (const item of layout) { + expect(item.w).toBeGreaterThan(0); + expect(item.h).toBeGreaterThan(0); + expect(item.x).toBeGreaterThanOrEqual(0); + expect(item.x + item.w).toBeLessThanOrEqual(columns); + expect(item.y).toBeGreaterThanOrEqual(0); + } + for (let i = 0; i < layout.length; i++) { + for (let j = i + 1; j < layout.length; j++) { + const a = layout[i], b = layout[j]; + expect(a.x + a.w <= b.x || b.x + b.w <= a.x || + a.y + a.h <= b.y || b.y + b.h <= a.y).toBe(true); + } + } + } + } + }); + + it('offers operational, battery-care and winter-ready compositions with distinct real widgets', () => { + for (const [id, required] of [ + ['operations', 'fleet-posture'], + ['battery_care', 'battery-degradation-trend'], + ['winter_ready', 'tire-pressure-visual'], + ]) { + const widgets = DASHBOARD_PRESETS.find((preset) => preset.id === id)?.widgets ?? []; + expect(widgets.length).toBeGreaterThanOrEqual(5); + expect(widgets.map((widget) => widget.widgetId)).toContain(required); + expect(new Set(widgets.map((widget) => widget.widgetId)).size).toBe(widgets.length); } }); }); @@ -482,6 +514,9 @@ describe('useDashboardLayout — dashboard CRUD', () => { expect(id).toBeDefined(); expect(result.current.activeId).toBe(id); expect(result.current.dashboards.find((d) => d.id === id)?.name).toBe('Daily Commuter'); + expect(result.current.dashboards.find((d) => d.id === id)?.widgets.map((w) => w.widgetId)) + .toEqual(DASHBOARD_PRESETS.find((preset) => preset.id === 'commuter')?.widgets.map((w) => w.widgetId)); + expect(result.current.dashboards.find((d) => d.id === id)?.layouts.xs).toHaveLength(7); }); it('persists per-dashboard settings and icon', () => { diff --git a/web/src/features/dashboard/hooks/useDashboardLayout.ts b/web/src/features/dashboard/hooks/useDashboardLayout.ts index fee5f35eab..2b2af24dfb 100644 --- a/web/src/features/dashboard/hooks/useDashboardLayout.ts +++ b/web/src/features/dashboard/hooks/useDashboardLayout.ts @@ -321,6 +321,30 @@ export const DASHBOARD_PRESETS: SavedDashboard[] = [ { widgetId: 'climate-status' }, { widgetId: 'quick-nav' }, ]), + makePreset('battery_care', 'Battery Care', [ + { widgetId: 'battery-gauge' }, + { widgetId: 'range-estimate' }, + { widgetId: 'battery-degradation-trend' }, + { widgetId: 'charge-status-live' }, + { widgetId: 'charge-history' }, + { widgetId: 'energy-flow' }, + ]), + makePreset('operations', 'Operations Desk', [ + { widgetId: 'fleet-posture' }, + { widgetId: 'alert-feed' }, + { widgetId: 'recent-drives' }, + { widgetId: 'charge-status-live' }, + { widgetId: 'fleet-stats' }, + { widgetId: 'uptime-monitor' }, + ]), + makePreset('winter_ready', 'Winter Ready', [ + { widgetId: 'weather-at-car' }, + { widgetId: 'climate-status' }, + { widgetId: 'tire-pressure-visual' }, + { widgetId: 'battery-gauge' }, + { widgetId: 'range-estimate' }, + { widgetId: 'quick-nav' }, + ]), ]; /* ─── Migration from legacy format ─── */ @@ -700,7 +724,7 @@ export function useDashboardLayout() { const createDashboard = useCallback( (name: string, fromPreset?: SavedDashboard) => { - const id = `custom-${Date.now()}`; + const id = `custom-${generateId()}`; const base = fromPreset ?? { ...DEFAULT_DASHBOARD, widgets: [], layouts: buildDefaultLayouts([]) }; const newDash: SavedDashboard = { ...base, diff --git a/web/src/features/dashboard/pages/DashboardPage.tsx b/web/src/features/dashboard/pages/DashboardPage.tsx index 8c4901c572..b87018777e 100644 --- a/web/src/features/dashboard/pages/DashboardPage.tsx +++ b/web/src/features/dashboard/pages/DashboardPage.tsx @@ -511,20 +511,22 @@ export default function DashboardPage() { )}
- - + - {vehicleList.length > 0 && ( <> @@ -666,7 +668,6 @@ export default function DashboardPage() { onCreate={(name) => createDashboard(name)} onDuplicate={duplicateDashboard} onReset={resetToDefault} - onToggleEdit={() => setEditMode(!editMode)} onPinToVehicle={pinToVehicle} /> setShowPicker(false)} onAddWidgets={addWidgets} - onApplyPreset={applyPreset} activeWidgetIds={activeDashboard.widgets.map((w) => w.widgetId)} /> diff --git a/web/src/features/settings/components/AppearanceSettings.test.tsx b/web/src/features/settings/components/AppearanceSettings.test.tsx index 9d5192b094..1c29e32444 100644 --- a/web/src/features/settings/components/AppearanceSettings.test.tsx +++ b/web/src/features/settings/components/AppearanceSettings.test.tsx @@ -149,6 +149,10 @@ function switchIn(testId: string): HTMLElement { return within(screen.getByTestId(testId)).getByRole('switch') } +function openMoreOptions() { + fireEvent.click(screen.getByRole('button', { name: 'More appearance options' })) +} + function findPutCall() { return mockedRequest.mock.calls.find( (c) => (c[1] as RequestInit | undefined)?.method === 'PUT', @@ -188,14 +192,32 @@ describe('AppearanceSettings — structure & delegation', () => { it('renders every preference section label', () => { renderPanel() expect(screen.getByText('Information density')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Preview' })).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(screen.getByRole('button', { name: 'Preview' })) + expect(screen.getByText('Sample row — Tesla Model 3')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'More appearance options' })).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('Default time format')).not.toBeInTheDocument() + openMoreOptions() expect(screen.getByText('Default time format')).toBeInTheDocument() expect(screen.getByText('Chart palette')).toBeInTheDocument() expect(screen.getByText('Status bar')).toBeInTheDocument() expect(screen.getByText('Celebration')).toBeInTheDocument() }) + it('collapses the long tail without losing saved preferences when reopened', async () => { + renderPanel() + openMoreOptions() + expect(screen.getByRole('radiogroup', { name: 'Chart palette' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'More appearance options' })) + await waitFor(() => expect(screen.queryByRole('radiogroup', { name: 'Chart palette' })).not.toBeInTheDocument()) + openMoreOptions() + expect(within(screen.getByRole('radiogroup', { name: 'Chart palette' })) + .getByRole('radio', { name: /Color-blind safe/i })).toBeChecked() + }) + it('exposes the three option groups as named radiogroups (a11y)', () => { renderPanel() + openMoreOptions() expect( screen.getByRole('radiogroup', { name: 'Information density' }), ).toBeInTheDocument() @@ -249,6 +271,7 @@ describe('AppearanceSettings — information density', () => { describe('AppearanceSettings — default time format', () => { it('shows the persisted default as checked', () => { renderPanel() + openMoreOptions() const group = screen.getByRole('radiogroup', { name: 'Default time format' }) expect(within(group).getByRole('radio', { name: /Relative/i })).toBeChecked() expect( @@ -258,6 +281,7 @@ describe('AppearanceSettings — default time format', () => { it('persists the alternate time format via PUT /settings', async () => { renderPanel() + openMoreOptions() const group = screen.getByRole('radiogroup', { name: 'Default time format' }) fireEvent.click(within(group).getByRole('radio', { name: /Absolute/i })) @@ -272,6 +296,7 @@ describe('AppearanceSettings — default time format', () => { describe('AppearanceSettings — chart palette', () => { it('renders both palettes with their full swatch sets and the CB-safe default checked', () => { renderPanel() + openMoreOptions() const group = screen.getByRole('radiogroup', { name: 'Chart palette' }) expect( within(group).getByRole('radio', { name: /Color-blind safe/i }), @@ -287,6 +312,7 @@ describe('AppearanceSettings — chart palette', () => { it('persists the neon palette via PUT /settings', async () => { renderPanel() + openMoreOptions() const group = screen.getByRole('radiogroup', { name: 'Chart palette' }) fireEvent.click(within(group).getByRole('radio', { name: /Stylistic neon/i })) @@ -301,6 +327,7 @@ describe('AppearanceSettings — chart palette', () => { describe('AppearanceSettings — status bar (localStorage-backed)', () => { it('toggles visibility and persists the preference', () => { renderPanel() + openMoreOptions() const sw = switchIn('statusbar-toggle-enabled') expect(sw).toBeChecked() @@ -313,6 +340,7 @@ describe('AppearanceSettings — status bar (localStorage-backed)', () => { it('flips the icon-only preference while the bar is visible', () => { renderPanel() + openMoreOptions() const wrap = screen.getByTestId('statusbar-toggle-icon-only') expect(wrap).toHaveAttribute('aria-disabled', 'false') const sw = within(wrap).getByRole('switch') @@ -330,6 +358,7 @@ describe('AppearanceSettings — status bar (localStorage-backed)', () => { // Arrange: bar hidden → the icon-only sub-preference is meaningless. setStatusBarPrefs({ enabled: false, iconOnly: false }) renderPanel() + openMoreOptions() const wrap = screen.getByTestId('statusbar-toggle-icon-only') expect(wrap).toHaveAttribute('aria-disabled', 'true') @@ -349,6 +378,7 @@ describe('AppearanceSettings — status bar (localStorage-backed)', () => { describe('AppearanceSettings — achievement celebrations (localStorage-backed)', () => { it('renders the four celebration switches at their documented defaults', () => { renderPanel() + openMoreOptions() expect(switchIn('celebration-toggle-toasts')).toBeChecked() expect(switchIn('celebration-toggle-sound')).not.toBeChecked() expect(switchIn('celebration-toggle-dashboard')).toBeChecked() @@ -357,6 +387,7 @@ describe('AppearanceSettings — achievement celebrations (localStorage-backed)' it('opts into the unlock sound and persists it', () => { renderPanel() + openMoreOptions() const sw = switchIn('celebration-toggle-sound') expect(sw).not.toBeChecked() @@ -372,6 +403,7 @@ describe('AppearanceSettings — achievement celebrations (localStorage-backed)' describe('AppearanceSettings — product tours', () => { it('replays each registered tour by id', () => { renderPanel() + openMoreOptions() fireEvent.click(screen.getByTestId('replay-tour-main')) expect(mockedStartTour).toHaveBeenCalledWith('main') fireEvent.click(screen.getByTestId('replay-tour-debugger')) @@ -383,6 +415,7 @@ describe('AppearanceSettings — product tours', () => { it('resets every tour on demand', () => { renderPanel() + openMoreOptions() fireEvent.click(screen.getByTestId('reset-all-tours')) expect(mockedResetAllTours).toHaveBeenCalledTimes(1) }) @@ -410,6 +443,7 @@ describe('AppearanceSettings — degraded load', () => { it('links to dashboard kiosk settings', () => { renderPanel() + openMoreOptions() const link = screen.getByTestId('kiosk-settings-link') expect(link).toHaveAttribute('href', '/?kiosk=settings') expect(link).toHaveAccessibleName(/open kiosk settings/i) diff --git a/web/src/features/settings/components/AppearanceSettings.tsx b/web/src/features/settings/components/AppearanceSettings.tsx index d8ee92efc1..e65644c287 100644 --- a/web/src/features/settings/components/AppearanceSettings.tsx +++ b/web/src/features/settings/components/AppearanceSettings.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { GlassPanel, IconBox, ThemePicker, Toggle, Button, HelpIcon, Heading, Text, HelperText, Label, BUTTON_BASE, BUTTON_VARIANTS } from '@/components/ui' +import { Accordion, GlassPanel, IconBox, ThemePicker, Toggle, Button, HelpIcon, Heading, Text, HelperText, Label, BUTTON_BASE, BUTTON_VARIANTS } from '@/components/ui' import { PrefetchLink } from '@/components/layout' import { useToast } from '@/components/feedback/Toast' import { useSettings, useSaveSettings } from '@/api/hooks/useSettings' @@ -200,15 +200,12 @@ export function AppearanceSettings() { {t('theme.density.help', 'Affects table rows, cards, and dashboard widgets across the app.')} - {/* Live preview — uses density Tailwind utilities so it reflows - instantly when the body[data-density] attribute changes. */} -
-
-

- {t('theme.density.previewTitle', 'Preview')} -

-
-
+ +
{[ t('theme.density.previewRow1', 'Sample row — Tesla Model 3'), t('theme.density.previewRow2', 'Sample row — Tesla Model Y'), @@ -222,9 +219,17 @@ export function AppearanceSettings() {
))}
-
+ + + + + {/* Time format default */}
@@ -576,6 +581,7 @@ export function AppearanceSettings() {
+
) } diff --git a/web/src/features/settings/components/FontFamilyPicker.tsx b/web/src/features/settings/components/FontFamilyPicker.tsx new file mode 100644 index 0000000000..a9c4affe97 --- /dev/null +++ b/web/src/features/settings/components/FontFamilyPicker.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Check } from 'lucide-react' +import { Button, Input, Label, HelperText, Text } from '@/components/ui' +import { + DEFAULT_FONT_PREFS, fontStylesheetHref, + type FontFamilyId, type MonoFamilyId, +} from '@/components/ui/FontProvider' +import { cn } from '@/lib/cn' + +export interface FontChoice { + id: Id + name: string + stack: string + group?: string +} + +interface FontFamilyPickerProps { + kind: 'sans' | 'mono' + label: string + choices: FontChoice[] + selected: Id + onSelect: (id: Id) => void +} + +export function FontFamilyPicker({ + kind, label, choices, selected, onSelect, +}: FontFamilyPickerProps) { + const { t } = useTranslation('settings') + const [query, setQuery] = useState('') + const previewLinkId = `teslasync-font-preview-${kind}` + const visible = choices.filter(choice => + `${choice.name} ${choice.group ?? ''}`.toLowerCase().includes(query.trim().toLowerCase()), + ) + + useEffect(() => () => document.getElementById(previewLinkId)?.remove(), [previewLinkId]) + useEffect(() => setQuery(''), [selected]) + + const preview = (id: Id) => { + const prefs = kind === 'sans' + ? { ...DEFAULT_FONT_PREFS, sans: id as FontFamilyId, mono: 'system' as const } + : { ...DEFAULT_FONT_PREFS, mono: id as MonoFamilyId, sans: 'system' as const } + const href = fontStylesheetHref(prefs) + if (!href) return + const link = document.getElementById(previewLinkId) as HTMLLinkElement | null + if (link?.href === href) return + const next = link ?? document.createElement('link') + next.id = previewLinkId + next.rel = 'stylesheet' + next.href = href + if (!link) document.head.appendChild(next) + } + + return ( +
+
+ + + {t('typography.familyHint', 'Focus or hover to preview a font, then select it to use it everywhere.')} + +
+ setQuery(event.target.value)} + aria-label={t('typography.searchFont', 'Search {{kind}}', { kind: label })} + placeholder={t('typography.searchPlaceholder', 'Search font families…')} + /> +
+ {visible.map(choice => ( + + ))} + {visible.length === 0 && ( + + {t('typography.noFonts', 'No font families match your search.')} + + )} +
+
+ ) +} diff --git a/web/src/features/settings/components/TypographySettings.test.tsx b/web/src/features/settings/components/TypographySettings.test.tsx index f92d172fe4..82dbc4ab95 100644 --- a/web/src/features/settings/components/TypographySettings.test.tsx +++ b/web/src/features/settings/components/TypographySettings.test.tsx @@ -125,35 +125,50 @@ describe('TypographySettings — header & preview', () => { expect(within(preview).getByText('The quick brown fox jumps over the lazy dog')).toBeInTheDocument() expect(within(preview).getByText(/Sync your Tesla fleet/i)).toBeInTheDocument() }) + + it('previews custom sample text without changing a saved preference', () => { + const font = setFont() + render() + fireEvent.change(screen.getByRole('textbox', { name: 'Try your own text' }), { + target: { value: 'Road trips and range' }, + }) + expect(within(screen.getByRole('group', { name: 'Typography preview' })) + .getAllByText('Road trips and range').length).toBeGreaterThan(0) + expect(font.setSans).not.toHaveBeenCalled() + expect(font.setMono).not.toHaveBeenCalled() + }) }) describe('TypographySettings — font family selects', () => { - it('renders both font selects reflecting the current preferences', () => { + it('renders both searchable font catalogs reflecting the current preferences', () => { setFont({ sans: 'roboto', mono: 'fira' }) render() - expect(screen.getByLabelText('UI font')).toHaveValue('roboto') - expect(screen.getByLabelText('Monospace font')).toHaveValue('fira') + expect(within(screen.getByRole('group', { name: 'UI font' })).getByRole('button', { name: /Roboto/ })) + .toHaveAttribute('aria-pressed', 'true') + expect(within(screen.getByRole('group', { name: 'Monospace font' })).getByRole('button', { name: /Fira Code/ })) + .toHaveAttribute('aria-pressed', 'true') }) it('lists every curated sans + mono option', () => { setFont() render() - const sans = screen.getByLabelText('UI font') - const mono = screen.getByLabelText('Monospace font') - // 7 sans presets (incl. Custom) and 5 mono presets (incl. Custom). - expect(within(sans).getAllByRole('option')).toHaveLength(7) - expect(within(mono).getAllByRole('option')).toHaveLength(5) - expect(within(sans).getByRole('option', { name: 'Atkinson Hyperlegible' })).toBeInTheDocument() - expect(within(mono).getByRole('option', { name: 'JetBrains Mono' })).toBeInTheDocument() + const sans = screen.getByRole('group', { name: 'UI font' }) + const mono = screen.getByRole('group', { name: 'Monospace font' }) + expect(within(sans).getAllByRole('button')).toHaveLength(17) + expect(within(mono).getAllByRole('button')).toHaveLength(10) + expect(within(sans).getByRole('button', { name: /Atkinson Hyperlegible/ })).toBeInTheDocument() + expect(within(sans).getByRole('button', { name: /Public Sans/ })).toBeInTheDocument() + expect(within(mono).getByRole('button', { name: /Source Code Pro/ })).toBeInTheDocument() }) it('routes a UI-font change to setSans with the chosen id', () => { const font = setFont() render() - fireEvent.change(screen.getByLabelText('UI font'), { target: { value: 'plex' } }) + fireEvent.click(within(screen.getByRole('group', { name: 'UI font' })) + .getByRole('button', { name: /IBM Plex Sans/ })) expect(font.setSans).toHaveBeenCalledTimes(1) expect(font.setSans).toHaveBeenCalledWith('plex') expect(font.setMono).not.toHaveBeenCalled() @@ -163,9 +178,46 @@ describe('TypographySettings — font family selects', () => { const font = setFont() render() - fireEvent.change(screen.getByLabelText('Monospace font'), { target: { value: 'plex-mono' } }) + fireEvent.click(within(screen.getByRole('group', { name: 'Monospace font' })) + .getByRole('button', { name: /IBM Plex Mono/ })) expect(font.setMono).toHaveBeenCalledWith('plex-mono') }) + + it('filters each font library independently and keeps the selected preference', () => { + const font = setFont({ sans: 'inter', mono: 'jetbrains' }) + render() + + fireEvent.change(screen.getByRole('searchbox', { name: 'Search UI font' }), { + target: { value: 'noto' }, + }) + const sans = screen.getByRole('group', { name: 'UI font' }) + expect(within(sans).getAllByRole('button')).toHaveLength(1) + fireEvent.click(within(sans).getByRole('button', { name: /Noto Sans/ })) + expect(font.setSans).toHaveBeenCalledWith('noto-sans') + expect(within(screen.getByRole('group', { name: 'Monospace font' })) + .getAllByRole('button')).toHaveLength(10) + fireEvent.change(screen.getByRole('searchbox', { name: 'Search UI font' }), { + target: { value: 'no-such-font' }, + }) + expect(screen.getByText('No font families match your search.')).toBeInTheDocument() + }) + + it('reveals the active font again when a reading preset changes it', () => { + setFont() + const { rerender } = render() + fireEvent.change(screen.getByRole('searchbox', { name: 'Search UI font' }), { + target: { value: 'noto' }, + }) + expect(within(screen.getByRole('group', { name: 'UI font' })).queryByRole('button', { + name: /Atkinson Hyperlegible/, + })).not.toBeInTheDocument() + + setFont({ sans: 'atkinson' }) + rerender() + expect(screen.getByRole('searchbox', { name: 'Search UI font' })).toHaveValue('') + expect(within(screen.getByRole('group', { name: 'UI font' })) + .getByRole('button', { name: /Atkinson Hyperlegible/ })).toHaveAttribute('aria-pressed', 'true') + }) }) describe('TypographySettings — custom font stacks (conditional inputs)', () => { diff --git a/web/src/features/settings/components/TypographySettings.tsx b/web/src/features/settings/components/TypographySettings.tsx index b59e088e2d..d48c96bd76 100644 --- a/web/src/features/settings/components/TypographySettings.tsx +++ b/web/src/features/settings/components/TypographySettings.tsx @@ -24,36 +24,19 @@ import { useFont, SANS_FAMILY_IDS, MONO_FAMILY_IDS, + resolveSansStack, + resolveMonoStack, LEADING_OPTIONS, TRACKING_OPTIONS, HEADING_WEIGHT_OPTIONS, FONT_SCALE_MIN, FONT_SCALE_MAX, FONT_SCALE_STEP, - type FontFamilyId, - type MonoFamilyId, type ReadingPresetId, } from '@/components/ui/FontProvider' - -// Brand names are proper nouns rendered verbatim in the option lists; only the -// surrounding UI copy is translated. -const SANS_LABELS: Record = { - inter: 'Inter', - system: 'System UI', - roboto: 'Roboto', - source: 'Source Sans 3', - plex: 'IBM Plex Sans', - atkinson: 'Atkinson Hyperlegible', - custom: 'Custom', -} - -const MONO_LABELS: Record = { - jetbrains: 'JetBrains Mono', - fira: 'Fira Code', - 'plex-mono': 'IBM Plex Mono', - system: 'System Mono', - custom: 'Custom', -} +import { FontFamilyPicker } from './FontFamilyPicker' +import { TypographySpecimen } from './TypographySpecimen' +import { SANS_LABELS, MONO_LABELS, SANS_GROUPS } from './fontChoices' /** Small block field label using the shared `label` typography role. */ function FieldLabel({ children }: { children: ReactNode }) { @@ -129,13 +112,18 @@ export function TypographySettings() { // Brand-name option lists are static; memoize so typing in a custom-font // field (which re-renders on every keystroke) doesn't rebuild them. - const sansOptions = useMemo( - () => SANS_FAMILY_IDS.map((id) => ({ value: id, label: SANS_LABELS[id] })), - [], + const sansChoices = useMemo( + () => SANS_FAMILY_IDS.map(id => ({ + id, name: SANS_LABELS[id], group: SANS_GROUPS[id], + stack: resolveSansStack(id, prefs.customSans), + })), + [prefs.customSans], ) - const monoOptions = useMemo( - () => MONO_FAMILY_IDS.map((id) => ({ value: id, label: MONO_LABELS[id] })), - [], + const monoChoices = useMemo( + () => MONO_FAMILY_IDS.map(id => ({ + id, name: MONO_LABELS[id], stack: resolveMonoStack(id, prefs.customMono), + })), + [prefs.customMono], ) // Each field label doubles as the accessible group name for its segmented @@ -226,29 +214,7 @@ export function TypographySettings() { - {/* Live preview — reflects the current font, scale, line-height, - letter-spacing, and heading weight via the shared CSS vars. */} -
- - {t('typography.preview.label', 'Preview')} - - - {t('typography.preview.heading', 'The quick brown fox jumps over the lazy dog')} - - - {t( - 'typography.preview.body', - 'Sync your Tesla fleet, chart every drive, and read the numbers clearly in any theme.', - )} - - - 0123456789 · kWh · °C · km/h - -
+ {/* Reading presets — one-click bundles. */}
@@ -265,20 +231,21 @@ export function TypographySettings() {
-
+
{/* UI font */}
- setCustomSans(e.target.value)} /> @@ -288,11 +255,12 @@ export function TypographySettings() { {/* Monospace font */}
-