Repository navigation
Mobile shell, /scan feature, and responsive page rewrites #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
31 commits
Select commit
Hold shift + click to select a range
8f10642
Add design doc for mobile shell and /scan feature
kroqdotdev 349cb05
Expand design doc with comprehensive testing plan
kroqdotdev 61d5d2d
Add useMediaQuery hook and PWA manifest
kroqdotdev 8c6fe01
Add mobile bottom nav and responsive shell swap
kroqdotdev 20510a1
Add scan resolver and barcode scanner hook
kroqdotdev 568e205
Add /scan page with camera, reticle, and manual entry
kroqdotdev 90b7868
Match SCAN button to the primary orange palette
kroqdotdev 4650966
Drop the sweeping scan line from the /scan reticle
kroqdotdev 499b0c9
Wire six quick actions into the scan result sheet
kroqdotdev 838a059
Show a green bounding-box dwell on the detected code
kroqdotdev 9bd320f
Two-phase dwell: orange "reading" then green confirm
kroqdotdev 7c3fd9e
Swap assets table for cards and a filter sheet on mobile
kroqdotdev 244791a
Polish asset detail for mobile viewports
kroqdotdev 7fb9a43
Group services list into bucketed collapsible sections
kroqdotdev 3b41ad9
Restructure dashboard as a vertical feed on mobile
kroqdotdev 947ecc5
Let the locations page scroll naturally on mobile
kroqdotdev 6dd8ea7
Add pb:seed script for mobile walkthrough demo data
kroqdotdev 3cc53e5
Stop the location tree from scrolling horizontally on deep nests
kroqdotdev 4f15554
Seed custom fields and shrink the fields table on mobile
kroqdotdev 035eaee
Swap the remaining admin tables for cards below md
kroqdotdev 937c974
Card-ify service group detail panels below md
kroqdotdev 0a2e39a
Let the document scroll on mobile with a sticky topbar
kroqdotdev 02b2080
Add mobile labels banner and turn crud dialogs into bottom sheets
kroqdotdev 8265782
Add dev:https script so the scanner works on a phone
kroqdotdev 893b255
Fix home-screen icons on iOS and Android
kroqdotdev 03aad8f
chore: lint, prettier, and render-time state reset
kroqdotdev 97012fe
Harden dev-server and PWA defaults
kroqdotdev c95bc5c
Harden scan lifecycle and error handling
kroqdotdev 141e247
Cut mobile FOUC and asset-list waterfalls
kroqdotdev 973e6b0
Address PR review feedback
kroqdotdev fb02c11
Address follow-up PR review feedback
kroqdotdev File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -49,3 +49,6 @@ import-meta.d.ts | |
| # dev artifacts | ||
| mprocs.log | ||
| .playwright-mcp/ | ||
| /*.png | ||
|
|
||
| /certificates | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| import { expect, test } from "@playwright/test"; | ||
| import { getLanding, signIn } from "./helpers"; | ||
|
|
||
| async function ensureAuthed( | ||
| page: import("@playwright/test").Page, | ||
| skipReason = "Set E2E_AUTH_EMAIL and E2E_AUTH_PASSWORD to run the mobile shell e2e", | ||
| ) { | ||
| const email = process.env.E2E_AUTH_EMAIL; | ||
| const password = process.env.E2E_AUTH_PASSWORD; | ||
| test.skip(!email || !password, skipReason); | ||
|
|
||
| const landing = await getLanding(page); | ||
| test.skip(landing !== "login", "Mobile shell e2e requires setup complete"); | ||
|
|
||
| await signIn(page, email!, password!); | ||
| } | ||
|
|
||
| test.describe("mobile shell", () => { | ||
| test("bottom nav is only visible on mobile viewports", async ({ | ||
| page, | ||
| }, testInfo) => { | ||
| await ensureAuthed(page); | ||
| await page.goto("/dashboard"); | ||
|
|
||
| const bottomNav = page.getByTestId("bottom-nav"); | ||
| if (testInfo.project.name === "mobile-chromium") { | ||
| await expect(bottomNav).toBeVisible(); | ||
| await expect(page.getByTestId("bottom-nav-scan")).toHaveAttribute( | ||
| "href", | ||
| "/scan", | ||
| ); | ||
| } else { | ||
| await expect(bottomNav).toBeHidden(); | ||
| } | ||
| }); | ||
|
|
||
| test("mobile bottom nav routes each slot correctly", async ({ | ||
| page, | ||
| }, testInfo) => { | ||
| test.skip( | ||
| testInfo.project.name !== "mobile-chromium", | ||
| "Runs only on mobile-chromium", | ||
| ); | ||
|
|
||
| await ensureAuthed(page); | ||
|
|
||
| await page.getByTestId("bottom-nav-assets").click(); | ||
| await page.waitForURL(/\/assets$/, { timeout: 10_000 }); | ||
|
|
||
| await page.getByTestId("bottom-nav-services").click(); | ||
| await page.waitForURL(/\/services$/, { timeout: 10_000 }); | ||
|
|
||
| await page.getByTestId("bottom-nav-home").click(); | ||
| await page.waitForURL(/\/dashboard$/, { timeout: 10_000 }); | ||
|
|
||
| await page.getByTestId("bottom-nav-scan").click(); | ||
| await page.waitForURL(/\/scan$/, { timeout: 10_000 }); | ||
| }); | ||
|
|
||
| test("more sheet opens and contains admin entries", async ({ | ||
| page, | ||
| }, testInfo) => { | ||
| test.skip( | ||
| testInfo.project.name !== "mobile-chromium", | ||
| "Runs only on mobile-chromium", | ||
| ); | ||
|
|
||
| await ensureAuthed(page); | ||
| await page.goto("/dashboard"); | ||
|
|
||
| await page.getByTestId("bottom-nav-more").click(); | ||
|
|
||
| const grid = page.getByTestId("more-sheet-grid"); | ||
| await expect(grid).toBeVisible(); | ||
| await expect(page.getByTestId("more-item-locations")).toBeVisible(); | ||
| await expect(page.getByTestId("more-item-taxonomy")).toBeVisible(); | ||
| await expect(page.getByTestId("more-item-labels")).toBeVisible(); | ||
| await expect(page.getByTestId("more-item-settings")).toBeVisible(); | ||
| await expect(page.getByTestId("more-sheet-signout")).toBeVisible(); | ||
|
|
||
| await page.getByTestId("more-item-locations").click(); | ||
| await page.waitForURL(/\/locations$/, { timeout: 10_000 }); | ||
| }); | ||
|
|
||
| test("assets list renders cards (not a table) on mobile", async ({ | ||
| page, | ||
| }, testInfo) => { | ||
| test.skip( | ||
| testInfo.project.name !== "mobile-chromium", | ||
| "Runs only on mobile-chromium", | ||
| ); | ||
|
|
||
| await ensureAuthed( | ||
| page, | ||
| "Set E2E_AUTH_EMAIL and E2E_AUTH_PASSWORD to run the assets card list e2e", | ||
| ); | ||
| await page.goto("/assets"); | ||
|
|
||
| // Card list is visible, mobile filters trigger is visible, no desktop table | ||
| await expect(page.getByTestId("asset-filters-mobile")).toBeVisible(); | ||
| await expect( | ||
| page.getByTestId("asset-filters-mobile-trigger"), | ||
| ).toBeVisible(); | ||
| await expect(page.locator("table")).toHaveCount(0); | ||
|
|
||
| const filterSheetButton = page.getByTestId("asset-filters-mobile-trigger"); | ||
| await filterSheetButton.click(); | ||
| await expect(page.getByTestId("asset-filters-sheet")).toBeVisible(); | ||
| await expect(page.getByTestId("asset-sort-recent")).toBeVisible(); | ||
| }); | ||
|
|
||
| test("sidebar is visible on desktop viewports", async ({ | ||
| page, | ||
| }, testInfo) => { | ||
| test.skip( | ||
| testInfo.project.name !== "chromium", | ||
| "Runs only on desktop chromium", | ||
| ); | ||
|
|
||
| await ensureAuthed(page); | ||
| await page.goto("/dashboard"); | ||
|
|
||
| await expect(page.getByTestId("bottom-nav")).toBeHidden(); | ||
| await expect(page.locator('[data-slot="sidebar-container"]')).toBeVisible(); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| import { expect, test } from "@playwright/test"; | ||
|
|
||
| test.describe("web app manifest", () => { | ||
| test("links a manifest from every served HTML page", async ({ page }) => { | ||
| for (const route of ["/", "/dashboard", "/assets", "/scan"]) { | ||
| await page.goto(route); | ||
|
|
||
| const manifestLink = page.locator('link[rel="manifest"]'); | ||
| await expect(manifestLink).toHaveAttribute( | ||
| "href", | ||
| /\/manifest\.webmanifest/, | ||
| ); | ||
| } | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| test("serves the manifest JSON with PWA fields", async ({ request }) => { | ||
| const response = await request.get("/manifest.webmanifest"); | ||
| expect(response.status()).toBe(200); | ||
|
|
||
| const manifest = await response.json(); | ||
| expect(manifest.id).toBe("/"); | ||
| expect(manifest.name).toBe("Stowage"); | ||
| expect(manifest.short_name).toBe("Stowage"); | ||
| expect(manifest.start_url).toBe("/dashboard"); | ||
| expect(manifest.display).toBe("standalone"); | ||
| expect(manifest.orientation).toBe("any"); | ||
| expect(manifest.theme_color).toBe("#c2410c"); | ||
| expect(Array.isArray(manifest.icons)).toBe(true); | ||
| expect(manifest.icons.length).toBeGreaterThan(0); | ||
|
|
||
| const maskable = manifest.icons.filter( | ||
| (icon: { purpose?: string }) => icon.purpose === "maskable", | ||
| ); | ||
| expect(maskable.length).toBeGreaterThanOrEqual(2); | ||
| const maskableSizes = maskable.map( | ||
| (icon: { sizes?: string }) => icon.sizes, | ||
| ); | ||
| expect(maskableSizes).toContain("192x192"); | ||
| expect(maskableSizes).toContain("512x512"); | ||
| for (const icon of maskable) { | ||
| expect(icon.src).toMatch(/-maskable\.png$/); | ||
| } | ||
| }); | ||
|
|
||
| test("declares a theme-color meta tag", async ({ page }) => { | ||
| await page.goto("/"); | ||
| const themeColor = page.locator('meta[name="theme-color"]').first(); | ||
| await expect(themeColor).toHaveCount(1); | ||
| const content = await themeColor.getAttribute("content"); | ||
| expect(content).toMatch(/#[0-9a-fA-F]{6}/); | ||
| }); | ||
|
|
||
| test("declares apple-touch-icon and web-app meta tags", async ({ page }) => { | ||
| await page.goto("/"); | ||
| const apple = page.locator('link[rel="apple-touch-icon"]').first(); | ||
| await expect(apple).toHaveAttribute("href", /\/apple-touch-icon\.png/); | ||
| await expect(apple).toHaveAttribute("sizes", "180x180"); | ||
|
|
||
| // Next 16 emits `mobile-web-app-capable` (the standardised replacement for | ||
| // the deprecated `apple-mobile-web-app-capable`). iOS Safari accepts both. | ||
| const capable = page.locator('meta[name="mobile-web-app-capable"]').first(); | ||
| await expect(capable).toHaveAttribute("content", "yes"); | ||
|
|
||
| const title = page | ||
| .locator('meta[name="apple-mobile-web-app-title"]') | ||
| .first(); | ||
| await expect(title).toHaveAttribute("content", "Stowage"); | ||
|
|
||
| const maskIcon = page.locator('link[rel="mask-icon"]').first(); | ||
| await expect(maskIcon).toHaveAttribute("href", /safari-pinned-tab\.svg$/); | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| test("serves all referenced icon files with 200", async ({ request }) => { | ||
| const icons = [ | ||
| "/images/web/icon-192.png", | ||
| "/images/web/icon-512.png", | ||
| "/images/web/icon-192-maskable.png", | ||
| "/images/web/icon-512-maskable.png", | ||
| "/images/web/safari-pinned-tab.svg", | ||
| "/images/web/apple-touch-icon.png", | ||
| "/images/web/favicon.ico", | ||
| // iOS also requests these at the server root as a legacy fallback; | ||
| // mirroring the file there silences the 404 we saw in dev logs. | ||
| "/apple-touch-icon.png", | ||
| "/apple-touch-icon-precomposed.png", | ||
| ]; | ||
| for (const url of icons) { | ||
| const response = await request.get(url); | ||
| expect(response.status(), `GET ${url}`).toBe(200); | ||
| } | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.