# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

**This directory (`web3p1`) is a copy of `~/web2` with `/var/www/shtml/web3.1`'s design system merged in — read `DECISIONS.md` first.** Everything below this point was written for the original `~/web2` (still untouched, at that path) and is accurate for the architecture, data model, routing, and SEO/analytics wiring — none of that changed here. It is *not* accurate for anything DECISIONS.md documents as changed: fonts are EB Garamond + Commissioner now, not Alegreya/Noto Sans; `tokens.css` has a full space/radius/shadow/blur/motion/z-index scale now, not just color+type; the wayfinder is unhooked from every live page (code untouched, just not linked from `layout.php`/`home.php`); the homepage and CV page carry a Neural Thread scroll signature that didn't exist before. Where this file and `DECISIONS.md` disagree, `DECISIONS.md` is the newer, correct account.

## Repository state

`PAGE.md` is the authoritative spec and takes precedence over anything below, which is only a summary — **read it in full before writing code.** The design token proposal (palette, type, breakpoint, wayfinder concept) was signed off before any implementation started; the confirmed values are recorded in this machine's Claude memory (`design-token-system`), not duplicated into this file. **The palette was revised 2026-07-05** (user feedback: the original read as too flat) — navy/oxblood/teal/brass, confirmed through a 3-round comparison artifact before touching any code; the memory file has the full rationale and exact hex values, `public/assets/css/tokens.css` has the implementation. Type/breakpoint/wayfinder-concept from the original sign-off are unaffected.

Implementation has completed all five steps of PAGE.md's mandated build order: **shared data model**, **nav component**, **anatomical wayfinder**, **remaining page templates**, and **SEO/OG/sitemap/analytics wiring**. Every one of the 33 nav destinations plus every procedure/condition page resolves to a real, rendered page with real per-page SEO (title/description/canonical/hreflang/OG/Twitter/JSON-LD); `sitemap.xml`/`robots.txt` are generated from the same route list; GA4 is gated behind a real cookie-consent banner. What's left is everything PAGE.md explicitly stubbed rather than asking to be invented - see `Config` and the flagged pending-content pages below - plus real browser testing (no headless browser here, see below).

Since the brief mandates no Composer and no Node.js, there is no package manifest or build step. Run the site locally with PHP's built-in server from the `public/` document root, **passing `public/index.php` as the router script**:

```
php -S localhost:8000 -t public public/index.php
```

The router-script argument matters and isn't optional now: without it, PHP's built-in server 404s `/sitemap.xml` and `/robots.txt` directly (it treats any path with a recognized extension as "should be a static file" and never invokes PHP for those specifically, unlike extensionless paths, which happen to fall through to `index.php` automatically even without a router argument - inconsistent, and only extension-based paths expose it). `index.php` has a `PHP_SAPI === 'cli-server'` guard at the top that hands static-file requests back to the built-in server once a router script is in play, so `/assets/...` keeps working too. That guard never runs under Apache/PHP-FPM (the real deployment target), which resolves static files and rewrites the rest to `index.php` via `public/.htaccess` on its own.

