@campminder/campminder-shell
v0.3.2
Published
> Published on public npm. Install and render: [`docs/consuming-the-shell.md`](../../docs/consuming-the-shell.md). > Working in here: [`CONTRIBUTING.md`](../../CONTRIBUTING.md).
Maintainers
Keywords
Readme
@campminder/campminder-shell
Published on public npm. Install and render:
docs/consuming-the-shell.md. Working in here:CONTRIBUTING.md.
The app shell for the Campminder portal. One portal, one shell.
This is the container Kevin estimated at roughly 5% of the code — it assembles the chrome and owns layout. It is deliberately ERP-specific; that is its whole job. The generic, composition-agnostic components become specific again here.
The rule it enforces
Components render themselves; the shell owns layout. No component may touch
document.body,document.head, or query for a sibling.
Concretely: the grid in app-shell.ts computes its tracks from which slots are
actually filled. A shell with no side nav has no side-nav column, so
BodyPaddingController's padding-left: 168px !important has no successor here —
it was solving a problem the shell no longer has.
Usage
<campminder-app-shell brand-color="#5B2D8E">
<cm-super-user-banner slot="banner"></cm-super-user-banner>
<cm-side-nav slot="side-nav"></cm-side-nav>
<cm-top-bar slot="top-bar"></cm-top-bar>
<main>…page content…</main>
</campminder-app-shell>Every slot is optional. Omitting one is a supported composition, not a degraded
state — which is the property apps/harness exists to prove.
The menu tree
src/tree/campminder.json is the Campminder portal's menu tree, three levels deep
(category → sub-category → leaf), typed by MenuCategory / MenuSubCategory / MenuLeaf
and validated against a JSON Schema — both of which now come from
@campminder/menu-tree on public npm, published by the nav Domain System and resolved
through the package manager. src/tree.ts re-exports the types so this package's public
surface is unchanged; there is no local copy of either the types or the schema.
That split is ADR D4 (the menu data model belongs to nav) plus D5 (validate
against a published schema rather than a copied one). What stays here is the instance:
campminder.json is one ERP's menu, which D6 puts in the app shell, along with
FilterContext and the derivation tooling — Campminder-specific by construction. nav is
ERP-agnostic and depends on portals in no direction.
One thing to know before hand-writing a fixture: the published MenuCategory requires
sortOrder, while sub-categories and leaves keep it optional (this tree omits it on 1 of
48 sub-categories and 214 of 249 leaves). Nothing in portals reads sortOrder — it is
carried faithfully for the host, never sorted on here.
Where it actually comes from
The canonical menu is navigation-data.json, an embedded resource in the Core .NET
assembly, read by NavigationAccessor's static constructor, filtered by
NavigationBuilder, base64-encoded and handed to the component as a data= attribute.
The authority for fields and gates is therefore the C# — MenuItem in
NavigationAccessor.cs and the Matches* calls in NavigationBuilder.cs.
The first port derived its types from core-js's
apps/side-nav/src/data/navigation-data.schema.json instead. That schema is stale, and
copying it cost us two defects: a phantom client rule (the field is dead upstream —
zero instances, no gate) and a silently dropped feature_keys gate (live, and
evaluated). Both corrected 2026-09-01.
Regenerate and compare with — both run from the repo root, and both take the host path as an argument so the repo stays uncoupled from any checkout layout:
pnpm derive:menu-tree <path-to-navigation-data.json> --diff packages/campminder/src/tree/campminder.json
pnpm check:host-contract <path-to-Core/Websites/CampMinder/navigation>Two things the tree cannot represent
Terminology tokens. Upstream labels carry
{{Bunk:Proper:Default}}, resolved per client byTerms.Factory.ConvertString. They are stored verbatim. The first port substituted them to the literal word "Group", which baked one camp's vocabulary into every client's menu. A test now fails if any token is resolved away.Database-sourced items.
DatabaseMenuItems.GetCustomReportMenuItems()andGetPhoneReservationMenuItems()inject per-client rows at request time, andNavigationBuilder.cs:104-111assigns them with=, not+=— so a static leaf inReporting > Custom ReportsorReporting > Phone Reservationsis replaced at request time, not merged with. Both sub-categories are therefore deliberately empty of leaves here, matching upstream, and a test pins that. The fourPhone Res: *leaves that used to sit here were hard-coded copies from the base64 fixture; they were removed 2026-09-02. Do not re-add them.Whether Phase 1 needs those leaves populated for a real user is an open product question, not a gap in the tree — a static tree cannot answer it, and answering it yes means Phase 1 needs a runtime call after all. See the plan's open questions.
The drift guard
src/tree/drift.test.ts compares every field of every node against
src/tree/upstream-derived.json, and any difference must be listed in
src/tree/known-deltas.json to pass — as must its absence, so a delta that has been
fixed cannot be left lying in the file. Both JSON files are test-only data; nothing
at runtime imports them and index.ts exports only campminder.json.
known-deltas.json is currently empty of deltas, and that is the goal state — as of
2026-09-02 the shipped tree is field-for-field identical to what
tools/derive-menu-tree.mjs produces from the host. An empty file is not a weaker guard:
dropping a single sortOrder, resolving a terminology token, or re-adding one
database-sourced leaf each turn the suite red, verified by mutation.
upstream-derived.json is committed rather than derived in CI because CI has no Core
checkout. That is the guard's one real limit: it catches hand-edits to the shipped
tree, not upstream changes. Refreshing the fixture is a local act, and
known-deltas.json records the Core commit the current one came from. Tracked as ADO
#26026 (Floor 5C) — #26019 raised it and closed as moved on 2026-09-02 once nav
became ours to build. Do not read the empty deltas file as that limit being closed.
Deleting this paragraph is one of #26026's acceptance criteria; until then it stands.
The schema's placeholder home here is gone — resolved 2026-09-08 by Floor 5B. ADR D4
gives the menu data model to the nav Domain System and ADR D5 requires the tree be
validated against a published JSON Schema; nav was an empty repo with nowhere to
publish to, so packages/campminder/src/tree/ held the copy in the meantime. Kevin, on
2026-08-29: "That stuff actually does belong to the domain system."
Both halves have now moved. menu-tree.schema.json and the structural types (which by then
had drifted to packages/components/src/menu-tree.ts) are published as
@campminder/menu-tree and consumed
here through the package manager. There is no local copy of either.
campminder.json itself does not move, ever. nav is mandated ERP-agnostic; this file
is one ERP's menu, and ADR D6 puts each portal's tree instance in its app shell. The same
test keeps tools/derive-menu-tree.mjs here too — it reads CampMinder's
navigation-data.json. nav gets the model and the generic guard; the Campminder-specific
derivation stays.
What it does not do
- It does not fetch. The tree is committed configuration, passed in as
tree. - It does not filter. Filtering is a pure function of context the host already
holds — enabled feature keys, client feature ids, version branch.
FilterContexttypes that contract; no implementation exists yet (Floor 6). Note the host also gates onAttendanceValidator.HasAccessTravelDateOnly(), which is imperative and cannot be expressed as a rule. Whether the evaluator therefore needs host-supplied overrides is provisional — Kevin is re-evaluating the gate (2026-09-01); it may be retired or moved upstream instead. See the plan's Floor 6 before designing around it. - It does not own the components. They are slotted, and it never reaches into them.
Phase 1 scope
Left nav is the priority. The top bar is secondary and may ship without a search widget purely to reserve the space. Search is not being rebuilt, and per-client terminology substitution is deferred.
carveOutOrigin is optional for exactly that reason — Phase 1 is expected to need
no runtime calls at all.
