# Frontend migration — D7 → D10, executed 2026-08-27

The first **runtime-verified** frontend pass. `FRONTEND_CONVERSION_STATUS.md` records the
authoring work (34 Twig templates, 16 preprocess functions) and states plainly that all of it was
**RUNTIME-UNVERIFIED**. This document records what happened when it was run against real migrated
data, with **live D7 as the comparison** at every step.

**D7 remained strictly read-only.** Every D7 interaction was a `SELECT` through the SELECT-only
`migrate` account or an anonymous HTTP `GET`. No D7 file, schema, configuration or row was written.
Baseline re-verified unchanged.

---

## 1. Route parity against live D7

Both sites requested anonymously and compared status-for-status.

| path | D7 | D10 | |
|---|---|---|---|
| `/` | 200 | 200 | ✅ homepage |
| `/about`, `/faq` | 200 | 200 | ✅ migrated aliases |
| `/courses` | 200 | 200 | ✅ **rebuilt view** |
| `/node/22`, `/node/29`, `/node/36` | 200 | 200 | ✅ |
| `/node/27` | 403 | 403 | ✅ **access regression fixed** |
| `/users/<name>` | 403 | 403 | ✅ |
| `/courses/<course>` | 403 | 403 | ✅ ACL gating faithful |
| `/user/login`, `/user/register` | 200 | 200 | ✅ |
| `/blog` | 200 | **404** | ⛔ view not built |
| `/cart` | 200 | **404** | ⛔ commerce_cart not installed |
| `/checkout` | 302 | **404** | ⛔ commerce_checkout not installed |

**12 of 15 match.** The three that do not are listed as deferred work in §5.

---

## 2. Defects found and fixed

| # | defect | how it presented | fix |
|---|---|---|---|
| F-1 | ⭐ **All 13,969 URL aliases were 404.** `ce_url_alias` concatenated `constants/slash` but never DEFINED it, so aliases were stored as `node/36` and `about` instead of `/node/36` and `/about` | The migration reported 13,969 imported and `path_alias` held 13,969 rows — the count was right and **every alias on the site was dead** | Added `constants: slash: '/'`, as core's own `d7_url_alias` has. Re-ran; aliases resolve |
| F-2 | **Front page pointed at `/user/login`** — the D10 install default | `/` served the login form; D7 serves the homepage | Set `system.site.page.front` to `/front`, matching D7's `site_frontpage` variable (literally `front`) |
| F-3 | **Site name was the install placeholder** "CE Online (D10 migration)" | Every page title differed from D7 | Set to D7's `site_name`, "CE Online Training Courses" |
| F-4 | ⭐ **Every Views page returned HTTP 500** — `getBundles()` returned a non-existent config entity for `commerce_product` / `video_product` | Site-wide: any view, not just the new one | See F-5; the dangling storage was removed |
| F-5 | ⭐ **162 product variations carried bundles D10 never declared**, and **150 product→video-node links were lost.** `ce_commerce_product` copied D7's `type` with no matching bundle, so `d7_field_instance` could not attach `field_video_reference` | Latent until something rendered a product | Created the three `commerce_product_variation_type` entities from D7's own `commerce_product_type` table; created the field on the variation; migrated the links — **143 of 150**, the other 7 point at video nodes deleted in D7 |
| F-6 | **`field_weight` had no D10 field**, and D7's course listing sorts on it | 44 course weights lost; listing order wrong | D7 stores it as `int(11)` (verified), so a core `integer` field holds it exactly. Created and migrated: **44 rows, −50…0, matching D7** |
| F-7 | **5 orphaned block configs** for themes never ported (`bartik`, `cetc`) | Disabled and unrenderable, but their unmet dependency made **`drush config:import` fail outright**, blocking all config deployment | Deleted; enabled blocks are never touched |
| F-8 | ⭐ **Access regression: `/node/27` was public in D10, 403 in D7** | D10 exposed the evaluation webform D7 protects | D7's restriction is webform's per-node role list, migrated into `ce_webform_roles` (node 27 → administrator, authenticated) but unenforced with no Webform module in D10. Added `ce_core_node_access()` which reads that **migrated** table. It can only narrow access, only on nodes D7 narrowed |
| F-9 | **D7's menus had never migrated**, so the navigation and footer blocks had nothing to bind to | Main menu absent | Ran `d7_menu` (7) and `d7_menu_links` |

---

## 3. Implemented

- **URL aliases** — 13,969, resolving
- **Front page** — `/front`, the `ce_core` HomepageController, matching D7
- **Course listing `/courses`** — rebuilt as a D10 view from D7's `courses` view definition:
  filters `status=1` + `type=course` + three exposed filters (CE/non-CE list, State and Course type
  as `taxonomy_index_tid` selects); sorts CE/non-CE ASC, `field_weight` DESC, `created` DESC;
  full pager at **9 per page**; `<h2>No courses found.</h2>` empty text; Bootstrap grid row classes.
  Verified: **37 published courses in D7 and D10**, 9 rendered rows, 9 unique courses
- **Menus** — 7 menus, main and footer links
- **Product → video node relationships** — 143 restored
- **Course weights** — 44 restored
- **Webform node access** — D7's role restriction enforced from migrated data
- **Content access / ACL gating** — verified faithful: course and user pages 403 for anonymous, exactly as D7

---

## 4. Behavioural differences, recorded not hidden

- **Course listing secondary sort.** D7 sorts second on taxonomy term weight through a
  `term_node_tid` relationship with `group_type: min` (D7 also has the `views_distinct` module
  installed). Reproducing the relationship in D10 without aggregation **duplicated rows** — 9 rows
  but only 5 distinct courses. A duplicated listing is worse than a differing secondary sort, so the
  relationship was removed. **The listing is correct and complete; its secondary ordering differs
  from D7.** Reproducing it needs Views aggregation with MIN, which changes row rendering and needs
  visual verification.
- **Page weight.** D7's `/courses` is 67 KB, D10's 19 KB. D7 includes blocks whose modules have no
  D10 counterpart (newsletter signup, related-courses view, cart status, checkout progress).
- **12 blocks of D7's 31 migrated.** The other 19 depend on D7-only modules.

---

## 5. Deferred / not yet implemented

| area | why |
|---|---|
| `/blog` page | D7 view not yet rebuilt; the `Blog` menu link failed validation because its target does not exist |
| `/cart`, `/checkout` | `commerce_cart` and `commerce_checkout` not installed |
| Webform UI (submission) | No Webform module; 441,970 submitted-data rows are migrated and intact |
| Newsletter / Mailchimp block | `drupal/mailchimp` deferred to Phase 3.3 |
| Related courses, cart status, checkout progress blocks | D7-only modules |
| `field_video_from_wistia_video` | 141 rows; D7 Media 1.x field type has no D10 field. **Needs a field-type decision** (entity_reference → file, or → media) |
| Golden-master visual comparison | The 18-page × 12-breakpoint matrix in `D10_FRONTEND_MIGRATION.md` needs a browser; status-code and content parity only is proven here |

---

## 6. Validation

`php -l` across all custom PHP: clean · 545 YAML files parse: 0 problems ·
`drush cr`: success · `drush config:status`: no differences · route parity 12/15 ·
D7 baseline unchanged.

---

# Homepage rebuild — same day, second pass

The reported screenshot showed broken images, no layout and serif text. Audited against **live D7**,
not against assumption.

## D7 homepage structure found

Page Manager page at `/front` (D7's `site_frontpage` is the literal string `front`), rendering:

| region | content |
|---|---|
| header | logo `images/CE-logo.svg` + `ksc_commerce_cart_status_block` (cart count → `/cart`) |
| navigation | `system` / `main-menu`, weight −8, no title |
| content | 6 hard-coded nodes (22–25, 28, 29) as panes; newsletter, related-courses, non-CE message and checkout-progress blocks |
| footer | custom block 1 "Company info" — contact boxes using `at.png`, `phone-with-wire.png`, `address.png` |
| footer_bottom | custom block 2 "Footer bottom" — `footer_logo.png` + copyright |

Body class `html front not-logged-in no-sidebars`. 5 stylesheets, hybrid Bootstrap.

## Root causes found

| # | cause | effect |
|---|---|---|
| H-1 | ⭐ **`filter_null` in every migrated text format.** Core's `d7_filter_format` maps an unmappable D7 filter to a placeholder that only exists in the test-only `filter_test` module | **All filtered text on the site rendered EMPTY** — every node body, every block body — while the data was perfectly intact (543 node body rows, 2 block body rows). It stands for D7's `video_filter` and `media_filter`; both modules are absent from D10, so those token behaviours are a reported loss |
| H-2 | **Logo 404.** Theme had `images/CE-logo.svg`; Drupal fell back to a non-existent `<theme>/logo.svg` | The broken "Home" image in the screenshot. D7's `logo_path` points at `CE-logo.svg`; applied |
| H-3 | **D7's custom blocks had never migrated** (`block_content` module not installed) | Empty footer and footer_bottom |
| H-4 | **Block bodies embedded D7 theme paths** `/sites/all/themes/cetc_new/images/*` | Every footer image would 404. Rewrote the theme prefix only, after verifying each file exists in the D10 theme |
| H-5 | **No `block_content.basic` view display** | Blocks rendered as empty `<section>` shells |
| H-6 | **`d7_field_formatter_settings` had never run** (190 rows) | Node displays missing most fields |
| H-7 | **D7's image styles had never migrated** | `/courses` HTTP 500 — `ImageFormatter` dereferenced the missing `course_listing` style |
| H-8 | **Latent theme defect:** `_cetc_d10_main_menu_items($variables['items'] ?? [], 0)` passes a temporary by reference | HTTP 500 site-wide the moment the main-menu block was placed. Never exercised before, because no menu block existed |
| H-9 | **Install-default blocks in `sidebar_first`** that D7 does not have | Body class was `one-sidebar sidebar-first` instead of D7's `no-sidebars` |

## Changed

**Config:** `system.site` front + name · `cetc_d10.settings` logo · `views.view.courses` ·
`block_content.basic` default+full displays · 3 text formats · block layout (main menu placed,
2 install defaults removed, footer blocks placed).
**Code:** `cetc_d10.theme` (by-reference fix) · `ce_core.module` (webform node access) ·
`ce_url_alias.yml`, `ce_commerce_product.yml`, `ce_taxonomy_term.yml`.
**Migrations run:** `d7_menu`, `d7_menu_links`, `d7_filter_format`, `d7_custom_block`,
`block_content_type`, `block_content_body_field`, `d7_field_formatter_settings`, `d7_image_styles`.
**Scripts:** 6 new under `scripts/reconcile/`.

## Result

| | before | after |
|---|---|---|
| homepage HTTP | 200 (broken render) | **200** |
| broken images | 2 of 2 | **0 of 5** |
| homepage local assets 200 | — | **8 / 8** |
| homepage links ok | — | **13 / 13** |
| body class | `one-sidebar sidebar-first` | **`no-sidebars`** — matches D7 |
| regions rendered | content, header | content, header, navigation, footer, footer-bottom |
| visible text | 2,351 chars | **3,464** (D7: 8,392) |
| route parity | — | **10 / 10** |

The homepage now renders the logo, main navigation, the video pane, all five panes **with body
text**, and the full footer with contact details and copyright — all from migrated entities, no
hard-coded content.

## Remaining visual differences

Still absent, all because the D7 module has no D10 counterpart: newsletter signup
(`mailchimp_signup`), student testimonials, the states-links block (`states_link_block` view), the
blog block, related-courses block, cart-status block and checkout-progress block. These account for
the remaining text gap.

**Not verified:** pixel-level appearance. The 18-page × 12-breakpoint golden-master comparison in
`D10_FRONTEND_MIGRATION.md` needs a real browser; only status codes, assets, links, regions, body
class and text content are proven here. `col-*` grid classes were not detected on the homepage and
should be checked visually.

⚠️ `//html5shiv.googlecode.com/...` 404s — **D7 loads it too**. Faithful, not a defect.

---

# Homepage — third pass: the remaining panes

## D7's homepage has FIVE panes, D10 rendered four

`panels_pane` for the homepage panel (did 11):

| pos | type | D10 status |
|---:|---|---|
| 0 | node 29, `homepage_video` | ✅ rendered |
| 1 | block `mailchimp_signup` | ⛔ module absent |
| 2 | **custom pane** — "Your Continuing Education Provider" + testimonials | ✅ **migrated this pass** |
| 3 | mini-panel `education_provider_block` (node 28 + states view) | ✅ **states view built this pass** |
| 4 | mini-panel `information_blocks` (nodes 25, 24, 22, 23) | ✅ rendered |

`HomepageController` rendered panes 0–3 and had no slot for the custom pane at all — the four/five
discrepancy `HOMEPAGE_FLOW.md` had already flagged. Panes are now 0–4.

## Built this pass

- **`views.view.states_link_block`** — the state links, rebuilt from D7's view: `state` vocabulary,
  weight ASC, name linked to `courses/<name>` with D7's `replace_spaces` + grid classes
  `col-xl-4 col-xs-12 col-sm-12 col-md-6 col-lg-6`. Wired into the controller, replacing a
  placeholder that pointed at `states_block` — **a view that does not exist in D7**; the live
  homepage renders `states_link_block`.
- **`views.view.courses_by_states`** — serves `/courses/<state>`, the target of those links.
- **The custom pane's content** — migrated from `panels_pane.configuration` into a `block_content`
  entity, so it stays editable rather than baked into a template.

## Defects found this pass

| # | defect | consequence |
|---|---|---|
| H-10 | **`ce_block` recreated block configs for `bartik` and `cetc`** — D7 themes never ported | Their unmet theme dependency made `drush config:import` fail outright. Fixed in the migration itself: only `cetc_new → cetc_d10` and `seven → claro` map; other themes are skipped countably |
| H-11 | **D10's `taxonomy_term_name` argument validator never sets the term id.** It replaces dashes, confirms the name exists, returns TRUE — and leaves the argument as the string | The view queried `taxonomy_index.tid = 'georgia'`, returning nothing while answering HTTP 200. Every state link would have led to an empty page that looked fine. D7's validator did convert. Handled in `ce_core_views_pre_view()` |
| H-12 | **`taxonomy_index_tid_depth` rejected every value**, including a valid tid, 404ing all state pages | Switched to the `field_state_target_id` argument, which targets the same data — index counts are identical in D7 and D10 (Georgia 20 · Illinois 13 · North Carolina 29 · Ohio 10 · Other 14 · South Carolina 13 · Texas 12 · Washington DC 21) |
| H-13 | An unknown state returned **200 with an empty list** where D7 returns **404** | `ce_core_views_pre_view()` now throws `NotFoundHttpException`, matching D7 |
| H-14 | My own error: views empty-text used `basic_html`, a format this site does not have | "Missing text format" warnings on every view render. Corrected to `filtered_html`, D7's own value |

## Result

| | pass 2 | pass 3 |
|---|---:|---:|
| homepage visible text | 3,464 | **1,851 of D7's 1,955 — 95%** (stripping scripts/styles) |
| homepage assets + links resolving | 13 | **27 of 28** |
| route parity | 10/10 | **13/13** |
| Drupal errors after full render | — | **0** |

*(pass-2's 3,464 counted inline script text; pass 3 strips `<script>`/`<style>` for a like-for-like
comparison with D7.)*

## Still absent — all D7-module dependent

The Mailchimp signup pane (pane 1) accounts for essentially all remaining text — CAPTCHA, Email,
Send, and the "3 Tips to Increase Client Retention" copy. Also absent: the blog block, related
courses block, cart-status block and checkout-progress block.

## ✅ State link case — FIXED

D7 emits `/courses/georgia`; D10 was emitting `/courses/Georgia`. Core's `path_case` cannot fix
this: `FieldPluginBase::renderAsLink()` lowercases the path **template** and `str_replace`s it into
the already-substituted path, so `courses/{{ name }}` never matches and the token value is left
untouched.

Resolved by turning `make_link` off and building the link in Twig, where the transform D7 performed
is expressible:

    templates/views-view-field--states-link-block--name.html.twig
    {% set slug = term.label|lower|replace({' ': '-'}) %}

**Verified: the 8 state links are now byte-identical between D7 and D10** (`diff` reports no
difference), and all 8 return 200 on both sides — `georgia`, `illinois`, `north-carolina`, `ohio`,
`other`, `south-carolina`, `texas`, `washington-dc`.

Final route parity: **15 / 15**, including `/courses/nonsensestate` → 404 on both.
Drupal error log after rendering every homepage and course route: **0 entries**.

---

# Homepage — fourth pass: side-by-side against the D7 screenshot

Driven by a direct visual comparison of the two rendered homepages.

## Differences found and fixed

| # | difference | cause | fix |
|---|---|---|---|
| H-15 | ⭐ **No background images anywhere** — D7's large hero filling the left half was simply absent | `cetc_d10/css/style.css` was carried over byte-identical from D7, which also carried D7's **absolute** theme paths: `url(/sites/all/themes/cetc_new/images/home_banner.jpg)`. That directory does not exist in D10, so all 9 references 404'd. Relative `url(../images/…)` references were always fine | Rewrote only the absolute prefix, after verifying all 8 referenced files exist in the D10 theme. The byte-identical D7 copy is preserved as `style.css.d7-original` |
| H-16 | ⭐ **No video player.** D7 shows an embedded Wistia player; D10 showed nothing | `field_video_from_wistia_video` is a D7 Media 1.x `media` field. Core has no field plugin for it, so `d7_field` never created it and its **141 values never migrated** | Created the field as `entity_reference → file` — which is what D7 actually stores (node 29 → fid 2452 → `wistia://v/42lx1o2wez`) — migrated all 141, and wrote a `ce_wistia_embed` formatter that builds the embed from the file URI. **The rendered iframe is byte-identical to D7's** |
| H-17 | **COURSES and BLOG missing from the menu** | Both failed `d7_menu_links` validation because `/courses` and `/blog` did not exist when it first ran | `/courses` existed after pass 2; built `views.view.blog` for `/blog`, then re-ran. **The main menu now matches D7 exactly** — 7 items, same titles, targets and weights |
| H-18 | **Logo rendered twice**, once over the hero area with the site name | `page--front.html.twig` renders the logo directly, exactly as D7's `page.tpl.php` does. D10's theme install *additionally* placed `system_branding_block` in the header region; D7's header holds only the cart block | Removed the install-default branding block |

## Verified after this pass

| | D7 | D10 |
|---|---|---|
| main menu items | Home, Courses, Blog, Help, About, Login | **identical** |
| logo `<img>` | 1 | **1** |
| Wistia iframe `src` | `…/embed/iframe/42lx1o2wez?version=1.0` | **identical** |
| `home_banner.jpg` | served | **200, 811,298 bytes** |
| `.fix-menu-banner` hero element | present | **present** |
| `/blog` | 200, 10 posts | **200, 10 posts** |
| homepage assets + links | — | **29 of 30** |
| Drupal errors after render | — | **0** |

The one remaining non-200 is `//html5shiv.googlecode.com/…`, which **D7 also 404s** — faithful, not a defect.

## Still absent — unchanged, all D7-module dependent

The Mailchimp signup pane, the cart-status block (D7's `ksc_commerce_cart_status_block`, the basket
icon top-right), related-courses, and checkout-progress. Each needs a D7 module with no D10
counterpart.

---

# Homepage — fifth pass: the content section

## Differences found and fixed

| # | difference | cause | fix |
|---|---|---|---|
| H-19 | **Every pane heading rendered TWICE** — once as a pink node label, once as the body's own `<h2>` | D7 renders these articles with an **empty `<header>`**: `node.tpl.php` prints the title only when `!$page && !empty($title)`, and Panels blanks the pane title. The visible heading is the BODY's `<h2>`. D10 had no equivalent mechanism and printed `{{ label }}` as well | Added `node--article--full.html.twig` reproducing D7's markup — article wrapper, empty header, body, no label |
| H-20 | **Node 28's heading then disappeared entirely** | Unlike nodes 22–25, node 28's body carries **no heading of its own**. D7 supplies it as a *pane* title above the article: `<h2 class="pane-title">Your State Approved …</h2>` | `HomepageController::paneTitle()` emits it, taking the text from the node's own title — nothing hard-coded |
| H-21 | **The state links stacked vertically** instead of forming D7's grid | D7 uses `views_bootstrap_grid_plugin_style`, a contrib style with no core equivalent. The view's `row_class` supplied the column classes but there was no `.row` flex container | Added `views-view-unformatted--states-link-block.html.twig` supplying D7's two wrappers |
| H-22 | My own error: the first version of that template called `row.attributes.class` and `removeAttribute()` | **Infinite recursion, HTTP 500 site-wide.** Caught immediately by bisecting the two new templates | Reduced to core's exact row loop plus the wrappers |

## Verified

| | D7 | D10 |
|---|---|---|
| "Your State Approved Online Continuing…" | 1 | **1** |
| "Lower Your Education Expenses" | 1 | **1** |
| "Study From Anywhere" | 1 | **1** |
| "Set Your Own Schedule" | 1 | **1** |
| "Expand Your Knowledge" | 1 | **1** |
| "Your Continuing Education Provider" | 1 | **1** |
| `pane-title` elements | 2 | **2** |
| states grid `col-xl-4` columns | 8 | **8** |
| visible text | 1,956 | **1,743 — 89%** |
| Drupal errors after rendering 5 routes | — | **0** |

The remaining 11% is the Mailchimp signup pane and its CAPTCHA — every missing word belongs to it.

---

# Homepage — sixth pass: the panel wrapper classes

## The cause of "CSS not coming as in D7"

The homepage CSS was never broken. **The panes were not wrapped in D7's markup**, so none of the
selectors that style them could match.

D7's homepage is a Panels page, and every pane sits inside a `<div class="panel-pane …">` whose
extra classes are what `style.css` and `responsive.css` actually target — `info-content`,
`info-blocks`, `custom-text`, and a per-node class on each information block. `HomepageController`
emitted a single bare container instead, so the panes rendered with no styling on a flat background.

## Reproduced D7's structure exactly

    panel-display panel-1col clearfix
      panel-panel panel-col
        panel-pane pane-node                                   (node 29, video)
        panel-pane pane-block pane-mailchimp-signup-… info-content newsletter-block …
        panel-pane pane-custom pane-1 custom-text              (testimonials)
        panel-pane pane-panels-mini pane-education-provider-block info-content
          panel-flexible … #mini-panel-education_provider_block
            panel-pane pane-node                               (node 28 + states view)
        panel-pane pane-panels-mini pane-information-blocks info-blocks
          panel-display panel-1col clearfix
            panel-panel panel-col
              panel-pane pane-node ce-expenses                 (node 25)
              panel-pane pane-node ce-pin                      (node 24)
              panel-pane pane-node ce-schedule                 (node 22)
              panel-pane pane-node ce-knowledge                (node 23)

**Verified: the wrapper sequence is now IDENTICAL — 14 wrappers, same classes, same order.**

## H-23 — the base theme was adding a class D7 does not have

The first version used `'#type' => 'container'`. Drupal's bootstrap base theme appends
**`form-group`** to every container, and this theme styles `.form-group` with
`margin-bottom: 2rem` and a bottom border — spacing and rules D7's panes do not carry. Switched to
plain wrappers; `form-group` is back to D7's count of 2 (both from the newsletter form itself).

Class strings are built from a fixed, code-defined list and filtered to `[A-Za-z0-9_-]`.

## Verified

| | D7 | D10 |
|---|---|---|
| panel wrapper sequence | 14 | **14, identical** |
| all six pane headings | 1 each | **1 each** |
| `pane-title` elements | 2 | **2** |
| states grid columns | 8 | **8** |
| `form-group` | 2 | **2** |
| errors after rendering 5 routes | — | **0** |

## Still absent

The Mailchimp signup pane — its wrapper is now in place and correctly classed, but the block
itself needs `drupal/mailchimp`. That accounts for the newsletter form, the CAPTCHA and the
"GET IT NOW" button visible in D7. Likewise the cart-status block (the basket icon), which needs
`commerce_cart`.

---

# Homepage — seventh pass: side-by-side diff of D7 vs D10 (2026-09-07)

Both sites were run locally and their rendered homepages diffed directly:

    D7   http://ceonline.ddev.site        (D7 read-only throughout)
    D10  http://ceonline-d10.ddev.site

Method: fetch both, strip scripts/styles/comments, normalise entities and
whitespace, then diff the visible text AND compare structural markers by count.
That is what surfaced everything below — several of these were invisible to the
earlier passes, which compared only the panel wrappers.

## H-24 — the states list was in the WRONG HALF OF THE PAGE

`HomepageController` rendered the `states_link_block` view inside the
education-provider pane. Measured on the live D7 homepage:
`view-states-link-block` occurs **exactly once**, and it is **inside the main
menu** — attached to the "Courses" link. D7's education-provider pane contains
node 28 and nothing else.

Source of truth, read from D7 (SELECT only):

    SELECT mlid, link_path, options FROM menu_links WHERE menu_name='main-menu';

    mlid 647  courses  options['menu_attach_block']['name'] = 'views|states_link_block-block'

Exactly one link attaches a block. Every other main-menu link has an empty
`name`. The view moved to the menu; the pane now holds node 28 alone.

## H-25 — the main menu had no classes, so the whole navbar CSS was dead

    D7   <ul class="menu nav navbar-nav">
           <li class="first leaf active"><a href="/" class="active">Home</a>
    D10  <ul>
           <li><a href="/">Home</a>

`.home-menu nav ul.menu.nav …` — **38 rules** in style.css and responsive.css,
the entire navigation design including the dropdown — could not match a bare
`<ul>`. D7's `first` / `last` / `leaf` / `active-trail` / `active` vocabulary is
now reproduced in `cetc_d10_preprocess_menu()`, including D7's own quirk:

| page | D7 | D10 now |
|---|---|---|
| `/` | `first leaf active`, `a.active` | identical |
| `/courses` | `leaf active-trail active` | identical |

On the front page D7's active *trail* is empty (a `<front>` quirk), so the Home
link gets `active` and NOT `active-trail`. Reproduced deliberately.

## H-26 — menu_attach_block: the "Courses" dropdown

There is no such module in Drupal 10 and only one link uses it, so the feature
lives in the theme:

* `css/menu-attach-block.css` — a **verbatim copy** of D7's module CSS; the only
  change is the two arrow images moving to `images/`. Declared in the
  **`component`** CSS group, NOT `theme` — in D7 this is module CSS and loads
  *before* style.css, which overrides it to open on hover. Verified in the
  built aggregate: module rule at line 2, theme override at line 13.
* `js/menu-attach-block.js` — D7's behaviour, ported. D7's AJAX branch is
  deliberately not ported: every main-menu link has `use_ajax = 0`, so it never
  runs on this site, and there is no D10 endpoint for it to call.
* `images/arrow-asc.png`, `arrow-desc.png` — copied from D7.

## H-27 — `pane-content` and `panel-separator` were missing entirely

| | D7 | D10 before | D10 now |
|---|---|---|---|
| `pane-content` | 10 | **0** | 10 |
| `panel-separator` | 7 | **0** | 7 |

`.custom-text .pane-content p`, `.custom-text .pane-content a` and
`.custom-text .pane-content p a` are live rules in style.css that had nothing to
match. Panels puts a separator after every pane except the last in its column;
the pane TITLE sits outside `pane-content`, which `pane()` now models.

**The full 31-marker pane sequence is byte-identical to D7.**

## H-28 — every field class in the theme CSS was dead

Drupal 10 renamed one dash into two:

    D7   <div class="field field-name-body field-type-text-with-summary field-label-hidden">
    D10  <div class="field field--name-body …">

Measured in this theme's own stylesheets:

| selector | occurrences |
|---|---|
| `.field-name-*` | **135** (24 distinct fields) |
| `.field-type-*` | 21 |
| `.field-items` | 29 |
| `.field-item` | 43 |
| `.field-label-*` | 3 |

All unmatchable — including `div#node-29 .field-name-field-video-from-wistia-video`
(style.css:370), which sizes the homepage hero video.

Fixed with `cetc_d10_preprocess_field()` plus a `field.html.twig` that restores
D7's `field-items` / `field-item even` nesting (D10 collapses all three divs into
one for single-value, label-hidden fields).

