The application has one shell with two shapes. Above 768px it is a permanent sidebar beside a content column. At or below, the sidebar becomes a modal drawer, tables become cards, and the header sheds everything that is not needed to move around.
MOBILE_BREAKPOINT_PX in src/app/core/services/viewport.service.ts is the only breakpoint the shell has. It appears in three places that cannot see each other:
| Where | What it is |
|---|---|
ViewportService | the TypeScript signal components read |
@media (max-width: 768px) | the CSS in _common.scss and the shell components |
playwright.config.ts mobile project | Pixel 7, 412×915 |
These must agree. A shell that reflows at 768px with a table that reflows at 640px has a band where the sidebar is a drawer and the table is still a table, and nobody looks at 700px. scripts/check-responsive.mjs fails the build on a media query that does not match, unless the file is listed in its ALLOWED_OTHER_BREAKPOINTS with a reason.
A component may legitimately have its own breakpoint when the reason is its own content — a wide chart, a parameter grid that wants two columns while it can have them. That is what the allow-list is for. It is not for the shell.
CSS can move the sidebar off-canvas. It cannot tell the component that the sidebar is now a dialog — that it takes role="dialog", traps focus, closes on Escape, and must be inert while hidden so it is not a run of invisible tab stops. That behaviour needs the breakpoint in code, and once it is in code it has to be the same number as the one in the stylesheet.
The sidebar becomes a drawer. Off-canvas, over a backdrop, closed by default. It closes on a backdrop press, on Escape, on its own close button, and on navigating — that last one matters, because otherwise the page the user just asked for renders behind the menu they asked with.
SidebarService keeps isCollapsed and isDrawerOpen as separate state deliberately: someone who collapsed the sidebar on a desktop should still find the drawer closed on a phone, and get their collapsed column back on rotating to landscape rather than an overlay they never opened.
Tables become cards. DataTableComponent hides the header row and renders each cell as a label: value pair, reading the label from a data-label attribute. This is CSS off one attribute rather than a second template, so a column added to columns() appears in both layouts and cannot be added to one only.
The header sheds what does not fit. Business date, render time, the guide, the user's name and the language selector are all reachable elsewhere and are hidden rather than crushed. Navigation, search and sign-out stay.
Spacing tightens. --content-padding drops from 2rem to 12px and --header-height from 64px to 56px. Both are tokens, so a deployment can set them — see DOCS/CUSTOMIZATION.md.
npm run check:responsive fails the build on:
height: 100vh, anywhere. Mobile browsers measure vh against the viewport with the URL bar retracted, so 100vh is taller than the screen and the bottom of the page sits under the browser chrome. Use 100dvh.max-width in the same rule is fine and is not flagged.npm run test:e2e:mobile covers what a regex cannot see: that the drawer is genuinely modal, that focus moves into it, that inert is applied while it is closed, that a tap on a link dismisses it, that the page does not scroll sideways, and that every header control clears a 44px touch target.
Most screens need nothing. They sit in the content column, inherit --content-padding, and use DataTableComponent for lists — which is already responsive.
What to avoid:
max-width.100vh. Use 100dvh.If a screen genuinely cannot work as a single column — a comparison view, a wide reconciliation grid — put it in a horizontally scrollable container of its own rather than letting the page scroll. The page scrolling sideways is the defect; a table that scrolls inside its own box is not.