build v0.1.0 · November 4, 2026 · back to the preview index
Every deliberate simplification in the AutoChanneling prototype, with what production does instead. This is a working list, not a defect log — each entry is a decision we made on purpose to keep the prototype focused on look, content and navigation.
This file feeds the design-freeze annexure. Add an entry here in the same change that introduces a limitation, so the annexure can be assembled from it rather than reconstructed from memory.
| # | Limitation | Why | Production instead | |
|---|---|---|---|---|
| 1 | No authentication. Login, OTP and PIN screens navigate but never authenticate. | The prototype proves flow and content; auth adds no review value and a lot of surface. | Real OTP issue/verify, JWT access + refresh with rotation, scoped RBAC. | |
| 2 | Any 6-digit OTP succeeds (C-04), with a muted "Demo: enter any 6 digits" hint on screen. | A reviewer must never be stuck guessing a code. | POST /auth/otp/verify checks a hashed, rate-limited, expiring code. | |
| 3 | All data is mocked in the browser via MSW from packages/mocks; the store is in page memory and resets on reload. | No backend exists yet, and a resettable store lets a reviewer redo a journey cleanly. | A real API; state persists in PostgreSQL. | |
| 4 | C-18 payment is simulated. The gateway page is unbranded and carries "Pay" and "Simulate a failed payment" actions; C-19's poll settles after ~2.5s. | The client must be able to walk both outcomes on demand, and imitating a real processor's branding in a mock would be misleading. | PayHere hand-off; the outcome arrives as a signed webhook, deduplicated by payment_webhook_events. | |
| 5 | Changing the payment method rewrites the payment, not the history. C-18's "Pay at the station instead" and C-21's "Change method" both go through POST /bookings/:ref/payment/abandon, which puts the booking's payment into exactly the state a booking created in that mode starts in — so the mode really does change, the window is kept and the abandoned attempt is not recorded as a failure. What is missing is the paper trail: no payment_attempts row, no status event saying the customer changed their mind. | The screens need the mode to change and the booking to survive; an attempt ledger is a backend concern with no screen in the pack. | Each attempt is a row of its own — initiated, abandoned, failed, succeeded — and the mode change is an audited event on the booking. | |
| 6 | C-06 Session renewal has no inbound link and is reachable by direct URL only. | The prototype pack has no spec line for it — it is the silent token refresh from the data contracts, invisible in the real product. | Invisible: the client refreshes the token in the background and the screen never appears. | |
| 7 | A-02 slot-utilisation and the two growth tiles are fixed values. The other eleven KPI tiles are derived from the booking fixtures. | Deriving them needs a capacity-history rollup that does not exist yet. | daily_station_stats rollups over the templates/exceptions history. | |
| 8 | Fonts load from Google Fonts via <link> (Inter, Noto Sans Sinhala). | Fastest route to the right typography during design review. | Self-hosted WOFF2 subsets on our own origin, font-display: swap, preloaded. | |
| 9 | Station photos are generated SVG monograms, deterministic per station id. | The prototype deploys behind an access wall and must render with no network; there is no photography yet. | Real station photos from R2, served over CDN; fixtures already carry photo_url. | |
| 10 | C-09 map is not a map. Price pills are projected from fixture coordinates onto a stylised backdrop, with markers nudged apart where they would overlap. On a desktop the backdrop stretches to the panel; there is no pan, no zoom and no search-as-you-move-the-map. | A real map SDK means an API key, a network dependency and a cost, for no design signal. | A map SDK with real tiles, clustering and viewport-driven search — which is also what makes the desktop split view's map worth interacting with. | |
| 11 | Search filters are applied client-side. The mock search endpoint understands only category and sort; distance, rating, price and open-now are filtered in the browser. | The handler logic is validated and was not worth changing for a UI concern. | GET /stations/search takes every filter and applies it in PostGIS/SQL. | |
| 12 | Search and booking-flow state is in memory. Filters and the booking flow reset on reload. C-13–C-15 open against a default station; the steps after them need a hold or a booking reference and answer a direct open with the guard state in limitation 52. | Keeps every /book/* route directly reviewable from ROUTES.md without walking the whole flow. | Filters in the URL; flow state carried by the slot hold and the created booking. | |
| 13 | C-26 "Download PDF" opens the browser print dialog against a print stylesheet; no file is generated. | Print-to-PDF gives the client the same artefact to look at, with no PDF pipeline. | Server-rendered PDF stored at invoices.pdf_path and served as a download. | |
| 14 | Dead links, styled but inert. Each is present because its screen spec calls for it, and each is visibly a real affordance so the layout can be judged. | Every one needs an integration the prototype has no reason to carry. | See the row below. | |
| 15 | Service reminders (C-30) are set but never sent. Settings persist in the mock store for the session and reset on reload. | The scheduler and the notification pipeline are backend work; the screen exists to agree the controls. | A vehicle_reminder_settings table (a deferred schema addition in the contracts) plus the P-08 scheduler firing through the notification pipeline. | |
| 16 | Reviews (C-12, C-34) are a Phase 2 module built early. A review posts straight to the station's list — no moderation, no publish/hide state, no rating recalculation on the station. | The screens were asked for now so the client can sign the whole customer app off in one pass. | reviews table with a published/hidden status, station replies from S-28, and rating_avg / rating_count recalculated by trigger or job. | |
| 17 | Terms & privacy (C-36) is placeholder copy marked "draft for legal review". It is real prose describing what we intend to say, not lorem ipsum and not legal text. | The client reads this page during review and needs the shape of it; the wording is a lawyer's job. | Counsel-reviewed terms and a PDPA privacy notice, versioned, with consent captured at first OTP verify. | |
| 21 | S-06 carries a "Simulate approval decision" control. Two muted links flip the registration to approved or rejected-with-reason, so both outcomes are reviewable without an admin signing in. Live (C1.8): the control is not shown — a decision needs a platform admin, which an applicant is not; A-04 decides. S-06's note to the reviewer is not shown either: the API has no field for one (limitation 164). | The approval happens in the admin portal; a reviewer walking the station wizard would otherwise hit a dead end at "under review". | Nothing — the control does not ship. An admin approves or rejects in A-04 and the applicant sees the result. | |
| 22 | File pickers list a chosen file by name and size only — S-04's registration documents, S-17's branch photos and S-27's organization documents. Nothing is read, uploaded or stored, and each screen says so. Live (C1.8): S-04's documents and A-05's are uploaded — the chosen file is kept and sent as multipart to PUT /orgs/{id}/documents/{type} once the organization exists. Live (C1.9): S-17's photos are uploaded too (POST /orgs/{org}/branches/{id}/photos, multipart) and shown as the uploaded images. S-27's documents are still names only. | The upload-card states are worth reviewing; a reviewer's own documents are not worth putting anywhere near the wire. | Multipart upload to R2, virus-scanned, with signed URLs for admin review. | |
| 19 | Cross-app state is per-app, and now architecturally so. The three apps deploy to three separate hostnames, so they do not share an origin, a service worker or browser storage: a booking made in the customer app cannot appear in the station portal by any means available to a static site. Each app demos independently, by design. | Four Pages projects at four domain roots is the deployment the client asked for, and separate origins are the consequence. Even a localStorage adapter is closed off — storage is per-origin — so the option recorded here earlier no longer exists for the prototype. | One backend: every app reads the same database. The seam is still in place — packages/mocks/src/store-adapter.ts defines the interface and every mutation reports through commit() — so wiring it to a real API later touches no handler and no screen. | |
| 20 | The station and admin apps are single-page apps served from one index.html. Each carries a public/_redirects catch-all (/* /index.html 200) so a deep link and a refresh both resolve. | Vite emits one entry document; the customer app prerenders a file per route and the index is two static files, so neither needs one. | Unchanged — this is how SPAs deploy. Any other host needs the same rewrite rule. | |
| 18 | Sinhala and Tamil are translated for C-33, C-02 and the shared chrome only (nav, statuses, common actions, the customer's bill), and Sinhala also for each new label since U4. Everywhere else falls back to English — a missing key or an empty string renders the English, never a blank or a key name. | C-33 is the agreed Sinhala demo screen; translating every screen before the copy is signed off would be wasted work. | Full si and ta resources once the English copy is frozen, through pnpm i18n:export / pnpm i18n:import (limitation 131). | |
| 23 | Station reports are computed in the browser from the mock store (S-29, S-35, S-36, S-37). Charts are inline SVG with no chart library. Since U3 the browser only adds up: each booking's earnings and their parts (money) and its item lines arrive on the list row from the handler, computed by the pricing module's one definition, so a report and S-09's tiles cannot disagree about a booking — but the summing, the bucketing by week or month and S-37's "Items sold most" are still done on the page, over at most 1,000 rows. | There is no rollup table to read and no backend to build one; the numbers still have to move when a reviewer books something. | daily_station_stats rollups (with item sales and item discounts as columns) queried by a reports endpoint, rendered with the charting library chosen at build time. | |
| 24 | Resolved in U3 — one definition of earnings, used everywhere money is summed. Earnings = completed bookings' package price − offer discount + item lines − item discounts, plus forfeited advances (what was paid on a cancelled or no-show booking less any refund). It lives once, in packages/mocks/src/pricing.ts (bookingEarnings / earningsOf), with package revenue, offer discounts, item sales, item discounts and forfeited advances as separate figures, and pnpm test pins it over the seeded bookings. S-09's tiles, S-29, S-30, S-35, S-37, A-02, A-13 and A-19's revenue and customer reports all read it, and the captions that explained which figure each screen meant are gone. Discount figures that sit beside earnings — S-29's offers table, A-02's "Discounts given", A-19's Discounts report — count completed bookings too, so they add up to it; the redemption views (S-37's "Offers used", A-28's summary) still count every booking that used an offer, because they answer how often an offer was taken, not what it cost. The old dashboard rule counted an advance on a booking still open as earned — and again when it completed. | — | The same definition, owned by the finance service; confirmed with the client at design freeze. | |
| 25 | S-36 occupancy is bookings against the weekly template's capacity for the 14 days around today. Days closed by an exception show as an empty column marked "Closed". | There is no capacity-history table, so past capacity is assumed to be today's template. | Occupancy from the capacity actually published on each day, kept in the slot history. | |
| 26 | S-16 job cards and S-29 exports open the browser print dialog against a print stylesheet; no file is generated. Same approach as C-26. | Print-to-PDF gives the client the artefact to review with no PDF pipeline. | Server-rendered PDF for the invoice; the job card stays a browser print, which is what a workshop actually does. | |
| 27 | S-28 replies live in the station app's own store. A reply posted here does not appear on C-12 / C-34, because each app runs its own MSW store (limitation 19). | Same root cause as 19 — three apps, three stores. | One backend: the reply is written once and both apps read it. | |
| 28 | S-15's "Override a full window" toggle is not role-checked. The screen says "Managers only", but with no auth there is nobody to check. | Follows from limitation 1. | The control is rendered only for branch_admin and above, and the override is recorded in the audit log. | |
| 29 | S-14's rejection reasons are a fixed list (fully booked, parts unavailable, staff shortage, service not offered, other) rather than a configurable one. | The list is the spec's; making it editable is admin surface the prototype does not need. | Reasons configured per platform in the admin portal, with the free-text detail kept as it is now. | |
| 30 | A-01 accepts any password and any six-digit 2FA code, with the demo hint on screen. | Same reason as limitation 1 — the portal proves flow and content, not authentication. | Password login, then a TOTP challenge against user_2fa, with hashed one-time recovery codes. | |
| 31 | A-02's growth tiles and slot utilisation are documented baselines, not derived, and each says so on the tile. The other eleven tiles recompute from the store for the selected range. | The fixture set is a working sample: a 30-day look-back has almost nothing behind it, and a truthful "+2500%" is a useless tile. Slot utilisation needs a capacity history that has no rollup. | daily_platform_stats rollups over real traffic; every tile derived, including the growth comparisons. | |
| 32 | A-04 lists documents but cannot open them, and says so inline. | BR certificates are sensitive and the prototype has no storage layer to sign a URL against. | Time-limited signed URLs from object storage, with the viewer inline in the review panel. | |
| 33 | A-05's suspend reaches only the admin app's own store. Suspending an organization unlists its branches and they disappear from that store's station search — but the customer app is a separate origin with its own store, so its search is unaffected (limitation 19). Live (C1.8): suspend and reactivate reach the API (POST /admin/orgs/{id}/suspend · /reactivate, each with its reason). | Same root cause as 19: separate hostnames, separate stores. | One backend: is_listed=false is written once and every app reads it. | |
| 34 | A-07's block cancels the customer's active bookings with a full refund, which is the semantics the contracts asked to fix at sign-off. The behaviour is a platform setting on A-10, and the confirm dialog quotes the live value. | A blocked account should not keep holding slots; the alternative (honour existing bookings) is selectable so the client can choose at sign-off. | Unchanged, once the choice is signed off — plus token revocation and permission-cache invalidation. | |
| 35 | A-09 edits category order as a number, not by dragging. | Drag-and-drop is interaction work with no design question attached; the field is the same one either way. | Drag to reorder, writing the same sort_order. | |
| 36 | A-10's station-fault refund is locked at 100%. The field is rendered, disabled, with a "platform default" badge. | It is deliberately not configurable: a station cannot keep a customer's money for its own cancellation. Showing the locked field makes the rule visible rather than invisible. | Unchanged — the rule ships as it reads here. | |
| 37 | A-11's audit log is seeded from the booking histories and appended to in page memory by every admin action taken during a session. It resets on reload. | The screens the log describes are the ones being reviewed, so it has to be consistent with them; a real event store is backend work. | Partitioned audit_events, queried by actor/entity/action/date, with export. | |
| 38 | A-12 announcements are recorded but never delivered. A-14 invitations are shown as a link on screen rather than emailed. | The prototype sends nothing anywhere — same reason C-30's reminders are set but never sent (limitation 15). | The notification pipeline fans out to customers and stations; invitations go by email and enforce 2FA at first sign-in. | |
| 39 | A-15 role editing is permissive — anyone reviewing can edit any non-system role. The scope whitelist is still enforced and, more usefully, rendered: platform permissions show as locked on an organization-scoped role. | There is no auth to check a permission against (limitation 1); the rule that matters at design time is the whitelist, and that one is visible. | platform.admins gates the screen; the whitelist is enforced server-side on write as well. | |
| 40 | A-18's fraud alerts are illustrative fixtures, under a banner saying detection activates after the pilot, and its Rules tab tunes thresholds nothing evaluates. A threshold or switch saved there is stored and audited like any platform setting, but no alert is raised, cleared or re-scored by it. | The rules can only be written against real traffic. The screen exists so the shape — severity, subject, evidence line — can be agreed now. | Rules and alerts tables, authored once there is pilot data; a detection job reads the stored thresholds on every run. | |
| 41 | A-19's exports are made in the browser. Export PDF opens the print dialog over a print stylesheet that shows only the result, and Export Excel downloads a CSV built from the rows on screen — not an .xlsx. Nothing is generated or kept, so the export log records exports rather than listing files. | Query-then-act needs no job queue to review, and a print and a CSV are real enough to open and judge. | The same query rendered server-side to PDF and XLSX, a report_exports row per export, and scheduled reports on top. | |
| 42 | A-20 is a designed placeholder, per the pack: it states what commission and billing will show and why they are dark. | The revenue model is not agreed yet. The booking value the screen needs has been captured since day one. | Commission per booking, monthly invoices, payout runs and disputes. | |
| 43 | X-03 offline is simulated by a toggle, not by pulling the cable. The banner, the queued-actions chip and the send-on-reconnect transition are all reviewable; nothing is actually queued or replayed. | The states are what need agreeing. The real thing is a service worker holding mutations and replaying them with an Idempotency-Key so a replay cannot double-apply — a backend prerequisite, not a design question. | Service-worker queue and replay, with the idempotency mechanism from the contracts. | |
| 44 | X-02's support reference is derived in the browser from the error message, so the same failure always shows the same code. Nothing is reported anywhere. | A support code is only useful if it is stable, and a client needs to see one to judge the screen. | The GlitchTip event id from the capture call, which is what ties "the code on my screen" to the exact stack trace. | |
| 45 | X-04 maintenance and X-09 update-required are direct routes only. A-10's maintenance toggle does not actually serve X-04 — its "Preview" link opens X-04 in the customer app, in a new tab — and no version check runs. | Both are gateway/launch behaviour rather than in-app navigation; wiring them would make the prototype unusable for review the moment someone flipped the toggle. | The gateway serves X-04 while the flag is set (admin sessions bypass); the client compares its build against min_supported_version at launch. | |
| 46 | X-05 carries a "Demo: skip the wait" link under the countdown. | The lock-out is a minute long; a reviewer should not have to sit through it to see the state on the other side. | Nothing — the control does not ship. | |
| 47 | X-06 and X-07 are pattern galleries, not screens. They exist so the loading and empty patterns can be reviewed in one pass; nothing links to them from inside an app. | The states themselves appear inside real screens. Judging them side by side is a review need, not a product need. | Neither page ships; the patterns stay where they already are. | |
| 48 | Three page models, and the card is now the exception. From lg a customer screen takes one of three shapes, from desktopLayout in the registry: page (12 screens — C-07, C-12, C-22, C-23, C-26, C-27, C-29, C-31, C-32, C-33, C-35, C-36) has no card at all — the bar, the title strip and the background are full bleed, the content sits on the page grid (1216px from xl, 1376px from 2xl, inside gutters that grow with the window), the screen's own sections are the cards, and a rail sits beside the main column where the content has two columns' worth; focus (24 screens — the auth steps, a confirm, C-30, the booking flow, the X-\* states) keeps the centred card, deliberately narrow, because it carries one decision; landing (C-01, C-08/C-09, C-11) is unchanged. Type, density and gutters still step at lg from packages/ui/src/density.ts. | The single card-on-a-page model made every content screen read as a narrow card floating in grey at a large width: reviewed on the deployed build at 1858px, and measured in docs/customer-responsive-audit.md — those 13 screens used 49.6% of a 1920px window and 37.1% of a 2560px one. Widening the card was not the fix; the card was. C-01 and C-08 always read correctly because they were already pages. | The three models are the intended design. What is still open is the remaining half of the audit's finding 2: the steps are keyed to the window, not to the box a component is in, so a Card in a 430px focus card and the same Card in a 1376px page are told the same thing. @container on the page grid and the card, with each component sizing to its own box, is the next pass. | |
| 49 | On a desktop, C-08 and C-09 are the same screen. Both routes render the split view — results on the left, map on the right, selection shared. Below 1024px they stay two screens with the toggle between them, exactly as on a phone. | The list and the map are one marketplace view on a desktop; keeping two separate desktop screens would be a worse design faithfully implemented. Both routes are kept so every screen ID in the index still resolves, and each keeps its own badge. | Unchanged. The mobile toggle and the desktop split are both real patterns; which one shows is a breakpoint decision. | |
| 50 | The desktop split view's selection is one-way on hover. Hovering a result card lights its pill and clicking a pill scrolls the card into view, but nothing is fetched or re-queried — the result set is the same one the list already has. | Synchronising a selection is the design question; re-querying by viewport needs the map SDK from limitation 10. | Moving the map re-runs the search over the visible bounds. | |
| 51 | --color-line (#a8a9ad) is 2.35:1 on white — below the 3:1 WCAG asks of a non-text UI boundary, and far below the 4.5:1 for text. CLAUDE.md rule 2 already restricts it to borders and disabled fills and forbids it for text, so no readable content sits on it; the exposure is that card, input and divider borders are quieter than the guideline wants, and disabled controls filled with it are low-contrast by construction. | The border weight is a deliberate part of the look: at 3:1 every card on a list screen reads as a boxed table rather than a surface. WCAG 1.4.3 exempts disabled controls, so the disabled case is compliant as it stands; the borders are the open question. | The accessibility pass splits the token — a line that still draws quietly and a line-strong at ≥3:1 for boundaries that carry meaning (focused fields, table rules, anything a user must perceive to operate the control). | |
| 52 | **A /book/* step opened without flow state renders a designed T-STATE**, not the step: "This step needs an active booking flow" with one action back to C-15, and C-20 pointing at C-22 instead. C-34 does the same for a booking that is not completed. | Flow state is in memory (limitation 12) and every screen must be openable from the index's checklist. Before this the steps sat on skeletons that never resolved, which reads as a broken build rather than an explained state. | Nothing — the guards stay. With a real backend the hold and the booking come from the server, so the states appear only when a link really is stale. | |
| 53 | Any password signs you in (C-37), and any six digits pass C-38's reset code, exactly as any six digits pass C-04's OTP. Each screen says so in a muted line. Registration at C-05 validates username and password length in the browser and then discards the password — no handler stores or checks one. | Follows from limitation 1: there is no auth, and a reviewer must never be locked out of the flow they were asked to look at. The screens exist to agree the fields, the copy and the routes. | Argon2id password hashes, a constant-time check, one identical 401 for "no such account" and "wrong password", and per-identifier plus per-IP rate limiting on the login endpoint. | |
| 54 | C-38's dormancy branch is a fixture flag, not a measurement. One account (suresh.w, phone-only) carries dormant: true so the extra-verification state is reachable in review; the extra check itself is described on that screen and then skipped by a demo button. Nothing reads a real last_login_at. | The state and its wording are what need agreeing with the client, and the rule behind it — Sri Lankan mobile numbers are reissued after disconnection, so on a long-idle account holding the number is no longer evidence of owning it — is a policy decision, not a UI one. | last_login_at compared against platform_settings.dormant_account_days; past the threshold a phone-channel reset requires a second factor (a security question, a recovery code, or a support-verified identity check) before the OTP is sent. | |
| 55 | The reset channel is chosen from the fixture set, and an unknown identifier is answered anyway — masked from whatever was typed, so C-38 always advances. Fixtures cover all three branches: a verified email (nimal.perera), no email at all (iresha.k), and an email present but unverified (dilhani.r), which falls back to the phone like the second. | A reviewer has to be able to walk both channels without editing fixtures, and the "we do not confirm whether this account exists" behaviour is a real property of the endpoint that a prototype which 404s on unknown input would misrepresent. | Unchanged in shape — the response stays deliberately uninformative. What changes is that the channel comes from the account's own email_verified_at, and the token is real, single-use and expiring. | |
| 56 | Google sign-in is prepared at data level only — auth_identities exists in the schema with unique(provider, provider_user_id) and nothing reads it. No button, no endpoint, no feature flag in P1. | The fleet pattern (schema §10) applied to identity: shipping the structure in v1 means activation is a provider row and a button, never a migration against live accounts. | A Google OAuth exchange writing auth_identities, with the account still carrying a verified phone — the provider is an additional credential, not a replacement for the number. | |
| 57 | Registration writes over the demo account rather than creating a second one. Completing C-05, or signing in at C-37 as one of the five fixture accounts, renames the single signed-in profile so C-07 onward reflects what was just typed. | The mock store holds one customer (limitation 3), and a reviewer walking registration should see their own name on the next screen rather than Nimal Perera's. | Real registration inserts a users row and a customer user_role_assignment; login resolves an existing one. | |
| 58 | The station's booking horizon is one platform-wide number. Every branch fixture takes bookings 30 days out, and S-21 edits a single slotConfiguration rather than a per-branch row. C-15 and C-24 page the date strip across whatever the station reports, so the mechanism is per-station even though the data is not yet. Live (C1.9): S-21 reads and writes the branch's own horizon and minimum notice (`GET | PUT /stations/{id}/hours`). | The per-branch table exists in the schema; wiring thirty fixtures to it adds no review value over one honest default. | station_slot_config per branch, which S-21 already writes to in the contracts. |
| 59 | "This device" on S-34 is remembered in sessionStorage, written by S-32 when the browser registers itself. Close the tab and the badge is gone. | The prototype has no device token to recognise a terminal by, and the point of the badge is the wayfinding — knowing which row is the machine you are sitting at. | The device token the terminal sends on every request identifies it, and the answer survives a restart. |
| 60 | Master data lives in the same in-memory store as everything else (limitation 3). A make approved in A-21, an FAQ edited in A-22 or a city added in A-23 survives until the page reloads, and is visible to the other apps only because they share this browser — not because anything was saved. | The point of these screens is that the lists are editable at all; where they persist is the backend's problem. | Real tables per schema §9b, read by every app over the same endpoints. |
|---|---|---|---|
| 61 | A-21's correction rewrites vehicle rows by string match. Merging "Tyota" into Toyota — or approving a corrected name — updates every vehicle whose make equals the old string. A real catalogue holds a foreign key, so nothing needs rewriting. | vehicles.make is a string in the fixtures, which is exactly the shape the catalogue exists to replace. The demo chain is real end to end — KL-2290 reads "Tyota Sunny" before and "Toyota Sunny" after. | vehicles.make_id → vehicle_makes, so correcting a name is one UPDATE and every vehicle follows automatically. |
| 62 | Free-text makes and models are submitted but never blocked on. C-14 and C-28 fire the POST and carry on; if it fails the booking still proceeds with the typed string. | A catalogue that can stop a booking is worse than one that occasionally misses an entry. | Unchanged in intent. The write is retried server-side and the entry still lands in A-21's queue. |
| 63 | A-08's multi-status filter and date range are applied in the browser over the first 50 results. The mock search endpoint takes one status and no date window. | Adding real multi-value filtering to a mock search adds no review value over showing the interaction. | GET /admin/bookings takes status[], from and to, filtered and paged in SQL. |
| 64 | Roles created in S-38 and A-15 are not enforced anywhere. There is no auth (limitation 1), so a role is a list of permission codes that nothing reads. The scope whitelist *is* real in the sense that platform permissions cannot be attached to an org-scoped role — the endpoint drops them and S-38 renders them locked. | The screens exist to agree the model and the boundary, both of which are design decisions. | hasPermission(user, action, scope) against the assignment, as schema §1 describes. |
| 65 | The station address keeps a denormalised city name alongside the province/district/city ids, because station cards, search results and the "Top district" KPI all still read a string. | Changing every consumer to resolve the hierarchy is backend-shaped work with no visible result. | The hierarchy is the source; a view or a join supplies the display name. |
| 66 | Change password is visual in all three apps — C-39 and the portal modals validate length and confirmation, then declare success. Nothing is checked against a current password because there is no account to check against. In live mode C-39 is real since C1.1 (POST /auth/password/change; a wrong current password is the API's invalid_current_password, inline); the portal modals are still visual in both modes. | Follows from limitation 1. | POST /me/password verifying the current hash, then revoking every other refresh token. |
| 67 | Both portal sidebars end with an "All screens (design preview)" link to the preview index. It is muted, sits below the navigation, and says what it is. | Reviewers move between the portals and the index constantly; the link saves them retyping a hostname. It is review chrome, not product navigation. | Removed. The preview index does not exist in production, so neither does the link to it. | |||
|---|---|---|---|---|---|---|
| 68 | The demo station account holds a different role per branch — organization admin at Nugegoda, branch admin at Rajagiriya, service advisor at Kandy — so switching branch context (S-08) is how a reviewer sees the sidebar's org-admin items appear and disappear. A PIN session (S-33) overrides it with the signed-in staff member's role. | One account has to demonstrate every scoping state without signing in and out. | An org admin is an organization-scoped user_role_assignment covering every branch; branch roles are station-scoped. The acting role resolves the same way — PIN session first — from real assignments. | |||
| 69 | Org-admin-only screens are hidden from the sidebar but still reachable by URL for branch roles. S-38 renders a notice to non-org-admins; S-27 and S-30 render normally. | Nothing is enforced client-side in a prototype with no auth (limitation 1); the sidebar scoping exists to agree what each role is shown. | hasPermission(user, action, scope) on every endpoint behind those screens, and a route guard that renders X-08 (permission denied). | |||
| 70 | A-19 "Email this report" is simulated. Recipients are validated as chips and recorded in the export log, and the sent state lists them — no email leaves the prototype. | There is no mail provider behind a prototype (limitation 1), and a send that looked real but went nowhere would be worse than one that says so on screen. | A delivery job that emails a signed, expiring link (or the file under a size cap) through the transactional mail provider, with bounces surfaced on the export log and a report.emailed audit event. | |||
| 71 | Date ranges are the viewer's local calendar days, and "Last N days" includes today. The custom range uses the browser's native date input, so the picker looks different from browser to browser. | The prototype has no timezone setting; a native input needs no date-picker dependency and is fully keyboard-usable. | from/to resolved in the platform timezone (Asia/Colombo) on the server, and a calendar picker drawn from the design system. | |||
| 72 | Analytics look back only as far as the fixture history — about six weeks. A 90-day or longer custom range shows empty early days; S-36 groups ranges longer than a month by week; A-19's results cover only the store's bookings. | The dataset is a deterministic sample (packages/mocks/FIXTURES.md) sized to demonstrate states, not a year of trading. | Rollups (daily_platform_stats, the S-29 period-series endpoints with granularity) computed over the full booking history. | |||
| 73 | Per-item saves on A-10 and A-22 write through the existing whole-resource PUTs — the saved values plus that card's edits: A-10's settings cards, policy cards, cancellation reasons and registration documents, A-22's contact channels and questions, A-18's rules and S-21's weekdays. Two admins editing different items at the same moment would overwrite each other. | The mock contract has one PUT per resource; the screens are built so each item saves on its own regardless of what is underneath. | PATCH per settings group, reason, document, contact channel, question, rule and weekday, with optimistic concurrency (a version or ETag) so a stale write is refused rather than applied. | |||
| 74 | A-24's holiday calendar reaches branches only in the admin app's own store. Adding or correcting a date on A-24 changes the admin store; S-18's "Apply Sri Lankan holidays" reads the station app's store, which starts from the same seed but never sees the admin's edits. Live (C1.9): "Apply Sri Lankan holidays" is the API's POST /stations/{id}/holidays/apply, so it applies the platform calendar as A-24 left it; the count of dates beside it is not shown (limitation 165). | The same root cause as limitation 33: separate hostnames, separate stores. | One backend: sl_holidays is written once on A-24 and every branch applies from it. | |||
| 75 | The seeded holiday dates are representative, not the gazette. Poya days sit in the right months on placeholder dates, and only the current year is seeded; the previous and next years open empty. | Poya follows the lunar calendar and moves every year, and the government publishes the list annually — which is why A-24 is an editable screen rather than a constant. | Each year's gazetted list entered or imported on A-24 before the year begins. | |||
| 76 | A-20 is an illustrative preview. Its tiles, per-organization branch lines and payout runs are invented — an assumed 5% rate over the store's completed bookings and a made-up payout calendar with one run per settlement state — under a banner that says so. Nothing is invoiced, collected or paid out. | The revenue model is not decided; the preview lets the client agree the screen's shape before it is. | Commission rules from the signed revenue model, invoices and payout runs from a billing service, reconciled against gateway settlements. | |||
| 77 | Withdrawing an announcement (A-12) changes its status and nothing else. No announcement is delivered in the prototype and neither the customer app nor the station portal displays them, so "disappears from the apps" and "already-delivered notifications are not recalled" are stated in the confirmation rather than demonstrable. | There is no notification pipeline behind the prototype (limitation 3) and no consumer screen for announcements yet. | Fan-out through the notification pipeline; withdrawal hides the in-app item and cancels queued sends, while delivered push and SMS stay delivered. | |||
| 78 | A changed station-fault refund % applies in the admin store only. S-14's rejection refund in the station portal keeps reading its own store's 100% (limitation 33), and nothing yet shows customers the figure before they pay. | Separate stores; the customer-facing policy line is fixed copy on C-16. | The policy engine resolves the platform policy for every rejection, and C-16 shows the resolved figure (the transparency invariant, schema §6). | |||
| 79 | A time-band change does not look at bookings already made. Saving S-21 with a band that removes or shortens a window leaves any booking in that window untouched and unflagged — it simply stops appearing in S-10's grid, which draws only the windows the template now has. Live (C1.9): the API looks. A change to S-21's bands, S-18's closures or its holidays that takes booked windows away is refused once with the bookings it touches; the screen names them, and going ahead writes the change and flags those bookings to be re-slotted (limitation 165). | Re-slotting affected bookings is a workflow of its own; S-22's preview is the pattern it would follow. | The template PUT previews affected bookings, like S-22, and routes each (keep, reschedule, or cancel with the station-fault refund) before the new row-set takes effect. | |||
| 80 | Multiple roles are held per person, but not by the demo account's own contexts. S-26, S-31 and A-14 assign several roles and show their union, and a PIN session carries all of a person's roles — but the station account's S-08 contexts still carry one role per branch, and the union gates nothing beyond the org-admin sidebar items (limitation 64). | There is no permission enforcement in the prototype to take a union over. | One user_role_assignments row per role; the permission cache resolves the union at the requested scope on every request. | |||
| 81 | A screen opened as a modal has no address of its own. S-20, S-22, S-24, S-26, S-31 and S-32 in the station portal, and A-04 and A-06 in the admin portal, open over their list without changing the URL, so a refresh or a copied address shows the list with the modal closed. Each screen's own route renders it standalone instead, and that is what the preview index links to. | Keeps each screen at one URL for the sign-off checklist without a routing layer for background locations. | Route-driven modals: opening one pushes the screen's URL and keeps the list as the background location, so a refresh or a shared link reopens the same modal over the same list. The standalone page is then only the cold-start rendering. | |||
| 82 | S-25 does not list pending invitations. An invitation sent from S-26 appears nowhere until it is accepted on S-07; S-25 confirms it with a status line only. | The review walks an invitation straight through, from the link to the new colleague. | S-25 lists pending invitations under the team, each with resend and revoke, and one expires after a fixed period. | |||
| 83 | C-25 Cancel booking is a full page — a post-freeze UX candidate, not a defect. Its one input (the reason) and its refund preview could sit in a danger confirmation over C-23, the way the portals confirm a consequential action. | The customer app is mobile-first and keeps single-record forms as full pages by design (CLAUDE.md), and C-25 matches the pack: a T-DETAIL with a refund preview, a reason select and an outlined danger confirm. | Unchanged at the design freeze. Afterwards, weigh a danger confirmation over C-23 holding the refund preview and the reason, with the same refund rules and C-22 as the landing. | |||
| 84 | A station registered on A-05 exists in the admin app's store only. The organization, its first branch and its approved registration show on A-05, A-03 and A-06 at once, but the station portal cannot act as it, the customer app does not list it, and no admin or staff account is created for it. Live (C1.8): it is made on the API — the organization (created approved), its documents uploaded, its first branch — but with no admin: the API has no field for another person (limitation 164). | Separate hostnames, separate stores (limitation 33); inviting the organization's first admin is a flow of its own. | One transaction creates the organization, its station and an invitation for its first org admin; the branch is listed once it has hours and services. | |||
| 85 | A-03's age and completeness filters read the fixture, not a clock. "Waiting" is each registration's waiting_days, and completeness is checked on every request against the document types A-10 requires now — so making a document type required moves registrations into "Missing required" at once. Live (C1.8): "waiting" is the API's age_days, and completeness is each registration's documents against the catalog's required types, both filtered in the adapter over every page. | The filters' behaviour is what needs agreeing, over a set small enough to reason about. | Age from submitted_at against now, and a completeness flag stored per registration when it is submitted or its documents change, indexed for the queue. | |||
| 86 | The desktop booking rail summarises; it does not navigate. From lg, C-13 to C-17 put the choices on the left and a sticky rail on the right holding station, service, vehicle, arrival and price as they become known, with the step's action in it. The rail's rows are read-only: an earlier choice is changed with the back arrow, not by clicking the rail. Its price is the chosen package's until C-15 takes a hold, after which the price and the token both come from the hold. Below lg the steps are unchanged — one column and the sticky footer. | The rail's job in review is to show that the flow keeps its context on a wide screen; making every row a jump-back target is a navigation design of its own, and one the pack does not specify. | Each rail row is a link back to its step, with the steps after it invalidated on change, and the hold re-priced when the package or vehicle changes. | |||
| 87 | On a phone, C-09 with a single result puts its pill in the map's top-left corner. With one station there is no bounding box to project, and the projection falls back to the corner. The desktop split view centres a lone pill; the phone was left as it was. | The rule for this app is that nothing below lg changes in a desktop pass, and the phone rendering is compared byte for byte against its baseline. | The real map SDK (limitation 10) fits its bounds to the results, which centres a single marker at a sensible zoom on every screen size. | |||
| 88 | Signed in is a flag, not a session. One client-side boolean decides whether a screen wears the public chrome or the app's. The sign-in screens (C-37, C-05) set it, the account menu's Sign out clears it, and a full page load starts signed out again — so a deep link to a public screen shows the marketing header, and walking the sign-in flow switches the chrome for the rest of that visit. | There is no auth anywhere in the prototype (rule 1), and a persisted flag would mean a reviewer landing on C-01 in whatever state they left it, which is worse for a review than starting where a visitor starts. | A real session: the server knows who is asking, the header renders from it, and a refresh keeps you signed in. | |||
| 89 | The search view carries the one-line footer, not the four-column one. C-08 and C-09 fill the window below the bar — list scrolling on one side, map on the other, no page scroll — and under the split sits SiteFooter's compact bar: copyright, the language switch and the agency credit, 55px tall. The four columns of links would either squeeze the map or reintroduce the page scroll the split view exists to avoid, and inside the list's scroller they would scroll away with the results. | The view is viewport-locked by design (the map is the point of it), and a marketplace search view is the one page where a tall footer costs more than it gives. | The same shape: a compact bar under a full-height results view, the full footer everywhere else. | |||
| 90 | The footer's "Service categories" goes to C-10, the filters screen. The category filter lives in memory (limitation 19's FiltersProvider), so a link cannot arrive at C-08 with a category already applied; C-10 is where a category is chosen, and its "Show results" lands on C-08 filtered. | A link that set a filter would need the filter in the URL, which is a routing change the pack does not ask for. | /search?category=… — the filter reads from the query string, so any link (or a shared URL) can open filtered results directly. | |||
| 91 | Every instant renders in Asia/Colombo, from a constant. The three format.ts files pin timeZone: 'Asia/Colombo', and a timestamp that carries no zone is read as station-local rather than as the viewer's midnight. Wall clocks that are not instants — S-21's band editor, the weekday names — are built and read in UTC so they render exactly what was passed in. | This is the product rule, not a prototype shortcut. A booking platform renders the *station's* local time: a customer booking from Dubai or London for a car in Colombo must see the Colombo window, and two screens in one portal must never disagree about the same booking — S-10's calendar axis (timezone-free template strings) read 8:00 AM while S-12's detail for a booking in that grid read 2:30 AM. Before this, every date and time in all three apps rendered in the viewer's timezone, and some bookings landed on the wrong day. | Production keeps the rule and drops the constant: the zone comes from the station record (stations.timezone), so a platform that one day operates outside Sri Lanka still renders each booking in its own station's time. The formatters already take the zone as a parameter in everything but name. | |||
| 92 | Browser Back is trapped in two places in checkout, and nowhere else. C-20 pushes a history entry and sends Back to C-22, so a confirmed booking can never step back into the gateway that paid for it. C-18 does not trap Back: it reads the booking instead and answers with a state — "We are confirming your payment" while an outcome is in flight, "This payment has already gone through" once it has landed — each with one action forward. Everything before C-18 is ordinary history: Back is how you change an earlier choice, and the hold survives it. | A prototype cannot intercept a browser's Back for a payment the way a real checkout does (a server-rendered redirect, or a gateway that owns its own page), and trapping Back everywhere would break the one thing the flow is for — changing your mind about a step. | The gateway is the processor's own page, so Back inside it never reaches ours; our return URL is a server-side redirect that resolves the payment once and lands on the confirmation, and the confirmation is a fresh navigation with no gateway behind it. | |||
| 93 | The job that sweeps unpaid bookings is a button. An online booking whose advance never arrives keeps status = pending with payment.status = initiated and reads "Awaiting payment" on C-22, C-23, S-11 and S-12 (AC-58515 is seeded in that state). C-23 carries a muted "Simulate the expiry job" control that fires POST /bookings/:ref/payment/expire — the booking is cancelled, the payment marked failed with "Payment window expired — booking released", and a history event recorded against System. Nothing expires on its own, and the five-minute hold is not what releases it. | The sweep runs on a schedule in production; a reviewer has the seconds they are looking at the screen, not the minutes the real window takes. | A scheduled job cancels bookings whose payment window has passed, releases the slot, notifies the customer, and writes the same status event. The control does not ship. | |||
| 94 | "Awaiting payment" is a badge beside the status, not a status. The eight booking statuses are a fixed semantic set (CLAUDE.md rule 2) and a held-but-unpaid booking is genuinely pending, so the extra fact rides as a tinted caution badge — on the customer's list and detail, and on the counter's list and detail. The flag itself is computed by one rule (isAwaitingPayment) and carried on list rows as awaiting_payment, because a row has no payment object to read. | Adding a ninth status would change every status filter, chip and report in three apps for a fact that is really about the payment, not the booking. | The same rule, resolved server-side: the list endpoints project it, and the counter's queue can filter on it. | |||
| 95 | A garage row is not a link, because it carries buttons. C-27's rows hold Edit (active) or Restore (sold), and an anchor cannot contain a button — so the row itself is not clickable and the plate is the link into the vehicle's history (C-29). The portals' lists with per-row actions are drawn the same way. On a phone this means the card is no longer tappable end to end: the plate and the button are. | The alternative is an overflow menu per row, which is a component the customer app does not have and a second tap for the one action a customer actually wants there. | Unchanged in shape. A production list would add a row menu for the longer tail of actions, with the plate still the link. | |||
| 96 | Restoring a sold vehicle asks nothing. "Mark as sold" confirms in a ConfirmDialog that names the plate and says what happens — it leaves the booking picker, its reminders stop, its history is kept — because it removes something. Restore gives it back and is reversed by one click, so it happens on press. | The confirm rule is for actions that deactivate, hide, delete, reject, revoke or approve; a restore is none of those. | The same. | |||
| 97 | A skeleton is replaced, not cross-faded. The content that arrives fades in over --duration-base; the skeleton under it goes in the same frame. A true two-element cross-fade needs a wrapper box to stack them in, and a wrapper box moves the phone layout this project compares byte for byte. | The reveal reads as a dissolve at 200ms and costs no layout risk. | The same reveal, or a view-transition once the browsers we support all have it. | |||
| 98 | The reveal is applied where a template owns the loading state, and on the screens whose skeleton a reviewer meets on the way in. Every T-LIST comes through ListPage, so both portals' lists have it; C-22, C-27, C-29, S-11, A-02, A-05 and A-07 roll their own list markup and were given it by hand. A screen not in that set still swaps its skeleton for content in one frame. | Each self-rolled list is a one-line addition, and the ones left are screens a review reaches after the lists that matter. | Every loading state goes through one component, so the reveal is not a per-screen decision at all. | |||
| 99 | Only the dialog animates out. useMotionPresence holds it mounted for one exit duration; everything else — a menu, a popover, the phone's sheet — is unmounted on the spot and simply goes. | An exit is the least valuable half of a transition and the most expensive to hold state for, and a menu that lingers after a click reads as lag. | The same, or a view-transition-driven exit once it is one line per surface rather than a hook per component. | |||
| 100 | No announcement is delivered, on any channel. A-12's delivery method is recorded on the announcement and rendered as badges, and the SMS counter meters the message exactly as a gateway would (160 GSM-7 / 70 UCS-2, 153 / 67 per part once it splits) — but nothing is sent: no SMS leaves the prototype, no email, and the in-app copies appear on no screen (limitation 77). | There is no notification pipeline behind the prototype and no SMS or email provider (limitation 3). What needed agreeing is the composer: which channels, what a message costs, and what a withdrawal can and cannot recall. | Fan-out through the notification pipeline per channel, with per-recipient delivery records, gateway part counts on the invoice, and withdrawal cancelling only what has not left. | |||
| 101 | A scheduled announcement is never sent by a clock. Choosing a date and time stores scheduled_at and the announcement sits in the list as Scheduled, editable and cancellable — and it stays there for ever, because the prototype has no scheduler. Nothing marks it sent when the instant passes. | The same reason as limitation 93's sweep: a job that fires minutes or days later cannot be demonstrated in a review, and the state a reviewer must agree is the one before it fires. | A scheduler sends it at scheduled_at, flips the row to sent, counts the recipients, and leaves the edit and cancel actions refusing from that moment. | |||
| 102 | A technician's photograph is their initials. S-26 and S-31 collect a public profile and C-11 renders it, but the photo field is read-only and the card draws a monogram from the display name — the same rule as the station photos (limitation 9): the prototype invents no faces. | There is no upload anywhere in the prototype, and a stock portrait standing in for a named person is worse than an initial. | An uploaded photograph on station_staff_profiles.photo_path, served from the same bucket as the station photos, with the monogram as the fallback. | |||
| 103 | Branch facilities are platform master data on three separate stores. A-25 writes the list in the admin app's store; S-17's toggles and C-11's chips read the station and customer apps' own copies, which start from the same seed. Deactivating a facility in the admin portal is therefore not visible in the other two apps in the same session — each app honours active in its own store. | The same root cause as limitation 33: separate hostnames, separate stores. | One backend: A-25 writes facilities once and every app reads it. | |||
| 104 | A resolved ticket never closes itself. A-27 stamps auto_close_at at resolved_at + ticket_auto_close_days (A-10's setting, 7 by default) and the screen says which day it falls on — but nothing sweeps it, so the ticket stays Resolved for ever. A-27 offers "Close it now" instead, which runs exactly the operation the job would and appends the same "Closed automatically after N days without a reply" message. | The same reason as limitations 93 and 102: a job that fires in seven days cannot be shown in a seven-day review, and what needs agreeing is the window and the wording, not the cron. | A nightly sweep over status = 'resolved' AND auto_close_at <= now(), closing each row and notifying its raiser; the button does not exist. | |||
| 105 | A ticket notification appears in the customer app only, and only in its own store. A status change or a public reply writes a notifications row, so C-31 and the bell show it — but a station ticket notifies nobody (S-39 has no notification centre in the pack), and a reply written in the admin portal lands in the admin app's store, not the customer app's, so the two cannot be walked in one browser session. Within the customer app the whole chain is visible: raise on C-40, reply through its own store, see the bell. | Limitation 33 again — four origins, four stores — plus the pack giving the station portal no notification surface to deliver to. | One backend and one pipeline: every status change and public reply notifies the raiser in-app and on their chosen channel, whichever app wrote it. | |||
| 106 | A ticket's attachments are file names. C-40's "Add attachment" appends photo-1.jpg, photo-2.jpg; A-27 lists what a ticket carries as names. Nothing is uploaded, stored or viewable — the same rule as S-04's documents (limitation 20) and the technician photographs (limitation 102). | There is no upload path in the prototype, and a placeholder image standing in for the evidence a complaint rests on would misrepresent the screen. | Real uploads to the app-assets bucket, thumbnailed in the thread and opened through signed URLs. | |||
| 107 | Ticket categories are master data on three separate stores, exactly as the facilities are (limitation 103). A category added on A-26 appears in C-40's and S-39's selects only after each app reloads its own seed, so "add a category, then raise a ticket under it" cannot be walked across two origins in one session. | Limitation 33: separate hostnames, separate stores. | One backend: A-26 writes ticket_categories once and both other apps read it. | |||
| 108 | A promo code is validated but never issued. S-41 and A-28 set a code on an offer and C-16 accepts it, but nothing generates one and there is no per-customer single-use code — the same code works for everyone, as many times as the offer's own limits allow. Since U9 a code is required to apply its offer (it never applies on its own), must be 4–12 letters and numbers, and may not be another live offer's (CODE_TAKEN). | Codes in a real campaign come from somewhere — printed cards, an email run, a partner — and none of those exist behind the prototype. What needed agreeing is the checkout behaviour: that a code applies only its own offer and says so, and that a wrong or another station's code is refused in plain words. | Unique codes per campaign, optionally per customer, issued and revoked from A-28, with a redemption ledger that makes single-use real. | |||
| 109 | An offer's status changes when someone looks at it, not when its date passes. scheduled → active → ended is recomputed on every read, so a scheduled offer becomes active the first time a screen asks — there is no job, and nothing happens at midnight if nobody is looking. The same is true of U9's time-based moves: a superseded or replaced offer "resumes" because its suspension's dates have passed, a withdrawal takes effect because tomorrow has come, and a campaign's start does not notify anyone. | The same reason as limitations 93, 102 and 105: the prototype has no scheduler, and what a reviewer must agree is the state, not the cron. | A nightly job flips the statuses, lifts lapsed suspensions and notifies the owner — and each station a campaign reaches — when a campaign starts and when it ends. | |||
| 110 | The customers an offer can be written for are the editing app's own store. S-41's picker is built from that branch's booking history in the station app's store, and A-28's platform-wide picker from the admin app's, so a customer who booked in the customer app in another tab is in neither list until both are reloaded — the usual four-origins problem (limitation 33). The rule it enforces is real and enforced in the handler; only the freshness is not. | Separate hostnames, separate stores. | One backend: the picker reads the bookings, and the station's own-customers rule is a server-side scope rather than a filter each app applies to its own copy. | |||
| 111 | A walk-in is never discounted. S-15 prices at the counter, off the price list: the engine runs on what a customer chose in the app, and a discount given at the desk is the station's to give. So an offer's redemption count and the S-29 discount totals describe app bookings only. U9 leaves this unchanged: whether a campaign or a station offer should reach a walk-in is a decision still open with the client. | The pack gives S-15 no offer picker, and adding one would make the counter the place offers are decided — which is what S-41 exists to avoid. | The counter can apply the one offer that holds the walk-in's slot, with the same engine deciding what is eligible; the totals then cover both. | |||
| 112 | A service record written on S-42 does not reach the customer app. The station portal and the customer app are separate origins with separate stores (limitation 33), so a drop-off or completion recorded at the counter never appears on C-23, C-43, C-29 or C-30 in the same session. The same holds for A-08's odometer decision: the branch is notified on its own dashboard, but the admin store's copy of the record is the one that changed, so the station app's record keeps saying “waiting” until both are reloaded. C-23 carries a demo control — "play the station's part" — that moves the booking to Ready for pickup and writes the record through the same two functions S-42 uses, so the odometer rule, the checklist snapshot and the automatic reminder a reviewer sees on the customer side are the real ones. | Separate hostnames, separate stores. | One backend: the station writes service_records once and the customer reads the same row; the demo control goes. | |||
| 113 | Service-record photos are file names only. S-42 takes files through a real picker but keeps only their names; C-43 and the report list the names rather than showing images. | The prototype stores no uploaded files. | Upload to R2 under stations/{id}/records/, served to the station and the owning customer through signed URLs; thumbnails on C-43, full size on tap. | |||
| 114 | The vehicle report is shared by handing it over. "Download vehicle report" opens the browser's print dialog (save as PDF), like C-26's invoice; choosing "for a buyer" leaves out the owner's name, phone and prices. There is no share link, and nothing leaves the account unless the customer passes the document on. | No PDF pipeline and no public pages in the prototype. | A server-rendered PDF, and optionally a revocable, expiring share link the owner creates for a named buyer — still owner-initiated, per the account-scoped history rule. | |||
| 115 | The record's author is always the demo counter user. S-42 writes recorded_by: Sanduni Fernando and the override request names her, whoever is at the counter — there is no session to take a name from (rule 1). | No auth in the prototype. | The signed-in staff member or PIN session, with the device, as every station write records. | |||
| 116 | A change of ownership can only be walked from the buyer's side. The engine is live: adding a plate another account holds retires that account's row, stops its reminders and writes its owner a notification, all in POST /me/vehicles. What cannot be walked is *being* that other owner, because there is no second account to sign in as (limitation 1) — so the seller's three screens are reviewed on a seeded row instead, WP KM-3360 in the demo garage, superseded three days ago with its notification and its stopped reminder already in the store. | One store, one signed-in account. Seeding the outcome is the only way to put C-27's banner, C-31's notice and C-30's stopped reminder in front of a reviewer, and the alternative — a demo button that supersedes your own car — would be a control the pack does not have and the product never will. | Both sides are the same code path and always were; with real accounts the seller simply signs in and sees what the seeded row shows. | |||
| 117 | A supersede cannot be undone from any screen. If a plate is typed by mistake, the previous owner's row stays stamped: superseded_at is only ever set, and restoring the mistaken row would need to work out which other row to un-retire. Archiving or deleting the mistaken vehicle does not give the plate back. | The prototype has no evidence behind a claim — no registration document, no OTP to the previous owner, nothing to reverse a claim *against*. Building an undo on top of an unverified claim would design the wrong thing. | A claim is evidenced (registration document, or an OTP to the number on record) before it supersedes anything, and a disputed claim is reversed by support against the vehicle_transfers ledger — which is also what makes an undo decidable. | |||
| 118 | The brand is wired, the white-labelling is not finished. Every logo, favicon, page title, manifest and on-screen name now comes from @autocare/config's brand.ts and the Logo component, and pnpm brand:sync copies the kit into each app — so re-skinning is one module plus one folder. What is still in place is the preview-only chrome: the footer's agency credit and the portals' sidebar credit, the "All screens (design preview)" links into the index hub, the "Preview build — for review only" footer line, and /admin/preview itself. | They are the review harness. A reviewer needs to get between the four apps and to see the design system, and that stops being true only when the review does. | U8 removes them, together with the noindex headers, and the wordmark spelling is settled first (packages/config/brand/README.md: the artwork reads "A-channeling", the registered brand is "AutoChanneling"). | |||
| 119 | The brand kit has no horizontal lockup, so chrome rows use the mark and the name. achanneling-logo.png is a stacked lockup (782×517): at the 24–40px a site bar or a portal sidebar's brand row allows, its wordmark is a few pixels tall. The customer site bar, both portal sidebars and the loading splash therefore render <Logo withName /> — the mark at a legible size with brand.name beside it as text — and the lockup itself is used where there is vertical room: the auth cards and the printed documents. | Scaling the supplied lockup to fit a bar makes it unreadable, and cropping a client's logo to invent a horizontal one is not ours to do. Nothing is hardcoded either way: the mark is a brand file and the name is brand.name. | A horizontal lockup from the client, asked for with the vector master (packages/config/brand/README.md). <Logo withName /> then becomes <Logo variant="full" /> at those four call sites and nothing else changes. | |||
| 120 | Resolved in U2 — station.bill.discount now governs S-13's discount field. The field is enabled only for someone holding it (a PIN session's person first, then the signed-in account), and otherwise unlocks with a manager's PIN (limitation 122); the handler checks the same roles and refuses with 403 whatever the screen sends. It remains grantable to a custom role on S-38 and refused to every platform role (canGrant). | — | — | |||
| 121 | The item catalogue is per branch and is not stock. S-43 holds what a branch sells and what it charges — name in three languages, unit, price, code. It does not hold how many there are: no quantity, no reorder level, and billing an item on S-13 (U2) moves nothing — a line on a bill is a sale, not an issue from stock. | The bill needs a name, a unit and a price; a stock ledger is a module, not a field, and inventing a quantity that never moves would misrepresent the screen. | Stock is its own module with receipts, issues and a running balance per branch; the catalogue row becomes the item master it hangs off, and a bill line issues from it. | |||
| 122 | The manager-PIN unlock is session-only, and any 4–6 digit PIN unlocks it. "Unlock with manager PIN" on S-13 lists the branch's people who hold station.bill.discount; choosing one and typing a PIN enables the discount field for this bill, on this visit to the screen — the unlock lives in S-13's own state and is gone on the next load. The PIN is not checked against a stored one (rule 1, as S-33), and there is no lockout after wrong attempts. The handler does check that the named manager is active at the branch and holds the permission. | No auth in the prototype. | The PIN is verified against staff_pins with S-33's attempt counter and lockout; the unlock is a short-lived, bill-scoped grant the server issues and records, not screen state. | |||
| 123 | Online balance payment is simulated, like C-18. C-23's "Pay balance" runs the real C-18 → C-19 → C-21 screens, but the gateway is the demo outcome: "Pay" succeeds and "Simulate a failed payment" fails, each settling on C-19's poll after about 2.5 seconds. S-13's "Payment link" is a method the counter records, not a link that is sent. | The prototype has no gateway (limitation for C-18). | PayHere for the balance as for the advance, confirmed by its webhook; "Payment link" generates a PayHere link the counter sends by SMS and the payment row is written when it is paid. | |||
| 124 | A bill written on S-13 does not reach the customer app, and a balance paid on C-23 does not reach S-12. The station portal and the customer app are separate origins with separate stores (limitation 33). C-23's existing demo control ("play the station's part") therefore also writes the counter's bill — the parts its service record lists, as item lines at today's prices — so "Your bill", "Pay balance" and C-26 can be walked end to end in the customer app. The bill's own seeds (AC-53910 settled, AC-58201 owing) are in both stores. | Separate hostnames, separate stores. | One backend: the counter writes booking_items once and C-23 reads the same rows; the demo control goes. | |||
| 125 | Who and on which device a bill change was made is approximated. The signed-in account has no name (rule 1), so bill rows name the staff member who holds the active branch context's role there (Dilani Weerasinghe at Nugegoda); a PIN session names its person. The device is the branch's first live terminal for a PIN session and "Station portal (browser)" otherwise — there is no device token to read. | No auth and no device registration in the prototype. | The authenticated user and the device token's device_id, as every station write records. | |||
| 126 | online_balance_payment has no switch yet. The branch flag exists, is on by default and is honoured by C-23 and the handler (409 ONLINE_BALANCE_OFF); Rajagiriya is seeded off. S-17 does not draw a toggle for it — nor, today, for accepts_pay_at_station, which it sits beside. | U2 was scoped to the bill; the branch profile's payment settings are one decision, not two toggles added one at a time. | S-17 gains a "Payments" group carrying both switches. | |||
| 127 | Resolved in U4 — Tamil is a language. ta is selectable on C-02, C-33, the public bar's switch and both portals' user menus, named தமிழ், rendered in self-hosted Noto Sans Tamil, with dates and times in Tamil and English for every key not yet translated. ta.json carries only real Tamil now (the U2 English placeholders are gone): drafts for the shared chrome, C-02, C-33 and the bill (limitation 131). An item's own Tamil name still prints from the S-43 catalogue, on C-23, C-26 and S-16, in the booking's language. | — | — | |||
| 128 | The seed's item trade is generated. Beyond the two bills written out (AC-53910, AC-58201), every third completed booking at the demo organization's three branches carries one of seven baskets — oil and filters, wipers, coolant, brake pads, gear oil, fuel and air filters — and every fourth of those a 10% loyalty or price-match discount, so item sales and item discounts have a period's worth of figures to report. The demo customer's bookings and the two other cars with service records are left alone, because a bill must not disagree with the parts its record lists. | A report over one bill is not a report; inventing a plausible counter trade is the only way S-29, S-35, S-37, S-30 and A-19 have anything to show. | Real bills. | |||
| 129 | Resolved in U4 — a late service record. A booking completed without its record keeps S-42 open, both halves, for service_record.late_completion_days after it was completed (A-10, 7 by default). The completion sets the reminder exactly as on time (writeCompletion → applyReminder, the same path) and writes record_completed_late to the history; S-12's nudge stays up until the window closes; after it S-42 says when it closed and what it cost, and the endpoint refuses the write (409 LATE_WINDOW_CLOSED). The reminder reaches the station app's store only — the customer app is another store (limitation 33), so C-30 shows a late record's reminder through C-23's demo control, not from S-42. | — | One backend: the reminder written late reaches the customer's vehicle like any other. | |||
| 130 | A-02's "Open support tickets" is a count of now, not of the period. It counts every ticket open or in progress whatever range the pills select, because a queue has no period; its link opens A-26 filtered to those two statuses, over A-26's default 90-day range, which every seeded ticket falls inside. A-19's Support tickets report is the period view: by subject (the ticket's category) and raiser type, with the open ones' mean age and the mean times to first public reply and to resolve. | A backlog tile that shrank when someone chose "Today" would be wrong. | The same, with A-26's range widened to "all time" when it is opened from the tile. | |||
| 131 | The Tamil in ta.json is the development team's draft, not the client's translation. 98 keys — the shared chrome, C-02, C-33 and the customer's bill — were written so the Tamil font, line heights and layout could be tested (U4's locale crawl); ta.drafts.json lists them, and pnpm i18n:export marks each "Draft by the development team — please check it, or replace it". pnpm i18n:import takes a draft off the list once the translator changes it; one returned exactly as it went out stays a draft, because a workbook cannot say whether it was read. The Sinhala added in U4 for new portal labels is likewise ours. | The client's translator (Mr. Rajan) supplies Tamil; without some Tamil on screen there was nothing to test the script against. | The translator's workbook, imported; the drafts list empty. | |||
| 132 | Amounts and quantities keep English digit grouping in every language — LKR 1,227,910, not the lakh grouping (12,27,910) the ta and si locales give. Dates, weekdays and times follow the language (localeFormat, U4); digits stay Western in all three. Units (FE-I18N-1): the words beside a figure go through i18n — units.km ("{{value}} km", Sinhala and Tamil drafts "කි.මී." / "கி.மீ."), units.minSuffix, units.kb / units.mb — and the currency code beside a price input is units.currency, kept "LKR" in all three languages, as formatLkr writes it. | A rupee figure is written one way in Sri Lanka whatever the language around it, and a bill that grouped differently by language would read as a different amount. | Confirmed with the client at design freeze; one line in each app's lib/format.ts if they want it otherwise. | |||
| 133 | Only the two non-Latin scripts are self-hosted. Noto Sans Sinhala and Noto Sans Tamil ship with each app (Fontsource, the script subset, 400–700, font-display: swap); Inter is still loaded from Google Fonts. | The scripts are what break when a CDN is blocked — a Latin fallback still reads; Inter's move is the production-hardening item CLAUDE.md lists. | Inter self-hosted the same way, with preload for the two weights above the fold. | |||
| 134 | The public site bar's four links go behind the menu button when they do not fit. The bar measures itself: in Tamil the links wrapped to three lines and Create account ran off the edge at 1024, so from lg they now collapse into the phone's menu sheet whenever the row overflows, in any language — including English at 1024 since the language switch gained தமிழ். They come back once the row is wide enough. | A bar that cannot hold its links must not clip them or wrap them; a measured collapse holds whatever a translator writes. | The same, or shorter labels agreed with the translator. | |||
| 135 | "Screen(s) where used" in the translation workbook is read from the code, not from the running app. A key is traced from every quoted occurrence in the three apps and @autocare/ui to the screens whose component imports that file; 118 of 2,852 keys are reached only through a name built at run time and are listed "not found by the scan". | It is what can be known without rendering every screen in every state. | The same, plus the keys the crawl records as rendered. | |||
| 136 | The 48-hour notice is measured by the browser's clock. A station-funded campaign must start at least 48 hours after it is written; the check compares the start date's midnight in Colombo with Date.now() in the admin app's tab, and the stations it reaches are "notified" by a row written into a notices list at save time — no SMS, no email, and nothing re-checks the notice if the start date is later moved. | There is no server time and no messaging in the prototype; what the client must agree is that the notice exists and that a branch can decline inside it. | Server time; the notice is checked on every change of start date; each reached station gets an SMS and an email with a one-tap decline, and the decline window closes when the campaign starts. | |||
| 137 | Supersede and resume are a demonstration control, and they stay on the origin that made them. A platform-funded campaign written on A-28 stands a station's offer down in the admin app's store; S-40 and S-09 run on another origin with their own store (limitation 33), so the station app shows the seeded state, not what was just superseded, and the notices A-28 writes do not reach the station app. Pausing or ending the campaign on A-28 lifts the suspensions at once — that is how "resume" is demonstrated — and A-28's detail shows both sides. | Four origins, four stores. The rule (only when the platform pays; only for the overlap; both histories record it; the station is told) is real in the handlers, and the walk proves it on A-28. | One backend: the station's S-40 and S-09 read the same suspensions and notices the admin wrote. | |||
| 138 | The clash check runs in the browser and is not safe against two saves at once. findClashes compares the candidate with every live and scheduled offer in the page's store, and the save commits if nothing is left — two editors saving overlapping offers at the same moment on a real backend could both pass. | The prototype has one user per store; what the client must agree is the rule and the four ways a clash is resolved. | The check runs in the save's transaction over the offer_slots view, under a per-station advisory lock, so the second save sees the first and is refused with OFFER_CLASH. | |||
| 139 | A campaign is written once on A-28, not edited. A-28 creates, pauses, resumes and ends campaigns; there is no edit. The handler does re-time the suspensions a campaign holds when it is edited through the API, but it does not re-check the carve-outs written for its first dates. | The brief asked for the create path and its clash resolution; editing a live campaign reopens every decision already made against it. | Editing reruns the Clashes step against the new dates, keeping the resolutions that still apply and asking again for the rest. | |||
| 140 | Two overlap policies are built and unreachable. offers.overlap_policy is a platform setting the engine reads, with exclusive the only value any screen can produce; best_single and stackable are implemented and unit-tested, the stackable flag on an offer is always false and never shown, and A-10 does not list the setting. | With one offer per slot, at most one offer is ever eligible — the other two policies only matter if the slot rule is ever relaxed, and building them now keeps that a setting rather than a rewrite. | Unchanged until the client asks for stacking; if they do, A-10 gains the setting and S-41/A-28 the flag. | |||
| 141 | "Funded by the platform" is reported, not paid. A platform-funded discount is recorded on the booking (booking_offer_applications.funded_by) and A-19's discounts report says per offer who funded it, but nothing computes or pays a station back — the station's earnings in the prototype still subtract every offer discount. | There is no payout ledger in the prototype at all (limitation 23's neighbour); what needed agreeing is who bears the discount and where it is visible. | The payout run credits each station the platform-funded discounts on its completed bookings, and S-29 shows them as a line of their own. | |||
| 142 | Live mode identifies rows by a per-tab surrogate number. The screens' domain types say id: number — they were transcribed from the contracts when the only backend was MSW, whose fixtures number their rows — while the live API issues UUIDv7 strings. @autocare/api's surrogate map gives each UUID a stable number for the tab and translates back on the way out, so no screen changes. It is kept in sessionStorage, so a reload keeps every link on the page working; a new tab, or a link copied into one, cannot resolve a surrogate minted elsewhere. The routes that carry one are the vehicle (/garage/<id>, and its edit/reminders/report children) and package ids; bookings route on reference_code and stations on slug, which are strings in both backends and pass through untouched — which is why the M3 booking path never mints one. | Widening the types to `string \ | number` was tried and backed out: it compiles to ~148 id declarations and ~90 further changes inside the mock's own arithmetic, indexing and fixture builders, in the package that serves every demo screen. That is a migration, not a slice. | C2 moves the domain types to UUID strings (and money to cents), toLocalId becomes the identity function and the map is deleted. The 148 declarations are listed by field and file in the C1-M3 report. | ||
| 143 | The branch's item catalogue is guarded against double-submit on the client only. Since B7.1 the API honours Idempotency-Key on POST /bookings, a cancel, a payment intent and every counter write — status, ETA, walk-in, bill lines, discount, counter payment — and the live mapping sends one key per press on each (once() in packages/mocks/src/live/guard.ts hands the write one Attempt; a transport retry carries the same key, so the API replays rather than writes twice). The item catalogue's create and edit on S-43 take no key on the API, so for them the client guard is all there is: an identical request in flight shares the first one's promise, and for 1.5 s after it settles an identical request gets the same answer — what a double click lands on. "Identical" is the route and the whole body, so two different items are two items. The screens also disable their control for the round trip. | The guard belongs on the server, where it survives a lost response and a second tab; a client flag does neither. Every money-moving and booking write now has it; the catalogue is the remainder. | Idempotency-Key on POST/PATCH /stations/{id}/items, with the replay semantics the other writes have. | |||
| 144 | Live mode is a hybrid: an unwired screen runs on fixtures and says so. In live mode the routing table (packages/mocks/src/live/routes.ts) sends what it maps to the API; MSW still runs, passes the API's origin straight through, and answers only the paths nobody has mapped. Such a screen renders on demo data, and its ScreenIdBadge carries a caution "Demo data" chip naming the unmapped calls. Writes on those screens act on the fixtures, which do not know the API's bookings, so they can fail with the mock's own not-found. Mapped in C1-M3: groups (a)–(f) — customer auth and profile, discovery, the booking flow and the customer's bookings, the station counter, the bill and the garage. | A screen that went blank or broke for want of a mapping would make live mode unusable until every screen was wired; one that silently showed fixtures would look live when it is not. The chip keeps it usable and honest. | Every path mapped, and MSW gone from the live build entirely. | |||
| 145 | The online payment goes to the gateway and returns to C-19's own route. C-18, in live mode, opens the payment intent and sends the browser to the gateway's page; the gateway returns it to /book/payment/processing/?intent=…&status=… (the API's PAYMENT_APP_RETURN_URL), where C-19 polls GET /payments/{id} — the return's status is never believed. Because that is a full page load, the booking flow is kept in sessionStorage in live mode (mock mode keeps it in memory as before). Locally the gateway is the API's fake one, with Pay / Fail buttons. A balance paid online from C-23 takes the same path. | It is the real path, so the hand-off, the return and the poll are exercised as they will run; only the gateway is fake. | The bank's hosted page (BOC IPG) behind the same intent, and the return URL set per environment. | |||
| 146 | S-09 is derived from the bookings list. The API has no dashboard endpoint, so live S-09 counts today's windows, the bays and what waits for a yes from GET /stations/{id}/bookings, and reads the bill of each completed booking in the last 30 days for its earnings — the one definition, bookingEarnings. A list row elsewhere (S-29, S-35, S-37) carries package money only: the list has no bill. | Affordable at a branch's volume, and it keeps the definition of earnings in one place rather than approximating it. | A report endpoint that returns the period's figures, so no screen reads bills one by one. | |||
| 147 | What the API does not have yet is answered honestly, not decorated. No offers (C-07's and C-11's strips are empty; every C-16 promo code is unknown), no reviews (C-12 is empty), no service records (C-23 says none; S-16's job card carries none; S-42 stays on fixtures), no station notices on S-09, no plate-check endpoint (C-28 checks against the customer's own garage only — free, already here, or sold here; a plate on another account is allowed and reads as free), no "send the customer a link" for a balance (the customer pays online from C-23). | Each is the live truth today; inventing data for it would make a live screen lie. | The offers (B11), reviews and service-record slices on the API, and the screens reading them as they are. | |||
| 148 | Some API values are folded into the screens' vocabulary. rejected reads as Cancelled (the design system has eight statuses; the cancellation says who and why). LPG reads as Petrol (the screens offer four fuels). A booking card has no city. A station's public page gives opening intervals, not windows, so C-11's hours are bands of 60-minute windows with a capacity of 1, and C-15 pages 14 days (the availability endpoint's maximum per call). Staff appear on the API by id only: the station resolves them to names through the branch's staff list; the customer's views show none. | The screens' types were written against the fixtures, and changing them is C2's migration rather than this slice's. | The domain types widened to the API's own values (C2). | |||
| 149 | Live search and the PIN tablet have local simplifications. Search asks for the API's widest radius, 50 km around its default origin (Colombo Fort), because the customer app does not ask for the visitor's location. A browser becomes a branch's counter tablet by registering on S-32, which keeps the device token in that browser's localStorage; the PIN session it then holds replaces the account's access token and has no refresh token of its own. When its token is refused it is not refreshed — the account's refresh token would turn the staff member into the account holder, with the account holder's permissions — so the PIN session ends and the tablet returns to S-33, where the staff member enters their PIN again (session.expirePin, pinned by packages/api/src/pin-session.test.ts). The account's refresh token is used only while no PIN session is open, which is the lock screen's own state. A walk-in is made against today's windows only, so after hours there is nothing to book. S-01 does not handle a second factor (no station role requires one). | Each keeps the flow working on the local stack without a feature the slice does not need. | Location-aware search, a device token held by the device rather than the page, the 5-minute idle lock the contract specifies (the PIN session today ends only when the API refuses its token), and 2FA on S-01. | |||
| 150 | C-11's availability teaser is mapped but not shown — a candidate enhancement. The customer contract has C-11 read "the next-3-days availability teaser", and the live station page carries it (availability_teaser: the first three days with a window a customer can still take). C-11 was signed without one, so live mode maps the data onto StationDetail.availability_teaser and the screen renders nothing new; mock mode carries no teaser at all. | C-11 is a signed screen; a new section on it — at 390 above all — is a change to the sign-off list, which is the client's to ask for, not a wiring slice's to add. | A teaser on C-11 ("Next free: Thu 9:00 · Fri 8:30 · Sat 10:00", each opening C-15 on that day) if the client asks for it, with the mock's station payload computing the same three days. | |||
| 151 | A live customer build serves the API's references through shell pages; the live dev server renders them on request. /bookings/[reference], /stations/[slug] and /garage/[id] (and their sub-pages, 11 routes) prerender the fixtures' references, slugs and ids (generateStaticParams), which is all a static export can serve, and the API mints its own (AC-70001 onward, its own slugs, surrogate vehicle ids). So next dev in live mode drops the export and renders them on request; and since C-OPS-1, when the four preview sites went live against https://achanneling-api.ithilam.com, a live build also prerenders each of those routes once under the placeholder _ (withShell), scripts/live-redirects.mjs generates out/_redirects from the route tree — a 200 rewrite of /bookings/:reference/… and its index.txt RSC payload onto /bookings/_/…, static siblings such as /garage/new/ pinned first — and the page gives the screen the param from the URL on the client (ShellParam). The shell renders nothing until it is in the browser, so a live deep link to one of these routes paints its content one tick later than a prerendered page; a mock build has no shell and no _redirects, and its export is byte-for-byte unchanged. | A static export cannot know ids it was never given; the shell is the smallest change that serves them from Cloudflare Pages without a server. Proved by the harness walk live-export (10 claims, an API-only slug, a real booking by client navigation and cold load, /garage/new/). | The production frontend is served by something that resolves a route at request time (a server, or an edge function), the shell and its rewrites are deleted, and the static export becomes the demo's alone. | |||
| 152 | text-positive still letters text in 57 places, where the rule is text-positive-text. The success colour is a fill colour; text takes its -text companion (CLAUDE.md rule 2). The customer app has 10 (8 lettering — the booking flow's discount and saved lines on C-16 and the flow rail, C-23's paid line, C-32/C-33 "saved", C-41's ticket status — and 2 icons, C-20's tick disc and C-18's gateway lock), the station portal 22 (19 lettering, mostly the "saved" status lines; 3 icon discs), the admin portal 24 (all lettering, including A-01's KPI deltas), and StatePage's positive tone 1. An icon is a graphic, so its 3:1 bar is met; the lettering is under 4.5:1 on white. | Deliberately not changed in C1-M3 or FE-M3.1: the customer screens are compared byte for byte against the signed-off 390 baseline, and a sweep across 57 call sites is its own change with its own visual review — not a rider on a live-mode fix. | One pass replacing the lettering uses with text-positive-text (the icons may keep the fill hue), reviewed at 1440/1920/390 with the 390 diff read as an intended change, and an ESLint rule that refuses a bare text-positive on text. | |||
| 153 | The route guard runs in live mode only, and decides on the client. Since C1.1 each shell asks one function (guardRedirect in @autocare/api) where to go: a customer screen without publicChrome, a station app/full screen, an admin app screen needs a session — without one the visitor lands on C-37 / S-01 / A-01 with next= (and reason=expired when the session has just ended, which the sign-in screen says in an info notice); a signed-in visitor on C-03, C-05, C-37, C-38, S-01 or A-01 goes on to next or the home screen. The X-* states are never guarded. S-33 stands while the tablet's account can still be resumed. While a redirect is pending the screen does not render, so it fires no calls. Mock mode has no guard: the demo is reviewed by deep link, signed in or not. | The guard is a convenience over the API's own refusal, which is the security boundary: every protected call 401s without a bearer whatever the client shows. Mock mode has no session to guard, and the reviewed renderings must not move. | The same rule at the edge — a server-rendered or edge-middleware redirect before the page is sent, so a protected screen's shell is never served to a visitor without a session. | |||
| 154 | The refresh token lives in localStorage. The access token is held in memory and dies with the tab; the rotating refresh token is kept in localStorage (autocare.refresh) so a reload resumes the session (C-06). Any script on the origin can read it. A failed refresh clears it and ends the session, and the guard (limitation 153) then sends the customer to sign in with "Your session has ended". | Acceptable against a local API for the C1 slices; it is the one storage decision that keeps a reload signed in without a server of our own. | C2: the refresh token in an httpOnly, Secure, SameSite=Strict cookie scoped to the refresh path, set and rotated by the API, so no script can read it. | |||
| 155 | Recovery codes cannot be reissued, and A-01's forgot-password is visual. A platform account's sign-in answers 2fa_required with a step token, which A-01 carries to its code step, and POST /auth/2fa/verify starts the session — with the authenticator's six digits or, since C1.3, with a recovery code ("Use a recovery code instead": one text field, case-insensitive, hyphen optional, normalised to XXXXX-XXXXX by normaliseRecoveryCode before it is sent; A-02 then says once that a recovery code was used, and how many are left only if the API says, which today it does not). An account that has never enrolled (2fa_enrolment_required) is enrolled as a step of A-01 — QR code and key, the first code to POST /auth/2fa/confirm — and its ten recovery codes are shown once before A-02 (RecoveryCodes in @autocare/ui: two-column grid, Download as .txt, Copy all, a required "saved" checkbox), in component state only, with the route guard held (signInHold) while they are on screen. What is still missing is getting new codes: the API has no reissue endpoint (only enrol, confirm, verify and disable) and the portal has no two-factor settings screen, so an admin who has used or lost their codes needs an operator to reset their two-factor. The step token's five minutes are counted down on the code step, and a lapsed token — at zero, or refused as step_token_expired — sends the admin back to the password with the identifier kept. A-01's forgot-password steps validate and declare success in both modes. Mock mode gives every sign-in a five-minute step token and A-14's Support Lead (support@autochanneling.lk) ten seconds, so the expiry can be walked. Mock mode: A-14's Finance Analyst (finance.analyst) is asked to enrol and any six digits confirm; the ten fixture codes the confirm issues each sign in once on any admin's code step. | Reissuing codes needs an API endpoint the backend does not have yet, and a settings screen the pack does not specify. | A two-factor settings screen in the admin portal: reissue recovery codes (re-authenticated with a current code) shown through the same RecoveryCodes component, and disable; A-01's reset wired to the API like C-38's. | |||
| 156 | ?empty=1 empties the mock's lists, not its records. For the zero-data audit (pnpm harness:crawl --empty) every GET the mock answers with a list — a top-level array, or a page — comes back with no rows, sticky for the tab until ?empty=0; records, dashboards' figures and rows nested in a record are untouched, and /me/contexts keeps the station portal's branch so a station screen is measured rather than the "no branch" state. A dashboard can therefore show a count beside an empty list. The crawl excuses a path only on a named screen and for one of two reasons — it feeds a single form field, or the screen looks one record up in it — each written beside the path in EXCEPTIONS (tools/harness/empty.mts); a skeleton is counted by Skeleton's own data-skeleton, so a status dot's designed pulse is not a hang. | The audit is about each list screen's own empty branch; emptying the whole store would measure the shell's missing-data states instead. | Not a product feature — the production empty states are the same components, reached by real zero data. | |||
| 157 | Frontend error reports are live mode only, and say only where and what failed. Since C1.2 each app reports to POST /error-reports (errorReports.ts in @autocare/api): an uncaught error, an unhandled rejection, a chunk that would not load, a render failure caught by X-02 (which then shows the API's request_id as its support reference when the failure was a request), and a request that ended in a 5xx, a timeout or no answer at all — after the transport's one refresh-and-replay. What a report carries: the app, the build (PUBLIC_BUILD_SHA, dev locally), the registry's screen ID and the route's path, the message and stack, the request_id when the cause was an ApiError, the locale, the user agent, and context {kind, viewport, mode, offline, and status/code for a request}. What it never carries: a query string or fragment (stripped, in the route and in every URL in the stack); a phone, an e-mail, a NIC, a bearer or JWT, or a token/otp/pin/password/secret/proof value (masked in the tab before it is sent, and again by the API); form state, localStorage/sessionStorage, cookies or request headers (never read). A 4xx is never reported — it is the API refusing something, shown by useApiError — nor an abort, nor a request that could not reach the server while the page was leaving (beforeunload, lapsing after 3 s, or pagehide until pageshow): the browser cancels in-flight fetches as a page goes, and a reload or a closed tab is not a fault. A tab sends the same message and stack at most once per 5 minutes and at most 30 reports in all, never reports a failure of its own send, and retries a send once at most. Mock mode, and VITE_ERROR_REPORTS=off, report nothing. The three preview app sites are live (C-OPS-1, 31 Oct 2026), so they do report, to the preview API's intake; the index calls nothing. Every report goes by fetch(keepalive) as JSON with the bearer, so the API attaches the signed-in user; a report whose first send failed and whose one retry is still waiting when the page hides is sent by sendBeacon instead, with a text/plain body (CORS-safelisted, so no preflight) — that beacon is the retry, and it carries no bearer, so such a report is stored without its user. `?__fault=render\ | api\ | api404\ | reject` throws on demand (C-01, and any station or admin route) on a dev server in live mode only. | The intake is the backend's B9.1; this is the client half, with nothing the prototype's review needs and nothing a demo site can send. Scrubbing on both sides is deliberate: the tab must not send what the API would have to mask. | A production client SDK in the same shape (source maps uploaded per release so a stack resolves to source, breadcrumbs of the last few navigations — never inputs), the bearer on every report, and the 30-per-tab cap tuned against the intake's daily cap. |
| 158 | No booking of the demo customer carries an offer discount and a counter discount together, so C-26 never shows both. The fixture's only one that does, AC-61252 (Nugegoda: Quiet hours' 20% on the package, a 10% price-match discount on its brake pads), belongs to another customer, and the customer export prerenders the demo customer's bookings only. The bill walk proves the combination on S-12's bill instead — the same figures an invoice would carry — finding the booking by that description through the mock API rather than by reference; AC-61252 is the same booking on every day (limitation 163), and fixture-identity.test.ts holds it to being the only one. (This entry used to name AC-61282 and AC-61362: written on another day, before references were stable; on 3 Nov 2026 neither carries a discount.) Separately, the mock's GET /me/bookings/{ref}/invoice does not check whose booking it is. | One signed-in account (limitation 1), and fixtures are not changed to make a walk pass: adding a third booking to the demo customer would move screens reviewed on the existing data. | A demo-customer booking with both discounts when the fixtures are next revised, so C-26 shows the combination; the API scopes /me/bookings/* to the signed-in customer, as live mode already does. | |||
| 159 | Resolved in C1.5 — the online advance is A-10's, and each screen reads it from the hold or the booking. C-16 and the desktop flow rail read the hold's token_amount; C-17 reads the hold's, or the booking's payment.advance_amount when it returns from C-18; C-18 reads the booking's; the gateway is asked for the same figure (the mock's checkout.amount, the API's intent); and C-23's "Paid so far" is what that payment settled. The mock computes the advance from A-10's advance_token_mode / advance_token_value through advanceFor (a fixed amount capped at the price, or a percentage in cents, HALF_UP once: 15% of LKR 3,950.50 is LKR 592.58), and snapshots it on the booking, so a later change to the setting never rewrites a booking already made. TOKEN_AMOUNT is gone. What remains is limitation 19's: in mock mode A-10's save reaches the admin app's own store only, and the customer app keeps its own seeded setting (fixed LKR 1,000), so a reviewer who changes A-10 sees the customer's advance unchanged; the walk carries the setting across by sending A-10's request through the customer tab. Fixture bookings were made under the seeded setting and carry LKR 1,000 (or the price, when it is less). | One backend decides the advance in live mode; the mock reproduces its rule rather than a constant, and cannot share A-10's store across origins. | The API's HoldService#advance decides the advance from the setting, the hold and the booking carry it (advance.amount_cents, amount_due_online), and the intent asks for it. | |||
| 160 | The demo data needs the service worker to control the page, and a page it cannot control shows X-02 rather than working. Every own-origin /api/ call is answered by MSW in the browser — all of them in mock mode, the paths the live routing table does not map in live mode. Since C1.4 each app's boot waits up to 4 s after worker.start() for navigator.serviceWorker.controller (bootMockWorker, worker-control.ts); a page still uncontrolled, or whose worker would not start, is reloaded once (a sessionStorage flag, cleared by the next controlled boot), and if it is still uncontrolled the app renders X-02 — "This page could not load its demo data", a Reload button and a web- reference — instead of its screens. At runtime, an /api/ answer that is not JSON (the Pages SPA catch-all's index.html on the portals, 404.html on the customer app) is refused before it is parsed: one ApiError mock_unavailable (status 503, the real status in details.http_status), reported to /error-reports with that reference, and the whole app replaced by the same X-02, because every other call on that page would miss the worker too. So a browser that blocks service workers (some private modes, an enterprise policy, a privacy extension), or DevTools' "Bypass for network", cannot use the preview past the X-02. | MSW is how the prototype has data at all (rule 1), and the hybrid keeps it for the screens nobody has wired yet; a demo that half-works on empty lists was the defect reported on the deployed A-05. | Production has no worker: every path is the API's, and mock_unavailable disappears with MSW. The refusal of a non-JSON answer stays in the transport (@autocare/api's http) as the general rule for an API path. | |||
| 161 | Master data is live, and a save is several calls that can stop part-way. Since C1.6 every masterDataApi call reaches the API: reads from the public /catalog/*, a managing screen's ?status=all from the admin list, and a save — the screens still send a whole list — as per-row calls worked out by one diff (listSync.ts in @autocare/api): POST new, PATCH changed, deactivate removed, reactivate restored, then PUT …/order (per group for the checklist) when the order changed. It is not atomic: it stops at the first failure, keeps what already happened, re-reads the list and shows the failure above it (useListSave over useApiError, the request id for a server fault). Delete is a deactivate: the API never deletes an admin-owned row, so live, a make, model, city or question "deleted" on A-21, A-23 or A-22 stays in that screen's list as hidden or off (the confirm copy no longer says "for good"); in mock mode it is removed. Also live: A-23 writes cities only — provinces and districts are fixed on the API, and a save that changes one is refused (GEO_READ_ONLY), bar their Sinhala and Tamil names (FE-I18N-2, limitation 166); a document type's code cannot change once created; A-21 shows "—" for who submitted a pending make or model, because the API has only their user id; sort_order comes back in the API's steps of ten. Parked: the public ticket-category read C-40 and S-39 make stays on MSW until B10's tickets; A-26's managed list is live. The mock's POST /_demo/fail-next fails the next matching request with a 500 and a request id, for the walk that proves the failure path; nothing in the product calls it. | The API keeps each row's history and the screens were written against one PUT per list; the adapter bridges the two without changing what a screen sends. | Per-row actions on each screen (or a batch endpoint, if the API gains one), so a failure is one row's and not a list's; the submitter's name in the admin catalogue views. | |||
| 162 | Tickets are live, with three gaps the API has not closed yet. Since C1.7 every ticket call — C-40, C-41, S-39, A-26, A-27 and A-06's count, and the public ticket categories by audience — reaches B10, and every ticket action runs through useListSave: a failure shows through useApiError with its request id, the ticket or the queue is read again, and the text being sent is kept. A customer or a branch never holds an internal note, an assignee or the customer a complaint is about, and an admin's name is dropped from the messages they receive (they read "AutoChanneling support"). Assignment: the API assigns by user id and names the assignee; A-26/A-27 pick by name from adminApi.adminUsers(), which the API has no endpoint for and which is still fixtures, so the adapter can resolve only the signed-in admin and admins already read as an assignee — any other name is refused (ASSIGNEE_UNKNOWN) rather than guessed. Attachments are file names, as in the mock, until B10.1 stores files. Auto-close: A-27's "close now" stays the mock's prototype control (POST …/auto-close, limitation 104); live, the API's nightly ticket_auto_close job does it, and the button reaches only the mock. A-26 and A-27 still wear the "Demo data" chip for the three admin calls they share with screens not wired yet (adminUsers, settings, organizations). | B10 has no list of platform admins and no file storage yet; the sweep is the API's to run. | GET /admin/users?scope=platform (or the A-14 list) for the assignee picker, by id; B10.1's upload for attachments; no auto-close button in production. | |||
| 163 | The fixtures keep a calendar of their own for the two things a weekday decides. Every fixture date is relative to today, but what a booking *is* — branch, customer, vehicle, package, status, offer discount, payment, bill — and every id built from the fixtures' order (references, A-11's audit ids, service-record ids, the late-record pair, offer redemption counts) is the same on every day: fixture-identity.test.ts builds the dataset on 3 Nov 2026, 8 Nov 2026 and 19 Feb 2027 and compares them. To get there, the history generator's week curve (Saturday busiest, Sunday quiet) and the seeded pricing of weekday-only offers (Quiet hours, the Avurudu carve-out at Nugegoda, the Galle Fort weekday offer) are decided on the weekdays as they fell on 3 Nov 2026 (FIXTURE_ANCHOR), not on today's. On the anchor day the two calendars agree and the data is byte for byte what it was. On any other day the weekday pattern turns with the dates: S-36's heatmap and the reports' busiest day move round the week (the shape stays, its weekday does not), and a seeded booking may carry a weekday-only discount on a date that is now a Saturday — or miss one on a weekday — because it keeps the discount it was given. Generated bookings a real weekend would close on move into the weekend band (8 AM–1 PM); that changes their time only. Hand-written bookings keep their hours as written, as before. Offers priced live — a booking made in the review — use the real calendar. | A reference a reviewer, a walk or this file names must mean one booking; a dataset that renumbers itself every morning (the bill walk's AC-61282 changed branch between two runs) cannot be deep-linked, and a booking's discount is a snapshot of when it was made, not a question to ask again each day. | Real bookings: a booking's date never moves and its discount is snapshotted at booking time, so the question does not arise. | |||
| 164 | Onboarding is live (C1.8, wiring batch 1), and the API leaves six things out. S-03 → S-06, A-03, A-04, A-05 and A-06 reach B2's organizations API; S-05's one press is its four calls — POST /orgs/register, a multipart PUT /orgs/{id}/documents/{type} per document, POST /orgs/{id}/branches, POST /orgs/{id}/submit — sent in order, stopping at the first failure (shown through useApiError), and a second press resumes where it stopped: the organization is not registered twice, a document is not uploaded again unless another file was chosen, and a field edited since is patched onto what exists. Progress is kept per tab and per account (sessionStorage); after a reload a document picked before it must be chosen again (DOCUMENT_FILE_MISSING). What is missing: (1) the registrant is the admin — POST /orgs/register makes the signed-in caller the org admin and takes no one else, so S-03 asks a visitor to sign in first and drops its admin fields, and A-05's register makes an organization with no admin _(resolved in C1.10 by B11.1: a visitor signs up on S-03 and A-05 invites the admin — limitation 168)_; (2) no submission date in the applicant's view (S-06 reads "—"); (3) no city on an organization — S-06 and A-04 show the first branch's, A-03, A-05 and A-06 none; (4) no admin contact on A-04, which shows the business's own phone and email; (5) no staff count or 30-day volume on A-05's list, and A-06's figure is the API's all-time booking_count, labelled "Bookings, all time"; (6) no note to the reviewer on S-06, which hides it. A-03's waiting and completeness filters and A-05's several statuses are applied in the adapter over every page, which reads one detail per row shown. A new branch's week is S-05's preset on weekdays (hour windows of three) and 8 AM–1 PM at the weekend; its location is Colombo's centre, as S-05's map is a placeholder. | Batch 1 wires what the API has, field by field, and never carries a fixture value into live mode: a missing field is a screen change ("—", a hidden control) or a backend change, decided per row. | A registration body that names its first admin; submitted_at on OrgView; an organization city; the admin contact on AdminOrgDetail; list-level staff and 30-day booking counts; a note field; server-side waiting and document filters. | |||
| 165 | Station setup is live (C1.9, wiring batch 2), with five seams. S-17's profile and photos, S-18's hours, closures and holidays, S-19/S-20's packages, S-21's weekly template and S-22's block-a-date reach B2's schedule, package and branch operations. (1) Weekdays are numbered as JavaScript does on every screen (0 = Sunday) and as ISO on the wire (7 = Sunday), converted in live/setup.ts only. (2) "Bookings affected": a change that takes booked windows away is refused once (409 bookings_affected, with the bookings); S-18 and S-21 name them in a confirm (useAffectedBookingsConfirm) and going ahead sends it with confirm, which writes it and flags them to be re-slotted. S-22 has already shown the day's bookings and sends confirm itself; its preview is read from the branch's own booking list (the five live statuses on that date), because the API's preview is the refusal of a write. (3) The holiday calendar is an admin read (GET /admin/holidays) a branch's staff may not make, so live S-18 names the calendar without its count of dates; applying it is the station's own call. (4) A package's state: on screen it is active or archived; the API keeps active apart from withdrawn, so a package shows active only when it is both, archiving is the withdraw call and restoring is restore (switching it back on if it was off). A package under a pending category request has no category yet and is listed under none (category_id 0) until the request is resolved. (5) S-18's exceptions are the API's range, today to a year on; past ones are history and are not listed. A photo is addressed by its URL on screen and by its id on the wire, found by the URL. | The screens were written against the mock's shapes and stay so; the adapter carries every difference, and nothing reaches a screen in the API's numbering or units. | A station-side read of the holiday calendar; a read-only impact preview; categories for a package awaiting its request on S-19; past exceptions on S-18 if the client wants them. | |||
| 166 | Stored translations are shown and edited, and the data does not carry many yet (FE-I18N-2). Every catalog and discovery name a screen shows goes through one picker (localText / useLocalText, @autocare/mocks) — the reader's language, the English when the translation is empty — and A-09, A-10, A-21, A-22, A-23, A-24, A-25 and A-26 edit the Sinhala and Tamil beside the English. Both are optional on every editor, as on the API: A-09, A-25 and A-26 used to refuse a row without its Sinhala name, and no longer do; A-26 no longer copies the English into an empty Sinhala name. What there is to show: the API's V048 adds the columns empty, and the fixtures keep only the Sinhala names they already had (categories, facilities, ticket categories, the checklist) — every Tamil value and every other Sinhala value is empty, so those screens read English in Sinhala and Tamil until the platform team (or the client's translator) types them in; no data value is invented here. Still English, because the API sends one name: the city on C-42's offer groups and on an offer's station (PublicOfferStation, OfferGroupStation), the category chips on S-41/A-28 (OfferSourceCategory), and the search card's offer_badge ("20% off"), which has no data beside it — the offers themselves are worded from headline_data / conditions_data, the English headline / conditions only the fallback. Offers live: C-07, C-11 and C-42 now read the API's offers, and C-16 the hold's offer and discount; the promo code is still held in the tab (the shelf), as before. Errors: five codes are worded from their extension fields (errors.<code>_values); a refusal without them reads the plain errors.<code>; the mock's booking hold never raises inside_minimum_notice or beyond_horizon, so those two are worded only from the API. A-09's save is not routed live (it stays on MSW, as before). A-23 translates a city after adding it, from the row's menu. | The columns, the reads and the editors exist so a translation can be shown the moment it is typed; what to type is the client's translator's, not the prototype's (rule 5). | Translations entered by the platform team; city_si/city_ta on the offer station shapes, a translated offer badge (or its data), and name_si/name_ta on OfferSourceCategory; A-09's save live. | |||
| 167 | Every Sinhala and Tamil string is now there, and nearly all of it is the development team's draft (I18N-DRAFT-FE). All 3,390 keys have Sinhala and Tamil: the 127 Sinhala strings the prototype pack signed off (C-02, C-33, the shared chrome) are the client's and unchanged; the other 3,263 Sinhala strings and all 3,390 Tamil ones were drafted by the development team in the terms of docs/i18n-glossary.md and are listed in si.drafts.json / ta.drafts.json, so pnpm i18n:export flags each row "Draft". The master-data fixtures (categories, facilities, ticket categories, the checklist, cancellation reasons, document types, the help content, provinces, districts, cities, holidays) carry drafts too (FIXTURES.md); the counter items keep their deliberate Tamil gap. Drafts are checked by machine — every placeholder kept, every length hint met, the crawl's clip check in both languages — and not yet by a native reader. Dates: Chromium has no Sinhala names for Intl and prints "Dec" and "AM" in si-LK; localeFormat replaces a Latin month, weekday or AM/PM with the dates.* key when the runtime gives Latin names (Node writes Sinhala itself and is left alone). Node's Tamil writes "AM"/"PM", so Tamil times now read மு.ப. / பி.ப. C-15's date strip names its weekdays the same way (DateStrip's weekdayLabel). | The screens can be reviewed in all three languages end to end, and the client's translators correct a complete draft rather than start from an empty column. | The client's translators' pass on both workbooks, through pnpm i18n:import, which removes each key it supplies from the drafts list. | |||
| 168 | Staff, invitations, devices and the platform team are live (C1.10, wiring batch 3 + B11.1), with six seams. S-07, S-23–S-27, S-29 and the dashboard's notices reach B2/B11.1's organization, staff, invitation and device operations; S-03 is the self sign-up in live mode; A-05 invites an organization's admin; A-14 lists, invites, resends and withdraws through B11.1, and A-01 accepts. (1) No link on S-26 live: the API e-mails or texts the invitation and never returns its token, so S-26 says it was sent and shows no link; the mock still shows one so the flow can be walked. A-14's link is shown once after an invite or a resend, because the API returns the token then. (2) A staff member's public profile and last activity are not on the API's staff list (only profile_visible per station): S-25 shows no "last active" line and S-24's profile fields open empty live. (3) Roles are read from MSW (stationApi.roles, batch 5), so S-26 and S-31 still wear the "Demo data" chip, and an invite's or edit's role codes are matched to ids against the seeded org roles. (4) A branch move is two calls (roles set at the new station, cleared at the old), and a failure between them leaves the person at both until it is retried. (5) A-14's edit and revoke stay MSW — B11.1 has no platform-user update or revoke — so those two A-14 actions still wear the chip. (6) A-01's invitation step is a step of A-01, not a screen of its own (/login?invite=, like the forgot-password steps), so the screen count is unchanged; its first sign-in enrols 2FA as every platform account's does. A new branch's location is Colombo's centre and its week S-05's preset, as in limitation 164. | Batch 3 wires what B11.1 serves and keeps each missing field a screen change, not a fixture value carried into live mode. | A station-side read of the staff public profile and last activity; the roles read (batch 5); a single move-branch operation; platform-user update and revoke; a dedicated invitation-accept page if the client wants one. |
- Open decision 1 — a promo code or an automatic offer, when both apply — is closed by the slot rule (U9). Two automatic offers can no longer meet on one booking, because no two live offers may share a station, a category and a moment; and a code-required offer never applies on its own, so "which wins" has nothing left to decide. The answer the checkout already gave (a code applies its own offer and says so) stands. - Still open: whether an offer reaches a walk-in at the counter (limitation 111).
| Control | Screen | Production instead |
|---|---|---|
| Get directions | C-11 | Deep link to the device's map app with the station's coordinates. |
| Call station | C-23 | tel: link to stations.contact_phone. |
| Share on WhatsApp | C-20 | wa.me deep link with the booking reference. |
| Sign out instead | C-06 | Clears the session and returns to C-01. |
| WhatsApp us | C-35 | wa.me deep link to the support number from platform_settings. |
| Call us | C-35 | tel: link to the support number from platform_settings. |
| Notify affected customers | S-22 | Email and SMS to every customer holding a booking on the blocked date, with a reschedule link. |
The prototype is complete: 43 + 43 + 28 + 9 = 123 screens (derived by pnpm -F @autocare/index build).
| Group | Screens | Route map |
|---|---|---|
| Customer app (C-01 – C-43) | 43 | apps/customer/ROUTES.md |
| Station portal (S-01 – S-43) | 43 | apps/station/ROUTES.md |
| Admin portal (A-01 – A-28) | 28 | apps/admin/ROUTES.md |
| System screens (X-01 – X-09) | 9 | listed once each in the three registries above |
The station pack heading says 34; its own list runs S-01 to S-37 with no gaps, so the heading was corrected. The nine system screens are registry-listed once each in whichever app they naturally belong to — six in the customer app, two in the station portal, one in admin — and the preview index links to all nine.
Two pages are deliberately outside the screen catalogue, because neither is a prototype screen: the @autocare/ui component gallery at /admin/preview, which is the review gate for the design system, and the preview index itself at the domain root, which is the sign-off checklist.
Deployment. Four Cloudflare Pages projects on four hostnames, each app at its own domain root: achanneling.ithilam.com (the preview index), customer-achanneling.ithilam.com, station-achanneling.ithilam.com and admin-achanneling.ithilam.com. Cross-app links come from @autocare/config; see the Deployment section of CLAUDE.md.