🛑 **Additive only.** D10's `field--name-*` classes are rebuilt in preprocess and
kept alongside the D7 names, so core and contrib CSS are unaffected.

⚠️ Two traps hit while doing this, both recorded in the code:
* those D10 classes are built *inside* core's Twig template, not in preprocess —
  overriding the template silently dropped them;
* `items[n]['attributes']` is a plain **array** in `hook_preprocess_field()`, not
  an `Attribute` object. `->addClass()` on it returned HTTP 500 sitewide.

## H-29 — the footer region and its Bootstrap classes

    D7   <div class="region region-footer">
           <section id="block-block-1" class="block block-block bg-secondary well-sm h6 clearfix">
    D10  <div>
           <section … class="block block-block-content … clearfix">

Two separate defects:

1. `region--footer.html.twig` (and `region--highlighted`) printed a bare
   `{{ attributes }}`. Like `field.html.twig`, D10 builds `region` /
   `region-NAME` **inside** the template — so the override emitted `<div>` with
   no classes. Both templates now call `attributes.addClass(classes)`.
2. `bg-secondary well-sm h6` come from D7's `block_class` module —
   `block.css_class` for `block/1`. `block_class` was in the repo but not
   enabled. Enabled, and `scripts/reconcile/set_block_class.php` applies D7's
   value. The script also **lists the three D7 css_class rows it cannot apply**
   (two mailchimp blocks, one `course_actions` block) rather than dropping them
   silently.

## H-30 — the front-page `<title>`

    D7   /         CE Online Training Courses
    D10  /         Home | CE Online Training Courses
    both /courses  Courses | CE Online Training Courses   ← already matched

Only the front page differed: D7's page title is empty there, so only the site
name survives. `cetc_d10_preprocess_html()` now unsets the title component on
the front page — the site name still comes from configuration.

## H-31 — D10-only markup around the menu

`block--cetc-d10-main-menu.html.twig` and `region--navigation.html.twig` now
emit `{{ content }}` only. D10's default block template added an inner `<nav>`
and `<h2 class="visually-hidden">Main navigation</h2>`, which showed up as page
text D7 does not have. The outer `<nav role="navigation" id="custom_main_menu">`
from `page.html.twig` is kept, so the landmark survives — unnamed, exactly as on
the live D7 site.

## H-32 — Google Tag Manager and Google Analytics were never enabled

D7 runs **two** tracker pairs: one hardcoded in `html.tpl.php`
(`GTM-K3Q77HV` + a gtag `AW-` snippet, already reproduced by the D10 theme) and
one **module-driven** pair that D10 had nothing for.

Config read from D7 and applied by `scripts/reconcile/configure_analytics.php`:

* **GTM** — D7 table `gtag_config`, row `google_tag.container.gtm_allspark`:
  container `GTM-WXP7FM9F`, label "GTM - Allspark", data layer `dataLayer`,
  path_toggle "exclude listed" over 7 admin paths. Now renders in `page_top`.
* **GA** — D7 `variable` rows `googleanalytics_*`: account `AW-16460607427`,
  anonymize_ip on, outbound/mailto/file/colorbox tracking on, the same 6-path
  exclude list.

⚠️ **The GA module emits nothing, and cannot.** `google_analytics` 4.x only
accepts `UA-` or `G-` measurement IDs (`GoogleAnalyticsAccounts::getDefaultMeasurementId()`
matches `GOOGLE_ANALYTICS_TRACKING_MATCH`); `AW-16460607427` is a Google **Ads**
conversion ID, which D7's 7.x module accepted. The Ads tag still fires — the
theme's hardcoded snippet loads the same id — so conversion tracking is intact.
What is lost is the module's outbound-link, mailto and download tracking.
Config is migrated and in place for the day a GA4 property is added; no `G-` id
was invented to make it render.

## Result

29 structural markers compared between the two live homepages:

**25 identical. 4 differ, all from one known and intended cause.**

| marker | D7 | D10 |
|---|---|---|
| panel wrapper sequence (31 markers) | — | **identical** |
| `pane-content` / `panel-separator` | 10 / 7 | **10 / 7** |
| `menu nav navbar-nav`, drop-link, attach-wrapper | 1 / 1 / 1 | **1 / 1 / 1** |
| states view, `col-xl-4`, state links | 1 / 8 / 8 | **1 / 8 / 8** |
| `region region-footer`, `bg-secondary`, `well-sm` | 2 / 1 / 1 | **2 / 1 / 1** |
| `GTM-K3Q77HV`, `fbevents` | 2 / 1 | **2 / 1** |
| `field-name-body` | 6 | 9 |

The remaining four counts are all the same three extra body fields: D7's
homepage custom pane and its two footer blocks are raw-HTML `block_custom` /
Panels panes, and in D10 they are `block_content` entities with a real body
field — the correct D7→D10 mapping, and the one that keeps them editable
instead of baked into a template. Verified harmless: **no CSS in this theme
uses a direct-child selector** that the extra wrapper would break.

9 routes return 200. **Zero Drupal errors.** D7 working tree still at its
baseline 11 files; `users 13,250 · node 620 · orders 87,640` unchanged.

## Still absent — both need a decision

1. **The newsletter / Mailchimp signup pane** (`Send me the 3 easy steps…`,
   email field, math CAPTCHA, `GET IT NOW`). Needs `drupal/mailchimp`, which is
   not in the repo, plus D7's **live API key** (`mailchimp_api_key` in D7's
   variable table). `captcha` IS already in the repo, unenabled. The key is a
   production secret and has deliberately NOT been copied into D10 config.
2. **The cart-status block** (`region-header`, the basket icon —
   `span.ce-cart > a[href="/cart"] > span.cart-tag`). 31 CSS lines style it and
   `commerce_cart` is available, but enabling it puts Commerce's cart handling
   and `OrderRefresh` in front of migrated draft/cart orders. Given that
   `ce_commerce_order` deliberately does not map `total_price` *because*
   `OrderRefresh` overwrites it, that is a data-integrity decision, not a
   theming one.

Neither was faked: no placeholder form, no invented cart count.

---

# Eighth pass: Mailchimp + CAPTCHA + Commerce cart (2026-09-07, approved)

## Mailchimp

`drupal/mailchimp` 3.1.4 installed. Both of D7's signup forms recreated as
config entities from `mailchimp_signup` (mcs_id 1 and 2), by
`scripts/reconcile/create_mailchimp_signups.php`:

| D7 | title | submit | audience |
|---|---|---|---|
| `3_tips_to_increase_client_retent` | 3 Tips to Increase Client Retent | Get it Now | 8006301bcc |
| `newsletter` | Join Our Newsletter! | Subscribe | 8006301bcc |

D7 enables exactly **one** merge field on both forms — `EMAIL`; every other tag
(`FNAME`, `MMERGE2`–`MMERGE14`) is stored as `0`. Reproduced. The merge-field
*definition* is fetched live from the audience rather than hand-written, so the
stored object can never drift from what the API returns.

API connectivity verified: audience `8006301bcc` = **"Master List-SC", 13,281
members** — matching D7's `mc_lists`.

### The API key

`mailchimp_api_key` from D7's `variable` table is a **live production
credential**. It is set as a `$config[]` override in
`web/sites/default/settings.local.php` — the same gitignored file that already
holds the read-only D7 database password. Verified:

    config/sync/mailchimp.settings.yml   ->  api_key: ''
    git grep <key>                       ->  no tracked file

A `$config[]` override wins at runtime but is never written back by
`drush config:export`, so the key works locally and cannot leak through config.
**Every other environment needs its own copy of this file** — nothing in the
repository supplies it.

### Two traps

* **The description rendered empty.** D7 stores it in
  `settings['description']`, but D10's module reads the **entity** property:
  `MailchimpSignupPageForm::buildForm()` line 129 does
  `['#markup' => $this->signup->description]`. Both are set now — `settings`
  mirrors D7's storage, the entity property is what renders.
* **D10 renamed the description wrapper**, dropping `-form` from the middle:
  `mailchimp-signup-subscribe-description` where D7 emits
  `mailchimp-signup-subscribe-form-description`. style.css selects the D7 name
  (`.popup-newsletter .mailchimp-signup-subscribe-form-description ul li`, …).
  `templates/mailchimp-signup-subscribe-form.html.twig` emits **both** names.

## CAPTCHA

`captcha` was already in the repo, unenabled. Enabled and configured from D7's
`captcha_*` variables — default challenge `captcha/Math`, D7's description and
error message, persistence 1, stats and wrong-response logging off.

🛑 **D7 has 15 `captcha_points` but only TWO are armed.** `captcha_type` is
`default` on the two Mailchimp forms; the other thirteen — every comment form,
`user_login`, `user_login_block`, `user_pass`, `user_register_form` — have
`captcha_type` **NULL**, which is D7's way of storing a point that is switched
off. All fifteen are created, the thirteen with an empty `captchaType`. Arming
any of them would put a CAPTCHA in front of login and registration, which D7
does not do.

The homepage form now renders the math question, its help text and D7's
description, above the "Get it Now" button.

## Commerce cart

`commerce_cart` enabled; `/cart` returns 200.

### The cart-status block

D7's `ksc_commerce_cart_status_block` reproduced as `ce_cart_status`
(`ce_core/src/Plugin/Block/CartStatusBlock.php`), placed in the `header` region
by `scripts/reconcile/place_cart_status_block.php`. The block's machine name is
`ksc_commerce_cart_status_block_0` **so Drupal derives D7's exact HTML id**, and
`block_class` supplies D7's `block-ksc-commerce-cart-status-block` class — 31
lines of style.css select on it.

    D7   <section id="block-ksc-commerce-cart-status-block-0" class="block block-ksc-commerce-cart-status-block clearfix">
           <span class="text-primary ce-cart"><a href="/cart" class="text-primary"><span class="cart-tag">0</span></a></span>
    D10  identical, plus D10's own block-ce-core / block-ce-cart-status classes

The count is read from Commerce's `CartProvider`, never hard-coded — an
anonymous visitor sees 0 because there is nothing to count, as in D7.

⚠️ **D7's rewards branch is not rendered, and that matches D7's own condition.**
D7 guards it with `module_exists('ce_referral') && user_access('use referral')`
then calls `get_referral_user_total()` and links to
`/user/UID/ce-referral-rewards`. The D10 `ce_referral` module provides neither
that helper nor that route, so the guard is false. No reward total was invented.

### 🛑 No migrated order was touched — verified, not assumed

Order state fingerprint taken before and after enabling `commerce_cart`:

    before  87,640 orders  MD5 8582c720bd9c284d653ea88f83f403e6
    after   87,640 orders  MD5 8582c720bd9c284d653ea88f83f403e6

Identical. `refresh_mode` on the order type is `customer`, so `OrderRefresh`
only ever runs for an order's own customer on a draft cart.

### ⭐ FINDING — the migrated `cart` flag never landed

`ce_commerce_order.yml` maps D7 status `cart` to `cart: 1`. **That column did
not exist**: it is a base field supplied by `commerce_cart`, which was not
installed when the orders were imported. Measured immediately after enabling
the module:

    SELECT cart, COUNT(*) FROM commerce_order GROUP BY cart;
    NULL    87640

So all 63,111 draft orders — including D7's abandoned carts — are drafts that
are **not flagged as carts**. Two consequences, in tension:

* it is why enabling `commerce_cart` was risk-free: the cart provider matches
  nothing, so no historical order is exposed to cart handling or `OrderRefresh`;
* but D7's cart/abandoned-cart distinction is not represented, so
  `commerce_abandoned_carts` and any cart report will see nothing.

Backfilling `cart = 1` on the D7 `cart`-status orders is a one-line update, and
it would put ~63k historical orders into live cart handling. **Not done — that
is a data decision, not a theming one.** Flagged for the same reason W-6 and
ANOMALY-194 are flagged rather than fixed.

## Result

37 structural markers compared between the two live homepages: **34 identical.**
The three that differ are all benign and explained:

| marker | D7 | D10 | why |
|---|---|---|---|
| `GTM-WXP7FM9F` | 1 | 2 | D7 loads the head script from a snippet **file**; D10 inlines it. Same container. |
| `ce-cart` | 5 | 4 | substring noise — "commer**ce-cart**-status" inside the block class. The real `<span class="ce-cart">` appears once in each. |
| `mailchimp-signup-subscribe-form` | 2 | 3 | D10's block template adds a `-form-block` wrapper div. |

Remaining visible-text differences are whitespace, D7's `*` required marker, and
captcha 2.x putting the sum inside the label (`Math question (1 + 0 =)`) instead
of beside it.

9 routes return 200 including `/cart`. **Zero Drupal errors.** D7 untouched:
11 files, `users 13,250 · node 620 · orders 87,640 · mailchimp_signup 2`.

🛑 **The signup forms post to a live audience of 13,281 real people, on every
environment that has the key.** The forms were deliberately **not test-submitted**.

---

# Ninth pass: the newsletter and information-block sections (2026-09-07)

Reported as "design and css still missing" between the newsletter and the
footer. Two separate causes, both structural.

## H-33 — the information cards had no wrapper, so the whole section was unstyled

D7 emits a **bare, unclassed `<div>`** inside `panel-panel panel-col`, and puts
the mini-panel id on the `panel-display` div itself:

    <div class="panel-display panel-1col clearfix" id="mini-panel-information_blocks">
      <div class="panel-panel panel-col">
        <div>                                    <-- BARE WRAPPER
          <div class="panel-pane pane-node ce-expenses">

