ADR 038 Information Architecture v2 - steilerDev/cornerstone GitHub Wiki

ADR-038: Information Architecture v2 ("One Home per Thing")

Status

Accepted (2026-10-08), in progress — EPIC-21 (UX restructure, #2190). Partly implemented: the shared route map, routeUrl(), generated redirects and NavConfig data (§2, §3) landed with story 0.10 (#2201); page identity (h1, browser title, breadcrumb, Back to origin, history hygiene) for Tasks, Purchases and Milestones (story 0.11a, #2202), for Money (story 0.11b, #2203) and for Site diary, Photos, Settings, sign-in and deep links (story 0.11c, #2204) follows the rules in Architecture › Page identity; the desktop sidebar of the navigation model (§1) is rendered from NavConfig with one active entry and no tab rows since story 0.12 (#2205, Architecture › Sidebar from NavConfig); the phone/tablet shell and title menu (§1 rule 4) are still the target. Query maps are live from #2202: the first one, /schedule?view=calendar → /schedule/calendar (D-11), consumes the key it matches (Route Map › Query maps). Each story that implements a part of this ADR updates the "Implementation status" table at the bottom in the same pull request.

Owner decisions of 2026-10-07 are the source of truth (restructuring plan §2.0, rows 1–26). Where the plan's older sections differ, §2.0 wins, and so does this ADR.

Related: ADR-039 (money figures), ADR-040 (History), Route Map (every URL, old and new), API Contract › EPIC-21.

Context

Cornerstone grew one epic at a time. Each epic added its own pages, its own tab arrays and its own navigation entry points, so by 2026-10:

  • the same object has several homes: companies live under Settings (/settings/vendors) although they are daily working data; funding sources and grants have separate pages; system events are mixed into the site diary;
  • the navigation is defined in about ten places (sidebar, ten copied tab arrays, ScheduleSubNav, the Home quick actions), so a page can highlight nothing or two things;
  • 17 of 42 page states carry a container heading ("Project", "Manage") instead of the thing shown, almost every browser tab reads "Cornerstone", and there are no breadcrumbs;
  • the server writes app URLs as string literals into vCards (server/src/services/vendorVcard.ts) and calendar events (server/src/services/calendarIcal.ts); those URLs live on in the household's address books and calendars and cannot be changed later;
  • rarely used capabilities are scattered; nothing guarantees they stay reachable when pages are rebuilt.

The owner decided the target navigation on 2026-10-07 and asked to keep today's URLs (Q13).

Decision

1. Navigation model

The sidebar is the navigation. There are no tab rows on Tasks, Money or Settings (§2.0 #1, #14).

Desktop sidebar (≥ 1024 px), in this order (§2.0 #12):

Position Entry Opens (route id → path) Views shown under it while inside
header house icon + household name home → / —
1 Home home → / —
2 Tasks tasks → /project/work-items (the list) Schedule · Calendar · Milestones
3 Purchases purchases → /project/household-items —
4 Site diary diary → /diary —
5 Photos photos → /photos —
6 Money money → /budget/overview (the overview) Invoices · Financing
7 Companies companies → /companies —
secondary Areas · History · Documents¹ /areas · /history · /documents —
footer Settings settings → /settings/manage (Project setup) Account · Users² · Backups² · Integrations²

¹ Documents only when Paperless-ngx is configured. ² Admin only (route-guarded, not just hidden).

Rules:

  1. Section entry = main view. "Tasks" always opens the task list, "Money" the money overview, "Settings" Project setup. The other views of a section appear indented under the section entry only while the current page belongs to that section, the current one highlighted.
  2. No Schedule entry of its own. The schedule is a view under Tasks (2 clicks from anywhere, or the g s shortcut). Search finds it by its name and by aliases ("Gantt", "Timeline", "Zeitplan", "Calendar", "Milestones"); Home's "This week" links to it. Technical words are aliases only, never labels (§2.0 #13).
  3. Exactly one highlighted entry at every width: the view entry when a view is open, otherwise the section entry (a view's parent may carry a "contains active" style, never the active style). Pages reached only by links (an invoice, a funding source page, the bank report) highlight the entry of the section that owns them in the route map (§3).
  4. Phones and tablets (< 1024 px): a bottom bar Home · Site diary · + · Photos · More; the More sheet lists Tasks · Purchases · Money · Companies, then Areas · History · Documents, then Settings, then the personal user block. Sections with views switch views from a title menu ("Tasks ▾", "Money ▾", "Settings ▾") under the top bar, never a sideways-scrolling tab row. The + opens capture (Photo · Note · Delivery, §2.0 #21).
  5. Page title names what is highlighted (§2.0 #2): the h1 of a section or view page is the label of the highlighted entry (Tasks, Schedule, Calendar, Milestones; Money, Invoices, Financing; Project setup, Account, …). Detail pages use the object's own name. No page is titled by a container ("Project", "Manage").
  6. Browser title = ‹page or object› · ‹section› · ‹household name›; Home prefixes the attention count. Built from the route map, never hand-written per page.
  7. Breadcrumb = parents only, built from the route map's parent chain, never from the URL string (an invoice: "Money › Invoices"; the bank report: "Money › Financing"; an area: "Areas › ‹parent area›").
  8. History hygiene: view, tab and filter changes use replace; opening another object uses push; no page pushes on mount. Filters live in the URL.

Staging: interim views (#2205). The table shows the target. Until Financing (story 4.10) is served, Money's views are Invoices · Funding sources · Grants · Bank report, the last three marked interimUntil: 'financing' in NavConfig: a view shown only while the named route is not served. Without them, deleting the Money tab row would remove the only navigation entry of three capabilities (MNY-022, MNY-036, MNY-116) before their new home exists, which §4 forbids. When financing is served they disappear and Financing appears; story 4.10 deletes them from NavConfig, as a story deletes interim from the route map (§2). Interim views follow rules 1 and 3 (shown only while inside Money, the open one is the single highlighted entry, and its h1 is that entry's label), but page identity treats them as non-views: their pages keep the "Money" breadcrumb. That inconsistency is accepted for the interim, because their target pages (under Financing) are non-views anyway.

2. One route source: the shared route map + NavConfig

Two artefacts, strictly layered:

Artefact Lives in Holds Used by
Route map shared/src/routes/ route ids, path patterns, params, parent route (breadcrumb tree), owning section, guard (public/member/admin), gate (paperless, paperless+ai), permanent flag, redirect table (path, query and conditional), typed routeUrl(id, params, query?) helper client router, redirects, titles, breadcrumbs, E2E page objects, server (vCard, iCal)
NavConfig client/src/navigation/navConfig.ts sidebar groups and order, views per section, phone bar slots, More sheet order, i18n label keys, icons, shortcuts sidebar, phone shell, title menu, command search's destinations
  • NavConfig references route ids only, never path strings. The route map knows nothing about labels or icons.
  • Production code (client and server) builds app URLs only through routeUrl(). A test fails on any string-literal app path outside shared/src/routes/ (JSX to="/…", navigate('/…'), template literals in vendorVcard.ts / calendarIcal.ts). This removes the class of defect where a link emits a URL that no route serves (D-01, D-24).
  • The ten copied tab arrays and ScheduleSubNav are deleted, not wrapped.
  • E2E page objects import their URLs from the route map (as source: shared/src/routes/index.js).
  • Two files, one truth. shared/src/routes/routeMap.ts is the runtime source (every entry plus id, stage, interim, parent, match). plan/restructure/routemap.json stays the curated record that carries the notes the Route Map wiki page is written from; it is not generated, and the notes stay out of the client bundle. npm run plan:check fails when the two disagree on from, to, kind, change, section, guard, gate, permanent or carries. A route change edits both in one PR.
  • Staged rollout. Each entry names its final target (to) and a stage: done (served as to/kind say), interim (served today, not yet in final form: interim: 'page' = today's page still serves it, any other value = a one-hop redirect to today's equivalent) or planned (not served; waits for its story). The story that builds a target page flips the stage and deletes interim (Route Map › Rollout stage).
  • Route ids are addressable paths only. Pages, interim pages and the section roots (/, /project, /budget, /schedule, /settings) have an id; legacy URLs (/work-items…, /budget/vendors…, …) never do, so code cannot emit them (D-24 is a type error). routeUrl() accepts served ids only; NavConfig may reference planned ids and drops them at run time.

3. Route policy (Q13: keep today's URLs)

  1. Existing URLs stay. The route map assigns each existing path to its new section; for example /project/work-items/:id highlights Tasks and shows the breadcrumb "Tasks". URL normalisation (/tasks, /money/…) is out of scope (optional plan story 6.4, only if the owner reverses Q13).

  2. A URL changes only when its page is retired or merged, or its parent is actively wrong (companies under Settings). New pages get paths inside the existing prefixes (/budget/financing, /budget/financing/sources/:id), except the new top-level objects (/companies, /areas, /history, /documents).

  3. Every old URL lands in one hop. Redirects use replace, carry the query string and the hash (target-defined query keys win), map renamed query values (see Route Map › Query maps), and never chain. A walk test (client/src/navigation/legacyUrlWalk.test.ts) visits every old path and its query variants (including /login?next= and ?error=) and asserts a single hop.

  4. Permanent URLs. URLs written outside the app are permanent: they are never removed, retargeted only within one hop, and exempt from any future normalisation.

    URL pattern Written by Status after EPIC-21
    /project/work-items/:id calendarIcal.ts (calendar feed) page, kept
    /project/milestones/:id calendarIcal.ts page, kept
    /project/household-items/:id calendarIcal.ts page, kept
    /budget/vendors/:id vendorVcard.ts before #2201 redirect → /companies/:id, kept forever
    /companies/:id vendorVcard.ts since #2201 page (interim redirect until story 1.7); permanent

    /companies/:id became permanent when the server's vCard and iCal writers switched to routeUrl() (story 0.10, #2201). Until story 1.7 builds the Companies page it is served as an interim one-hop redirect to /settings/vendors/:id (see the Deviation Log).

  5. Redirects are client-side. The server keeps serving index.html for every non-API path (SPA fallback, ADR-001/ADR-007); the client router performs the redirect. "Permanent" means "never removed from the route map", not HTTP 301.

  6. Conditional routes (an integration switched off, a member on an admin URL) redirect or show the "No access" page; their conditions come from GET /api/config, GET /api/paperless/status and the session's role, never from environment variables read in the client.

4. Reachability rule (Q10)

A capability may be moved, merged or tucked away, but never removed. A demoted or zero-use capability stays reachable in at most 2 clicks from its object's page. Every EPIC-21 story names the capability ids it touches and updates the capability map (plan/restructure/capmap.json) and, if a route changes, the route map, in the same pull request.

The capability map moves into the repository (plan story 0.0) without notes that quote real data; a CI check runs its builder and fails when any clicks value exceeds 2 or a capability has no placement. The architecture reviewer checks the rule in every EPIC-21 PR review.

5. Click-counting rule

Used by the capability map, the reachability rule and the journey targets of the plan's success metrics:

  • Start from the object's page in its default state (for a journey: from the stated start page), already loaded, signed in.
  • Counts: each click or tap that opens a page, tab, menu, dialog, sheet or collapsed section, or triggers a control (Save, Mark paid, a chip that applies a value).
  • Does not count: typing; choosing a value inside an already open field, picker or date chip row; scrolling; dismissing a toast; the keyboard shortcut alternative of a counted click.
  • A path that needs a hover to reveal its control is not reachable by this rule (touch has no hover).

Consequences

What becomes easier

  • One place changes navigation; the sidebar, phone shell, titles, breadcrumbs and redirects cannot disagree, because they are generated from the same two artefacts.
  • Server-emitted URLs are typed and tested; a renamed route cannot silently break vCards or calendars.
  • Keeping URLs means the about 220 /project and 200 /budget E2E references keep working; only about 34 production route references change (plan §10).
  • Every page highlights exactly one entry, so "where am I" is answerable at every width.

What becomes harder

  • URLs and section structure no longer mirror each other (/schedule/gantt belongs to Tasks, /budget/* to Money). The route map is the only reliable answer to "which section owns this URL"; reading the URL is not.
  • The route map is a new shared-package dependency for the server (vCard/iCal) and for the E2E workspace; build order shared → client → server already covers it.
  • Two-level navigation on phones depends on the title menu; it needs its own accessibility treatment (menu button semantics, aria-current on the active view).

Alternatives considered

  • Normalise URLs now (/tasks, /money/...): rejected by the owner (Q13); touches about 200 production and 360 E2E references for no user-visible gain.
  • Tab rows inside sections (plan §4.4 as first written): superseded by the owner in the fourth review round (§2.0 #1/#14): the sidebar alone is the navigation, so a page never shows two navigation levels at once.
  • Schedule as its own sidebar entry: rejected by the owner (§2.0 #1).
  • NavConfig as the only artefact (labels and paths together): the server would then depend on client-only concerns (i18n keys, icons); splitting route map (shared) from NavConfig (client) keeps the server dependency minimal.

Implementation status

Part Plan story Status
Route map in shared/, routeUrl(), redirects, walk test, server route helpers 0.10 Done (#2201) — targets roll out per story (Route Map › Rollout stage)
h1, browser title, breadcrumb, back link, history hygiene 0.11a–c 0.11a done (#2202: Tasks, Purchases, Milestones, the shared Breadcrumbs/useDocumentTitle/origin helpers, replace in SubNav/useTableState); 0.11b done (#2203: Money pages, the page-label registry PAGE_LABEL_KEYS, the PageLayout breadcrumbs slot, the edit-like and document-start history rules, the DataTable row-click guard); 0.11c done (#2204: Site diary, Photos and the photo viewer's own back link, Settings, sign-in, Not found and No access; deep links through /login?next= and SSO ?redirect=, the shared safeAppPath in-app path rule, useDocumentTitle section/house options, objectNames null = skip)
Query maps (§3.3) per story First live map in #2202 (/schedule?view=calendar); 10 planned
Sidebar and views from NavConfig, tab arrays removed 0.12 Done (#2205): Sidebar renders navSections(), one active entry from the route map (resolveNavActive), views nested while inside, interim Money views (interimUntil: 'financing'), the ten tab arrays and ScheduleSubNav deleted with a guard test, /settings → Project setup (done)
Phase 0 exit: defect registry 0.12 Done (#2205): plan/restructure/defects.json lists every Phase-0 defect id (D-01…D-29, D-32…D-38) with its covering tests; plan:check (check-defects.mjs) fails when a listed test file is missing or does not name the id. D-30 and D-31 belong to stories 4.6 and 2.7
Phone/tablet shell, title menu 1.2a Planned
Capability map + CI builder 0.0 Done (#2191)

Deviation Log

Date Section Deviation Resolution
2026-10-10 §2 (route map) The ADR described one route map seeded from routemap.json, with the Route Map page regenerated from the code, and did not say how final targets coexist with Q13 while most target pages do not exist yet. Implemented as a staged map (#2201): every entry carries its final to plus stage (done/interim/planned) and an optional one-hop interim target; the owning story flips it. §2 updated.
2026-10-10 §3.4 (permanent URLs) /companies/:id was to become permanent "when the vCard writer switches to routeUrl()", which presumed a Companies page. Story 0.10 switches the writer before story 1.7 builds that page. /companies and /companies/:id are served from #2201 as interim one-hop redirects to /settings/vendors(/:id), so vCards carry the permanent /companies/:id now; /budget/vendors/:id keeps landing in one hop (on /settings/vendors/:id until 1.7).
2026-10-10 §3.6 (conditional routes) All five conditional redirects were expected with the route map. Live from #2201: Paperless off on /budget/invoices/new/paperless (client RouteGate) and the member-on-admin-page guard (RoleGuard, #2200, checked against the map's guard). Planned: AI off on the same page (the prefilled dialog does not exist yet), auto-itemize with Paperless/AI off (taken by the story that rebuilds "Split with AI"; six E2E suites open that page without mocking Paperless status) and /documents (the page does not exist).
2026-10-10 §2 (two artefacts) The ADR said routemap.json becomes the seed and the Route Map page is regenerated from the code. routemap.json stays the curated record (notes) and routeMap.ts the runtime source; npm run plan:check enforces agreement on every routing field. The Route Map page is maintained from the notes, not generated.
2026-10-10 §3.3 (query maps) §3.3 said redirects carry the query string with target-defined keys winning, but did not say what happens to the key a query map matched; carried as-is it would leave ?view=calendar on /schedule/calendar. #2202: query maps consume the keys they match; all other pairs and the hash are carried. Recorded on the Route Map page.
2026-10-10 §1 (Money views) §1 lists Money's views as Invoices · Financing, but Financing is built in story 4.10 and story 0.12 removes the Money tab row, the only navigation entry of Funding sources, Grants and the Bank report. #2205: interim views (interimUntil: 'financing') keep the three in the sidebar under Money until Financing is served; recorded as a staging note under §1. The target is unchanged.