# S2 — specialties as card layouts in the visit shell: the nutrition board and the dental workspace, identical to today by default, customisable per clinic

Read `_COMMON.md` first. FE only (no BE, no migrations — if you find you need one, use `2026_11_03_270000`–`270049` and STOP to report first). Three serial WPs: **S2a** (shell extension points + nutrition «as is» + screenshot harness) → **S2b** (nutrition board split into its 13 cards, default identical) → **S2c** (dental workspace + consult as cards). Each section is a complete brief for its WP.

**Depends on:** S1 ✅ (the shell, `CARD_CATALOG`, `DEFAULT_LAYOUTS`, `resolveLayout`, editor, `VisitLayoutController` — S1-FE is 🔄 right now; S2 edits shell files, so it may not start before S1 ✅ and the orchestrator's live check). D2c ✅ is a soft dependency for S2c (the shared header replaces `app-dental-alerts`); without it keep `app-dental-alerts` as a card. Nothing in BE.

## Spec (read before coding)
- `../design-visit-shell.md` §1, §2 (owner clarifications 1–3: the nutrition board is «من تفاصيل الدكتور نفسه وهو طالبها كده — ما تتحلّش»; dental «ما تتختزلش»; the **client** controls), §2.1 (specialty cards: nutrition 13 + progress; dental jaws with every sub-view, today, exam, plan, perio, FMX, ortho, lab, recall), §2.3 (resolution + feature locks), §2.4 (customise mode), §2.5 (`flow:false` = board for nutrition and dental), §9 table rows **تغذية** and **أسنان** (exact target), §10 (responsive), §11 «يُعاد استضافته ككروت تخصصية», §12 step 3.
- `../master-plan.md` row S2 («شاشة التغذية والأسنان شبه النهارده بالظبط · العيادة تقدر ترتّبها · الشكوى والتشخيص كروت اختيارية في التغذية»). LEDGER owner directive: «Each specialty keeps its identity: nutrition board and dental workspace stay the default exactly».
- `../visit-shell-prototype.html` — switch the prototype to «تغذية» and «أسنان» to see the intended card grid (headless screenshot helper below).

## Code reality (verified 2026-10-22 — S1 is still moving these; re-read before editing)
**Shell (`src/app/features/clinic/visit-shell/`, S1):**
- `visit-layout.model.ts`: `CardDef { id; titleKey; icon; kind: 'shared'|'spec'|'ctx'; spec?: VisitClinicalType; sizes; layer; required?; requiredWhen?: 'insured'; feature?; station?; priv? }` — **no component ref** (design §2.2 had `component: Type<unknown>`), no permission. `VisitClinicalType = 'general'|'nutrition'|'dental'|'gyn'` (**BE `ClinicalType` value is `gyn_ob`** — not S2's problem, don't change it). `VisitLayout { version; columns: string[]; flow; cards: LayoutCard[] }`, `ClientProfile { layout; fields; features }`, `VisitContext`.
- `card-catalog.ts`: 15 shared cards (`header` required, `vitals`, `complaint`, `history`, `exam`, `diagnosis` (`requiredWhen:'insured'`), `lab`, `rad`, `rx`, `documents`, `previous`, `followup`, `notes`, `summary`, `orders`), `DEFAULT_LAYOUTS` has **only `general`**, `CHROME_CARDS = ['header','summary','orders']`, `catalogFor(type, features)` = shared + `spec===type`, minus chrome/ctx/feature-off.
- `resolve-layout.ts` `resolveLayout({type, catalog, defaultLayout, clientLayout?, doctorLayout?, allowDoctorOverride?, features?, context?, chrome?})` — replace-not-merge per level, drops unknown/chrome/ctx/other-spec/feature-locked/duplicate, fixes sizes, re-inserts `required` (only `required`, not `requiredWhen`), applies context, renumbers. `toPersistedLayout()`.
- `visit-shell.component.{ts,html}`: route `clinic/encounter/:id/shell`; `type = clinicalTypeOf(encounter.department.clinical_type).key`; `supported = !!DEFAULT_LAYOUTS[type]` (else «UNSUPPORTED» + link `?classic=1`); rows from `layout-edit.ts gridRows()` — F cards get a full-width row, others a grid with `grid-template-columns: columns.join(' ')`; each card = `<app-visit-card>` with content projection and a hard **`@switch (c.id)`** in html L202-252 (one `@case` per shared card). Editor `[typeLabel]` is hard-coded `'CLINICAL_TYPES.GENERAL'` (html L58). `VisitDataService` (shell-provided) holds vitals/diagnoses/orders/prescriptions signals.
- `consultation.component.ts` L439-446 redirects to the shell **only when the type is `general`** and `visit_shell_enabled` and no `?classic`.
- BE `VisitLayoutController` (`GET/PUT clinic/visit-layout…`): validates `layout.columns` max 4 entries × 32 chars, `cards.*.col` 0-3, max 80 cards, sizes S/M/L/F; client `features` stored per type in `clinic.visit_layouts`.

**Nutrition (`src/app/features/nutrition/visit/`):** `nutrition-consult-board.component.{ts 600, html 774, scss 412}`, selector `app-nutrition-consult-board`, inputs `patientId` (req), `encounterId`, `doctorId`, `locked`, `signing`, `canSign`, `signBlocked`, `orderingMode`, `labMode`, `radMode`, `orderId`; outputs `sign`, `discharge`, `intentsChanged`, `cashier`. Data: ONE call `NutritionVisitService.record(patientId, encounterId)` → `GET nutrition/patients/{id}/record` (`core/services/nutrition-visit.service.ts`). Grid `.nvb-grid` = 3 columns `minmax(250px,290px) minmax(0,1fr) minmax(280px,340px)` (scss L43-47). Action bar html L3-58 (Rx, labs, diet plan, print plan, note, next appointment, refresh · stop dealing, cashier, finish (`!locked && canSign`), discharge (locked)). Cards in order — **column 1** (html L90): ① patient 👤 (inline `nv-card` L91) ② questionnaire 📋 (inline L218) ③ `app-nv-lifestyle-card` (L243-244, **only when `lifestyleQs().length`** — the 14th, conditional) ④ appointments 📅 (inline L247); **column 2** (L323): ⑤ `app-nv-progress-card` (L324) ⑥ visits & weights ⚖️ (inline L326, «+ وزن») ⑦ measurements 📊 (inline L444) ⑧ `app-nv-sessions-card` (L503) ⑨ current plan 🥗 (inline L505); **column 3** (L542): ⑩ `app-nv-case-notes-card` (L543) ⑪ `app-nv-meds-card` (L550) ⑫ `app-nv-labs-card` (L558) ⑬ packages 🎟️ (inline table L569) ⑭ `app-nv-photos-card` (L598). Dialogs html L607-774 (plan edit, questionnaire, contact, shopping list, `rxOpen` → `app-clinic-prescription`, `ordersOpen` → lab/rad tabs, `planOpen` → `app-nutrition-patient-panel mode="plans"`, `weightOpen`, `stopOpen`). In `consultation.component.html` the board is `@case ('nutrition_consult')` L338-365 (`@defer`, wired to `confirmSign()`, `openDischargePanel()`, `sendToCashier()`, `onIntentsChanged()`); the other nutrition tab `@case ('nutrition_measure')` L319 = `app-nutrition-patient-panel mode="measurements"`; tabs/permissions in `features/clinic/clinical-types/clinical-types.ts` (`nutrition_measure` → `nutrition.visit.measure`, `nutrition_consult` → `nutrition.visit.consult`); `compactHeader` (ts L312) true for nutrition; `fullWidthTab` (L176) hides the sign rail for nutrition_consult/dental_chart.
→ The design says «13 كارت»: it counts the 13 always-present cards; the lifestyle card is the conditional 14th. Keep both.

**Dental (`src/app/features/dental/`):** `workspace/dental-workspace.component.{ts 626, html 357}` (`app-dental-workspace`, inputs `patientId` (req), `encounterId` (null = file view), `readonly`, `canSign`, `signing`, `hasOrder`; outputs `changed`, `sign`, `cashier`), view switch `view = signal<'chart'|'perio'|'ortho'|'xray'>` (buttons html L12-28; perio needs `!simple() && features().perio`, ortho `features().ortho`, xray `features().imaging`; bodies L96-101 `app-dental-perio`/`app-dental-fmx`/`app-dental-ortho`, else `app-odontogram` L107; plan `app-dental-plan-panel` L300; `app-lab-case-dialog` L355). Quick mode («الوضع السريع») = `settings.mode === 'simple'` (html L71-95). **Features reach the FE as `settings.features` from `DentalService.meta()` / `chart()`** (`dental.models.ts` L11-24: `surfaces, plan_signature, perio, lab, imaging, recall, note_templates, ortho`), backed by BE `dental.feature.*` (`Modules/Dental/app/Support/DentalSettings.php`, enforced server-side in Ortho/Lab/Perio controllers) — **not** by the visit-layout `client.features` map. In `consultation.component.html`: `@case ('dental_consult')` L368-479 = `app-dental-alerts` (L371) · quick-complaint chips (L383, `dentalQuickComplaints` ts L196) · `app-dental-today` (L409) · `app-dental-exam` (L415) · diagnosis panel (L420) · prescription · «المزيد» row `.dental-more` (L446-452: vitals/history/previous buttons → `@switch (dentalMore())` L453-477; labs/rad → `dentalOrdersOpen` p-dialog L649-663); `@case ('dental_chart')` L482-500 = `@defer` `app-dental-workspace` with `[encounterId]="isLocked() ? null : encounterId()"`. Tabs `dental_chart` (`dental.chart.view`) + `dental_consult`. Lab board / recall are separate routes (`clinic/dental/lab`, `clinic/dental/recall`) — they become optional cards, not defaults.

**Tooling:** Playwright 1.61.1 in `node_modules` (`require('/home/moonui3/public_html/moon-erp/node_modules/playwright')`), launch `chromium` with `executablePath:'/usr/bin/chromium-browser', args:['--no-sandbox']`. `e2e/playwright.config.ts` targets **moonui** (`https://moonui.elbaset.com/app`) — override `E2E_BASE_URL=https://moonui3.elbaset.com/app`; `e2e/helpers/auth.ts` logs in through the API (`X-Authorization: Bearer`, token in localStorage key `token`) with default user `e2e@moonerp.test` on moonui's API — on moonui3 set the API base to `https://moonui3.elbaset.com/moon-erp-be/api` and use credentials the orchestrator gives you (env `E2E_USER` / `E2E_PASS`); if none are available, STOP at the harness and report — the orchestrator runs it after deploy. No component harness route exists (all clinic routes need login).

## Decisions already made (do not re-litigate)
1. **Specialty cards plug in without growing the shared `@switch`.** Add `component?: () => Promise<Type<unknown>>` (lazy) and `inputs?: (ctx: SpecCardCtx) => Record<string, unknown>` plus `bare?: boolean` and `permission?: string` to `CardDef`; specialty catalogs live in their own files `visit-shell/specialties/nutrition.cards.ts` and `…/dental.cards.ts` (each exports its `CardDef`s and its `DEFAULT_LAYOUTS` entry) merged into `CARD_CATALOG` / `DEFAULT_LAYOUTS` by a 2-line spread in `card-catalog.ts`. The shell's `@switch` gets ONE `@default` branch rendering `*ngComponentOutlet="spec.component; inputs: spec.inputs"` (resolved once per card id, cached). Outputs from spec cards go through an injected `VisitShellBus` service (sign / discharge / cashier / intentsChanged / refresh) — not through `ngComponentOutlet` outputs.
2. **`bare: true`** = `app-visit-card` renders NO chrome of its own (no header/number/collapse) — the hosted component keeps its own `nv-card`/workspace header — except in customise mode, where the editor toolbar overlays the card. This is what keeps the board pixel-identical. All nutrition board cards and the dental workspace card are `bare`.
3. **`permission`** on a `CardDef` = the card is dropped at resolve time when the user lacks it (the same exact-permission rule the tabs use: `nutrition.visit.measure`, `nutrition.visit.consult`, `dental.chart.view`).
4. **Nutrition default = identical to today** (S2b): `flow:false`, `columns: ['minmax(250px,290px)','minmax(0,1fr)','minmax(280px,340px)']` (each ≤ 32 chars — the BE validator accepts them), 13 cards + the conditional lifestyle card in today's columns and order, the action bar as a required bare F card `nut_actions` at the top. **Until S2b's screenshot diff passes, the default is S2a's single `nut_board` card** (the unchanged component) — the owner never sees a «nearly identical» board.
5. **Complaint / diagnosis in nutrition** are available from the catalog («إضافة كارت») but NOT in the default. `diagnosis.requiredWhen: 'insured'` must stay a **soft warning at finish** (owner decision 8ب) — never re-inserted by `resolveLayout` (today it only re-inserts `required` — keep it that way and add a test).
6. **Dental features come from `DentalService.meta().settings.features`** (the BE truth enforced by the controllers), mapped into `resolveLayout`'s `features` input as `{ perio, ortho, lab, imaging, recall, note_templates, plan_signature, surfaces }`; the visit-layout `client.features` for dental is **ignored** (and the editor hides the feature switches for dental, linking to the existing dental settings screen instead) — one source of truth, no second switch.
7. **Dental default = identical to today**: the consult flow + the workspace. Because today they are two **tabs**, the default dental layout is `flow:false`, `columns:['1fr']`, cards: `dent_workspace` (F, bare, the unchanged `app-dental-workspace` with all its sub-views and quick mode) then the consult cards in today's order: `dent_today`, `dent_quick_complaint`, `dent_exam`, `diagnosis` (shared), `rx` (shared), `dent_more` (collapsed by default — vitals/history/previous/lab/rad buttons exactly as the «المزيد» row). `app-dental-alerts` → the shared header once D2c ✅ (until then a `dent_alerts` S card at the top). **The tab split is preserved as a layout choice**: the shell keeps a two-tab mode for dental when `layout.props.tabs = [['dent_workspace'], [rest…]]` — default ON so the screen is the same two tabs (`dental_chart` / `dental_consult`) the doctor knows; a clinic may turn it off to see everything on one board. Implement tabs as a small optional `tabs` prop of the shell's board rendering (`VisitLayout` gets an optional `tabs?: { key: string; titleKey: string; cards: string[] }[]` — additive, ignored when absent). **BE gap (verified):** `VisitLayoutController::updateClient/updateDoctor` save `$request->validate(...)['layout']`, and Laravel's `validated()` (`excludeUnvalidatedArrayKeys=true`, `Validator.php` ~L614-621) **rebuilds `layout` from its nested rules only** — `layout.tabs` and `layout.props` are silently dropped on every PUT (per-card `layout.cards.*.props` survives: it has its own `array` rule without nested rules). REPORT these rule lines for the orchestrator to add to `VisitLayoutController::rules()`: `'layout.tabs' => ['sometimes','nullable','array','max:8']`, `'layout.tabs.*.key' => ['required','string','max:32']`, `'layout.tabs.*.titleKey' => ['required','string','max:64']`, `'layout.tabs.*.cards' => ['required','array','max:80']`, `'layout.tabs.*.cards.*' => ['string','max:64']`, `'layout.props' => ['sometimes','nullable','array']`, `'layout.props.hideRail' => ['sometimes','boolean']`, `'layout.props.gridClass' => ['sometimes','nullable','string','max:32']`. Until they land, tabs/hideRail/gridClass exist only in the FE `DEFAULT_LAYOUTS` and a clinic-saved layout falls back to no tabs — so the editor must not offer «حفظ للعيادة» for nutrition/dental until the orchestrator confirms the rules are in (check: PUT a layout with `tabs`, GET it back).
8. **Nutrition tabs** the same way: default `tabs: [{key:'nutrition_measure', cards:['nut_measure']}, {key:'nutrition_consult', cards:[nut_actions…14 cards]}]`, `nut_measure` = bare F card hosting `app-nutrition-patient-panel mode="measurements"` (permission `nutrition.visit.measure`). Same tab labels/icons as `clinical-types.ts`. Tab permission = the union of its cards' permissions.
9. **Redirect**: `consultation.component.ts` redirects to the shell for `general` **and** for `nutrition`/`dental` only when the clinic's `visit_shell_enabled` is true **and** the specialty default is `supported` **and** that type's client feature `shell` is true (`GET visit-layout?clinical_type=nutrition` → `client.features.shell === true`; set with the existing `PUT visit-layout/client {features:{shell:true}}`, which already accepts boolean features — no BE change). One global flag must not move nutrition/dental the day the owner approves general (per-screen approval). For dental, Decision 6 ignores `client.features` for *dental feature locks* only — `shell` is read from it. `?classic=1` always forces the old screen.
11. **The nutrition patient card stays.** design §9 says «بطاقة المريض المكررة» disappears; the owner's «identical» directive wins: `nut_patient` is in the default, and a clinic may hide it (the shared header shows the same patient). The old `@case` blocks stay untouched (fallback for one release).
10. **Sign/finish:** nutrition's «إنهاء» (`sign` output) and dental's `sign` output call the shell's existing sign flow (`VisitShellBus.sign`) — the finish panel opens exactly like `openDischargePanel()` does today in the classic screen (same buttons). `fullWidthTab` behaviour = the shell hides its end rail for `flow:false` specialty layouts whose `props.hideRail` is true (default true for nutrition/dental; the clinic can turn it on).

## Shared-file rules
- **S2 owns (no other agent may touch them while S2 runs — orchestrator confirms):** `src/app/features/clinic/visit-shell/**`, the redirect lines in `consultation.component.ts` (L439-446 block only), the specialty card wrapper components you create under `visit-shell/specialties/**`.
- **Do NOT edit:** `features/nutrition/**` component internals except the extraction targets in S2b (see there), `features/dental/**` internals (host them as they are; if a component needs a new `@Input`/`@Output` to be hostable, add only that and report it), `prescription/**`, `vitals-panel/**`, any BE file, `clinic.routes.ts` (no new route needed).
- FE i18n targeted keys under `VISIT_SHELL.SPEC.*`; no `I18N_VERSION` bump.
- **Fable consult (mandatory, ONE per WP, focused):** S2a — the customise-mode overlay on a `bare` card (how the toolbar sits over the nv-card without moving it); S2b — none unless the diff fails on spacing; S2c — the dental «tabs as layout» toggle in the editor and the `dent_more` card. Apply; don't iterate.

## Proof of «identical» (applies to S2a, S2b and S2c — write the harness in S2a)
`e2e/specs/s2-identity.spec.ts` (+ `e2e/helpers/s2Identity.ts`), run with `E2E_BASE_URL=https://moonui3.elbaset.com/app npx playwright test -c e2e/playwright.config.ts e2e/specs/s2-identity.spec.ts`:
1. **Fixtures:** one nutrition patient with an open (not signed) encounter that has: ≥3 weights, a plan, a session package, a case note, a med, a lab result, a photo (lifestyle answers so the conditional card shows); one dental patient with a chart, a plan, a perio exam. Use existing data on moonui3 dev chosen by the orchestrator (ids via env `S2_NUT_ENC`, `S2_DENT_ENC`) — the harness never creates data.
2. **Freeze dynamic bits** before each capture: `page.clock.setFixedTime('2026-10-22T10:00:00')` (Playwright ≥1.45), `page.emulateMedia({ reducedMotion: 'reduce' })`, `document.fonts.ready`, wait for network idle + no PrimeNG skeleton/spinner (`.p-skeleton, .p-progress-spinner` count 0), and mask volatile nodes (`mask: [page.locator('[data-s2-volatile]')]` — add `data-s2-volatile` only inside the NEW wrappers for clocks/relative times, never inside nutrition/dental components).
3. **Baseline (before):** open `/clinic/encounter/{id}?classic=1`, select the tab (`nutrition_consult` / `dental_chart` / `dental_consult`), screenshot the **component element** (`app-nutrition-consult-board`, `app-dental-workspace`, the dental consult container) — not the full page (the shell adds an app bar and rails by design). Viewports **1440×900** and **1024×768** (tablet), locale ar (RTL) and en. Save to `e2e/screenshots/s2/{case}-{viewport}-{lang}-classic.png`.
4. **After:** open `/clinic/encounter/{id}/shell` with the clinic layout reset to default (`PUT visit-layout/client {layout:null}` through the API helper first), same tab, screenshot the shell's main area element with the same size (set `[data-s2-board]` on the shell's board container; clip to the hosted component's bounding box when sizes differ by the shell padding — record the clip rect in the report).
5. **Compare:** `expect(afterBuffer).toMatchSnapshot(baselineName, { maxDiffPixelRatio: 0.002 })` with the classic capture as the snapshot (copy it into the snapshot dir first), or `pixelmatch` (already a playwright dependency — `require('playwright-core/lib/utilsBundle')` is not public; use `pngjs`+`pixelmatch` only if present in node_modules, else Playwright's comparator). Threshold: ≤0.2 % differing pixels and **no layout shift** — additionally assert the bounding boxes of every card (`.nv-card`, `app-nv-*`, workspace view buttons) match ±1 px between classic and shell (dump `getBoundingClientRect()` of each, diff as JSON).
6. **Behaviour parity checklist** (scripted clicks, same assertions in both modes): Rx dialog opens and lists the drug search; lab/rad dialog tabs; «+ وزن» dialog; plan dialog; finish → discharge panel opens; dental: each view button (chart/perio/ortho/xray) visible exactly when today, quick-mode grid when `dental.mode=simple`.
7. **Customisation proof:** as `clinic.layout.manage`, hide `nut_packages`, move `nut_photos` to column 2, save for the clinic → reload shows it; «رجّع الافتراضي» → screenshot equals the baseline again.
Deliver the PNGs + the JSON box diffs under `e2e/screenshots/s2/` and paste the pass/fail table in the report.

---

## S2a — extension points + nutrition «as is» + harness

### Do
1. `visit-layout.model.ts`: `CardDef` + `component`, `inputs`, `bare`, `permission`; `VisitLayout` + optional `tabs` and `props?: { hideRail?: boolean }` (additive; `VISIT_LAYOUT_VERSION` unchanged — absent = today's behaviour). `SpecCardCtx = { encounter, encounterId, patientId, doctorId, locked, signing, canSign, signBlocked, orderingMode, labMode, radMode, orderId, isInsured }`.
2. `resolve-layout.ts`: drop cards whose `permission` the user lacks (pass `can: (perm) => boolean` in the input); validate `tabs` (unknown ids removed, every resolved card belongs to exactly one tab when tabs exist — orphans go to the last tab); never re-insert `requiredWhen` (test). Pure-function tests: add `resolve-layout.spec.ts` style cases as a plain TS test file runnable with `npx tsx` if Jest/Karma is absent (the app has `skipTests: true` — write `visit-shell/resolve-layout.check.ts` that asserts with `console.assert` and run it with `npx tsx` if tsx exists, else report).
3. `VisitShellBus` (`visit-shell/visit-shell-bus.service.ts`, provided by the shell): `sign()`, `discharge()`, `cashier()`, `intentsChanged(kind)`, `refresh(part)` — the shell subscribes and calls its existing methods.
4. Shell: `@default` dynamic branch (Decision 1), `bare` rendering in `app-visit-card`, board **tabs** rendering (Decision 7/8) using the same tab-bar styling as the classic consultation tabs (reuse its classes), `hideRail`, editor `[typeLabel]` from the real type (`clinicalTypeOf(type).labelKey`), editor feature switches hidden for dental (Decision 6).
5. `specialties/nutrition.cards.ts`: `nut_board` (bare F, permission `nutrition.visit.consult`, component = a thin wrapper `NutritionBoardCardComponent` hosting `<app-nutrition-consult-board>` with every input from `SpecCardCtx` and outputs → `VisitShellBus`), `nut_measure` (bare F, `nutrition.visit.measure`, wrapper hosting `app-nutrition-patient-panel mode="measurements"` exactly as consultation L319 does); `DEFAULT_LAYOUTS.nutrition` = tabs measure/consult with those two cards. `supported` becomes true for nutrition.
6. Redirect (Decision 9) for nutrition.
7. Harness (section «Proof») + run it for nutrition (classic vs shell `nut_board`) — must pass ≤0.2 %.
8. `npx ng build --base-href /app/` green.

## S2b — nutrition board as 13 (+1) cards (default identical)

### Do
1. **Shared data store** `visit-shell/specialties/nutrition-board.store.ts` (provided by the nutrition wrapper scope): loads `NutritionVisitService.record(patientId, encounterId)` ONCE and exposes signals for each section; `reload()` after any dialog save (the board's current `load()` semantics — read the board ts and copy the exact refresh triggers).
2. **Extract** the board into card components under `features/nutrition/visit/board-cards/` (new folder — you own it): for the 9 inline `nv-card` blocks create `nv-patient-card`, `nv-questionnaire-card`, `nv-appointments-card`, `nv-weights-card`, `nv-measurements-card`, `nv-plan-card`, `nv-packages-card` (+ the existing `app-nv-lifestyle-card`, `app-nv-progress-card`, `app-nv-sessions-card`, `app-nv-case-notes-card`, `app-nv-meds-card`, `app-nv-labs-card`, `app-nv-photos-card` reused as is) — **move the markup verbatim** (same classes, same structure) and move the matching scss rules into each card's styles (keep `.nv-card` base styles in one shared scss partial imported by all, copied from `nutrition-consult-board.component.scss`). The action bar → `nv-actions-card` hosting the bar markup and the dialogs it opens (dialogs live in the actions card or in a `nv-board-dialogs` host component rendered once by the wrapper — choose the one that keeps the DOM order identical; the dialogs are `p-dialog` overlays so their DOM position doesn't affect the screenshot).
3. **The classic board must keep working unchanged:** after the extraction, `app-nutrition-consult-board` renders the same new card components in its own grid (so there is ONE implementation, two hosts). Run the harness classic-vs-classic-before (git stash is not allowed — capture the classic baseline in S2a BEFORE extraction and keep the PNGs) → must still pass.
4. `nutrition.cards.ts`: 14 card defs (`nut_actions` required bare F; `nut_patient`, `nut_questionnaire`, `nut_lifestyle`, `nut_appointments` col 0; `nut_progress`, `nut_weights`, `nut_measurements`, `nut_sessions`, `nut_plan` col 1; `nut_case_notes`, `nut_meds`, `nut_labs`, `nut_packages`, `nut_photos` col 2), sizes `['M','L']` (M = the column width), `nut_lifestyle` auto-hidden when it has no questions (the card component renders nothing — the store tells it). `DEFAULT_LAYOUTS.nutrition` switches from `nut_board` to the 14-card grid **only when the harness passes** at both viewports and both languages; otherwise keep `nut_board` as default, ship the cards as catalog options, and report the diff.
5. Shell grid for `flow:false` + 3 columns must reproduce `.nvb-grid` gaps/paddings: add a `props.gridClass` (e.g. `'nvb-grid'`) the shell applies to the board container so the nutrition grid scss is reused, not re-derived.
6. Complaint/diagnosis optional (Decision 5) — verify they can be added from the catalog and render in column 1/2 sizes M.
7. Harness run (all cases) + customisation proof.

## S2c — dental as cards

### Do
1. `specialties/dental.cards.ts`: `dent_workspace` (bare F, permission `dental.chart.view`, wrapper hosting `app-dental-workspace` with `[encounterId]="locked ? null : encounterId"` exactly like consultation L486, outputs → bus), `dent_today`, `dent_quick_complaint` (wrapper with the chips logic moved from consultation ts L196 — copy, don't import from the consultation component), `dent_exam`, `dent_more` (bare, the «المزيد» row + its inline switch + the orders dialog), `dent_alerts` (until D2c), optional catalog cards `dent_lab_board` (feature `lab`), `dent_recall` (feature `recall`), and per-sub-view cards `dent_perio` (feature `perio`), `dent_fmx` (feature `imaging`), `dent_ortho` (feature `ortho`), `dent_plan` — the sub-view cards are **optional extras** (a clinic may pin perio as its own card); the default keeps them inside the workspace's own view switch.
2. Features from `DentalService.meta()` (Decision 6) fetched once by the shell when type = dental; `catalogFor`/`resolveLayout` get them.
3. `DEFAULT_LAYOUTS.dental` (Decision 7) with tabs `dental_chart` / `dental_consult`.
4. Redirect for dental (Decision 9).
5. Harness: dental chart tab, consult tab, quick mode, each sub-view button, feature off (perio off ⇒ the button and the `dent_perio` catalog card both vanish).

## Tests / verify (every WP)
`npx ng build --base-href /app/` green; `resolve-layout.check.ts` passes (permission drop, tabs, requiredWhen not re-inserted, bare cards keep sizes); the identity harness table. No deploy — the orchestrator deploys and re-runs the harness live.

## Out of scope
GYN cards (G1+), inpatient context (S3), BE, nutrition/dental feature behaviour changes, removing the classic `@case` blocks (next release, after owner approval).

## Report (each WP)
Files, build output, harness pass/fail table (+ clip rects), any nutrition/dental component you had to give a new input/output, Fable consult outcome, i18n keys added, anything not done.