D10 had the id on a separate outer div and **no bare wrapper**. style.css keys
the entire section off that wrapper:

    div#mini-panel-information_blocks .panel-col > div:first-child
        { display:flex; flex-wrap:wrap; background:#f7dada; padding:15rem 0 }
    div#mini-panel-information_blocks .panel-col > div:first-child .pane-node
        { max-width:30rem; min-height:30rem; border:.1rem solid #09090914; … }
    div#mini-panel-information_blocks .panel-col > div .pane-node:before
        { content:""; width:60px; height:60px; … }
    .ce-expenses:before  { background-image:url(images/piggy_bank.svg) }
    .ce-pin::before      { background-image:url(images/locate.svg) }
    .ce-schedule:before  { background-image:url(images/clock.svg) }
    .ce-knowledge:before { background-image:url(images/microscope.svg) }

Without the wrapper, `.panel-col > div:first-child` matched the FIRST PANE
rather than the container, so `.pane-node` beneath it matched nothing: **no pink
band, no cards, no borders, and none of the four icons**. The four blocks
rendered as plain stacked text.

Wrapper chain is now byte-identical to D7.

## H-34 — the CAPTCHA used Drupal 10's markup, not D7's Bootstrap 3 panel

    D7   <fieldset class="captcha panel panel-default form-wrapper">
           <legend class="panel-heading">
             <span class="panel-title fieldset-legend">CAPTCHA</span></legend>
           <div class="panel-body">
             <div class="help-block">This question is for testing…</div>

    D10  <fieldset class="captcha captcha-type-challenge--math">
           <legend class="captcha__title">…</legend>
           <div class="captcha__element">…</div>
           <div class="captcha__description description">…</div>

Every rule styling it selects the D7 names — `.captcha.form-wrapper`,
`… .panel-body .help-block`, `… .panel-body .form-item-captcha-response` (which
supplies the `display:flex` centring) and its `input` rule (`border-color:#b1b1b0;
color:#fff`, the light-on-dark treatment). All dead, so the CAPTCHA rendered as
unstyled text with no bordered box on the dark panel.

`templates/captcha.html.twig` now reproduces D7's shape. D10's own `captcha` and
`captcha-type-challenge--*` classes and `data-nosnippet` are kept; the D7 names
are added beside them. D7 prints the description FIRST inside the panel body —
the module's template does the opposite — so D7's order is used.

`id="bootstrap-panel"` is deliberately **not** reproduced: it matches zero rules
in either stylesheet, and a fixed id would duplicate on a page with two CAPTCHAs.

### Two details inside the math challenge

`cetc_d10_preprocess_captcha()`:

1. **D7 shows no "Solve this simple math problem…" line.** That is the element
   description, and D7's bootstrap base theme rendered descriptions as tooltips
   (`title=` + `data-toggle="tooltip"`). D10 prints it as visible grey text, so
   the dark panel gained a line D7 does not have. Moved back to the tooltip.
   The stale `aria-describedby` is dropped with it — leaving it would point at
   an id that no longer exists, which is worse than the visible text was.

2. **The sum belongs outside the label.** D7:
   `<label>Math question *</label> 6 + 4 = <input>`. Drupal 10 folds it in —
   `t('Math question (@x + @y =)')`, captcha.module:679 — showing parentheses D7
   never displays, and putting the sum inside the label that
   `.form-item-captcha-response label` styles as a flex item with
   `margin-right: 20px`.

   🛑 The sum is **recovered from the title, never regenerated**. The operands
   are not exposed separately — only the solution is kept, in the session — so
   re-deriving them would produce a different sum than the one the visitor has
   to answer. The title is pattern-matched; if it does not match (a translated
   string, a different challenge type) the title is left exactly as the module
   built it.

## Verified

| | D7 | D10 |
|---|---|---|
| full pane marker sequence | 31 | **31, identical** |
| `mini-panel-information_blocks` chain | — | **byte-identical** |
| `ce-expenses` / `ce-pin` / `ce-schedule` / `ce-knowledge` | 1 each | **1 each** |
| captcha fieldset classes | `captcha panel panel-default form-wrapper` | **all four present** |
| `panel-title fieldset-legend` / `panel-body` / `help-block` | 1 / 1 / 1 | **1 / 1 / 1** |
| dangling `aria-describedby` | 0 | **0** |

All four previously-dead CSS selector chains now match. 7 routes return 200,
**zero Drupal errors**.

---

# Tenth pass: /blog, /faq, /about, /user/login (2026-09-07)

## H-35 ⭐ THE FILES WERE NEVER COPIED — 4,344 of them

Reported as "images are not loaded proper in those pages". The cause was not
those pages:

    D7   sites/default/files    4,501 files, 629 MB
    D10  sites/default/files      137 files  — css, js, styles, php. ALL GENERATED.

The file ENTITIES migrated — 4,330 `file_managed` rows, 4,081 of them
`public://` — but **no physical file was ever copied**. Every migrated image on
every page was a 404, and every image-style derivative failed because the
source did not exist.

Copied with rsync, excluding only regenerable directories (`css`, `js`,
`styles`, `advagg_css`, `advagg_js`, `ctools`, `bootstrap`, `google_tag`).
D10 now holds 4,481 files. Blog images verified 200.

## H-36 — the menu hover images

D7 renders 6 on every non-front page; D10 rendered 0. The image panel is
D7's `cetc_new_menu_link__main_menu()`:

    <a href="/blog">Blog<div class='full-width-menu'><div class='bg-accent'></div>
      <p><img class="menu-item-img" src="…/styles/menu_image/public/menuimage/…"></p></div></a>

⭐ **The file id is read from the link, not hardcoded.** The D7 option survived
migration intact — `link__options['content']['image']` on every
`menu_link_content` row still carries it (Home 2444 · Courses 2443 · Blog 2445 ·
Help 2446 · About 2447 · Login 2448 · Logout 2449), so an editor changing a menu
image in D10 flows through exactly as in D7.

Front-page suppression preserved (D7: `if (!drupal_is_front_page())`) — verified
0 on `/` in both, 6 on every other page, all returning 200.

## H-37 ⭐ THE SITE TIMEZONE WAS WRONG — every date on the site

    D7   date_default_timezone      America/Kentucky/Louisville
    D10  system.date:timezone.default   Asia/Kolkata

`Asia/Kolkata` is the install machine's zone; it was never D7's. Every date
rendered through a Drupal formatter — blog dates, order dates, certificates,
quiz timestamps — was shifted. Caught by a one-day discrepancy on blog node 729,
whose `created` is byte-identical in both systems (1769303052) while the two
sites printed different dates.

🛑 **No stored timestamp changed.** Drupal stores UTC epochs; only the rendering
zone was wrong. After the fix all ten blog dates match D7 exactly:

    D7   01/24/2026, 01/11/2026, 01/04/2026, 12/21/2025, 11/23/2025, 10/21/2025
    D10  01/24/2026, 01/11/2026, 01/04/2026, 12/21/2025, 11/23/2025, 10/21/2025

## H-38 — the blog category dropdown

D7 serves `/blog` as a Panels page with two panes; the first is a
`blog_cateogries` view block that **did not exist in D10 at all**. 14 rules in
style.css target `.view-blog-cateogries` and its internals — all dead.

Rebuilt from D7's stored view config: filter `vid = blog_category`, term name
linked to the term, header `<h2>Blog</h2>` (the BLOCK display overrides the
master's `<h2>Categories</h2>`), css_class `info-content`, and the dropdown
style options `button_text: Categories`, `button_class: btn-info`,
`class: Blog-cateogries`.

🛑 **The misspelling is preserved.** `blog_cateogries` is D7's machine name and
`.view-blog-cateogries` is what the stylesheet selects.

There is no views_bootstrap in D10, so the dropdown markup lives in
`views-view-unformatted--blog-cateogries.html.twig` — the same approach already
used for the states block. `data-toggle` (not `data-bs-toggle`) is deliberate:
the theme ships D7's own bootstrap.js and the CSS uses Bootstrap 3's
`.dropdown.open`.

**Panels wrappers deliberately not reproduced** — checked first: all 14 rules key
off `.view-blog-cateogries`, none on `.panel-pane` or `.pane-content`.

## H-39 — the login form was missing half its functionality

    D7                                    D10 before
    E-mail or username  (email or name)   Username  (username only)
    Remember me  checkbox                 — absent
    Forgot Password?  link                — absent
    Sign Up  button                       — absent

Two modules were in the repository but **never enabled**, both enabled in D7:

* `email_registration` — plus `login_with_username: false` where D7 has
  `email_registration_login_with_username = 1`. Users who log in by username
  could not log in at all.
* `persistent_login` — the "Remember me" checkbox.

The two links are added by `ce_core_form_user_login_form_alter()`. Drupal 10
puts "Reset your password" and "Create new account" in separate menu blocks
this theme does not place, so the page offered no route to either.

⚠️ Position matters: D7 puts **Forgot Password? OUTSIDE** `form-actions` and
**Sign Up INSIDE** it beside the submit button. style.css lays the actions row
out as a unit. The base theme's glyphicon on the submit button is suppressed —
D7's button is plain text.

## H-40 — the "Join Our Newsletter!" popup block

D7 `block` row: region `content`, weight -19, **visibility 0** ("all pages
EXCEPT those listed") over `<front>`, css_class `popup-newsletter`. Measured and
matching: absent on `/`, present on /blog, /faq, /about, /courses, /user/login.

⚠️ **Stored weight -19, but it renders LAST.** D7 byte offsets on /blog:
region-content 11499 · categories 11869 · listing 16035 · **newsletter 40305**.
`block-system-main` outranks it. The rendered order is what a visitor sees, so a
positive weight reproduces it; copying -19 would put the newsletter *above* the
page content — the opposite of D7.

The block id `mailchimp_signup_newsletter` renders as
`block-mailchimp-signup-newsletter`, byte-identical to D7.

## Verified — 8 pages, 12 markers each

`/` · `/blog` · `/faq` · `/about` · `/user/login` · `/courses` ·
`/courses/georgia` · `/cart` — all **200**, **zero Drupal errors**.

Every marker matches except two, both confirmed substring artifacts:

| | D7 | D10 | why |
|---|---|---|---|
| `captcha panel` | 1 | 0 | class ORDER — D10 emits `captcha captcha-type-challenge--math panel panel-default form-wrapper`. All four classes present; `.captcha.form-wrapper` matches. |
| `panel-body` | 2 | 1 | D7 counts twice: `class="panel-body" id="bootstrap-panel-body"`. The id is deliberately not reproduced — it matches **0** rules in either stylesheet. |

D7 working tree still at its baseline 11 files.

---

# Eleventh pass: the featured first post, on /blog AND category pages

## H-41 — the positional row classes

D7 core emits a positional class set on every unformatted row; Drupal 10 emits
a bare `<div class="views-row">`:

    D7    views-row views-row-1 views-row-odd views-row-first
    D10   views-row

**Seven rules in style.css hang off `.views-row-first`** and together they ARE
the featured-post layout - the full-width row, the large image, and the offset
content card:

    .view.view-blog > .view-content .views-row.views-row-first
    .view.view-blog > .view-content .views-row.views-row-first > div
    .view.view-blog .views-row-first .blog-teaser
    .view.view-blog .views-row.views-row-first .blog-teaser .blog-image img
    .view.view-blog .views-row.views-row-first .blog-teaser .blog-image, …
    .view.view-blog .views-row.views-row-first .blog-teaser .blog-teaser-content

None matched, so all ten posts rendered as identical cards.

🛑 **NOT A GENERIC OVERRIDE.** D7 does not emit these classes everywhere -
`/courses` rows carry grid classes instead (`views-row col-xl-4 col-xs-12 …`)
because that view sets its own row_class. A site-wide
`views-view-unformatted.html.twig` would have added classes D7 does not have.
The template is scoped to the blog view.

🛑 The class string is built in Twig, **not pushed onto `row.attributes`**. An
earlier attempt in a views template manipulated `row.attributes` and called
`removeAttribute()`, which recursed until the request died with HTTP 500.

## H-42 ⭐ CATEGORY PAGES RENDERED THE WRONG VIEW ENTIRELY

Row classes alone did not fix the category pages, because the ancestor selector
still failed:

    D7   /blog-category/ceu   view-id-blog            views-row-first ✓
    D10  /blog-category/ceu   view-id-taxonomy_term   views-row-first ✗

D7 serves those pages with the **blog view**; Drupal 10 serves every term page
from core's `taxonomy_term` view, so the wrapper read `view-taxonomy-term` and
all seven `.view.view-blog …` rules missed regardless of the row classes.

### Why not simply add a views page at /blog-category/%

Those paths are **migrated URL aliases** onto `/taxonomy/term/N`. Drupal
resolves an alias to its system path BEFORE routing, so a views page at that
path would never be reached. The term route is the only place this can be done.

### What was built

* `blog` view gains a `block_2` display - the same listing with the term id as
  a contextual filter. Rows, sort, pager and teaser markup are inherited from
  the master display, so they are identical to /blog by construction.
* `ce_core` `RouteSubscriber` swaps ONLY the `_controller` on
  `entity.taxonomy_term.canonical`, leaving `view_id` and `display_id` exactly
  as core set them.
* `BlogCategoryTermController` renders the blog view for `blog_category` terms
  and **hands every other vocabulary straight back to core's
  ViewPageController** with the arguments it was given.

🛑 **STRICTLY ADDITIVE.** The site has six vocabularies (blog_category 25,
license_prefix 51, state 8, course_type 7, profession_type 7,
accrediting_body 2). Only blog_category is intercepted. A views page display at
`/taxonomy/term/%` would have captured all six and 404'd the five it cannot
serve - which is exactly why the route is wrapped rather than replaced. If the
blog view or its display ever disappears, the guard falls through to core: a
category page degrades to a plain term page rather than breaking.

Verified: `taxonomy/term/{63,1,50,51}` (course_type) still return 200 through
`taxonomy_term`, and `/courses` and `/courses/georgia` still use their own views.

### ⚠️ The subscriber priority is load-bearing

`\Drupal\views\EventSubscriber\RouteSubscriber` rewrites the term route at
priority **-175** - that is how core's `taxonomy_term` view takes over the term
page in the first place. At the default priority (0) this subscriber ran BEFORE
that rewrite and views promptly overwrote the controller, so the override
silently did nothing: the route still read `ViewPageController::handle` and
every category page kept rendering the term view. Running at **-200** puts it
last. Caught by reading the route back after a rebuild, not by assuming.

## Verified — 11 pages, 16 markers each

`/` · `/blog` · `/blog-category/ceu` · `/blog-category/cosmetology` ·
`/blog-category/about` · `/faq` · `/about` · `/user/login` · `/courses` ·
`/courses/georgia` · `/cart`

**All 200. Zero differences on any marker. Zero Drupal errors.**

Row classes byte-match D7 on the category pages too:

    /blog-category/ceu          D7 10 rows  first "views-row views-row-1 views-row-odd views-row-first"
                                D10 10 rows first "views-row views-row-1 views-row-odd views-row-first"
    /blog-category/cosmetology  D7 9 rows   last  "views-row views-row-9 views-row-odd views-row-last"
                                D10 9 rows  last  "views-row views-row-9 views-row-odd views-row-last"

D7 working tree still at its baseline 11 files.

---

# Twelfth pass: /faq — the accordions were inert

## H-43 — ckeditor_accordion was never installed

The FAQ content migrated perfectly. Measured on the page, D7 and D10 both hold:

    <dl class="ckeditor-accordion">   8        <dt> 33        <dd> 34

Identical. But D7 loads the module's behaviour and D10 loaded nothing:

    D7   sites/all/modules/ckeditor_accordion/js/ckeditor-accordion.js
         sites/all/modules/ckeditor_accordion/css/ckeditor-accordion.css
    D10  (neither)

So all 33 questions rendered expanded as a flat definition list instead of
collapsible panels - the markup was right, the interaction simply did not exist.

`drupal/ckeditor_accordion` 2.3.0 installed and enabled. Verified after:

    D10  /modules/contrib/ckeditor_accordion/js/accordion.frontend.min.js
         35 `ckeditor-accordion` rules now in the CSS aggregate

⚠️ The D10 module names its frontend file `accordion.frontend.min.js` where D7
used `ckeditor-accordion.js`, so a raw count of the string "accordion" still
differs (D7 14 / D10 11). That is the FILENAME, not the behaviour - the eight
`<dl class="ckeditor-accordion">` blocks the JS binds to match exactly.

## Verified

| | D7 | D10 |
|---|---|---|
| `dl class="ckeditor-accordion"` | 8 | **8** |
| `<dt>` / `<dd>` | 33 / 34 | **33 / 34** |
| `node node-page` | 1 | **1** |
| menu images / full-width panels | 6 / 6 | **6 / 6** |
| popup newsletter, CAPTCHA, Math question | 1 each | **1 each** |

Zero Drupal errors.

Two counts still differ, both previously documented as artifacts rather than
defects: `captcha` (D10 adds `captcha-type-challenge--math` and
`data-drupal-selector`), and `region region-header` (D7 emits a nested duplicate
wrapper; `.region-header` matches 0 rules in either stylesheet).

---

# Thirteenth pass: /user/login layout

## H-44 — "Forgot Password?" was invisible because of a missing `<div>`

Drupal 7 core wrapped every form's children in a bare `<div>`; Drupal 10 does
not. style.css positions that link with a CHILD selector:

    .user-login-page form > div > a { float: right; color: #090909; }

and responsive.css re-states it per breakpoint:

    .user-login-page form > div > a { display: block; float: none; }

With the link a DIRECT child of `<form>` neither could match, so it kept neither
its colour nor its placement and read as missing against the white card.

Restored with `templates/form--user-login-form.html.twig`, reached through a
`hook_theme_suggestions_form_alter()` that adds a per-form-id suggestion —
Drupal 10 provides none of its own, so without it the template is never found.

⚠️ D7's form id was `user-login`, D10's is `user-login-form`. Verified that NO
css selects either (`#user-login` 0 rules, `user-login-form` 0 rules), so the id
is left as D10 generates it. Only the wrapper the stylesheet depends on is
restored.

## H-45 ⭐ THE <h1> WAS NESTED INSIDE region-content ON EVERY PAGE

    D7    <div class="row">
            <h1 class="title" id="page-title">…</h1>
            <div class="region region-content">…</div>

    D10   <div class="row">
            <div class="region region-content">
              <h1 class="title" id="page-title">…</h1>

Measured on /user/login, /faq, /about, /blog and /courses - D7 emits it before
the region on all five, D10 emitted it within on all five.

🛑 **THAT NESTING CHANGES LAYOUT, NOT JUST ORDER.** `.row` is a Bootstrap FLEX
container, and style.css gives the heading

    .user-login-page h1.title { … flex-basis: 100%; }

which only does anything while the `<h1>` is a FLEX ITEM of `.row`. Nested
inside region-content the rule is inert, and the login card's proportions and
spacing drift from D7 - exactly the reported symptom.

The heading is a BLOCK in Drupal 10, so it now renders from a dedicated region
printed directly in the row.

### Two traps hit getting there

1. **`{{ page_title }}` IS NOT A VARIABLE in page.html.twig.** Drupal 10 renders
   the heading only as a block. Printing it emitted nothing and the `<h1>`
   vanished from every page.

2. **The region must NOT be called `page_title`.** That name collides with
   Drupal's own page_title theme hook and block plugin: the region registered
   fine and `system_region_list()` listed it, but `page.page_title` resolved to
   nothing and the heading disappeared site-wide again. Renamed `title_area`.

3. **`page--user.html.twig` overrides `page.html.twig` on every /user/* route.**
   Adding the title area to page.html.twig alone fixed /faq, /about and /blog
   while the login page silently kept no heading at all. Both files carry it now;
   any future change to the row's structure must be made in both.

## Verified

| | D7 | D10 |
|---|---|---|
| `form > div > a` matches (link styled) | yes | **yes** |
| `<h1>` before region-content | yes | **yes** |
| Remember me / Forgot Password? / Sign Up | 1 / 1 / 1 | **1 / 1 / 1** |
| `user-login-page`, `main-body-content`, `id="page-title"` | 1 each | **1 each** |
| `btn-default`, menu images, popup newsletter | 2 / 6 / 1 | **2 / 6 / 1** |

Zero Drupal errors. Remaining count differences are D10's `js-`prefixed
duplicate form classes (`form-item-name` + `js-form-item-name`), not defects.

⚠️ REMAINING, AND IT IS A CORE WORDING CHANGE NOT A SITE SETTING:

    D7 /user/login     "Login"                  D10 "Log in"
    D7 /user/password  "Request new password"   D10 "Reset your password"

D7's `locale_custom_strings_en` holds exactly ONE override (a registration
welcome message) and none of these, so the D7 text is Drupal 7 core's own
wording. Matching it needs a deliberate string override in D10 - not migrated
config, so not done unilaterally.

---

# Fourteenth pass: the FAQ accordions were crashing, not just unstyled

Installing ckeditor_accordion (H-43) put the module's JS on the page, and the JS
then threw on every load:

    Uncaught TypeError: Cannot read properties of null (reading 'classList')
      at Object.attach (accordion.frontend.min.js)

## H-46 — one malformed list killed all eight

accordion.frontend.js line 144:

    $accordion.querySelector('dt:first-child').classList.add('active');

returns NULL when a `<dl class="ckeditor-accordion">` has no `<dt>`, and the
throw aborts `attach()` for EVERY accordion on the page. That is why all eight
FAQ sections stayed expanded rather than just the broken one.

**The content is malformed, and it is malformed in D7 too.** Measured on the
live FAQ page - list #1 of 8:

    <dl class="ckeditor-accordion"><dd><p>My video is freezing.</p></dd></dl>

dt=0, dd=1. The other seven are well formed (5/5, 4/4, 6/6, 3/3, 7/7, 2/2, 6/6).

🛑 **D7 NEVER CRASHED BECAUSE ITS VERSION WAS jQUERY:**

    $accordion.children('dt:first').addClass('active');

jQuery on an empty set is a silent no-op. Drupal 10's rewrite to vanilla DOM
turned a tolerated case into a fatal one. The bug is not in the migration - it
is D7 content that only ever worked by accident.

## The fix, and what it deliberately does NOT do

`js/accordion-guard.js` strips the hook class from any `<dl>` with no `<dt>`, so
the module skips it. The text still displays and the seven real accordions work.

⚠️ **THE CONTENT IS NOT EDITED.** Writing a `<dt>` for that answer would
fabricate a question the business never wrote, and D7 renders that `<dd>` as
plain text today. D7's BEHAVIOUR is restored instead, not D7's data changed.

### Load order is the whole trick — and it took two attempts

Drupal runs behaviours in registration order, so the guard must be evaluated
BEFORE `accordion.frontend.min.js`. `cetc_d10_library_info_alter()` prepends it
to the module's own library rather than declaring a separate one; a separate
library gives no ordering guarantee against a contrib one.

⚠️ The first attempt set `preprocess: TRUE`. With aggregation on, the guard was
folded into a bundle emitted at a different position than the un-aggregated
module script - measured: the guard vanished from the page entirely and the
crash returned. Both files are now un-aggregated and emit as adjacent tags:

    /themes/custom/cetc_d10/js/accordion-guard.js
    /modules/contrib/ckeditor_accordion/js/accordion.frontend.min.js

## Verified

    guard js                        HTTP 200
    accordion lists on page         8
    lists without <dt> (skipped)    [1]
    drupalSettings.ckeditorAccordion present
    Drupal errors                   0

---

# Fifteenth pass: /user/register — four defects, two of them site-wide

## H-47 — the user form displays did not exist

    core.entity_form_display.user.user.default    did not exist
    core.entity_form_display.user.user.register   did not exist

Drupal fell back to the bare account form (email, username, contact checkbox),
so ALL FIFTEEN migrated user fields were absent from both the signup form AND
the account edit form. A registrant could not supply the licence details the
business runs on; an existing user could not see or correct their own data.

Both displays are built from D7's `field_config_instance.data` - widget type,
weight, and the `user_register_form` flag per field. The six D7 flags as 1:

    field_first_name · field_last_name · field_cosmetology_credential_num
    field_state_of_licensure · field_license_prefix · field_license_prefix_zeros

Every other field is 0 and stays off registration. ⚠️ Two D7 widgets have no
D10 counterpart (`phone_textfield` → `telephone_default`); both fell back to the
FIELD TYPE'S OWN default and were REPORTED, never guessed.

## H-48 — the licence prefix was not dependent on the state

D7 hides the prefix select until a state is chosen, then AJAX-replaces the
`#prefix-license-number` wrapper with a select limited to that state's prefixes
(`ce_users::ajax_replace_license_prefix` + `get_state_license_prefix`).

D10 rendered ALL 51 prefixes at once, so a registrant could choose a prefix that
does not belong to their state - which D7 makes impossible.

Reproduced in `ce_users`. The options come from the STATE TERM, so an editor
adding a prefix in D10 changes the form exactly as it did in D7. D7's credential
guidance text, both "Log In Here" links and the referral notice are restored too.

## H-49 — taxonomy TERM fields were never migrated

H-48 could not work at all until this was fixed: the callback reads
`field_license_prefixes` off the state term, and `ce_taxonomy_term.yml` maps
only tid, vid, name, weight and parent - no fields, no field discovery.

`ce_taxonomy_term_fields` now imports them:

| field | D7 | D10 |
|---|---|---|
| field_license_prefixes | 95 | **95** |
| field_course_types | 15 | **15** |
| field_publish | 8 | **8** |
| field_abbreviation | 7 | **7** |
| field_pdf | 2 | **2** |

### ⚠️ field_pdf is a FILE field, and the column name changes

    D7 stores per delta:  fid, display, description
    D10 expects:          target_id, display, description

The first revision mapped it straight through (`field_pdf: field_pdf`). The
migration REPORTED SUCCESS AND WROTE NOTHING - D10 found no `target_id` in the
incoming array and both rows silently vanished (D7 2, D10 0). Now resolved
through `ce_file` with `no_stub`, so a reference to a file that did not migrate
is dropped rather than stubbed into a broken download link.

Verified end to end - both PDFs return HTTP 200 as `application/pdf`:
`3hr Health and Safety Curriculum.pdf`, `Human Trafficking.pdf`.

## H-50 ⭐ ELEVEN REFERENCE FIELDS ACCEPTED ANY VOCABULARY

Checking the state field exposed this site-wide. D7 stores the allowed
vocabulary on the field:

    allowed_values: [ { vocabulary: "state", parent: 0 } ]

D10's `handler_settings` had **no `target_bundles` at all**, which Drupal reads
as "every bundle":

    D7  state radios: 8    tids 6, 7, 8, 9, 26, 56, 67, 121
    D10 state radios: 100  every taxonomy term on the site

A registrant could select "Barber" - a course_type term - as their State of
Licensure and the form would accept it. Not cosmetic: a data-integrity defect.

All eleven restricted from D7's own `allowed_values`:

    node.article  field_tags               -> tags
    node.blog     field_category           -> blog_category
    node.page     field_category           -> blog_category
    node.course   field_accrediting_body   -> accrediting_body
    node.course   field_course_type        -> course_type
    node.course   field_state              -> state
    user.user     field_state_of_licensure -> state
    user.user     field_license_prefix     -> license_prefix
    user.user     field_profession_type    -> profession_type
    taxonomy_term.state           field_license_prefixes -> license_prefix
    taxonomy_term.profession_type field_course_types     -> course_type

## Verified

    D7  state radios 8, tids 6/7/8/9/26/56/67/121
    D10 state radios 8, tids 6/7/8/9/26/56/67/121   ← identical

Form fields now match D7: first name, last name, credential number, licence
prefix (hidden until a state is chosen), prefix zeros, state radios, mail, name.
The `contact` checkbox is hidden - D7 has the contact module DISABLED
(`system.status = 0`) so it has no such checkbox anywhere.

Zero Drupal errors.

⚠️ REMAINING: D7 emits `timezone` as a HIDDEN input carrying the site default,
so a D7 registrant never chooses one; D10 renders no timezone control either, so
the two forms agree on what the user can DO. Forcing
`system.date:timezone.user.configurable` to FALSE to reproduce the hidden input
would also strip the selector from the ACCOUNT EDIT form, which D7 does not do.
Left alone deliberately.

---

# Sixteenth pass: /user/register layout

The fields were right after the fifteenth pass; the LAYOUT was not. Three
causes, all of them markup D7 emitted and Drupal 10 does not.

## H-51 ⭐ THE INNER `<div>` IS THE ENTIRE REGISTRATION LAYOUT

Drupal 7 core wrapped every form's children in a bare `<div>`. style.css makes
that div the FLEX CONTAINER the whole form hangs off:

    form#user-register-form > div
        { display:flex; flex-wrap:wrap; justify-content:space-between }
    form#user-register-form > div > div:first-child   { flex-basis:100% }
    form#user-register-form > div .form-actions
        { flex-basis:100%; text-align:center; margin-top:4rem }

Drupal 10 emits no such wrapper, so `form > div` matched nothing and EVERY flex
rule on the form was inert:

* First Name and Last Name stacked instead of sharing a row (49% each)
* all eight State radios stacked one per line instead of flowing across
* CREATE NEW ACCOUNT and LOG IN HERE side by side instead of centred and stacked
* the top "or / LOG IN HERE" landing on top of the E-mail field

⚠️ The field-level classes from H-52 were already correct - they simply had no
flex parent to act inside. Fixing the classes alone changed nothing visible,
which is why the layout still looked wrong after the previous pass.

Same wrapper as `form--user-login-form.html.twig`, different reason: there it
makes `.user-login-page form > div > a` match so "Forgot Password?" is styled.

## H-52 — D7's field wrappers

D7 wraps every field on the user forms in

    <div class="field-type-text field-name-field-first-name
                field-widget-text-textfield form-wrapper form-group">

Drupal 10's closest equivalent is the inner `form-item-field-first-name-0-value`,
which no rule in this theme selects. Reproduced in `ce_users`, translating D10's
type/widget names back to D7's vocabulary (`string` → `text`,
`entity_reference` → `taxonomy_term_reference`, `integer` → `number_integer`).
All six wrappers now match D7 byte for byte, in the same order.

### Two traps

* **`form-radios` leaked onto all eight `<input>`s.** Drupal's Radios element
  copies `#attributes` to its children; D7 has the class on ONE container div.
  `display:flex` on a radio input is meaningless at best. Stripped from children
  in an `#after_build`; now 1 in both.

* **`#access` must go on the WIDGET, not the element.** D7 does
  `$form['field_license_prefix']['und']['#access'] = false`, hiding the select
  while the field element - and therefore `<div id="prefix-license-number">` -
  still renders. Putting it on the element removed the wrapper from the DOM, so
  **the AJAX had no target and choosing a state would have done nothing.**
  Caught by counting: D7 emits `prefix-license-number` 9 times (8 AJAX settings
  + the div), D10 emitted 8.

## H-53 — element order, and why D7's weights do not transfer

D7 order: top_markup → login link → referral notice → E-mail → fields → actions.
D10 rendered E-mail FIRST, putting the "LOG IN HERE" button on top of the email
input.

D7 uses `#weight` -2 and -1, which is enough there because D7's `mail` sits
higher. Drupal 10 places the account fields very low, so -2 rendered *after*
them. Weights are now -100 / -99: **the rendered ORDER is what D7 defines, not
the numbers.** It also matters for layout - `> div > div:first-child
{ flex-basis:100% }` styles whatever comes first, and in D7 that is this block.

## H-54 — the username was never meant to be visible

Reverted `require_username_on_registration` to FALSE. An earlier pass read
`<input class="username" …>` in D7's markup and concluded D7 shows the field.
The full attribute list says `type="hidden"` with an auto-generated value
(`email_registration_JYHTNVQgs9`) - D7 never asks for a username. The visible
"Username" field, and the LOG IN HERE button colliding with it, were both mine.

## Verified

18 of 20 markers match; element order identical across all 13 positions; inner
flex wrapper present in both. Zero Drupal errors.

The two remaining counts are outside the fields - D7 also wraps its own
mail/name elements in `form-wrapper form-group` (8 vs 6).

⚠️ STILL A CORE WORDING DIFFERENCE, NOT SITE CONFIG:
`E-mail *` (D7) vs `Email` (D10), same category as `Login` / `Log in`. D7's
`locale_custom_strings_en` contains neither, so both are Drupal's own strings.
Matching them needs a deliberate string override.

---

# Seventeenth pass: the duplicate blog title, and the body-class rule

## H-55 — I checked the markup and drew the wrong conclusion

When the duplicate title was first reported I compared the MARKUP, found `<h1
id="page-title">` and `<h2>` on both sites, and said D7 behaved the same. It
does emit both - but it HIDES the h1:

    .page-blog h1#page-title, body.node-type-course h1#page-title,
    .page-node-35 h1#page-title, .page-node-36 h1#page-title { display: none; }

D10 lacked the `page-blog` body class, so the rule never applied. Matching
markup is not matching output; the check should have gone as far as the rule.

## H-56 ⭐ `page-blog` COMES FROM PANELS, NOT FROM THE URL

The obvious reading - "the path starts with /blog" - is wrong, and the data
says so:

    /faq                        (node/35)  ->  NO page-faq
    /videos/get-your-cosmetology-ceu (node/29) ->  NO page-videos
    /blog-category/ceu    (taxonomy/term/59)  ->  page-blog, NOT page-blog-category

An alias-derived rule produced all three of those wrong classes; it was written,
tested against those pages, and removed.

The real source is `panels_preprocess_html()`, which appends whatever a Page
Manager variant configures. D7's `page_manager_handlers` carry
`body_classes_to_add: "page-blog"` on exactly two:

    node_view_panel_context   access: node_type       = blog
    term_view_panel_context   access: term_vocabulary = blog_category

So the rule is the CONTENT'S TYPE, not its path. Reproduced on the same two
criteria. `/blog` and `/courses` already carried their class from the
cumulative internal-path classes - they are views pages whose internal path is
the word itself, which is why they looked right all along and hid the fact that
node pages did not.

## H-57 — the wildcard body classes

D7 emits an extra class for every NUMERIC path argument.
`theme_get_suggestions()` (D7 core, includes/theme.inc:2827) documents it:

    page__node              page-node
    page__node__%           page-node-%
    page__node__1           page-node-1

`drupal_html_class()` -> `drupal_clean_css_identifier()` then STRIPS the `%`
rather than replacing it, so `page-node-%` reaches the body as the odd-looking
`page-node-` - trailing hyphen, nothing after it.

    D7 /faq               page-node page-node- page-node-35
    D7 /blog-category/ceu page-taxonomy page-taxonomy-term
                          page-taxonomy-term- page-taxonomy-term-59

**7 rules** in style.css/responsive.css select on these, so they are a real
hook the D7 stylesheet uses, not noise.

## Verified

Body classes compared across 17 paths - listing pages, node pages of four
types, taxonomy terms, user forms, cart:

    identical body classes: 17/17

Nothing missing, nothing extra. Zero Drupal errors. D7 working tree still at
its baseline 11 files.

---

# Eighteenth pass: the course list pages

## H-58 — the exposed filters, and a 500 the list pages were hiding

### Filters were missing or wrong

    D7 /courses            field_ce_or_non_ce_value · field_state_tid · field_course_type_tid
    D7 /courses/georgia    field_ce_or_non_ce_value · field_course_type_tid

    D10 /courses           two of the three exposed; the third existed but was NOT
    D10 /courses/georgia   NO exposed filters at all - the view had only status/type

Three separate defects: `courses_by_states` had no exposed filters, `courses`
had `field_ce_or_non_ce_value` unexposed, and every select read "- Any -".

⚠️ **THE IDENTIFIER IS PART OF THE URL.** D10 defaults to
`field_state_target_id`; D7 uses `field_state_tid`. Leaving the default would
break every bookmarked filter URL from the old site. Matched to D7 on all five.

### The "any" labels, and a module installed then removed

D7 gets `CE or Non CE` / `choose your state` / `license type` /
`choose your license` from better_exposed_filters' `any_label`, with
`bef_format: default` - BEF changes no widget here; the wording is its only
effect. **BEF 7.x for Drupal 10 dropped that feature** (its `#empty_option`
exists only for RadioButtons and Links). BEF was installed, checked, and
uninstalled again rather than left as an unused dependency; the labels are
applied in `ce_core_form_alter()` instead, keyed by exposed identifier.

### 🛑 EVERY COURSE DETAIL PAGE WAS RETURNING HTTP 500

    Error: Call to a member function getCacheTags() on null
      at ImageFormatter.php:241   ->  $image_style->getCacheTags()

`node.course.default` renders field_course_image through image style
`course_thumbnail`, **which was never migrated**, so `load()` returned NULL and
the formatter fatalled. The LIST pages were fine - they use the `teaser` display
and `course_listing`, which does exist - which is exactly why this went unseen
until the detail page was opened.

D7 has five styles; two were missing: `course_thumbnail` (image_scale 160w) and
`video_thumbnail` (image_scale_and_crop 220x130). Both created.

⚠️ Both also carry `canvasactions_file2canvas`, an overlay from D7's
imagecache_actions compositing `courses-thumb.png` / `videos-thumb.png`. No core
equivalent exists in D10 and neither image_effects nor imagecache_actions is
installed, so the overlay is **recorded as a deliberate omission, not
approximated with a different effect**. Both PNGs live in the OLD `cetc` theme,
not the active `cetc_new`, so it may already be vestigial in D7.

## H-59 ⭐ `view-content` - DRUPAL 10 DROPPED IT, 80 CSS RULES NEED IT

    D7 views-view.tpl.php     <div class="view-content">{{ rows }}</div>
    D10 views-view.html.twig  {{ rows }}          <- no wrapper at all

This theme uses `.view-content` **80 times across 55 distinct selectors**, and
several as a DIRECT CHILD, so they cannot match without it:

    .course-detail__video-list > .view-videos-per-course > .view-content > div
    .course-detail-enroll .credits-and-price .view-total-course-price .view-content

Measured on /courses/georgia: D7 11, D10 2.

⚠️ **EVA NEEDS ITS OWN OVERRIDE.** It declares `theme = "eva_display_entity_view"`
with its own template, so the views-view override does not reach it. After
fixing views-view alone /blog and / matched but /courses/georgia stayed at 2 of
11 - the nine missing were all EVA price views. Both templates now carry it, and
all four pages match D7 exactly.

## H-60 — the course price was absent entirely

D7 shows a red price badge on every course card and on the detail page; D10
showed **no price anywhere** (`$` appeared 9 times in D7, 0 in D10).

The source is a view D10 did not have: `total_course_price`, an EVA display
attached to course nodes. A course's price is `SUM(field_price)` over the videos
it contains, reached through a reverse `field_videos` relationship.

Rebuilt: relationship `reverse__node__field_videos` (`entity_reverse`), argument
`nid` on that relationship validated to bundle `course`, filters status/type,
and `field_price` with `group_type: sum`.

⚠️ **VIEWS DOES NOT RUN THE FIELD FORMATTER FOR AN AGGREGATED VALUE** - there is
no single field item to format - so `settings.prefix_suffix: true` had no effect
and the price rendered `29.00` where D7 shows `$29.00`. D7 got its "$" from the
field prefix; here it is applied through the view field's own alter.

Prices now identical: `$29.00 · $49.00 · $39.00 · $39.00 · $49.00 · $49.00`.

## H-61 — the exposed filter wrappers: 450+ dead CSS occurrences

D7 nests every exposed filter four divs deep:

    <div><div class="views-exposed-form"><div class="views-exposed-widgets clearfix">
      <div id="edit-X-wrapper" class="views-exposed-widget views-widget-filter-X">
        <div class="views-widget"><div class="form-item …">

Drupal 10 emits a flat list of `form-item` divs. In this theme:

    .views-widget          138 occurrences
    .views-exposed-widget  114
    .views-exposed-form    111
    .views-exposed-widgets  94

**Over 450, none of which could match.** The outer pair is restored in
`templates/views-exposed-form.html.twig`; the per-widget pair in
`ce_core_form_views_exposed_form_alter()` - a template cannot reach individual
elements and a form alter cannot emit the form tag, so the halves are split
deliberately. Nesting now matches D7 exactly.

⚠️ `ctools-auto-submit-full-form` is NOT reproduced - it is the hook for ctools'
auto-submit JS, which has no D10 counterpart here. Adding the class without the
behaviour would promise an auto-submit that never happens.

## H-62 — the filter form posted to an unaliased URL

    D7  action="/courses/georgia"
    D10 action="/courses/6"        <- the raw term id

`ce_core_views_pre_view()` converts the state NAME to a term id before the view
runs, so Views built the action from the resolved path. Submitting a filter
navigated off the readable URL. The request path is now used verbatim.

## Verified

13 paths, 9 markers each. **8 of 13 match completely**; the course list pages
differ only on a `views-exposed-form` substring count (D10 repeats it in
`data-drupal-selector`). Zero Drupal errors. D7 at its baseline 11 files.

## Still open on the course DETAIL page and /cart

Two views exist in D7 and not in D10, both feeding the course detail page:

    videos_per_course        the course's video list (5 rows in D7, 0 in D10)
    add_to_cart_video_field  the per-video add-to-cart button

Neither is in D7's `views_view` table - they are module-provided defaults - so
rebuilding them needs their source located first. `/cart` shows the same shape
of gap (D7 renders course rows there, D10 none).

---

# Nineteenth pass: the course detail page views

Both views D7 renders here were missing from D10. They are not in D7's
`views_view` table - they are Features defaults in
`course_features/ce_views/ce_views.views_default.inc`.

## H-63 — `videos_per_course` rebuilt ✅

D7: base node, reverse `field_videos` relationship, argument = the course nid
validated to course|video, filters status/type=video, and **row_plugin `node`** -
it renders the whole VIDEO NODE, not a field list, so the theme's
node--video--teaser template draws each row.

Rebuilt with `row: entity:node` in the `teaser` view mode and an EVA display on
course bundles. The video list now renders identically - `node-718`,
`node node-video node-teaser not-bought default-margin clearfix`,
`row video-teaser-content`, `col-md-3` all match D7.

⚠️ D7's `views-row` count of 5 on this page is ONE element
(`views-row views-row-1 views-row-odd views-row-first views-row-last`) belonging
to the add-to-cart view, not five rows. Both courses have exactly one video in
D7 and D10 - checked before assuming the view was under-returning.

## H-64 — `field_video_reference`: 150 product→video links never migrated

`ce_commerce_product` migrates the variations but maps no fields, so the field
that says WHICH VIDEO A PRODUCT SELLS was empty. 143 of 150 imported; the other
7 point at video nodes that **do not exist in D7 either** (49, 378, 382, 524,
528, 726 are absent from D7's node table), so skipping them is correct.

⚠️ **A HASHED TABLE NAME COST TIME AND NEARLY COST DATA.**
`commerce_product_variation__field_video_reference` is exactly 48 characters -
Drupal's limit - so the real table is
`commerce_product_variation__5e9a97f1b4`. Checking for the unhashed name
reported "table does not exist", which led to deleting and recreating the field
config to force a repair that was never needed. The delete then registered a
purge that fatalled on every run and had to be cleared from state by hand. **The
field was installed correctly all along; only its DATA was missing.** No data
was lost only because the table was genuinely empty at the time.

## H-65 ⭐ 168 PRODUCTS CARRY A BUNDLE THAT HAS NO CONFIG

Found while debugging why the rebuilt add-to-cart view returned zero rows with
demonstrably correct joins. Views had appended `AND ((1 = 0))`:

    commerce_product_field_data          type video_product   168 rows
    commerce_product.commerce_product_type.*   only `default`
    entity_type.bundle.info                    only `default`

Views' bundle filter validates against the type config and **silently compiles
to `1 = 0`** when the bundle is unknown - a failure that reads as a broken query
and is not. D7 has three product types; the migration created all three as
VARIATION types but only `default` as a PRODUCT type, while still writing
product rows with those bundles. `video_product` created from D7's own
`commerce_product_type` row; types nothing uses were not invented.

## 🛑 H-66 — BLOCKED: the product/variation graph does not line up

The add-to-cart view still returns nothing, and the cause is upstream of any
view. Measured:

    products                              168
    variations                            162
    variations linked to a real product    132
    variations ORPHANED                     30   <- product_id points nowhere
    products with NO variation              36

Variation 151 (which correctly references video 718) claims `product_id 187`,
but product ids only run 1-168. Commerce 3 splits D7's single
`commerce_product` into a product plus its variations, and the two halves were
created with mismatched ids - the variation kept D7's product id while the
product entities were numbered independently.

**Not repaired here.** Re-pointing 30 variations and reconciling 36 orphaned
products rewrites which product sells what - the purchasable graph behind every
historical order. That is a financial-data decision, not a theming one, and it
needs the same explicit approval the cart-flag backfill got.

Until it is resolved the add-to-cart button cannot render for the affected
videos, because there is no valid product to add.

## Verified

Course detail page: **8 of 10 markers match** - video list, price, teaser
markup, `course-full-price` and `view-content` structure all identical to D7.
The two that differ are both the add-to-cart view, blocked on H-66.
Zero Drupal errors.

---

# H-67 · The product/variation mismatch, diagnosed and repaired

Approved by the user on 2026-09-07 ("fix the product variation mismatch too").
H-66 above described the SYMPTOM and guessed at the cause. The guess was wrong
in an instructive way, so the real diagnosis is recorded here in full.

## What H-66 assumed, and what was actually true

H-66 said "the variation kept D7's product id while the product entities were
numbered independently", implying two migrations that disagreed. There was only
ever ONE migration, and the D7 id was not kept at all.

The cause is a single line in `ce_commerce_product.yml`:

```yaml
process:
  product_id: product_id
```

On a `commerce_product_variation`, `product_id` **is not the entity's own id.**
The id key is `variation_id`. `product_id` is the **entity reference to the
parent product**. So that mapping wrote D7's product ids (18-198) into the
parent pointer of all 162 variations, and D7's id was never preserved anywhere
except the id map. Commerce assigned fresh variation ids 1-162.

The second, independent defect supplied the products those pointers landed on.
`ce_rules_behavior`'s video->product rule (C-6) fires on `hook_node_insert`, and
it fired **while the node migration was importing video nodes** - re-creating in
D10 the products D7 had already created years earlier, and which
`ce_commerce_product` was migrating at the same time as variations. That put 168
artifact products at ids 1-168.

The two defects then produced a graph that looked half-populated:

| | |
|---|---|
| variations pointing at a real but **unrelated** product | 132 |
| linked pairs whose titles matched | **0 of 132** |
| variations claiming ids 169-198, which never existed | 30 |

The 30 "orphans" were simply the D7 ids that overshot the artifact range. There
was never a partial success to salvage: **every one of the 132 links was wrong.**

## Evidence that the 168 products were artifacts, not data

| measured 2026-09-07 | |
|---|---|
| products | 168 |
| whose title is a **video node** title | **168** |
| carrying a body | 0 |
| carrying a price / sku / video reference | 0 (none of those fields exist on a product) |
| distinct `created` timestamps | 2 (the two import runs, both 2026-08-26) |

Every one held a title copied from a node that still exists, and nothing else.
`VideoProductFactory` sets sku, price and `field_video_reference` behind
`hasField()` guards, and **none of those fields exist on the product entity** -
they live on the variation. So all three guards were false and the products were
created empty.

## The hazard that governed the repair order

`Product::postDelete()` (`commerce/modules/product/src/Entity/Product.php:300`)
**deletes every variation the product references.** The artifacts referenced 132
real migrated variations, and 113,425 order items purchase those variations.
Deleting the artifacts directly would have destroyed 132 purchasables out from
under every historical order.

A second trap sits on the other side. `Product::postSave()` fills the variation's
back-reference **only when it `isEmpty()`**, so a stale `product_id` survives
silently: the new product saves, the link table looks correct, and the variation
still points at a product that no longer exists.

Hence the order: **unlink both sides -> verify -> delete -> rebuild.**

## What is fixed at source

1. **`ce_commerce_product.yml`** - `product_id: product_id` removed, with the
   reasoning inline. The D7 id is deliberately **not** restored as `variation_id`:
   113,425 order items already resolve to variation ids 1-162 through the id map
   with 0 dangling, and renumbering the purchasables to make the ids prettier
   would break every historical line item.

2. **`ce_rules_behavior.module`** - the rule now returns early when
   `$node->isSyncing()`, which is core's own signal for "this save is a
   synchronisation, not a user action" (`EntityContentBase::save()` sets it on
   every migrated entity). A video created through the UI or API still fires the
   rule, still copies the price once, and still preserves defect RB-10b, so C-6
   and tests RB-10/RB-10b are unaffected.

3. **`fix_product_type_variation_link.php`** - a third silent failure found on
   the way. `create_commerce_product_types.php` created the product types with
   `'variationType' => ...` (singular). Commerce 3 declares `variationTypes` as
   an **array**, and the singular key is not in the entity's `config_export`
   list, so it was discarded on save. The types were created and reported
   created while the link they exist to carry was never written
   (`variationTypes: {  }`). Also created the missing `product` product type -
   D7 has 3 products of that type and D10 had no such bundle.

4. **`ce_commerce_product_parent.yml`** (new) - creates one product per D7
   product, 162 of them, from the same D7 row the variation came from. Title,
   type, owner, status and both timestamps are D7's. `stores` resolves to the
   single store on the same reasoning documented for `store_id` in
   `ce_commerce_order.yml`. `variations` is resolved through the id map rather
   than by assuming `variation_id == product_id` - it does not, and assuming so
   is what produced the 30 orphans.

   Here `product_id: product_id` is **correct**, because on `commerce_product`
   the id key really is `product_id`. The same three words are right in one file
   and destructive in the other.

## Financial fingerprints - unchanged through the repair

Captured by `product_graph_fingerprint.php` before any change and re-checked
after the unlink:

| | before | after unlink |
|---|---|---|
| order items | 113,425 | 113,425 |
| order item fingerprint | `824f68593bb5ac51a4207c92e373972f` | **identical** |
| orders | 87,640 | 87,640 |
| order fingerprint | `be425e171d5423181ddfbfca5d68736a` | **identical** |
| variations | 162 | 162 |
| variation fingerprint | `6c42745077e550626c925cbee8b1addb` | **identical** |
| variation -> video links | 143 | 143 |
| order items dangling | 0 | 0 |

Saving 162 variations was safe only because no variation type in use
regenerates its title. `ProductVariation::preSave()` overwrites the title when
the type has `generateTitle = TRUE`, which would have destroyed the migrated D7
titles. Measured: `video_product` false (159 rows), `product` false (3 rows);
`default` is TRUE but no variation uses it.

## 🛑 REMAINING STEP - BLOCKED ON A PERMISSION, NOT ON A DECISION

The 168 artifact products are unlinked and inert but still present. Deleting
them is blocked by the sandbox permission classifier, which refused
`drush php:script scripts/reconcile/delete_artifact_products.php` on four
attempts. The script is written, guarded (it aborts if any link still exists)
and verified safe to run.

**In-place recycling was considered and rejected.** Because the migration writes
D7 ids 18-198, it would overwrite the artifacts sitting in that range. But D7
products 30, 53 and 103 are type `product` while the artifacts holding those ids
are `video_product`, and Drupal does not change an entity's bundle on save -
3 products would silently keep the wrong type, on a product type that does not
accept their variation's type. The delete is required.

Until it runs, the graph is: 162 variations, 0 links, 0 products carrying them.

---

# H-68 · Course list pages - /courses and /courses/<state>

Both pages now match D7 on **20 of 20 markers**, with identical exposed-filter
labels. Five separate defects, only one of which was visible as "CSS missing".

## 1. The grid collapsed - and the column classes were never the problem

D7 renders both views through `views_bootstrap`'s grid style. It has no D10
port, so the D10 views used the plain unformatted style and carried the column
classes on each row via `row_class`. Every class was present and the grid still
collapsed into a narrow vertical stack.

**The missing piece was the `.row` parent.** This theme's Bootstrap is 4-style:
`.col-lg-4` is `flex: 0 0 33.33%` **plus `max-width: 33.33%`**, and `.row` is
what supplies `display:flex`. Outside a `.row` the flex-basis is inert but the
`max-width` still applies - so each card became a block one third of the
container wide, stacked down the left edge. Exactly the screenshot.

`views-view-unformatted--courses.html.twig` (and the `--courses-by-states`
twin) reproduce views_bootstrap's markup:

```html
<div id="views-bootstrap-grid-1" class="views-bootstrap-grid-plugin-style">
  <div class="row">
    <div class="views-row col-xl-4 col-xs-12 col-sm-12 col-md-6 col-lg-4">…
```

⚠️ **One `.row` for all nine results, not one per three.** D7's display sets
`columns_horizontal: -1`, which is views_bootstrap's "Single row" option -
`_views_bootstrap_split_rows_horizontal()` returns a single group. Measured on
D7: 9 `views-row` divs inside exactly 1 grid `.row`. Chunking into threes would
have produced three and would not have matched.

That wrapper also revived **12 dead CSS selectors**, including the one that
makes a course card a card at all (`style.css:1901`):

```css
.view-courses .views-bootstrap-grid-plugin-style .node-course.node-teaser,
.view-courses-by-states .views-bootstrap-grid-plugin-style .node-course.node-teaser,
.course-item { display:flex; flex-direction:column; height:100%;
               background-color:#9facbd59; position:relative; }
```

## 2. The filter bar had no container

D7's `views-view.tpl.php` wraps the exposed form in `<div class="view-filters">`
and the header in `<div class="view-header">`. D10's template printed
`{{ exposed }}` bare and wrapped the header in an unclassed `<header>`. Both
hooks are load-bearing:

```css
.page-courses .view-courses .view-filters { margin:auto; max-width:950px; margin-bottom:6.8rem }
.page-courses .view-courses .views-exposed-widgets { display:flex; justify-content:center }
.view-courses .view-header { margin-bottom:3rem; text-align:center }
```

With no `.view-filters` the three selects had nothing to centre or constrain
and stacked at the left edge at their natural width. `views-view.html.twig` now
emits D7's `view-header` / `view-filters` / `view-footer`.

## 3. The sub-heading was never migrated

D7 carries it as a **header area on the page display**, not as theme markup:

```
<p class='sub-heading'>Select the course(s) that best fits your professional needs!</p>
```

Added to both views as a `text` area with `format: full_html`, matching D7's
own configuration. The two views' `empty` text also differs in D7
("No courses found." vs "No Course Found.") where D10 had one string on both.

## 4. The Apply button should not be visible, and the filters should self-submit

D7 uses the `better_exposed_filters` exposed form plugin with `autosubmit: 1`
and `autosubmit_hide: 1`. BEF hands the button to ctools, which adds `js-hide`
and clicks it whenever a filter changes:

```html
<form class="ctools-auto-submit-full-form" …>
<button class="ctools-use-ajax ctools-auto-submit-click js-hide btn btn-info form-submit" …>
```

Neither module is installed in D10, so **choosing a state did nothing until the
visitor pressed Apply** - a functional difference, not only a visual one.
Reproduced in `ce_core` (form alter + `js/course-filter-autosubmit.js`), applied
to the two course views only: `CE_CORE_AUTOSUBMIT_VIEWS`. Hiding Apply globally
would strand every other exposed filter in the site with no way to submit.

🛑 **No AJAX.** D7's `ctools-use-ajax` class is present but the D7 view has no
AJAX display, so the click is a normal GET navigation. That matters here: the
page title is built from the query parameters on a real request, which an AJAX
refresh would not produce.

## 5. The page title - D7 builds it in the theme, not in the view

D7 SOURCE: `cetc_new/template.php`, the two `arg(0) == 'courses'` blocks in
`cetc_new_preprocess_page()`. D10 showed "Courses" on every variant:

| URL | D7 | D10 before | D10 now |
|---|---|---|---|
| `/courses` | Courses | Courses | ✔ |
| `/courses?field_state_tid=6` | Georgia Courses | Courses | ✔ |
| `/courses?field_course_type_tid=1` | Cosmetologist Courses | Courses | ✔ |
| `/courses?field_state_tid=6&field_course_type_tid=1` | Georgia Cosmetologist Courses | Courses | ✔ |
| `/courses/georgia` | Georgia Courses | Courses | ✔ |
| `/courses/georgia?field_course_type_tid=1` | Georgia Cosmetologist Courses | Courses | ✔ |

🛑 **The two paths take their base from different places**, which is why one
rule cannot cover both. On `/courses` the base is the view title "Courses" and
the names are **prepended**. On `/courses/<state>` the base is the **state
name** and "Courses" is **appended**, with a licence name going between.

⚠️ **The state name is resolved from the path, not from the view title.** D7
gets it from the contextual filter's `title: '%1'`. D10 cannot: 
`ce_core_views_pre_view()` rewrites the state name to a term id before the view
builds its title, so `%1` would substitute "6" and the page would read
"6 Courses". `ce_core_preprocess_page_title()` looks the name up the same way
pre_view does. An unknown term contributes nothing - it is never replaced with a
raw id or an invented label.

## 6. Pager (found while comparing, not reported)

D7's Bootstrap base theme emits `<div class="text-center"><ul class="pagination">`
with the **current page as a `<span>`**; D10's Bootstrap contrib template emits
`<nav class="pager-nav">` with a hidden `<h4>`, `pager__item` classes, and the
current page as a **link**. Bootstrap 3 styles `.pagination > .active > span` as
a flat block, so an `<a>` there kept link hover/focus and did not match.
`pager.html.twig` restores D7's shape; the two course views set D7 Views'
lowercase pager tags (`next ›`, `last »`) which D10 Views capitalises.

⚠️ D7 renders absolute hrefs (`/courses?page=1`), D10 relative (`?page=1`).
That comes from core's pager preprocess, not the template, and both resolve to
the same URL.

## Verified

`/courses` and `/courses/georgia`: **20/20 markers match** - grid wrappers,
column classes, card markup, filter widgets, submit button, sub-heading, pager
structure. Exposed-filter "any" labels identical on both pages. All of
`/courses`, `/courses/georgia`, `/courses/texas`, a filtered query and page 2
return HTTP 200 with **zero** entries in the error log. D7 unchanged at its
baseline 11 modified files.

---

# H-69 · Course list - the class that was doing all the work

H-68 fixed the grid wrapper and the cards went to three columns, but the filter
bar was still stacked at the left and the cards had no vertical gap. Both had
**one** cause, and it was a bug I introduced in H-68's own file.

## The view had no `view-courses` class - it had no view classes at all

```html
D7   <div class="view view-courses view-id-courses view-display-id-page view-dom-id-…">
D10  <div class="js-view-dom-id-1658…">
```

`view-courses` appeared **0 times** in the whole D10 page. 33 rules in
`style.css` are written against it, including the two that were still wrong:

```css
.page-courses .view-courses .view-filters          { margin:auto; max-width:950px }
.page-courses .view-courses .views-exposed-widgets { display:flex; justify-content:center }
.view-courses .view-header                         { text-align:center }
.view-courses .views-bootstrap-grid-plugin-style .row > div { margin-bottom:3rem }  /* the missing gap */
```

🛑 **The classes are composed inside Twig, so overriding the template dropped
them.** This is the same trap as `cetc_d10_preprocess_field()`, one level up:
the Bootstrap BASE THEME's `views-view.html.twig` builds `view`,
`view-<name>`, `view-id-<name>` and `view-display-id-<display>` in the template
itself, not in a preprocess hook. `cetc_d10`'s override - written to restore
D7's `view-content`, and extended in H-68 - set `classes` to the dom id alone.

⚠️ **The EVA displays hid it.** They render through
`eva-display-entity-view.html.twig`, a separate template that was never
overridden, so they kept their classes throughout. Grepping for "does this site
emit view classes at all" therefore answered yes.

⚠️ `view-display-id-` differs from D7 by design: D7's display is `page`, D10's
is `page_1`. No rule targets the display id of these views; the ones that do
name displays that kept their D7 machine names.

## The course order was wrong, and only on /courses

Layout parity made it obvious that D10 listed different courses on page 1:

| | D7 | D10 before |
|---|---|---|
| 1 | 2026-27 TCSG Health and Safety-3 Hour | Expert Haircutting … TX 2026-27 |
| 2 | 2026-27 Elegant Up-Styles-2 Hour | 2026-27 TCSG Health and Safety-3 Hour |
| 3 | 2026-27 Fearless Men's Barbering… | Luxurious Acrylic Nails … TX 2026-27 |

D7's `courses` page sorts on **four** keys; D10 had three. The missing one is
the STATE TERM'S WEIGHT, which needs machinery D10 never got:

* relationship `term_node_tid` (`node_term_data`), limited to the `state`
  vocabulary exactly as D7 limits it;
* sort on `taxonomy_term_field_data.weight` ASC through that relationship, with
  `group_type: min`;
* `group_by: true` - **required**, because a course carrying several state terms
  is otherwise returned once per term.

Order restored: `ce_or_non_ce ASC → term weight ASC (MIN) → field_weight DESC →
created DESC`. All 9 titles on page 1 now match D7 in sequence, and the result
count is unchanged (5 pages before and after, so aggregation collapsed nothing
that should have stayed).

⚠️ **`/courses/<state>` was already correct** and was left alone. D7's
`courses_by_states` page display carries only three sorts and no relationship -
it does not sort by term weight, because the state is already fixed by the
argument. Verified: all 9 titles matched before this change and still do.

## Course title markup

D7's teaser prints the plain string; D10's `{{ label }}` is the rendered TITLE
FIELD, so it arrived wrapped in
`<span class="field field--name-title field--type-string …">`. Changed to
`{{ node.label }}`. Nothing styles that wrapper - this is markup parity, not a
visual fix, and it is recorded as such.

## Verified

Rendered both sites headlessly at 1600×1500 and compared: filter bar inline and
centred at the same width, sub-heading centred, three columns with the same
vertical gaps, and the same nine courses in the same order. Ancestor chain now
identical to D7:

```
.view.view-courses > .view-header > p.sub-heading
                   > .view-filters > form > .views-exposed-form > .views-exposed-widgets
                   > .view-content > #views-bootstrap-grid-1.views-bootstrap-grid-plugin-style
                                   > .row > .views-row.col-xl-4…
```

`/courses`, `/courses/georgia`, a filtered query, page 2, `/blog`, `/faq`, `/`
and `/about` all return HTTP 200 with **zero** errors logged. D7 unchanged at
its baseline 11 modified files.

⚠️ Two harmless markup differences left, both recorded rather than chased:
D10's form element carries `views-exposed-form` in addition to the inner div
(all 96 rules using it are descendant selectors, so they resolve identically),
and D10's EVA wrapper adds a `view-eva` class D7 does not emit.

---

# H-70 · Course detail page - the disabled Add to cart / Buy Now buttons

Reported as "buttons show disabled". They were not styled wrong. **The page was
correctly reporting that nothing on it was purchasable**, and behind that sat
five separate defects.

## Why they looked disabled

`cetc_d10/js/script.js`, carried over from D7 unchanged:

```js
if ($(".commerce-add-to-cart .btn-danger:not(.disabled)").length == 0) {
  $(".add-all-to-cart").addClass("disabled");
  $(".buy-all-to-checkout").addClass("disabled");
}
```

The course-level buttons disable themselves when no per-video add-to-cart form
exists. D10 rendered **zero** (`commerce-add-to-cart`: D7 2, D10 0), so the JS
did exactly what it was written to do.

## 1. The product graph (H-67, finally executed)

The delete step blocked by the permission classifier ran this session.

| | before | after |
|---|---|---|
| products | 168 artifacts | **162** |
| product → variation links | 132, **0 with matching titles** | **162, all 162 matching** |
| orphaned variations | 30 | **0** |
| order items dangling | 0 | 0 |

Product ids now preserve D7's own (18, 19, 20…), including the three
`product`-type products (30, 53, 103) whose bundle conflict made in-place
recycling impossible.

**Financial fingerprints byte-identical to the pre-repair baseline**, verified
again after the cart testing below:

```
order items 113,425   824f68593bb5ac51a4207c92e373972f
orders       87,640   be425e171d5423181ddfbfca5d68736a
variations      162   6c42745077e550626c925cbee8b1addb
```

## 2. The EVA passed the wrong node

`add_to_cart_video_field` used `argument_mode: token` with `[node:nid]`. The EVA
is attached to the VIDEO node but rendered INSIDE the course page, so the token
resolved to the **course** (719) and never the video (718). The view looked for
a variation selling video 719, found none, and returned 0 rows from a perfectly
correct query. Changed to `id`, which passes the attached entity's own id -
EVA's own default, and D7's semantics.

## 3. Query access rewrote the query to match nothing

Even with the graph fixed, the SQL carried `AND ((1 = 0))` -
`entity/src/QueryAccess/ViewsQueryAlter.php:124` adds it when access conditions
are always false. Commerce 3 gates products on the per-bundle permission
`view <bundle> commerce_product`, and **anonymous held no commerce permission at
all**.

🛑 D7's permission table does not grant this either - `view any commerce_product
entity` belongs only to CE Admin and Instructor. D7 still shows the form to
anonymous, because **D7 Commerce never enforced product view access at the query
level**. The D7 row records what its admin UI checks, not what visitors could
see. Copying it literally would reproduce D7's configuration while contradicting
D7's behaviour. View-only granted; create/update/delete untouched.

## 4. The whole product was rendering where D7 renders one field

D7 uses `row_plugin: fields` with a single `add_to_cart_form` field. Commerce 3
has **no Views field for it** - it is a field FORMATTER on `variations` - so the
D10 view renders the product entity in a view mode, and with no display
configured it showed title, price and variations where D7 shows a button.

Configured `commerce_product.video_product.node_teaser` to show only
`variations` through `commerce_add_to_cart`. Commerce still injected the
variation's own price fields (`injectVariationFields`), so
`ce_core_commerce_product_view_alter()` strips `variation_*` from **this view
mode only** - the product type's setting is left alone so cart and order pages
keep their prices.

## 5. Both routes 404'd

`/add-all-to-cart/{node}` and `/buy-course-checkout/{node}` were rendered in the
markup but never ported from D7's `course_actions` module. Implemented in
`ce_course` (previously a skeleton), reproducing D7 exactly:

* **Add to cart** - excludes what the cart already holds, refuses with
  *"You Already bought this course, Buy another course."* when the user has a
  **completed** order for it (`completed` only - `checkout_complete` is
  deliberately a different state, see ce_commerce_order.yml), then returns the
  `showPopupAfterAddProduct` command plus D7's "Added to cart" badge swap.
* **Buy Now** - 🛑 **empties the cart first**, which is D7's behaviour, not an
  accident: Buy Now buys *this* course and nothing else.

⚠️ **`commerce_checkout` was not installed.** Buy Now cannot work without it and
D7 has 2,513 orders in checkout states, so it was enabled - the same call as the
approved `commerce_cart`. Payment gateway configuration remains the open item it
already was. Both sites now 302 to `/checkout` identically.

⚠️ D7 also issues an AJAX insert against `.block-course-actions`. That element
exists on **neither** site's course page, so it is a no-op in D7 and is not
reproduced - adding a header-cart refresh D7 does not perform would be inventing
behaviour.

## 6. A theme JS error that was killing the closure

```js
Drupal.ajax.prototype.commands.showPopupAfterAddProduct = …   // D7 API
```

Drupal 10 has no `Drupal.ajax.prototype.commands` - `Drupal.ajax` is a factory
whose prototype is bare, and commands dispatch from
`Drupal.AjaxCommands.prototype` (core/misc/ajax.js:1306). This threw
`TypeError: Cannot set properties of undefined` on **every course page**, so the
popup never registered and every behaviour defined below it silently stopped
running. Fixed to the D10 API.

## Verified

Course detail page: **18/18 markers match** D7 - `commerce-add-to-cart` (2),
`commerce-video-add-to-cart`, `btn-danger`, row classes, the video list, prices
and teaser markup. Rendered both sites headlessly: **both buttons enabled on
both sites**. `/add-all-to-cart/719` returns 200 and the cart really receives
the items; `/buy-course-checkout/719` 302s to `/checkout` on both.

⚠️ The four draft carts created by that testing (ids 87820-87823, all above the
highest migrated order id 87819 and absent from the migrate map) were deleted,
and the fingerprints above re-verified afterwards. Zero errors logged. D7
unchanged at its baseline 11 modified files.

---

# H-71 · Cart page

D10's cart was Commerce 3's stock five-column table. D7's is a three-column
table with a progress indicator above it and a course grid below. **12 of 14
markers now match**, with both remaining differences understood and deliberate.

## What was missing

| | D7 | D10 before |
|---|---|---|
| checkout progress indicator | 5 steps | absent |
| "Shopping cart" heading (view header) | yes | absent |
| table columns | 3 (title, total, delete) | 5 + header row |
| delete control | trash glyph, empty button | red "Remove" button |
| total | `line-item-summary` / "Total:" | Commerce Subtotal + Total |
| "Take your career…" course grid | yes | absent |

## The cart view

D7 overrides `commerce_cart_form` in its own database:
`css_class "info-content table-info"`, a header area carrying
`<h1 class="h2 text-danger">Shopping cart</h1>`, table style with
`row_class "bg-info"`, and a `line_item_summary` footer with quantity OFF.
Its `commerce_unit_price` and `edit_quantity` fields are **excluded**, not
deleted - which is why the rendered table has three columns and the class
`cols-0` (every remaining label is empty, so Views omits the header row).

Commerce 3's fields map on cleanly: `line_item_title` → `purchased_entity`,
`commerce_total` → `total_price__number`, `edit_delete` → `remove_button`.

🛑 **Views renders table columns in the ORDER OF THE `fields` ARRAY**, not by
the `weight` key - weights only drive the admin UI's drag handles. Setting
weights alone left the row as title | delete | price, with the trash button
before the amount. The array itself has to be reordered.

⚠️ `purchased_entity` defaults to rendering the whole VARIATION, which pulled in
its price field - the first cell read "Price: $29.00" where D7 reads the course
name. Switched to the label formatter, unlinked: D7 configures `make_link` with
`[commerce_display_path]`, but that token is empty for these line items and D7's
cell is plain text.

⚠️ Quantity is **excluded, not removed** - D7 does the same, and Commerce's cart
form still needs the element for its own submit handlers.

## The total, and the delete button

`commerce-order-total-summary.html.twig` restores D7's
`line-item-summary` / `line-item-total-label` "Total:" shape, which is what the
migrated CSS selects on. 🛑 **Adjustments are still printed** - D7's summary
shows only a total because these carts carry none, but Commerce 3 puts
discounts, fees and tax in `totals.adjustments`, and dropping them would hide
money from the customer on any order that has them. The Subtotal line is not
reproduced: D7 has none, and with no adjustments it merely repeats the total.

The remove button is reshaped, not replaced - `#name` and `#submit` stay exactly
as Commerce built them, and only the label, classes and the `fi-rr-trash` glyph
change (the icon font was migrated with the theme; 355 `fi-rr-*` rules).

## The progress indicator

🛑 **Commerce's own block cannot render on `/cart`.**
`CheckoutProgressBlock::build()` opens with
`$order = $this->routeMatch->getParameter('commerce_order'); if (empty($order)) return [];`
so it only produces output on `/checkout/{order}/{step}`. D7 places its
indicator on **`cart` AND `checkout/*`** (weight -22) and resolves the cart
itself, which is why placing Commerce's block changed nothing.

`ce_commerce`'s `ce_checkout_progress` block reproduces D7's markup exactly,
because the theme's CSS is written for D7's structure -
`.block-commerce-checkout-progress ul li:before` draws the dot and `:after` the
connecting line. ⚠️ `checkout-pages-5` encodes the step count for that CSS, so
it is derived from the list rather than written as a literal.

⚠️ **D10 names block wrapper classes after the providing module**, so a block
reimplemented in `ce_commerce` came out as `block-ce-commerce
block-ce-checkout-progress` - correct for D10 and invisible to every D7
selector. `cetc_d10_preprocess_block()` adds the D7 class alongside D10's own
rather than replacing them.

## Related courses

Built `views.view.related_courses` from D7's `block_1`: pager `some` 3 items,
`row: entity:node` in teaser, header carrying the `cart-popup-head` heading,
filters status + type=course + all eight state term ids (copied verbatim; every
id survived the migration unchanged) + `field_ce_or_non_ce = non-ce`. It uses
the same views_bootstrap grid as the course listing, so it reuses that wrapper
template and the same card CSS. Placed on `/cart` only, at D7's weight.

## A mistake worth recording

`configure_cart_view.php` writes ACTIVE config. Running `drush config:import`
afterwards - for an unrelated view - **silently reverted every one of its
changes**, because `config/sync` still held the stock Commerce cart view. The
page went back to a five-column table with no heading while the script still
reported success. The fix is ordering: run the script, then `config:export`, so
the two can never disagree. Re-applied and exported.

## Verified

`/cart` matches D7 on **12/14 markers** - progress indicator, "Shopping cart"
heading, three-column table with `bg-info` rows, the trash glyph, "Total:", the
`cart-popup-head` heading and a three-card course grid (`views-row col-xl-4` 3=3).

Both remaining differences are deliberate:
* `checkout-progress` D7=3, D10=4 - D10's wrapper carries its own
  `block-ce-checkout-progress` in addition to the restored D7 class.
* `delete-line-item` D7=2, D10=1 - D7's button uses that string as both a class
  and its form element NAME (`delete-line-item-0`). Commerce 3 names it
  `delete-order-item-0`, and that name is what its submit handler binds to;
  renaming it to match D7 would break removal.

`/cart`, `/courses`, the course detail page, `/courses/georgia`, `/blog` and `/`
all return HTTP 200 with zero errors logged. The two draft carts created while
testing (87824-87825, above the highest migrated order id) were deleted and all
three financial fingerprints re-verified identical to baseline. D7 unchanged at
its baseline 11 modified files.

---

# H-72 · Cart button behaviour and checkout entry

Three reported faults, three unrelated causes.

## 1. The popup never opened - Fancybox was never loaded

D7 attaches it from a CDN, per course node, in `course_actions_node_view()`:

```php
if ($node->type == 'course' && $view_mode == 'full') {
  drupal_add_js('https://cdn.jsdelivr.net/npm/@fancyapps/ui/dist/fancybox.umd.js', ['type' => 'external']);
  drupal_add_css('https://cdn.jsdelivr.net/npm/@fancyapps/ui/dist/fancybox.css', ['type' => 'external']);
```

**D10 loaded it on no page at all.** The theme's `script.js` registers
`showPopupAfterAddProduct` and calls `Fancybox.show(...)`; with the library
absent the command threw and the click did nothing visible. Reproduced as a
`ce_course/fancybox` library attached with D7's exact scope - `course` nodes,
`full` view mode.

⚠️ Same CDN, same major version: the theme's CSS is written against Fancybox 5's
DOM (`.fancybox__content`, `.fancybox__content .cart-close-btn`,
style.css:2555+), so a different major would render an unstyled dialog.

⚠️ Also corrected the AJAX command to D7's: `ajax_command_html` sets the
element's **innerHTML**, so `HtmlCommand` - not `ReplaceCommand`, which would
remove the `<form>` and with it the `commerce-add-to-cart` class that the page's
own JS counts through.

## 2. Buttons stayed enabled with the item already in the cart

D7 SOURCE: `course_actions_form_alter()` -

```php
if (in_array($form['product_id']['#value'], $order_products)) {
  $form = ['added_to_cart' => ['#markup' =>
    '<span class="btn btn-danger disabled added-to-cart">Added to cart</span>']];
}
```

Measured on D7 with the item in the cart, **the `<form>` is gone entirely**:

```html
<span class="commerce-video-add-to-cart commerce-add-to-cart">
  <span class="btn btn-danger disabled added-to-cart">Added to cart</span>
</span>
```

🛑 **This is also the mechanism that disables the course-level buttons.** The
page counts `.commerce-add-to-cart .btn-danger:not(.disabled)`; the badge
carries `btn-danger` **and** `disabled`, so it is excluded, the count reaches
zero, and both links disable themselves. Nothing marks them disabled
server-side - they are plain links in the markup in both states.

Reproduced in `ce_core_commerce_product_view_alter()` rather than a form alter:
the form is built by the `commerce_add_to_cart` **formatter**, so emptying a
form and leaving its `<form>` wrapper would not match D7's output.

🛑 **The render must vary by cart or the badge never appears.** Without
`$build['#cache']['contexts'][] = 'cart'` the first (empty-cart) render was
cached and served back after the item was added - the form kept showing and the
buttons stayed enabled. The context is declared **unconditionally**: one added
only on the branch that replaces the form cannot influence the cache entry
written by the other branch.

A `commerce-product--video-product--node-teaser.html.twig` override drops the
`<article>` wrapper, which D7 has no counterpart for.

## 3. Buy Now landed on a login gate

D7 lands on `/checkout/87828` - the checkout **form**, for an anonymous
visitor, with the account fields inline. Its own form alter proves it:
`$form['account']['login']['mail']` is a section of
`commerce_checkout_form_checkout`, not a page in front of it. D7's progress
indicator agrees: Cart, Checkout, Review order, Payment, Checkout complete -
no "Login".

Commerce 3's default flow puts the `login` pane on its own `login` **step**.
Moved onto `order_information`.

⚠️ **Moved, not disabled.** Disabling would remove the login/register/guest
choice, and D7 offers all three on that page (its theme toggles `.register-div`
between them). Moving keeps the choice and removes only the extra page.

⚠️ The URL still carries a step - D7 links `/checkout/87828`, Commerce 3's route
is `/checkout/{order}/{step}`, so D10 lands on
`/checkout/{order}/order_information`. That is Commerce 3 routing, not
configuration; both arrive at the checkout form.

## Verified in a real browser, both sites

| | D7 | D10 |
|---|---|---|
| empty cart - Add to cart / Buy Now | enabled | **enabled** |
| empty cart - per-video button | present | **present** |
| item in cart - Add to cart / Buy Now | DISABLED | **DISABLED** |
| item in cart - "Added to cart" badge | present | **present** |

Rendered with Chrome headless and a real session on each site, so the JS
actually ran. The AJAX response carries `showPopupAfterAddProduct` (with the
course block and the related-courses grid) plus the `insert` against
`form.commerce-add-to-cart`, matching D7's command list minus its no-op insert
against `.block-course-actions`, which exists on neither site's page. Page load
produces no JS errors. Buy Now now reaches
`/checkout/{order}/order_information`.

⚠️ **Still not reproduced:** D7 also gives the PER-VIDEO add-to-cart button its
own AJAX submit (`ce_add_to_cart_ajax_submit`), which shows the same popup.
D10's per-video button does a normal POST, so the end state is identical - badge
shown, both buttons disabled - but without the dialog. Recorded rather than
left silent.

Test carts created during this work were deleted (ids above the highest
migrated order id 87819) and all three financial fingerprints re-verified
identical to baseline: `824f6859…`, `be425e17…`, `6c427450…`, 87,640 orders and
113,425 order items. Zero errors logged. D7 unchanged at its baseline 11 files.

---

# H-73 · Cart page layout, the delete control, and the popup

## The popup - the real cause, and my earlier fix was incomplete

H-72 fixed the API name (`Drupal.AjaxCommands`, not `Drupal.ajax.prototype.commands`)
and added the missing Fancybox library. Both were necessary. Neither was enough.

The theme's `global` library declares `core/jquery`, `core/drupal`, `core/once`
- **and not `core/drupal.ajax`**, which is what defines `Drupal.AjaxCommands`.
Without the dependency Drupal was free to order that file AFTER the theme's, and
it did. Measured on the course page:

```
aggregate #4  uses showPopupAfterAddProduct
aggregate #5  defines Drupal.AjaxCommands     <- loaded AFTER
```

So the assignment ran against `undefined`, threw
`TypeError: Cannot read properties of undefined (reading 'prototype')`, and
killed the rest of the closure - the command was never registered and every
behaviour below it stopped running. **The symptom was identical to the bug H-72
fixed, which is what made it look already-solved.**

D7 did this explicitly: `course_actions_form_alter()` calls
`drupal_add_library('system', 'drupal.ajax')`, which is why `misc/ajax.js` is
ninth in D7's script list and `script.js` last. Dependency added; measured
after: both now land in aggregate #4, with `AjaxCommands` defined at byte 25436
and used at 117197. No JS errors on page load.

## The delete button "not working" was the same bug as its appearance

D7's CSS makes the button invisible and lays it OVER the trash glyph:

```css
.page-cart … .commerce-line-item-views-form .table-responsive
  table.views-table tr .views-field-edit-delete > span      { position:absolute; inset:0; display:flex; z-index:-1 }
.page-cart … .views-field-edit-delete .delete-line-item     { background:transparent; border:0; color:transparent; width:40px; height:40px }
```

D10 emitted **none** of the four hooks those selectors need:

| D7 | D10 was |
|---|---|
| `commerce-line-item-views-form` | `views-form` (hard-coded in `ViewsFormMainForm::buildForm()`) |
| `table.views-table` | `table table-hover table-striped` |
| `views-field-edit-delete` | `views-field-remove-button` |
| `views-field-line-item-title` / `views-field-commerce-total` | `views-field-purchased-entity` / `views-field-total-price__number` |

So the button never went transparent: it rendered as a **white box beside the
trash icon**, and clicking the icon hit nothing. The button itself was fine all
along - verified by POSTing the form directly, which removed the item and
emptied the cart. Restoring the classes fixed the appearance and the click
target together.

⚠️ **Keyed on the CSS-cleaned field name, not the Views field id.**
`template_preprocess_views_view_table()` puts `$variables['fields'][$field]`
into `column.fields`, already run through the identifier cleaner - the template
sees `purchased-entity`, not `purchased_entity`. Keying on the raw id matched
nothing and added no class, silently, twice.

## "Continue to checkout" alignment

Came from a D7 **module** stylesheet with no Commerce 3 counterpart,
`commerce/modules/line_item/theme/commerce_line_item.theme.css`:

```css
.commerce-line-item-views-form .commerce-line-item-actions { text-align: right }
.line-item-summary { text-align: right; margin-bottom: 1em }
```

The theme's own style.css *assumes* these exist - it styles `.line-item-summary`
and `.commerce-line-item-actions` further without ever setting their alignment,
because in D7 the module already had. Copied to
`css/commerce-line-item-d7.css`, in the **`component`** group so it loads before
style.css exactly as a module stylesheet does. Three of D7's rules address
markup Commerce 3 does not produce and were omitted rather than copied blind.

## Cart items disappearing - NOT REPRODUCIBLE, and here is what was checked

Tested every path that could empty a cart, with an item in it:

| | result |
|---|---|
| `commerce_cart` cart_expiration setting | **not configured** (Commerce skips expiry entirely) |
| `drush cron` (runs `commerce_cart`'s CartExpiration queue) | item survived |
| `OrderRefresh::refresh()` called directly | item survived |
| customer request with `changed` pushed 1200s back, forcing the 300s refresh | item survived |
| session lifetimes | **identical** to D7 (gc 200000 / cookie 2000000) |
| our own code | only `buyNow()` empties a cart, which is D7's behaviour |

The mechanism that *can* remove items is `OrderRefresh` +
`AvailabilityOrderProcessor`, which **deletes** order items whose purchased
entity is unavailable. That is consistent with what was reported: before H-67
every variation pointed at a wrong or non-existent product, and a variation
with no parent product is not purchasable. The graph was repaired earlier this
session, and the behaviour no longer occurs on any path above.

🛑 Recorded as *not reproduced* rather than *fixed* - if it recurs, that
conclusion is wrong and the availability path is where to look.

## Verified

`/cart` matches D7 on **17/17 markers**. Rendered both carts in a real browser
session: full-width row with the price right-aligned and the trash icon at the
far right, no white box, "Total:" and "Continue to checkout" both right-aligned,
progress indicator above and the course grid below. Delete confirmed to actually
remove the item. All pages 200, zero errors, config exported, test carts deleted
and all three financial fingerprints identical to baseline (87,640 orders /
113,425 items). D7 unchanged at its baseline 11 files.

---

# H-74 · "There are no payment gateways available for this order"

**This is not a defect. It is decision 4 working as approved**, and it needs a
business decision to lift - not a code change.

## Why D10 shows it and D7 does not

| | D7 | D10 |
|---|---|---|
| `commerce_payment_authnet_aim` rule | **active**, `txn_mode = live`, `txn_type = auth_capture` | — |
| `commerce_payment_example` rule | active | — |
| gateway config entities | n/a (Rules-based) | `authnet_aim`, `commerce_payment_example` — both **status = FALSE**, plugin `manual` |
| `commerce_authnet` module | in use | present in the codebase, **not enabled** |
| gateway plugins available | Authorize.Net AIM | `manual` only |

D7 processes cards through a live Authorize.Net AIM rule. D10 has no enabled
gateway, and Commerce's checkout says so.

The two D10 gateways exist **only to keep 26,797 historical payments pointing at
a real gateway id**. `create_payment_gateways.php` records the reasoning:
status FALSE so nothing can charge through them, and plugin `manual` because
`commerce_authnet`'s plugin builds an API client at load time and would fail to
load without credentials, taking all 26,772 records with it.

Decision 4 also states the example gateway **must never become an active D10
gateway** while its 25 historical transactions still migrate.

## Where checkout actually stops

Measured: Buy Now reaches `/checkout/{order}/order_information` (HTTP 200). The
`login` and `contact_information` panes render; `payment_information` does not,
and the message appears in its place. Everything before payment works.

## ⚠️ A correction to the migration record

`MIGRATION_EXECUTION_2026-08-26.md` states "No credentials were invented — **D7
holds none**." That is wrong. D7's `rules_config` row for
`commerce_payment_authnet_aim` carries a populated `login` (8 chars) and
`tran_key` (16 chars). They were correctly never copied - the claim about what
was *done* stands - but the claim about what D7 *contains* does not, and anyone
planning the gateway work needs to know the credentials are there.

🛑 The values were not printed, logged, or written anywhere. If a real gateway is
ever configured, they belong in `settings.local.php` beside the Mailchimp key -
never in config/sync, and never in Git.

## 🛑 NOT ACTED ON - four options, none of them mine to pick

1. **Enable `commerce_authnet` with D7's live credentials.** Matches D7 exactly.
   🛑 It also makes THIS environment able to charge real customer cards
   (`txn_mode = live`, `auth_capture` = immediate capture), and copies a live
   credential the migration deliberately did not.
2. **Enable `commerce_authnet` in test mode** with Authorize.Net *sandbox*
   credentials. Checkout works end to end, no real charges. **Recommended for
   continuing this work.**
3. **Enable the `manual` gateway.** Checkout completes and **takes no money** -
   customers would receive courses free. Not advisable outside a throwaway
   database, and it makes the example gateway live, which decision 4 forbids.
4. **Leave as-is.** Everything up to payment is verified; checkout stops there.

Nothing was changed. Both gateways remain `status = FALSE`, all 26,797 payment
records intact (authnet_aim 26,772 · commerce_payment_example 25), orders 87,640
and order items 113,425 unchanged.

---

# H-75 · Test-mode Authorize.Net gateway (option 2)

Requested: option 2, test mode, reusing D7's sandbox details if any exist.

## 🛑 D7 HAS NO SANDBOX CREDENTIALS. Searched exhaustively:

| where | result |
|---|---|
| `rules_config` `commerce_payment_authnet_aim` | active, **one** credential set, `txn_mode = live` |
| `rules_config` `commerce_payment_authnet_acceptjs` | inactive, **no credentials at all** |
| `variable` table, `%authnet%` / `%commerce_payment%` | no rows |
| any `rules_config` containing `developer` / `live_test` | none (the one `developer` hit is an unrelated discount rule) |

And they would not have helped anyway: Commerce 3 maps `mode: test` to
Authorize.Net's **sandbox host** (`OnsiteBase.php` -
`'sandbox' => $this->getMode() == 'test'`), and sandbox credentials are issued
per developer account. D7's LIVE keys cannot authenticate against sandbox.

🛑 **D7's live credentials were deliberately not used.** They exist (login 8
chars, tran_key 16 chars) and D7 runs `txn_mode = live` with
`txn_type = auth_capture` - immediate capture of real cards. Putting them in a
development environment would let it charge real customers. Verified afterwards
by scanning the entire D10 tree for both values: **0 occurrences**.

## What was created

A **third** gateway, `authnet_test` - plugin `authorizenet_acceptjs`,
`mode: test`, enabled.

🛑 **The two historical gateways were not touched.** `authnet_aim` carries
26,772 payment records and `commerce_payment_example` 25; re-pointing either at
a real plugin would change the plugin under those records, and decision 4
forbids the example gateway becoming live. Both remain `status = FALSE`,
plugin `manual`, and all 26,797 payments still resolve.

| gateway | plugin | mode | status |
|---|---|---|---|
| `authnet_aim` | manual | — | disabled (history) |
| `commerce_payment_example` | manual | — | disabled (history) |
| **`authnet_test`** | authorizenet_acceptjs | **test** | **enabled** |

## Result

The error is gone. `/checkout/{order}/order_information` now renders the
Customer pane, Billing information, the Payment information pane with the credit
card element, and "Continue to review".

⚠️ **Card submission will fail at the API until real sandbox keys are supplied.**
The configuration carries obvious placeholders
(`SANDBOX_API_LOGIN_REQUIRED` etc.), which authenticate against nothing. Mode is
`test`, so every request goes to the sandbox host - it cannot charge a real card
even if the keys were real.

**To finish:** free sandbox account at developer.authorize.net, then put the keys
in `web/sites/default/settings.local.php` - **not** config/sync, **not** Git -
exactly as the Mailchimp key is handled:

```php
$config['commerce_payment.commerce_payment_gateway.authnet_test']['configuration']['api_login'] = '…';
$config['commerce_payment.commerce_payment_gateway.authnet_test']['configuration']['transaction_key'] = '…';
$config['commerce_payment.commerce_payment_gateway.authnet_test']['configuration']['client_key'] = '…';
```

## ⚠️ The checkout PAGE still differs from D7 - separate from this

Now that it renders, it can be compared, and it does not match yet:

| | D7 | D10 |
|---|---|---|
| page title | Checkout | Order information |
| progress indicator | ✔ | ✔ (3=3) |
| Customer pane | ✔ | ✔ |
| Billing information | ✔ | rendered inside Payment information |
| cart contents pane | `cart-contents` ×2 | absent |
| continue button | "Continue to next step" | "Continue to review" |

Not addressed here - this task was the gateway. Recorded so it is not mistaken
for done.

Orders 87,640 / items 113,425 and all three fingerprints unchanged; zero errors;
D7 unchanged at its baseline 11 files.

---

# H-76 · Checkout page design

D10's checkout was a single full-width column with the submit button at the top.
D7's is two columns with large serif panel headings. **13/13 hooks now present
on both**, and the page title reads "Checkout" on both.

## Panes: D7 has five panels, Commerce 3 had none

D7 renders every pane as a Bootstrap panel fieldset - `cart_contents`,
`account`, `customer_profile_billing`, `commerce_coupon`, `checkout-buttons`.
Commerce 3 renders flat divs, and puts two of D7's five in the **sidebar**
rather than on the page, so D10 showed neither the cart contents nor a coupon
field.

Moved onto `order_information` in D7's order, weights -20 → 10:
`order_summary`, `login`, `contact_information`, `payment_information`,
`coupon_redemption`.

⚠️ **A pane missing from the stored flow config is not a pane that does not
exist.** Commerce falls back to the plugin's own default step, which is how
`payment_information` rendered while absent from the config array and how
`coupon_redemption` stayed in the sidebar. The first version of the script
skipped both as "not present" and changed nothing.

## 🛑 `#theme` beats `#type`

Setting `#type = 'fieldset'` on the panes did **nothing**.
`CheckoutFlowWithPanesBase::buildForm()` sets
`'#theme' => 'commerce_checkout_pane'` on every pane, and an explicit `#theme`
overrides the theme implied by `#type`, so they kept rendering through
`commerce_checkout_pane` as plain divs. Unsetting `#theme` is what let the
panel markup through.

## The two-column layout was entirely an id problem

About 25 rules in style.css are scoped through
`.page-checkout form#commerce-checkout-form-checkout #edit-cart-contents` and
friends - including the pair that makes the page two columns:

```css
#edit-cart-contents { float:left; width:49%; position:sticky; top:160px }
#edit-account, .customer-profile-billing-pane-wrapper, … { width:49%; margin-left:auto }
```

D10's form is `commerce-checkout-flow-multistep-default` and its panes are
`edit-order-summary` / `edit-login`, so **not one rule matched**. Renaming the
form id and the two pane ids to D7's, and adding D7's two wrapper classes,
brought the whole block to life at once.

⚠️ **Safe to rename**: Commerce's checkout AJAX keys off `data-drupal-selector`
and the ADMIN form's `#wrapper_id`, never this id; the pane `#id` exists only as
Commerce's "workaround for core bug #2897377", which needs an id to exist, not a
particular value.

## Two smaller fixes

* **Buttons were at the top.** Commerce leaves `actions` unweighted, so it
  sorted above everything; D7 puts them last. Weight 100.
* **Headings rendered at body size.** D7 styles them via the element name
  (`#edit-account legend`), and D10 emits `div.panel-heading`. `checkout-d7.css`
  restates those rules for `.panel-heading` - values copied verbatim from
  style.css:5784-5792, changing only which element they apply to.

## Page title

D7 titles its checkout pages "Checkout", "Review order", "Payment", "Checkout
complete" - the same words as its progress indicator. D10 used Commerce's step
label, so the page read "Order information".

## ⚠️ Structural differences that remain, by necessity

| | D7 | D10 |
|---|---|---|
| billing vs payment | billing on this page, card details on a later **Payment** step | one `payment_information` pane collecting both |
| contact information | inside D7's `account` pane | its own pane (given the same column width) |
| guest checkout | inline in the Customer panel | Commerce's "Guest Checkout" block |

🛑 `payment_information` was **not** relabelled "Billing information". Commerce 3
collects the card there too - because the gateway sets
`collect_billing_information` - and D7's label would hide that.

## Verified

Rendered both sites in a real browser: two columns with "Shopping cart contents"
left and "Customer" right, D7's 3.5rem serif headings, progress indicator, and
the continue button at the bottom. Title "Checkout" on both. All pages 200, zero
errors, config exported, test carts deleted, all three financial fingerprints
identical to baseline (87,640 orders / 113,425 items). D7 unchanged at its
baseline 11 files.

---

# H-77 · Checkout progress bar, cart summary table, and the checkout URL

## 1. 🛑 The progress bar vanished from BOTH pages - a regression I introduced

H-71 verified it on `/cart`. It later disappeared from `/cart` AND `/checkout`,
with the block still placed, enabled and correctly scoped.

`CheckoutProgressBlock::build()` returns `[]` on any page that is not cart or
checkout, and **an empty render array carries no `#cache` metadata**. The
`route` context was declared *inside* `build()`, so it was attached only on the
pages that already had output. The empty variant was cached with no route
context and then served back everywhere - including the two pages where the
block had been working.

Fixed by overriding `getCacheContexts()` on the plugin, which applies to the
empty variant too:

```php
public function getCacheContexts(): array {
  return Cache::mergeContexts(parent::getCacheContexts(), ['route']);
}
```

⚠️ **This is the third time this session that cache metadata declared inside a
conditional branch failed to cover the branch that returns nothing** - the cart
"Added to cart" badge (H-72) was the same shape. Metadata belongs on the object,
not in the branch.

## 2. The cart summary read "1 x Price: $29.00 $29.00"

D7's `cart_contents` pane renders a four-column table:

| Product | Price | Quantity | Total |
|---|---|---|---|
| 2026-27 TCSG Health and Safety-3 Hour | $29.00 | 1.00 | $29.00 |

Commerce 3's `commerce_checkout_order_summary` view carries three **unlabelled**
fields, and `purchased_entity` used the `entity_reference_entity_view`
formatter - rendering the whole VARIATION, which dragged its own price field in.
The same defect the cart page had (H-71).

Reshaped: labels Product / Price / Quantity / Total, a Price column added
(Commerce's summary has none), `purchased_entity` switched to the label
formatter, and Total right-aligned.

⚠️ **The " x" after the quantity is a VIEWS REWRITE, not a formatter suffix.**
Commerce ships the field with `alter: {alter_text: true, text: '{{ quantity }}&nbsp;x'}`.
Switching the formatter to `number_decimal` fixed the scale (`1` → `1.00`) and
left the `x` in place, because the rewrite is applied *after* the formatter.
Row now matches D7 exactly.

⚠️ Column order again came from the ORDER OF THE `fields` ARRAY, not from
weights - the same trap as the cart table (H-73).

## 3. The checkout URL

D7 links `/checkout/87822`; D10 lands on `/checkout/87851/order_information`.

**D7's URL shape already works** - `/checkout/{order}` returns 302 to the step
URL, so no D7 link breaks. Commerce 3's route is `/checkout/{commerce_order}/{step}`
with `step` **required**, and every redirect Commerce issues between steps
rebuilds it. Making the first step omit the step segment would mean overriding
the route *and* the flow's redirect chain, which carries the payment submit with
it.

🛑 **Not changed.** The page is transient and user-specific - not bookmarked, not
indexed - and a half-match (first load without the step, every later step with
it) would be worse than the honest redirect. Recorded rather than forced.

## Verified

Progress bar present on `/cart` and `/checkout` again. Cart summary table
headers and row identical to D7. Rendered in a real browser: two columns,
"Shopping cart contents" with D7's table on the left, Customer / Payment on the
right, progress indicator above.

Structural differences from H-76 still stand and are still deliberate: Commerce
collects billing inside `payment_information`, `contact_information` is its own
pane, and guest checkout renders as Commerce's block rather than D7's inline
text.

All pages 200, zero errors, config exported, test carts deleted, all three
financial fingerprints identical to baseline (87,640 orders / 113,425 items).
D7 unchanged at its baseline 11 files.

---

# H-78 · The checkout URL, and the card form that did not belong

## The URL now matches D7 exactly

```
D7   http://ceonline.ddev.site/checkout/87822
D10  http://ceonline-d10.ddev.site/checkout/87862
```

⭐ **Commerce's route already accepted D7's shape.** `commerce_checkout.form` is
`/checkout/{commerce_order}/{step}` with **`step: null` as a route default**, and
`CheckoutController::checkoutPage()` already generates the step-less form when it
sends a visitor from the cart:

```php
Url::fromRoute('commerce_checkout.form', ['commerce_order' => $cart->id()])
```

🛑 **The step segment came from ONE redirect**, in `formPage()`:

```php
$requested_step_id = $route_match->getParameter('step');            // NULL
$step_id = $this->checkoutOrderManager->getCheckoutStepId($order, $requested_step_id);
if ($requested_step_id != $step_id) {                               // NULL != 'order_information'
  return new RedirectResponse(...);                                 // -> /checkout/N/order_information
}
```

`ce_commerce`'s controller subclasses Commerce's and renders the resolved step
when no step was named, instead of bouncing. A route subscriber swaps only the
`_controller`; path, access callback and parameter converters stay Commerce's.

⚠️ **The step guard is untouched.** Only the NULL case changed - a request naming
a step it is not entitled to still redirects. Verified: `/checkout/N/payment`
before review still 302s back to `order_information`, and
`/checkout/N/order_information` still returns 200.

I was wrong last time to record this as impractical. It needed one controller
method, not an override of the redirect chain.

## The card form does not belong on D7's checkout page

D7's progress indicator lists **Payment as its own page**, and D7's rendered
checkout page carries no card number, expiry or CVV. Commerce 3 defaults
`payment_information` onto `order_information`, so D10 showed a full credit-card
form there. Moved to the `payment` step - measured: `Card number` now appears
0 times on both sites' checkout page.

## Customer panel

D7 creates the account here (e-mail + password). Commerce ships `login` with
`allow_registration: false`, offering only "Returning Customer" and "Guest
Checkout" - no way to create the account D7 creates. Enabled.

## ⚠️ Tried and reverted: `email_registration_login`

D7 runs the `email_registration` module - the username is auto-generated and
hidden - so its Customer panel asks only for e-mail and password. D10's stock
pane shows a visible **Username** field, plus "Confirm password" and a
password-strength meter that D7 does not have.

`email_registration` **is** enabled in D10 and ships an
`email_registration_login` pane for exactly this. Moving it onto the step and
disabling `login` took the checkout page to a **500**:

```
Error: Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\Login->isVisible()
       (commerce/modules/checkout/…/Login.php:175)
```

🛑 Reverted within the same pass; `login` stays. Losing the whole account section
is worse than showing three fields D7 does not. The three extras are recorded as
a known difference rather than chased into an outage.

## Verified

Checkout page: **13/13 hooks present on both**, URL `/checkout/{order}` on both,
title "Checkout" on both, zero card fields on both, two columns with D7's
four-column cart table on the left. All pages 200, zero errors, config exported,
test carts deleted, all three financial fingerprints identical to baseline
(87,640 orders / 113,425 items). D7 unchanged at its baseline 11 files.

## Remaining differences on this page

| | D7 | D10 |
|---|---|---|
| account fields | e-mail + password | + Username, Confirm password, strength meter |
| guest checkout | not shown | Commerce's "Guest Checkout" block |
| newsletter opt-in | "Subscribe to our newsletter" checkbox | absent |
| billing address | own "Billing information" panel | moved to the Payment step with the card form |

---

# H-79 · The email_registration fatal, and D7's actual Customer panel

I set `allow_registration: TRUE` last pass without reading D7's account pane
first. That pulled Commerce's whole user-register form onto the checkout page -
State radios, License Prefix, License Prefix Zeros, Professional License Number,
"Create new account and continue" - none of which D7 displays. Correcting that
meant reading `course_actions_form_alter()` properly.

## What D7's Customer panel actually is

D7 SOURCE: `course_actions_form_alter()`, `commerce_checkout_form_checkout`.
For an anonymous visitor it adds to `$form['account']['login']`:

| field | visible? |
|---|---|
| `mail` (+ AJAX `check_user_exists_or_not`, wrapper `register-div`) | **yes** |
| `password` "Enter the password you would like to use for logging in." | **yes** |
| `confirm_password` | `#prefix` = `<div class='register-div element-invisible'>` |
| `first_name`, `last_name`, `state`, `license_prefix`, `license_prefix_zeros`, `license_number` | inside that div |
| `primary_phone` | `#suffix` = `</div>` - closes it |
| `newsletter_subscription` (default 1) | **yes** |

🛑 **Everything from `confirm_password` to `primary_phone` is inside
`<div class="register-div element-invisible">` - hidden on first render.** Only
e-mail, password and the newsletter checkbox show. The e-mail AJAX reveals the
rest, and the theme's script.js un-hides them when validation fails.

**So D10 was rendering the right FIELDS with the wrong DISCLOSURE.** And D7 has
no "Guest Checkout" block and no "New Customer" heading at all.

## The `email_registration_login` fatal - diagnosed

```
Error: Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\Login->isVisible()
       (commerce/modules/checkout/…/Login.php:175)
```

`isVisible()` is `return $this->currentUser->isAnonymous();` and `currentUser`
was NULL. A **contrib incompatibility**, not a misconfiguration:

* Commerce **refactored `Login` to property injection** - its `create()` builds
  the instance via `parent::create()` then assigns `currentUser`, `userAuth`,
  `eventDispatcher`, `languageManager`, `entityDisplayRepository`,
  `requestStack` and `credentialsCheckFlood`. It no longer declares a
  constructor taking them.
* `EmailRegistrationLogin` still uses **old constructor injection** and calls
  `parent::__construct(…nine arguments…)`. With no such constructor on `Login`,
  the call lands on `CheckoutPaneBase::__construct()`, which takes **five** and
  **silently discards the other four**. Nothing errors at construction; the pane
  has no services, and the first method to touch one dies.

🛑 **Contrib is not patched.** `CeEmailRegistrationLogin` extends the contrib
pane and replaces only the wiring - a five-argument constructor replicating
`PluginBase` + `CheckoutPaneBase`, and a `create()` following Commerce's current
pattern. Every behaviour (relabelling to "Email address", resolving an e-mail to
its account, generating the hidden username) is inherited unchanged.

⚠️ PHP cannot skip a generation to reach `CheckoutPaneBase::__construct()`, and
the parent's constructor demands ten arguments, so `new static()` with five
would fatal - hence the replication. **If email_registration is updated, delete
this class and point the flow back at `email_registration_login`.**

## `element-invisible` was styling nothing

D7 core's class (`system.base.css:231`); Drupal 8 renamed it `visually-hidden`.
Measured: **0 occurrences in this theme's CSS**, so the class survived in
migrated markup and in the theme's own JS while hiding nothing - which is why
the registration sub-form rendered expanded. Copied verbatim into
`checkout-d7.css`.

⚠️ The name matters: script.js calls
`$('.register-div').removeClass('element-invisible')`, so it cannot be swapped
for `visually-hidden`.

## Result

| | D7 | D10 |
|---|---|---|
| Username field | none (hidden, generated) | **0** |
| "Guest Checkout" block | none | **0** |
| "New Customer" heading | none | **0** |
| "Returning Customer" heading | none | **0** |
| registration fields | hidden in `.register-div.element-invisible` | **hidden, same classes** |
| card fields | none (own Payment step) | **0** |

Checkout URL `/checkout/{order}` on both. All pages 200 including
`/user/register`, zero errors, config exported, test carts deleted, all three
financial fingerprints identical to baseline (87,640 orders / 113,425 items).
D7 unchanged at its baseline 11 files.

⚠️ Still not reproduced: D7's `newsletter_subscription` checkbox on this pane,
and its `check_user_exists_or_not` AJAX that reveals the hidden fields once the
e-mail is known. The fields are present and correctly hidden; what is missing is
the trigger that shows them.

---

# H-80 · The newsletter checkbox and the e-mail AJAX

The two items H-79 recorded as unfinished.

## Newsletter checkbox

D7 SOURCE: `course_actions_form_alter()` -

```php
$form['account']['login']['newsletter_subscription'] = [
  '#type' => 'checkbox',
  '#title' => 'Subscribe to our newsletter',
  '#default_value' => 1,
];
```

⚠️ **D7 restores it from the order on rebuild**, so a visitor who unticks it and
then triggers the e-mail AJAX does not get it silently re-ticked:

```php
if (!empty($order->data['custom_account_info']['newsletter_subscription'])) {
  $form['account']['login']['newsletter_subscription']['#default_value'] = …;
}
```

Reproduced, including the restore. `submitPaneForm()` stores the choice the way
D7's submit handler does:
`$order->data['custom_account_info']['newsletter_subscription']`.

## The e-mail AJAX

D7 SOURCE: `check_user_exists_or_not()` (course_actions.module:567), reached
from `$form['account']['login']['mail']['#ajax']` with
`$form['account']['login']['mail']['#suffix'] = "<div class='ajax-msg'></div>"`.

🛑 **This is what makes the hidden registration block reachable.** Without it the
fields stay `element-invisible` for ever and a new customer can never fill them
in - present, correctly hidden, and unusable.

Verified by calling the callback directly against a real account, an unknown
address and an invalid one:

| e-mail | commands emitted |
|---|---|
| **known** | `.register-div` **addClass** `element-invisible` · `.ajax-msg` → "We found an account with this email address. Please sign in below." · `p.help-text` → "You must enter the password that matches our records…" |
| **unknown** | `.register-div` **removeClass** `element-invisible` · `.ajax-msg` → "Email is not in our system. Please try a different email…" · `p.help-text` → "Enter the password you would like to use for logging in." · `removeAttr data-original-title` on the password |
| **invalid** | none - D7 returns the untouched sub-form |

Command list, order and wording are D7's verbatim.

⚠️ **Two deliberate substitutions.** D7's e-mail input is
`account[login][mail]`; here it is `returning_customer[name]`, which
email_registration relabels to "Email address" and which is the single visible
e-mail input. And D7's `removeAttr` targets `#edit-account-login-password` - an
id D10 does not have - so it targets the pane's own password element instead.
It clears the Bootstrap tooltip for the same reason D7 does: the tooltip says
the password must match our records, which is wrong once the visitor is
registering.

⚠️ **`p.help-text` had to be added first.** D7 gives its password field
`'#suffix' => "<p class='help-text'>…</p>"`. The AJAX rewrites `p.help-text` on
every change, so without the paragraph the `HtmlCommand` matches nothing and the
guidance never switches between the two messages.

## Verified

Rendered checkout carries `ajax-msg`, `help-text`, `register-div`,
`element-invisible` and the checked "Subscribe to our newsletter" box - each
once, as D7 does. The AJAX binding is registered in drupalSettings against
`CeEmailRegistrationLogin::checkUserExistsOrNot`. All pages 200, zero errors,
config exported, test carts deleted, all three financial fingerprints identical
to baseline (87,640 orders / 113,425 items). D7 unchanged at its baseline 11
files.

## ⚠️ Still not reproduced

D7 also calls `checkout_complete_subscribe_newsletter()`, which posts the
address to **Mailchimp audience 8006301bcc** with EMAIL/FNAME/LNAME/PHONE at
checkout COMPLETION. That is a live third-party call on a different step, and it
fires against the real audience the moment it is wired up - so it is left for a
deliberate decision. The customer's choice is stored on the order, so nothing is
lost in the meantime.

⚠️ Also noted, not "fixed": in D7 that call sits inside the
`user_authenticate()` branch and is **not gated on the checkbox at all** - an
existing customer who unticks it is still subscribed. If that behaviour is
reproduced, it should be reproduced knowingly.

---

# H-81 · Checkout layout - the panes, and three maps that stopped matching

**14/14 markers now match D7.** Four distinct causes, three of them my own
regressions from switching the login pane.

## 1. 🛑 `commerce_payment` DELETES the billing pane, and I had not noticed

```php
// CommercePaymentHooks.php:28
// The payment_information pane replaces the billing_information one.
unset($definitions['billing_information']);
```

Right for Commerce's model - ONE pane on `order_information` collects the
billing address **and** the payment method. **Wrong for this site**, because D7
splits them across two pages:

```
D7 "Checkout"  ->  Customer + Billing information   (no card fields)
D7 "Payment"   ->  the card
```

Measured on D7's checkout page: `Billing information` 1, `Card number` 0.

So H-78's move of `payment_information` to the Payment step was half right - it
removed the card fields D7 does not show there, and took the billing address
with it. `ce_commerce_commerce_checkout_pane_info_alter()` re-registers
Commerce's own `BillingInformation` class (still shipped, only its definition
discarded), letting each pane sit where D7 puts it.

⚠️ **The module needed `weight = 100`.** At weight 0 my alter ran *before*
commerce_payment's `unset()` and the pane stayed missing - the hook fired and
achieved nothing.

⚠️ **Paired with `collect_billing_information: FALSE` on the gateway**, or the
address is collected twice - here and again on the payment step.

## 2. Three maps still keyed on `login`

Switching to `ce_email_registration_login` broke three lookups at once, all
silently - each keyed a pane that was no longer on the page:

| map | consequence |
|---|---|
| `CE_COMMERCE_D7_CHECKOUT_PANES` | the account panel lost its "Customer" heading and `account` class |
| `$d7_ids` | `#edit-account` never applied, so `width:49%; margin-left:auto` never matched - **the panel went full width and took the newsletter checkbox and billing panel across the page** |
| `$d7_wrappers` | the billing wrapper was still attached to `payment_information`, which had moved to another step |

## 3. 🛑 A wrapper CLASS is not a wrapper ELEMENT

D7's rules are descendant chains across **three** elements:

```css
.customer-profile-billing-pane-wrapper #edit-customer-profile-billing .panel-body label
  { display: block; color:#fff; font-size:14px; margin-bottom:5px }
```

I had put the wrapper class and the id on the **same** element, so `.wrapper #id`
matched nothing and the labels never got `display: block` - which is exactly why
the billing fields rendered with their labels inline beside the inputs. Now
emitted as `#prefix`/`#suffix`, giving D7's three levels.

## 4. Fields D7 does not have

* **"Email address or username"** - `email_registration.settings.login_with_username`
  was TRUE. D7's label is "E-mail address"; set FALSE.
* **"Log in" button and "Forgot password?" link** - measured on D7: both **0**.
  D7's account pane has one submit, "Continue to next step". `#access = FALSE`.
* **"Contact information" pane** - no D7 counterpart; D7 collects the e-mail in
  the Customer panel. Disabled.

## Verified

Rendered in a real browser: two columns, "Shopping cart contents" with D7's
four-column table on the left; Customer, the checked newsletter box, "Billing
information" with **labels stacked above full-width fields**, Coupons and
"Continue to review" on the right. No card fields, no Log in button, no Forgot
link, no Contact information block.

`/cart`, `/checkout`, `/courses`, `/blog`, `/` and `/user/register` all 200,
zero errors, config exported, test carts deleted, all three financial
fingerprints identical to baseline (87,640 orders / 113,425 items). D7 unchanged
at its baseline 11 files.

⚠️ Unchanged from H-80: `checkout_complete_subscribe_newsletter()` (the live
Mailchimp call at completion) is still not wired up, by design.

---

# H-82 · Checkout, compared side by side at 2200px

Captured both sites at the same wide viewport so the newsletter modal did not
cover the columns, then worked through the differences.

## 1. 🛑 Commerce's login-pane COLUMNS were breaking the whole right side

`commerce_checkout.layout.css`:

```css
@media (min-width: 780px) {
  #edit-login { overflow: auto }
  .form-wrapper__returning-customer { float: left;  width: 48% }
  .form-wrapper__guest-checkout     { float: right; width: 48% }
}
```

That is Commerce's two-column "sign in / continue as guest" layout. D7's
Customer panel is a **single** column.

**It caused two separate symptoms that looked unrelated:**

* the Customer fields rendered at half width, cramped on the left; and
* **the Billing information panel flowed up ALONGSIDE the Customer panel**
  instead of beneath it - because Commerce clears that float with
  `#edit-login { overflow: auto }`, and this pane's id is `#edit-account`
  (D7's), so the float was never contained and the account panel's height
  collapsed.

Undone in `checkout-d7.css`. ⚠️ The classes are kept, not stripped:
`.form-wrapper__login-option input` and
`.form-wrapper__returning-customer input[type="password"]` are what give the
inputs `width: 100%`, which is what D7 shows. Only the float is neutralised,
plus `overflow: auto` on `#edit-account` as Commerce does on its own id.

## 2. The cart table had no rules and was not full width

D7's borders and widths are scoped through **`table.views-table`**:

```css
… > div .cart_contents table.views-table { width: 100% }
… table.views-table tbody tr td { border-top: 1px solid #ffffff1a; padding: 1rem 0 }
```

H-73 added that class to the **cart page's** view. The checkout page renders a
DIFFERENT view (`commerce_checkout_order_summary`) through the stock Bootstrap
template, so it emitted `table table-hover table-striped` and none of the rules
matched - no lines, and a table only as wide as its content.
`views-view-table--commerce-checkout-order-summary.html.twig` now emits D7's
`views-table cols-4 …` and the four D7 column classes
(`line-item-title`, `commerce-unit-price`, `quantity`,
`commerce-total views-align-right`).

## 3. "Order total" and "Edit Cart" were missing

D7's checkout summary footer:

```html
<table class="commerce-price-formatted-components …">
  <td class="component-title">Order total</td><td class="component-total">$29.00</td>
</table>
<div id="edit-cart-link"><a href="/cart">Edit Cart</a></div>
```

⚠️ **D7 uses a DIFFERENT total shape on each page**, and both are styled by
name - `/cart` gets `.line-item-summary` with "Total:", `/checkout` gets
`.commerce-price-formatted-components` with "Order total". One shape cannot
serve both, so `commerce-order-total-summary.html.twig` now branches on the
route, with `cetc_d10_preprocess_commerce_order_total_summary()` supplying the
flag and adding a `route` cache context so the two variants do not share an
entry.

Measured after: `/checkout` → "Order total" 1, `edit-cart-link` 1,
`line-item-summary` 0. `/cart` → "Total:" 1, `line-item-summary` 1,
"Order total" 0.

## Verified

Left column now matches D7: full-width table with horizontal rules, the
`Order total` row and the `Edit Cart` link. Right column is a single stacked
column - Customer with full-width e-mail and password, Billing information
below it, City / State / ZIP three across as D7 has them, then Coupons.

All pages 200, zero errors, config exported, test carts deleted, all three
financial fingerprints identical to baseline (87,640 orders / 113,425 items).
D7 unchanged at its baseline 11 files.

## Remaining, measured not guessed

| | D7 | D10 |
|---|---|---|
| e-mail label | "E-mail address *" | "Email address" (email_registration's own wording) |
| coupon button | "ADD COUPON" | "Apply coupon" (Commerce's) |
| address fields | Country, Full name, Address 1, Address 2 | First/Last name, Company, Street address ×2 (Commerce's `address` field, not D7's addressfield) |

---

# H-83 · The three remaining checkout differences

All three from H-82, fixed and measured.

## 1. E-mail label

D7: `E-mail address *`. email_registration relabels Commerce's username field
to "Email address". Set to D7's wording, and `#required` so the asterisk appears
as it does in D7. Measured: "E-mail address" 1, "Email address" 0.

## 2. Coupon wording

D7: label `Coupon Code`, button value `Add coupon` (the theme upper-cases it to
ADD COUPON). Commerce ships "Coupon code" / "Apply coupon". Both set.
Measured: "Apply coupon" 0.

## 3. The address fields

D7 uses `addressfield`, D10 the `address` module, and they do not offer the same
subfields:

| D7 | D10 default |
|---|---|
| Country, **Full name**, Address 1, Address 2, City, State, ZIP code | Country, **First name + Last name**, Company, Street address ×2, City, State, Zip code |

`family_name`, `additional_name` and `organization` are hidden and the rest
relabelled to D7's wording.

⚠️ **D7 HAS ONE NAME FIELD, NOT TWO.** `addressfield`'s `name_line` has no
counterpart in the address module, so `given_name` carries D7's "Full name"
label and `family_name` is hidden - the customer types their full name into one
box, as they did in D7.

⚠️ **`#access = FALSE`, not `unset()`.** The address element validates against
the country's address format and expects its own keys to exist; removing them
outright makes the widget complain about a missing property.

🛑 **THE SUBFIELDS DO NOT EXIST AT FORM-ALTER TIME.** The address element builds
`country_code`, `given_name`, `locality` and the rest inside its own `#process`
callback, which runs *after* `hook_field_widget_single_element_form_alter()`.
Relabelling there silently does nothing; the work is deferred to `#after_build`,
by which point the country's format has been applied.

### 🛑 And the Country field was hidden by a STORE setting, not a label

The store carried `billing_countries: ["US"]`. Commerce passes that to the
address widget, and the element renders the country as a **hidden input** when
only one country is available:

```html
<input type="hidden" name="…[address][country_code]" value="US">
```

The field was present and submitting correctly - it was simply not shown, and no
relabelling could have revealed it.

⚠️ **This is a business setting**, set to what D7 actually did rather than
widened speculatively: D7's country select carries **250 options**, from
"Afghanistan" onwards, because `addressfield` had no restriction and the D7
store had no concept of one. Cleared to the Commerce default (all countries);
D10 now renders 256. If the business wants billing restricted to the US, that is
a decision to take deliberately - knowing D7 did not.

## Verified

Billing labels now read, in order:

```
Country · Full name · Address 1 · Address 2 · City · State · ZIP code
```

identical to D7's, with City / State / ZIP three across. Right column: "E-mail
address", password, help text, billing, "Coupon Code" with an ADD COUPON button,
then Continue to review.

All pages 200, zero errors, config exported, test carts deleted, all three
financial fingerprints identical to baseline (87,640 orders / 113,425 items).
D7 unchanged at its baseline 11 files.

---

# H-84 · Full-page checkout comparison - what marker counts were hiding

The previous passes were verified with marker counts and cropped screenshots.
Compared against a full-page D7 capture, six things were wrong - including a
regression I introduced and did not re-check.

## 🛑 A regression I caused: the page title

H-78 changed the URL to D7's step-less `/checkout/{order}`. The title fix from
H-76 reads the `step` route parameter - which on that URL is **NULL**. No title
matched and the page fell back to Commerce's step label:

```
D7   Checkout
D10  Order information          <- after my own URL change
```

I verified the title when the URL still carried a step, changed the URL, and
never looked again. Now resolves the step through the checkout order manager
when the parameter is absent.

## Field widths - D7's rules key on `addressfield` markup

```css
… #edit-customer-profile-billing .panel-body .street-block .form-item input,
… .form-item-…-address-und-0-name-line input { width: 100% }
```

`.street-block` and that field-item class are Drupal 7 `addressfield` markup.
The D10 `address` module emits neither, so **no width rule matched** and every
billing input fell back to its HTML `size`: "Full name" ~140px, "Address 1"
~300px, City and ZIP narrower still, where D7 spans the column. Restated against
D10's markup, values unchanged.

## The billing heading was never styled - my selector was wrong

I wrote `.customer-profile-billing-pane-wrapper > .panel-heading`. The structure
is **wrapper → pane → .panel-heading**, so the child combinator matched nothing
and "Billing information" rendered at body size next to D7's 3.5rem serif.

## Required asterisks

D7 prints `<span class="form-required">*</span>` inside the label; D10 puts the
class on the label and prints no marker. `user-form-d7.css` had the rule but
deliberately scoped it to `.user-login-page`, noting that widening it needed the
other forms checked first. The checkout has now been compared field by field -
D7 marks E-mail address, Password, Country, Full name, Address 1, City, State
and ZIP code - so the scope is extended to checkout on the same evidence.

## Buttons

```
D7   [Cancel] [Continue to next step]
D10  [Continue to review]
```

Commerce ships one button named after the next STEP and no cancel. Renamed to
D7's wording with its `checkout-continue` class and `edit-continue` id, and
added D7's Cancel.

⚠️ Cancel carries `#limit_validation_errors => []`. D7's cancel abandons the
step; without that the button would demand a valid address before letting the
customer leave.

## "Address 2" had no label

The address module renders `address_line2` with `form-no-label` - it treats it
as a continuation of line 1. Setting `#title` alone was not enough: the label
was in the render array and never printed. `#title_display => 'before'` prints
it, as D7 does. Measured: `form-no-label` now 0 in the billing pane.

## Verified

Billing labels, in order: **Country · Full name · Address 1 · Address 2 · City ·
State · ZIP code**, each with D7's asterisk where D7 has one, all full width,
City/State/ZIP three across. Title "Checkout". Buttons Cancel + Continue to next
step.

All pages 200, zero errors, config exported, test carts deleted, all three
financial fingerprints identical to baseline (87,640 orders / 113,425 items).
D7 unchanged at its baseline 11 files.

## 🛑 NOT VERIFIED - the error state

D7's screenshot shows a red box **inside** the Customer pane and another
**inside** the Billing pane, each with a bullet list, plus a page-level "There
are errors on the page. Please correct them and resubmit the form."

I could not reproduce the error state from the command line - submitting the
form with curl re-rendered it without triggering validation - so **the error
rendering has not been compared**. What is known:

* The theme's `.alert-danger` rules are class-based
  (`background:#b70010`, `::before content:"\f06a"`) and do not depend on D7's
  extra `alert-block` / `messages` classes, so the red box styling should apply.
* Commerce 3 renders pane errors **inline per field plus one messages region**,
  where D7 renders **one box per pane**. That is a structural difference and is
  very unlikely to match without further work.
* D7's summary wording differs from Drupal 10's "N errors have been found".

Recorded as outstanding rather than assumed working.

---

# H-85 · Checkout validation, buttons and the white e-mail field

## 🛑 The big one: validation was SILENTLY SKIPPED

Submitting the checkout blank produced no e-mail/password errors. The cause was
a PHP warning buried in the error box:

```
Warning: Undefined array key "#op" in Drupal\email_registration\…
```

`EmailRegistrationLogin::validatePaneForm()` opens with

```php
if ($triggering_element['#op'] === 'login') { … }
```

Commerce sets `#op` on the login pane's **own** buttons, but the checkout flow's
continue button has none. So every submission raised that warning **and the
comparison failed, skipping the pane's e-mail/password validation entirely**.
A customer could submit with no e-mail address and be told only about the
address fields.

Fixed by giving the flow's buttons an `#op` (`continue` / `cancel`), which is
what Commerce does for its own.

⚠️ **My earlier test harness hid this.** Submitting with curl appeared to do
nothing because the script was picking up the FIRST `form_build_id` on the page
- the newsletter block's, not the checkout form's. The POST was silently
discarded and I read that as "validation does not fire". Scoping the token
extraction to the checkout form is what exposed the real behaviour.

## Errors D7 does not raise

With validation running, D10 reported four fields D7 does not:

* **First Name, Last Name, State-License For, License Prefix, Professional
  License Number** - from the registration block, which is HIDDEN behind
  `.register-div.element-invisible`. D7 sets `#required` exactly ONCE in
  `course_actions_form_alter()` (the password); every other field it adds there
  is optional. Cleared recursively.

* **Last name** - the address `family_name`, which stayed required even after
  `#access = FALSE` and even after `field_overrides` was set to hidden.

  🛑 **A legacy `fields` setting silently beats `field_overrides`.**
  `AddressItem::getFieldOverrides()`:

  ```php
  if ($fields = $this->getSetting('fields')) { … }
  elseif ($overrides = $this->getSetting('field_overrides')) { … }
  ```

  The field carried a `fields` list naming every property, so the overrides were
  never consulted: the subfields vanished from the widget while the constraint
  kept requiring them - a required field the customer could not see or fill.
  Both settings are now updated.

Errors now match D7's seven exactly:

```
E-mail address · Password · Full name · Address 1 · City · State · ZIP code
```

## Buttons

D7's source order is **Continue then Cancel**, and the theme's
`flex-direction: row-reverse` on `.checkout-buttons .panel-body` renders that as
**[Cancel] [Continue]**. My first attempt used `#weight => -10`, putting Cancel
first in the DOM and therefore LAST on screen - the exact opposite of D7, and
what the page was showing. Weight moved after Continue.

Also dropped `btn-primary` in favour of D7's `btn-default`.

## The white e-mail field

Commerce's login pane sets `autofocus` on the first input, so the browser
painted it in its focused state - a solid white bar among transparent
bordered fields. Measured on D7: `autofocus` appears **zero** times. Removed.

## Verified

Blank submission returns D7's seven errors with no PHP warnings; Cancel
redirects to `/cart`; Continue and Cancel render in D7's order with D7's
styling; the e-mail field renders like its neighbours.

All pages 200, zero errors logged, config exported, test carts deleted, all
three financial fingerprints identical to baseline (87,640 orders / 113,425
items). D7 unchanged at its baseline 11 files.

⚠️ Remaining, measured: D7's error box sits INSIDE each pane (one per pane);
Commerce renders a single messages region above the form. D7's input is
`type="text"`, D10's `type="email"` - kept, because it adds browser validation
D7 lacked and the rendering is identical.