Lint any PHP file with `php8.2 -l path/to/file.php` (not the bare `php -l` - this box's CLI `php` is 8.4, but the live PHP-FPM serving the Apache test deployment is 8.2; `php8.2` is also installed here and is what's actually being verified against. This distinction cost a real bug once already - see `Config.php`'s comment on typed constants - so keep using `php8.2` for this project, not just whatever `php` resolves to by default). Syntax-check a JS module with `node --input-type=module --check < path/to/file.js`, or a plain (non-module) script with `node --check < path/to/file.js` (Node is only used as a local linting tool here, never as a project dependency — no `package.json` in this repo). There is no test suite yet; the data model is self-checked instead — `DataStore::integrityErrors()` walks every cross-reference (symptom→anatomy, procedure→condition, nav parent ids, ...) at load time and returns human-readable errors for any dangling id, so a typo'd reference fails loudly rather than silently rendering a broken link.

No headless browser is available in this environment (Playwright's Chromium needs system libraries — `libatk-1.0.so.0` etc. — that aren't installed, and installing them wasn't attempted since it needs root). Nav and wayfinder interaction (hover delay, accordion, Escape/focus handling, flyout clipping, roving tabindex, dialog drill-in) has been verified by careful code reading plus `curl`-based structural checks (ARIA attribute counts, correct locale resolution, JSON payload validity) — not by driving an actual browser. Treat that as unverified until someone opens it in a real browser; see the manual test checklists in the nav/wayfinder architecture sections below.

### Live test deployment on this box

The app is also reachable through the real Apache + PHP-FPM 8.2 stack on this server (not just `php -S`), at **https://nyx.erns.eu/mweb/web2/** — set up specifically so a human can test with an actual browser, since no headless browser is available here. This is a symlink (`/var/www/shtml/mweb/web2` → `/home/evrokas/web2/public`) into the docroot of the existing `nyx.erns.eu` vhost (a shared box also hosting Nextcloud, the real `web.erns.eu`/`web3.erns.eu` production site, and unrelated tools) — not a dedicated vhost of its own, and not the production deployment target. No Apache config was touched or reloaded; only two filesystem changes were made:

- `chmod o+x /home/evrokas` — the home directory was `750` (blocking traversal by any user outside the `evrokas` group, including `www-data`, the Apache/PHP-FPM worker); this adds execute-only ("can `cd` into a known path, can't list contents") for everyone else, the minimal fix for `www-data` to reach `web2/` at all. Nothing under `/home/evrokas/web2/` itself needed changes (already `755`).
- `public/.htaccess` — a standard front-controller rewrite (`RewriteCond %{REQUEST_FILENAME} !-f/!-d` → `index.php`), needed regardless of *where* this is deployed, not specific to this test path.

The app is otherwise **subpath-aware by design**, not hacked around for this one test: `public/index.php` computes `$basePath` from `dirname($_SERVER['SCRIPT_NAME'])` (empty string when mounted at a domain root, e.g. via `php -S` or PAGE.md's actual target — a dedicated vhost) and threads it through `Router::dispatch()`, every `DataStore` path-builder (`navPath()`/`procedurePath()`/`conditionPath()`/`wayfinderPayload()`, all taking an optional `$basePath = ''` parameter), and every template's asset/nav links. A root-mounted deployment is completely unaffected (empty-string prefix is a no-op); this isn't a special case bolted on for testing, it's the same mechanism a real subpath deployment would use.

Unrelated: there's a pre-existing, unrelated `web2.erns.eu` vhost on this same box (`DocumentRoot /var/www/wordpress`) — a coincidental name collision with this project's local directory name, not connected to it in any way.

### Shared data model — architecture

No Composer means no autoloader from `composer dump-autoload`; `src/autoload.php` hand-rolls a `spl_autoload_register` mapping `App\` to `src/`. Everything targets PHP 8.1+ syntax (enums, readonly properties, constructor promotion) — confirmed available via the local `php -v` (8.4.18), but don't reach for 8.4-only syntax (e.g. calling a method directly on `new Foo()` without wrapping parens) since production PHP version isn't confirmed.

- `src/Model/` — typed value objects: `Locale`, `LocalizedText` (the GR/EN pair every bilingual field uses), `SpecialtyAxis` (the cranial/spine/pain enum that owns the axis→color-token mapping — nav, wayfinder, and content tags all read color from here, not from CSS or templates re-deciding it), `AnatomySystem`, `SeoFields`, `NavItem`, `AnatomyRegion`, `Symptom`, `Procedure`, `ConditionEntry`, `Clinic`, `PracticeProfile`.
- `src/Data/` — one `*Data` class per entity, each a static `all()`/`tree()`/`profile()` returning hydrated model objects with real bilingual content (not lorem ipsum). `NavData` and `AnatomyData` are complete (full fixed nav taxonomy; full spine C1–S1 + 7 cranial regions — textbook nomenclature, not a clinical claim). `SymptomData`, `ProcedureData`, `ConditionData` started as an illustrative sample and were **confirmed by the user (2026-07-04) as the working roster** — 3 symptoms verbatim from PAGE.md, 8 procedures/6 conditions with full bilingual `body` essay content (standard textbook fact only: what it is, general approach, general candidacy/recovery notes — no outcome statistics or claims about Dr. Rokas's personal technique, since none of that was supplied). Expanding this roster further still needs the same sign-off PAGE.md asks for on clinical/specialty content.
- `src/Data/DataStore.php` — the single load point. Entities declare only their own outbound ids (e.g. `Procedure::$anatomyIds`); `DataStore` derives every reverse index (anatomy→procedures, symptom→conditions, ...) at construction time so the two sides of a link can't drift out of sync by hand-editing only one file. Also resolves full nested nav paths (`navPath()`, joining ancestor slugs) and runs the integrity check above.
- Content-entity ids (symptoms, anatomy, procedures, conditions) are a single ASCII clinical-term slug shared across both locale URL trees (e.g. `lumbar-disc-herniation` under both `/el/...` and `/en/...`) — only nav-section slugs are actually translated per locale. Flagged as a pragmatic default, not requested in PAGE.md.
- Locale codes are ISO 639-1 (`el`, not `gr`) to match `hreflang`/`lang`/`og:locale` directly — PAGE.md's own "GR/EN" phrasing is colloquial, not the code to use in markup.
- Contact fields (office phone, mobile phone, email) are real — supplied by the user 2026-07-04, on `PracticeProfile` and echoed into `PAGE.md`'s Practice context paragraph. `PracticeProfile::telHref()` turns the human-readable spaced form into a `tel:`-safe digits-only string for links; the display text always uses the as-given spaced form.

### Nav component — architecture

- `src/Support/Router.php` + `Response.php` — a minimal `{param}`-pattern router and a plain response value object. `src/Support/View.php` is the "Twig-like" renderer CLAUDE.md's stack line asks for: plain PHP templates (no template language/parser, since there's no Composer to bring in Twig itself) with layout wrapping and partial includes. Templates live in `templates/`, not `src/`, and get `$view` injected automatically so they can call `$view->partial(...)`. `e()` (in `src/Support/helpers.php`) is the one escaping helper; PHP's `<?=` is unescaped by default so every template calls it explicitly on user/content-facing text.
- **Bilingual live-toggle**: any element that should swap language client-side carries `data-el-text`/`data-en-text` (and optionally `data-el-href`/`data-en-href`, `data-el-aria-label`/`data-en-aria-label`). `public/assets/js/locale.js` is the one generic engine that reads these — not nav-specific, so future page templates opt in with the same attributes rather than writing their own swap logic. "No page reload" is literal: it never fetches; it updates the DOM directly and keeps the address bar honest via `history.pushState` to whatever `<link rel="alternate" hreflang>` already points at for the target locale (that tag has to exist for SEO anyway, so it's reused rather than duplicated as a second route map).
- **Nav markup/behavior** (`templates/partials/nav.php`, `public/assets/css/nav.css`, `public/assets/js/nav.js`): every non-leaf nav item is a real link *and* gets an adjacent disclosure toggle button (split control) — PAGE.md asks explicitly to decide whether top-level items are landing pages or pure toggles; the call made here is "both, uniformly, at every depth" (not just top-level) since every nav node already resolves to a real path via `DataStore::navPath()`. Desktop open/close works via CSS `:hover`/`:focus-within` alone as a no-JS baseline (panels are always in the DOM); `nav.js` layers a JS-held `.is-open` class on top (additive in the same CSS rule) to implement the mouseleave-delay, Escape, and `aria-expanded` sync that CSS can't do alone. Mobile accordion state is JS-only. The two required delegated listeners (click, keydown) sit on the nav root per PAGE.md's explicit "event delegation, not one listener per item" for mobile; desktop's mouseenter/mouseleave are attached directly to the small, bounded set of items that actually have children (7, never all 33) because those events don't bubble and can't be delegated without hand-rolling hover semantics.
- Fonts are self-hosted under `public/assets/fonts/` (woff2, Latin+Greek subsets already split by the design-token sign-off) rather than linked from Google Fonts' CDN — deliberate, not just convenient: this is a Greek/EU medical site already adding a cookie-consent banner for GA4, and a third-party font CDN leaks visitor IPs the same way a tracking pixel would.
- No headless browser was available to drive real hover/focus/tap interaction (see note above) — this is the biggest gap in "test both before moving on." Before trusting this component, check in an actual browser: desktop hover-open with diagonal mouse movement into the panel, keyboard Tab through the whole nav with visible focus throughout, Escape closing one level at a time, the two nested flyouts (Πληροφορίες για ασθενείς, Συνεργαζόμενες κλινικές) actually flipping side when opened near the right edge of a narrow desktop window, mobile hamburger open/close, mobile recursive accordion (opening a sibling collapses the previous one, at every level), and the GR/EN toggle both visually and via `view-source` after toggling (confirm the URL bar updated too).

### Anatomical wayfinder — architecture

- `src/Support/WayfinderLayout.php` — pure geometry, no rendering: turns the ordered `AnatomyRegion` list into SVG hotspot shapes. Spine is grouped by id prefix (`c`/`t`/`l`/`s`) and each group gets an independent half-sine curve that starts and ends at the center baseline (a schematic approximation of cervical/thoracic/lumbar curvature, continuous at every boundary by construction) - genuinely computed from the data, not a hand-typed coordinate table. Cranial's 7 lobes/regions have no linear-stack analog, so their relative position and per-lobe bump layout *is* a hand-tuned lookup table keyed by region id - the one deliberately-hardcoded set of parameters, flagged in that file's docblock.
- **Cranial hotspot shapes redesigned 2026-07-08** from plain ellipses/rect (originally deliberate - "schematic, not a medical illustration" - but read as too generic once seen live) to organic gyrus-bump outlines, guided by a reference image the user supplied. First pass used a pure formula (`WayfinderLayout::gaussianBumpPath()`: a base radius plus a handful of named Gaussian bumps at chosen angles, sampled around a circle and splined) - the user judged that pass "not successful" and asked for a more precise trace, so the five most distinctive lobes (frontal, parietal, temporal, cerebellum, and occipital by reuse - see below) were replaced 2026-07-09 with **literal traced outlines**, not a formula: the reference image was masked by color per lobe (numpy/scipy, no CV library available - color-distance threshold, `binary_fill_holes`, largest-connected-component), each region's boundary extracted via matplotlib's marching-squares contouring, resampled to 40 evenly-spaced points, fit into that lobe's target box, and smoothed through a closed Catmull-Rom spline into a static `'d'` string baked directly into `cranialGeometry()` - genuinely the reference's own pixel outlines, not an approximation of them. `occipital-lobe` has no distinct region in the reference (fused into the same blue mass as temporal/brainstem there), so it reuses parietal's traced silhouette, mirrored and rescaled, rather than being invented from nothing. `brainstem` and `trigeminal-skull-base` still use the original `gaussianBumpPath()` formula, computed live at request time - the reference's equivalent area is a decorative spiral flourish, not a real brainstem shape, so there was nothing to trace there. Verification for this one had to go further than usual: the generated path data was rendered to a real PNG locally (ImageMagick) at every iteration and visually compared against the source image before being committed to PHP, then the *actual live-rendered* SVG output was pulled from the running dev server and rendered again to confirm the PHP port matched the verified prototype exactly - code reading alone can't catch "does this look like a brain," only a rendered image can. Colors are still unchanged from the first pass (axis-based - navy for Cranial, oxblood for the one Pain-axis trigeminal hotspot) - see the first-pass note below for why the reference's per-lobe rainbow coloring was deliberately not copied. `templates/partials/wayfinder.php`'s `$renderShape` closure gained a `'lobe'` branch (alongside the existing `ellipse`/`rect`/`trapezoid` ones) that emits the pre-built path string as-is - `wayfinder.js`, the embedded JSON payload, and hover/focus/active behavior are all unaffected, since hotspot geometry was already opaque to the JS (it only ever reads the `<g>` wrapper's classes/data attributes, never the child shape).
- **A background brain silhouette added 2026-07-16**, because the individually-traced lobes above - each one correct on its own - still read as scattered separate pieces once actually seen together, with no overall "this is a brain" cue (visible gaps, cerebellum floating disconnected from the rest). `WayfinderLayout::cranialOutlinePath()` is a new static method returning one more traced path, built the same way as the five lobe shapes but from the *union* of all four color-masked regions instead of one at a time - the four regions don't touch at the pixel level in the source art (each lobe is a separately-outlined shape), so a generous dilate/fill-holes/erode pass bridges the small gaps between them before the union gets contoured - still literally the reference's own overall outline, not hand-drawn. Rendered in `wayfinder.php` as one `<path class="wf-brain-outline" aria-hidden="true">` behind the hotspot loop in the cranial `<svg>` only (spine's `<line class="wf-baseline">` is the existing analog for a static non-interactive backdrop element) - low fill-opacity (`wayfinder.css`, 0.12) so it unifies the composition without competing with the actual axis-colored hotspots drawn on top of it. Same verification approach as the lobe-shape work: rendered locally and visually compared before touching PHP, then the actual live-rendered SVG pulled from the dev server and re-rendered to confirm the port matched.
- `DataStore::wayfinderPayload()` builds the wayfinder's entire data need - every region with both-locale labels and its resolved symptom/condition/procedure cross-links (both-locale text and hrefs) - as one array, embedded once as JSON (`templates/partials/wayfinder.php`, `<script type="application/json" id="wayfinder-data">`) so `wayfinder.js` never makes a server round trip to populate a tooltip or dialog. `DataStore::procedurePath()`/`conditionPath()` are the first real route builders for those two entity types (parent nav path + the entity's own id) - **the pages they point to don't exist until step 4**, so clicking a hotspot that drills straight through to a single matching condition (see below) currently 404s. Expected at this stage, not a bug; revisit once step 4 lands.
- Hotspots are SVG `<g role="button" tabindex>` elements (SVG elements support focus/tabIndex/ARIA natively via the `HTMLOrSVGElement` mixin in current browsers - no HTML-overlay-positioned-over-SVG workaround needed). Same `data-el-aria-label`/`data-en-aria-label` convention as the nav, so `locale.js`'s existing generic swap loop updates hotspot accessible names for free. Tooltip/dialog *content*, by contrast, is populated dynamically by `wayfinder.js` reading `document.documentElement.lang` at interaction time (never pre-rendered per locale) - the one known gap is a tooltip already open when the language is toggled won't update until the next interaction; accepted rather than adding a second locale-change listener for it.
- Deliberately the one component styled dark (Bone Ink chrome) - everywhere else on the site is a light, paper-toned reading surface. This is intentional contrast (a radiology lightbox/instrument you step into and back out of), not an inconsistency - see the design-token sign-off's self-critique log for why a site-wide dark theme was rejected but this contained exception wasn't.
- **Hotspot color fixed 2026-07-08 to show at rest, not just on hover/focus/active**: hotspots originally rendered a flat neutral off-white at rest (`rgba(243,245,246,0.32)`) and only picked up their real `--axis-color` (teal/navy-mid/oxblood) once hovered/focused/active - since only 1 of the wayfinder's 32 hotspots is pain-axis (the trigeminal/skull-base region, on the non-default Cranial tab), this meant almost no visitor would ever see oxblood without deliberately finding and hovering that one hotspot, which read as "the site is just white and blue." Fixed by making `fill` permanently `var(--axis-color, ...)` and using `fill-opacity` (0.45 at rest, 1 on interaction) as the rest/interactive distinction instead - same mechanism, now visible without interaction. `stroke` deliberately stays neutral at rest rather than also axis-tinted (a tinted stroke at that width/opacity tests less visible than the current neutral one per WCAG luminance math, and blurs the "which axis" vs. "is this the one you're interacting with" signal the fill/stroke channels currently split cleanly).
- The homepage's condition-teaser row (`home.php`) picks one condition per `SpecialtyAxis::cases()` (Cranial/Spine/Pain, in that fixed enum order) rather than `array_slice(allConditions(), 0, 3)` - the raw first-3 slice happened to land on 2 Spine + 1 Pain and zero Cranial, an accident of `ConditionData`'s declaration order, not a deliberate content choice. Symptom chips got the same rest-state treatment via `.symptom-chip`'s existing `--axis-wash` custom property as a background (previously only consumed on hover) - already correctly wired per-axis, just not read until hover before this fix.
- **CTA buttons moved from navy to oxblood 2026-07-08** (`.hero__cta`, `.form__submit`, `.consent-banner__accept`): the palette-comparison round the user actually approved ("Navy + Oxblood") showed oxblood as the action-button color, not just the pain-axis tag - the site implementation had oxblood-as-CTA gets narrowed to oxblood-as-pain-axis-only along the way, which was part of why oxblood read as nearly absent site-wide. Restored the approved split: navy stays structural chrome (nav bar, footer, general links), oxblood is now the "click here to act" signal on every page. Axis-contextual CTAs (`.essay__cta` on condition/procedure pages, correctly teal/navy-mid/oxblood depending on which specialty the page is about) are unchanged - this only touched the three CTAs that had no axis context to begin with. Each got its own `:focus-visible` override to `--color-field-white` (same reasoning as the footer's existing override) since the sitewide oxblood focus ring measures 1:1 - invisible - against an oxblood button background.
- Click/Enter on a hotspot: exactly one linked condition navigates straight there; zero or multiple opens a native `<dialog>` (`showModal()`) listing symptoms/conditions/procedures. Escape-to-close and backdrop-click-to-close both rely on native `<dialog>` behavior (the `event.target === dialog` check for backdrop clicks is the standard trick, not a hack specific to this codebase) - the `close` event listener's only job is returning focus to whichever hotspot opened it.
- Roving tabindex is order-based (`AnatomyRegion::$order`, already needed for wayfinder layout) rather than a hand-authored spatial-adjacency graph for the cranial system - simpler and uniform across both systems, and still fully satisfies PAGE.md's "roving focus" ARIA requirement without over-building.
- Hover/leave uses real event delegation (`mouseover`/`mouseout` + `relatedTarget`, two listeners on `.wayfinder__viewer` covering every hotspot in both stages) rather than nav's bounded-direct-listener approach - wayfinder hotspots are flat siblings with no nesting, so the delegation trick that's awkward for nav's nested submenus is straightforward here.
- Same testing gap as the nav: no headless browser available, so hover/focus/keyboard/dialog interaction is verified by code reading and `curl`-based structural checks (hotspot count, ARIA roles, embedded-JSON validity, correct per-locale `aria-label`) only. Check in a real browser before trusting it: hover a few spine and cranial hotspots and confirm the tooltip appears positioned sensibly and doesn't run off the right edge, Tab into the wayfinder and arrow-key through hotspots (focus should move and the tooltip should follow), Enter on a hotspot with exactly one condition (e.g. C5 → Cervical Radiculopathy, or the trigeminal/skull-base region → Trigeminal Neuralgia) and confirm it navigates straight to that condition's real page (procedure/condition pages exist now, since step 4 landed - this used to 404, no longer does) while a multi-condition hotspot (e.g. L4) opens the dialog instead, Escape/backdrop-click closing the dialog and returning focus to the hotspot, and the Spine/Cranium tab switch with arrow-key tab navigation.

### Page templates — architecture

- `src/Support/ContentDispatcher.php` is where "which nav id gets which template, with what data" actually lives - `public/index.php` stays a thin bootstrap. It resolves through `DataStore::resolvePath()` (built at construction from every nav/procedure/condition path, both locales - the single place a URL gets decided), then calls one of a handful of private render methods per content shape.
- `src/Support/Router.php` gained a `{*param}` catch-all placeholder (matches the rest of the path including slashes, since content paths are arbitrary depth - `/el/o-iatros` is 1 segment, a nested patient-info page is 3) alongside the existing single-segment `{param}`.
- Six template files cover all 33 nav destinations plus every procedure/condition page - deliberately not 33 separate files: `page.php` (generic static content, with an honest pending-content state built in), `section-hub.php` (nav parents that just link onward - to nav children, to procedures filtered by `navParentId`, or to all conditions; same template regardless of which), `procedure.php`/`condition.php` (the essay layout from Fig. 04 - body column plus a cross-link rail: related anatomy, related symptoms, related procedures/conditions, a referring-physician note, a booking CTA), `video.php`, and `contact-form.php` (shared shape for booking/email/scan-upload).
- `DataStore::breadcrumbTrail()` builds the same trail data JSON-LD's `BreadcrumbList` reads from (see the SEO section below); `templates/partials/breadcrumb.php` is just the visual rendering of it. A procedure/condition page passes its own name as `$currentLabel` to extend a nav item's trail with one more, unlinked, final step.
- **Content depth is deliberately uneven, and that unevenness is a decision, not an oversight**: procedures/conditions get full bilingual essay `body` content (standard medical fact, confirmed roster - see above); pages requiring facts nobody supplied - Βιογραφικό, Ενδιαφέροντα, Τομείς Έρευνας, Νέα/Blog, Νεώτερες Τεχνικές, Συνέδρια, pre-/post-op instructions - render `page.php`'s honest "not published yet" state instead of invented copy (confirmed with the user before writing this pass). Ιατρείο Μάνδρας, the partner-clinic pages, and the affiliations strip use real facts already in `PracticeProfile`/`ClinicData`. Πληροφορίες για ιατρούς / για επαγγελματίες υγείας get real short framing copy (who the section is for, that referral/contact is welcome) since that's editorial structure, not a clinical or biographical claim.
- **Τεχνικές (Techniques) populated 2026-07-11**, at direct user request - two named technique write-ups (`ContentDispatcher::techniquesPage()`, `templates/techniques.php`), standard technical fact about two real technologies the practice uses: robotic-guided spinal fusion (Excelsius GPS) and image-guided neuronavigation (pre-op CT/MRI plus intraoperative O-arm). Deliberately not modeled as `Procedure` entries - neither is tied to a single condition the way a Procedure is (both are techniques used across many different surgeries), so they render as a fixed list of articles on one page instead of getting their own routes/cross-link machinery. `info-newer-techniques` is unrelated and still pending. Also added the same day: `sacral-root-stimulation`, a third procedure under Νευροτροποποίηση alongside the two already there (spinal-cord-stimulation, deep-brain-stimulation) - ships with `conditionIds: []` since the confirmed 6-condition roster has nothing urinary/bladder-related to cross-link to, the same precedent `deep-brain-stimulation` already set.
- **`kyphoplasty` added 2026-07-11**, under Χειρουργική Σπονδυλικής Στήλης (Spine axis), sourced from the user's own real practice site (evangelosrokasmd.gr/el/operations/kyphoplasty) rather than invented - fetched, then rewritten from scratch in this site's own editorial register rather than copied, same rule PAGE.md applies to web.erns.eu. The source page states international success/complication-rate statistics (85-90%, 1-3%); deliberately not carried over, consistent with every other procedure in `ProcedureData.php` stating no outcome statistics at all, sourced or otherwise - checked directly in the rendered output (grepped for the literal figures) that neither leaked through. `anatomyIds: ['t12', 'l1']` names the thoracolumbar junction as representative levels (standard textbook epidemiology for osteoporotic compression fractures, not a claim about any specific patient); `conditionIds: []` for the same reason as `sacral-root-stimulation` above - `chronic-low-back-pain` describes a different clinical picture than an acute compression fracture, so cross-linking them would overstate the connection.
- **Τεχνικές got a third article, the OrbEye 3D exoscope, 2026-07-12** - unlike kyphoplasty, no matching page was found on evangelosrokasmd.gr, so this is standard technical fact about a real, publicly-documented surgical visualization platform (a digital 4K 3D alternative to the traditional operating microscope) written directly, same "no outcome/personal-technique claims" rule as the other two articles on that page and as `ProcedureData`.
- **`Procedure::$body` became nullable 2026-07-12**, to support genuine placeholder procedures - real name/nav placement/axis/summary, routed and listed on their hub page, but no essay content because none was supplied. `null` renders `templates/procedure.php`'s honest pending notice (the exact same bilingual sentence `page.php` already uses for pending nav pages) instead of the `essay__body` paragraph loop; `$summary` stays required regardless, since hub cards always need real preview text - only the deeper essay `body` is ever deferred. First used for 6 procedures added the same day, at direct user request, explicitly asked for as placeholders rather than full articles: `posterior-cervical-fusion`, `posterior-lumbar-fusion`, `thoracic-discectomy` (Spine axis, real vertebral-level `anatomyIds` following the roster's existing "loose but genuine" tagging convention - C4-C6, L4-S1, T10-T12 respectively); `hydrocephalus-shunting` (one combined article covering VP/AP/LP routing variants, confirmed with the user rather than assumed) and `endoscopic-third-ventriculostomy` (Cranial axis, `anatomyIds: []` - deliberately empty, since their real anatomical object, the ventricular/CSF system, has no representation among the 7 available cranial regions, not even loosely the way `brainstem` loosely fits `deep-brain-stimulation` - tagging an entry-site lobe instead would misrepresent surgical access as "Related Anatomy"); and `programmable-pump-implantation` (Pain axis, under Νευροτροποποίηση alongside the section's other implanted-programmable-device procedures, added when the user extended the request mid-conversation with a 6th topic). **All 6 upgraded to full bilingual `body` essay content 2026-07-20**, at direct user request ("replace previous placeholders with full articles, first in greek and then in english") - each entry's Greek paragraphs were drafted first and the English written second from those, rather than in parallel, per the user's explicit ordering. Same standard-textbook register as every other entry in this file (technique/approach, indications, general recovery notes - no outcome statistics or personal-technique claims); `name`/`summary`/`axis`/`navParentId`/`anatomyIds`/`conditionIds` were left untouched, since only the deferred essay content was ever missing. `hydrocephalus-shunting` and `programmable-pump-implantation` both run 4 paragraphs rather than the usual 3 - the former to cover all three VP/AP/LP variants individually rather than compressing them, the latter to separate implantation technique from ongoing refill/reprogramming - a length exception, not a departure from the content rules. The `Procedure::$body` nullable mechanism itself is unchanged and still available for any future placeholder; it's just that none of the current 19 procedures use `null` anymore (confirmed via `DataStore` after this change - zero null bodies).
- **4 hematoma/hemorrhage conditions + 3 hematoma procedures added 2026-07-18**, at direct user request (unlike the 6 placeholders above, not explicitly asked for as placeholders, and these are extremely standard/foundational neurosurgical topics, so they ship as full essay content rather than `body: null`). Conditions (`ConditionData.php`, all Cranial axis, `navParentId: 'info-conditions'`): `subdural-hematoma`, `epidural-hematoma`, `intracranial-hematoma` (titled "Intracerebral Hematoma" in both locales - the precise clinical term for bleeding within brain parenchyma - while the id keeps the user's own "intracranial" wording), and `subarachnoid-hemorrhage`. Procedures (`ProcedureData.php`, all Cranial axis, `navParentId: 'surgery-brain'`): `burr-hole-hematoma-evacuation`, `craniotomy-hematoma-evacuation` (a distinct id from the earlier `craniotomy-tumor-resection` - same general technique, different indication), and `cranioplasty`. Real, non-empty cross-links this time (a departure from `sacral-root-stimulation`/`kyphoplasty`/the 6 placeholders, none of which had a clean condition match): `subdural-hematoma` links to both new evacuation procedures (burr-hole for the classic chronic case, craniotomy for acute/large ones); `epidural-hematoma` and `intracranial-hematoma` link to `craniotomy-hematoma-evacuation` only, since burr holes aren't a definitive option for either. `subarachnoid-hemorrhage` gets `anatomyIds: []`/`procedureIds: []` - its classic origin (basal cisterns/circle of Willis) has no honest match among the 7 cranial regions, and its definitive treatment is securing the causative aneurysm, not evacuating a discrete clot, so linking it to the new evacuation procedures would misrepresent what they treat. `cranioplasty` similarly ships with both lists empty - a reconstructive follow-up procedure with no single typical defect location or indicating condition, same "no forced match" precedent used throughout both data files.
- Real photography now lives under `public/assets/photos/` and `public/assets/video/` (copied from `/var/www/shtml/apps/mweb/web/assets` - resolution/rights caveats on some of that folder's other assets were flagged during the design-token sign-off and still apply). Φωτογραφίες is a plain gallery of what's available; Video uses `<video controls preload="none">` with no autoplay - deliberate, since an autoplaying video is exactly what the sub-2s mobile LCP budget can't afford.
- `public/assets/cv/` holds the user's real extended CV documents (GR + EN, copied from `~/devel/cv`) as standalone attachments - self-contained HTML with their own inline/external styling, deliberately *not* re-themed into the site's own CSS/font system, since they're linked-to reference documents rather than site pages. `templates/cv.php` links to both from the condensed on-site Βιογραφικό page. DOCX source files were read by unzipping and stripping XML tags with Python's stdlib (`zipfile` + `re`) - no docx/pdf conversion tooling is installed, and none was added just to read two files.
- Contact forms (`contact-form.php`) render real, styled, accessible field markup but are genuinely inert: the submit control is `<button type="button">`, not `type="submit"`, so clicking it does nothing at all rather than silently reloading the page as if it had submitted. A visible notice above the form says so explicitly, and now also points at `templates/partials/contact-info.php` (office phone / mobile / email, real `tel:`/`mailto:` links) as the actual working fallback - added once real contact info existed, same partial reused on the Mandra office page. Don't wire the forms to `type="submit"` without also building the backend (booking calendar / SMTP / file storage) they'd need - none of that exists yet.
- Verified with the same method as the nav/wayfinder (`curl`-based structural sweep, no headless browser available): all 97 real URLs (33 nav ids × 2 locales, 8 procedures × 2, 6 conditions × 2, home/root) return 200 or the expected redirect, with no PHP warning/notice/fatal text anywhere in the rendered output. Real interaction (clicking through a form, viewing the gallery/video, following cross-links visually) still needs a real browser.

### SEO / OG / JSON-LD / sitemap / analytics — architecture

- `src/Support/Config.php` holds every value PAGE.md asks to be stubbed rather than guessed: `SITE_URL` (real production domain not yet decided - canonical/OG/sitemap URLs are literally `https://TODO-real-domain.example/...` until it's set, so a stale/fake-looking domain is never silently shipped), `GA4_MEASUREMENT_ID`, `GOOGLE_SITE_VERIFICATION`. **Do not use PHP 8.3+ typed class constants here** (`public const string X = ...`) - this project targets 8.1+, and the live PHP-FPM on this box is 8.2; that syntax was written once by mistake and caught by linting with `php8.2 -l` specifically (the CLI's bare `php` is 8.4 and would have passed it silently). Lint this project's PHP against 8.2, not whatever `php` resolves to.
- `src/Support/Seo.php` builds every JSON-LD node from data already in `DataStore`/`PracticeProfile` - nothing hand-authored per page. `physicianNode()` includes real `telephone`/`email`/`contactPoint` (office + mobile as separate `ContactPoint` entries) now that real values exist on `PracticeProfile` — these were deliberately omitted while they were still `TODO:` placeholders, since a literal "TODO: ..." string has no business in indexed structured data; that's exactly the case that changed 2026-07-04. `toGraph()` wraps whatever node list a page needs into one `@context`/`@graph` script tag. Every page gets `Physician` + `BreadcrumbList` (except the homepage, which has nothing to trail from) + `MedicalWebPage`; procedure/condition pages nest the specific `MedicalProcedure`/`MedicalCondition` via `about`; condition pages additionally get an auto-derived `FAQPage` (2 Q&A pairs built from the condition's own `lede` and linked procedure names - restated existing approved content, not new claims). FAQ question phrasing deliberately avoids embedding a gendered Greek article (η/ο/το) before the condition title - condition titles span all three grammatical genders and a single hardcoded article would be wrong for some of them (caught by testing "Χρόνιος Πόνος" - masculine - against a template written for a feminine title). Greek uses `;` as its question mark, not `?` - also caught the same way, in the same fix.
- `scripts/generate-og-images.php` is a one-time GD script (`php8.2 scripts/generate-og-images.php`), not part of any request path - it writes `public/assets/og/{default,cranial,spine,pain}.png`. None of the real photography on hand is a 1200×630 landscape asset, so this is the "generated fallback" PAGE.md explicitly allows; it uses DejaVu Serif/Sans (real system `.ttf` files with full Greek coverage, already on this box) rather than the site's actual Alegreya/Noto Sans, which only exist here as `.woff2` - GD's `imagettftext()` needs `.ttf`/`.otf`. Re-run this script (and update it) if the practice name or palette ever changes; don't hand-edit the PNGs. `ContentDispatcher` picks the axis-matching variant for procedure/condition/specialty-hub pages and falls back to `default.png` everywhere else.
- **A basePath bug worth remembering the shape of**: `ContentDispatcher::axisOgImage()` and `layout.php`'s default-image fallback both originally built the og:image path without the `$basePath` prefix that every other path-building method in this codebase carries. It was invisible under the root-mounted dev server and under `php -S` (empty basePath - the bug had nothing to multiply against) and only showed up testing through the Apache subpath deployment, where `og:image` pointed at `.../assets/og/pain.png` instead of `.../mweb/web2/assets/og/pain.png`. Any *new* path-building code in this project needs to thread `$basePath` through the same way `navPath()`/`procedurePath()`/`conditionPath()` already do - it's easy to miss on a value that isn't itself a page route (an image path, in this case).
- `src/Support/SitemapBuilder.php` walks the exact same `navTree()`/`allProcedures()`/`allConditions()` DataStore already exposes - not a second, hand-maintained URL list. `/sitemap.xml` and `/robots.txt` are registered as literal routes in `public/index.php`, ahead of `/{locale}` (that pattern's `[^/]+` segment would otherwise happily match "sitemap.xml" as if it were a locale value - the router takes the first matching route, so order here matters).
- **GA4 + consent** (`templates/partials/consent-banner.php`, `public/assets/js/consent.js`, `public/assets/css/consent.css`): the banner renders `hidden` by default so a returning visitor who already decided never sees a flash of it - `consent.js` only un-hides it when `localStorage['cookie-consent']` has no value yet. `gtag.js` is never even requested (no script tag created, not just "configured to no-op") unless `localStorage['cookie-consent'] === 'accepted'`, whether that's from a fresh click or a stored decision from an earlier visit. No third-party consent-management platform - localStorage plus two buttons is the entire mechanism, per PAGE.md.

### Footer — architecture

- Not asked for by PAGE.md at all (grep confirms zero mentions) - added 2026-07-07 as a direct user request, one `templates/partials/footer.php` + `public/assets/css/footer.css` rendered once from `layout.php` (between `</main>` and the consent-banner partial), covering every page automatically the same way the nav does.
- Condensed **two-level** sitemap from `navTree()` (depth-0 + depth-1 only, not the full 3-level tree) - both depth-2 clusters (Πληροφορίες για ασθενείς's pre/post-op pages; Συνεργαζόμενες κλινικές's three hospitals) are themselves real landing pages one click from their depth-1 parent, so nothing becomes unreachable by stopping there. No toggle/accordion markup like `nav.php` has - a footer sitemap has no open/close state, it's a static always-visible list, so it's a plain nested `foreach` rather than `nav.php`'s recursive closure (depth is fixed at exactly 2 here, recursion built for arbitrary depth would be the wrong tool). Reuses the `contact-info` partial verbatim rather than re-implementing contact markup.
- **Color is dark navy** (`--color-navy-deep`), a deliberate choice weighed explicitly against staying light like the rest of the page (which would have kept the wayfinder as the one dark exception per its own self-critique note) - the user chose the navy bookend-with-the-header look after being asked directly. Reuses the exact light-text opacities (`rgba(243,245,246, 0.55-0.85)`) `nav.css`'s desktop header-link override and `wayfinder.css`'s muted-text rules already established for text on this same dark chrome, rather than inventing new ones. `--color-brass` deliberately stays out of it entirely - it's documented in `tokens.css` as hairline-rules-and-captions only, too low-contrast for anything meant to be read as text.
- **A focus-ring contrast gap this surfaced, fixed here, not elsewhere**: the sitewide `a:focus-visible`/`button:focus-visible` outline color (`--color-oxblood`, set in `tokens.css`) measures 1.28:1 against `--color-navy-deep` and 1.55:1 against `--color-bone-ink` - both far under WCAG's 3:1 minimum for focus indicators, i.e. nearly invisible on dark chrome. `footer.css` scopes an override (`.site-footer a:focus-visible { outline-color: var(--color-field-white) }`, 13.24:1 contrast) rather than changing the sitewide token. **Flagged, not fixed**: `wayfinder.css`'s tab focus ring has the identical gap against `--color-bone-ink` and should get the same treatment - out of scope for "build a footer," left as a known follow-up.
- No social links, no legal/privacy/terms link - neither exists anywhere in the data model (`PracticeProfile`, `Config`, `Seo.php`'s JSON-LD builder have no such field) or in `NavData`/`PAGE.md`. Omitted with an inline comment explaining why, rather than invented - same "ask before guessing" convention as everywhere else in this project (see `page.php`'s honest pending-content state).

## What this project is

A bilingual (Greek primary / English) website for Dr. Evangelos M. Rokas, consultant neurosurgeon in Athens, Greece — practice info, clinical content, and patient-facing tools across multiple hospital affiliations and a private office in Mandra. Built in plain PHP with vanilla JS/CSS: **no Composer, no Node.js/build step, no jQuery, no frontend frameworks** unless something is genuinely impossible otherwise — and any such exception must be explicitly flagged.

## Required process (from PAGE.md — follow in order)

1. Propose the design token system first — color palette (4–6 hex values with rationale), type scale, layout as ASCII wireframes, and the signature "anatomical wayfinder" element — and get sign-off before writing implementation code.
2. Self-critique that plan against the "generic AI site" failure modes (warm cream/terracotta/serif-display cliché, stock photos of smiling models in coats, generic numbered process steps, vague trust-badge grids) and revise anything that reads as a default rather than a deliberate choice.
3. Build in this order: shared data model (symptoms ↔ anatomy ↔ procedures ↔ SEO/schema fields) → nav component (desktop + mobile behavior, test both before moving on) → anatomical wayfinder → remaining page templates → SEO/OG/sitemap/analytics wiring.
4. Screenshot/trace as you go if tooling allows. Flag any compromise on the "no external library" constraint and why.
5. Ask before guessing on clinical content, specialty details, or contact information not already given in `PAGE.md` — do not invent it.

## Key constraints to hold onto while implementing

- **Stack**: Plain PHP with a lightweight custom router + Twig-like template renderer. Reuse that pattern if it already exists in the codebase by the time you're reading this; otherwise build a minimal equivalent from scratch. Semantic HTML5, mobile-first CSS (Grid/Flexbox, `clamp()` for fluid type), no CSS framework. Vanilla JS only: event delegation for nav, `IntersectionObserver` for reveals, native `<dialog>` for modals.
- **Bilingual GR/EN**: a live toggle with no page reload. Every page needs a canonical URL, `hreflang` alternates for both locales plus `x-default`, and per-locale `<title>`/meta description — no boilerplate duplicated across pages.
- **Content sourcing**: use web.erns.eu only for factual/structural reference (same info, same section coverage, same nav taxonomy) — never copy-paste its copy. Every sentence is rewritten from scratch in an editorial, clinical-journal register. Where source content is thin, write real substantive content rather than a placeholder; flag any clinical claim you're not confident about instead of inventing it.
- **Navigation taxonomy is fixed** — 5 top-level sections (Ο Ιατρός, Χειρουργική, Ενημέρωση, Δραστηριότητες, Επικοινωνία), two of which have nested sub-submenus (Πληροφορίες για ασθενείς; Συνεργαζόμενες κλινικές). See `PAGE.md` for the exact tree and labels. Desktop: hover + `:focus-within` open dropdowns, nested items flyout as a secondary panel avoiding viewport clipping, closes on mouseleave-with-delay/Escape/focus-out. Mobile: off-canvas hamburger menu, tap-only submenus, recursive accordion (opening one submenu closes siblings at that level), event delegation rather than per-item listeners.
- **Design direction**: PAGE.md's own text below says Cormorant Garamond (display) + Noto Sans (body) — superseded during design-token sign-off: Cormorant Garamond ships with no Greek glyphs at all, so **Alegreya** carries display type for both locales instead (Noto Sans for body stands as originally specified). Full confirmed palette/type/breakpoint values are in this machine's Claude memory (`design-token-system`) and applied directly in `public/assets/css/tokens.css` — don't re-derive them from this paragraph.
- **Signature feature**: an interactive spine/cranial SVG "wayfinder" (pure SVG + vanilla JS, radiology-viewer/lightbox styling) cross-linking symptoms, anatomy, and procedures through one shared data model — the same model should drive nav content and JSON-LD.
- **SEO/schema**: JSON-LD (`Physician`/`MedicalOrganization`, `MedicalWebPage` per content page, `FAQPage` where relevant, site-wide `BreadcrumbList`), Open Graph + Twitter Card mapped from the same fields, and `sitemap.xml`/`robots.txt` — all generated from the shared data model, not hand-authored per page.
- **Analytics/consent**: GA4 via the standard `gtag.js` snippet, loaded `async`/deferred so it never blocks LCP, and gated behind a minimal vanilla-JS cookie-consent banner (accept/reject, stores choice, no third-party CMP) — `gtag` must not fire before consent. Measurement ID lives in a single config constant, placeholder value marked `TODO`. Same pattern for the Search Console verification stub.
- **Accessibility**: visible keyboard focus throughout, full ARIA on the nav (`aria-expanded`, `aria-haspopup`, roving focus) and the wayfinder, reduced-motion respected, contrast checked.
- **Images**: WebP with fallback, `srcset`, `loading="lazy"`.
- **Performance target**: sub-2s LCP on mobile — verify with the chrome-devtools plugin after building.
