# Consultation form — rev2 **Changes since rev1:** "See it on the client" passes the systems in "Your recommendation" (`o.product = chosen.join(",")`), so the try-on's colour chart shows only the colours the first one comes in. This closes part of the known gap "the suggestion can recommend a product unavailable in the client's shade" (offered colours only; stock still needs an inventory feed). # Consultation form — rev1 in the handoffs repo **What this revision is.** The consultation project (the stylist's consultation form with the AI system suggestion and dictation) moved into the handoffs repo from its own folder (`consultation-handoff`); its earlier git history stays there. Everything below the line is the developer handoff as it was, unchanged, plus one addition: **New: "See It On The Client" (AI try-on).** A section after "System Recommendation" in `hairstylist-form-full-prototype.html`: an AI badge, "Show the client their new hair on their own photo.", "Starts from the answers above…" and a "See it on the client" button. It opens the hair try-on (`../hair-try-on/`, the project next to this one) as a pop-up over the form, in consultation mode, through `../hair-try-on/tryon.js`: - **The form's answers are the try-on's starting choices** (the stylist can change them there): hairpiece type + baldness level → where the new hair goes (full cap → full head, frontal patch → front, crown patch → crown, partial toupee → top, toupee by level: 6–7 whole top, 5 front + crown, 3–4 top, 1–2 front); Coverage Area palm test → area size; Length wanted on top → length (short 2″, 3″–6″ → 3″, longer → 8–10″, very long → 8–10″); Thickness of remaining hair → his own hair; Front pushed back → styled back / switches / falls forward; Sides and back → faded / covered; Hair Texture → Coily = afro, Curly = curly, else straight / wavy; Density → light (Light, Light-Med) / medium / full (Med-Heavy, Heavy). - **Consultation mode:** no customer sign-up step, a staff allowance of 30 looks a day, "From the form" tags, and "Save to consultation", which closes the try-on and adds the look to "Saved to this consultation" in this section (the finished look, its cut or texture, length and time; × removes one). - **Live portal, to do:** - pass the client's Front picture as `photo` (see `tryon.js`), so the try-on opens straight on it; the prototype's picture tiles don't upload, so it starts on "Add a photo"; - save the looks with the consultation record and to the client's profile (the try-on's handoff, "Saved looks in the customer's profile"); - turn on consultation mode from the stylist's staff login on the server, not from the link; - point `tryon.js` at wherever the try-on is hosted. --- # Hair system suggestion — developer handoff **Superhairpieces branch portal. 14 September 2026.** The customer quiz already scores 64 products against a client's answers. This puts the same engine in the consultation, so the **stylist sees a shortlist and recommends from it** instead of scanning a catalogue. Same engine both sides, so the storefront and the portal cannot contradict each other. ``` customer fills the form -> stylist opens the consultation -> stylist recommends (what only they know) (sees 3 systems + why) (adds up to 3) ``` --- ## Read this before you change anything This is going to a live consultation form that stylists use with clients in front of them. Whoever implements it — person or coding agent — these are the parts that bite: 1. **Do not implement straight onto production.** Step 1 is a schema migration that changes the meaning of an existing column (`hair_length`). Work on staging, and see §1 and §2 before touching either column. 2. **`SHP_CONFIG.hairLengthCutover` must be set to the real deploy date.** The engine treats any `hair_length` written before that date as unanswered, so the old option set is never silently reinterpreted as the new one. Leave the placeholder date in and the field appears to do nothing, with no error. 3. **There is one engine: `shp-suggest.js`. Load it, never paste it.** A copy was already made once in this project and drifted out of sync, hiding three bugs until it was found. §8 has the details. 4. **Delete the dev-only block from the prototype** if you reuse any of it: `DEV_PREFILL`, `DEV_PREFILL_MULTI`, and the `Clear form` / `Refill` buttons write a complete fake consultation into the form. 5. **Run `node engine.test.js` before and after your changes.** 28 tests, no key, no network, no DOM. If they fail after a change to `shp-suggest.js`, the change is wrong — that file is the customer quiz's engine and the storefront depends on it behaving identically. 6. **Three rules have failed silently in this codebase already** — R-SWEAT and R-EXPERIENCE from a boolean/string mismatch, R-BASE from a missing entry in `FIELD_MAP`. None produced an error; each simply stopped applying. After wiring, confirm a sweaty-scalp profile returns **no skin bases** before you call it done. The parts you can be relaxed about: §6 is design rationale, not requirements, and §4 (the AI sentence) is optional and should not hold up §1–§3. **§10 is a checkable definition of done.** Work through it before calling this finished — most of its lines catch a specific failure this codebase has already had. --- ## Build it in this order **The two schema changes come first.** Nothing downstream can be tested until they land — the panel correctly refuses and names them. | # | Step | Needs | |---|---|---| | 1 | **Schema: `remaining_thickness` + `hair_length`** | migration, §1 | | 2 | **The two questions on the hairstylist form** | §2 | | 3 | **The panel on the hairstylist form** — rules engine only | §3 | | 4 | **Six questions on the customer form** | `CUSTOMER-FORM-HANDOFF.md` | | 5 | **The AI layer** — optional, last | §4 | Steps 1–3 are the working feature: no API key, no endpoint, no per-consultation cost, no latency. **Do not make the whole thing wait on step 5.** --- ## 1. Schema — the gate on everything **`remaining_thickness`** — new column. String enum `thick` | `average` | `fine`. Nullable, no default. **Do not backfill it.** An unanswered value must stay unanswered: the engine refuses and names the field, which is the correct behaviour for every consultation written before this shipped. A default would make old records look answered and produce confident suggestions from data nobody entered. **`hair_length`** — exists; its option set changes. Today it offers `Short (6-10")` / `Medium (10-14")` / `Long (14-18")` / `Extra Long (18"+)` — extension ranges, on a form where the shortest choice already exceeds what most men wear on top. It also has a live off-by-one: the fourth button renders **"Extra Long"** but stores **`not sure`**, because the enum only holds `short | medium | long | not sure`. New options: `short` (about 1.5–2″), `medium` (3″–6″), `long` (longer than 6″), and optionally `very long` (12″+, a new fifth value the engine understands). **Do not migrate existing rows.** Their stored values stay exactly as they are — the cutover date below is what stops them being misread. Also fix the off-by-one while you are in there: the fourth button must stop storing `not sure`. > **Stamp a cutover date.** `short`/`medium`/`long` change meaning without > changing value, so a row written before the deploy is indistinguishable from > one written after — except by date. Set `SHP_CONFIG.hairLengthCutover` in > `shp-suggest.js` to your deploy date. Earlier consultations then read > `hair_length` as unanswered rather than being silently reinterpreted. ## 2. Two questions on the hairstylist form Both in **Hair & Scalp Assessment**, both required by the engine. **Length wanted on top** → `hair_length` Helper: *"What the client wants on top — not what is there now."* `short` · `medium` · `long` · `very long`. Do **not** offer "Not sure" here — a stylist sitting with the client should not be able to shrug. (The customer form does offer it; see that handoff.) Show a consequence line only for the options that change what happens next: `long` and `very long` are custom orders; `short` means the base edge may show. **Thickness of remaining hair** → `remaining_thickness` Helper: *"Volume of the hair that is still there — not how much is gone. This is what the system has to blend into."* `thick` · `average` · `fine`. `hairstylist-form-full-prototype.html` in this folder renders both. It is a reference, not production: it embeds the whole engine inline and carries a dev prefill block. ## 3. The panel ```jsx import SuggestionPanel from './integration/SuggestionPanel.jsx'; import { useSuggestion } from './integration/useSuggestion.js'; const suggestion = useSuggestion(watchedValues, { useAi: false }); // step 3 ``` Put it in the **System Recommendation** section, above the field the portal currently calls **"Discussed hair system(s)"** — which this work renames to **"Your recommendation"** (§6.3 has the reasoning; `consultation-rules-source.json` carries it as `integration.note_field_rename`). The column does not change, only the label. `maxProducts` is the value you already read off the yup schema — pass it rather than hardcoding 3. The portal is React + Vite with react-hook-form, yup, Tailwind/DaisyUI and Poppins; `SuggestionPanel.jsx` is written against those. Put the three `integration/` files wherever the consultation feature's own modules live and fix the import paths — they are plain ES modules with no build step of their own. `/api/consultation/suggestion-log` does not exist yet; you are creating it. **It writes through `useFieldArray` on `recommendedProducts`, not local state.** That field is `array of SKU strings, max 3` and is required unless `consultantSuggestedCustomHairpiece` is true or `measureMethod` is `No measurement`. A pick added any other way leaves the form's own validation and the "N required left" counter blind to it. This is the detail most likely to get "simplified" later and quietly break. `SuggestionPanel` needs `FormProvider` above it. ### Keyboard navigation in the product search The stylist types a code and should be able to go straight to it without reaching for the mouse. The prototype implements this; the portal's own `recommendedProducts` multi-search needs the same behaviour: | Key | Does | |---|---| | `Down` | next result — and re-opens the list if it was dismissed | | `Up` | previous result | | `Home` / `End` | first / last result | | `Enter` | add the highlighted result | | `Escape` | close the list, keep what was typed | Built as the ARIA combobox pattern rather than roving focus: **the input keeps focus throughout** and points at the highlighted row with `aria-activedescendant`, so a screen reader announces each row as it is stepped through and typing never breaks. The input carries `role="combobox"`, `aria-expanded`, `aria-controls` and `aria-autocomplete="list"`; the list is `role="listbox"` and each row `role="option"` with `aria-selected`. Four details worth copying: - **The first hit is highlighted on open**, so Enter alone adds the obvious match after typing a code. - **Arrowing wraps** at both ends. - **Hover and keyboard drive the same highlight**, so the two never disagree and the row under the mouse is the row Enter will add. - **`Enter` is `preventDefault`ed** when a row is active, so it adds the product instead of submitting the consultation. The list scrolls once there are more than a few hits, so the active row is kept in view with `scrollIntoView({ block: 'nearest' })` — `nearest` rather than the default, which would otherwise jump the whole page on every keypress. ### The save handler ```js const log = shpLogFinalChoice(formToAssessment(values), values.recommendedProducts, { consultationId, consultant, location }); await api.post('/api/consultation/suggestion-log', log); ``` POST it on **every** save, including when the stylist agrees. Agreement is signal the override log alone cannot show, and this payload is the only way anyone will ever know whether the suggestion is any good. ## 4. The AI layer — optional `server/ai-suggest.js`. Two things the matrix cannot do: it reads the free-text fields (the consultant's note, what the client wore before, their goals — filled on 25–50% of consultations and currently read by nothing), and it writes one sentence per pick the stylist can say out loud. ```js app.post('/api/consultation/ai-suggest', requireAuth, aiSuggestHandler(helpers)); ``` Then flip `useAi: true` in the hook. - `ANTHROPIC_API_KEY` goes in the **server** environment. Never in the Vite bundle — `VITE_`-prefixed vars compile into JS that ships to every branch machine. - Keep `requireAuth` on it. An open endpoint is someone else's Claude budget. - **The model proposes; `enforceRules()` decides.** Every response is re-validated against the same hard rules; a skin base for a sweating client, a wrong family, an invented SKU or a fourth pick is dropped before the stylist sees it, and logged in `dropped`. - If the endpoint fails, times out, the key is missing, the model declines, or enforcement empties the shortlist, the panel falls back to the rules result with a `rules only` badge. The consultant is never left with nothing. ## 5. What to expect from it Cross-validated on 1,218 real recommendations: **19% top-1, 36% top-3**, against 12% for always guessing the most popular product. That is not a flaw in the model. Consultants agree with *each other* only about 20% of the time on identical client profiles, and the strongest predictor of a pick is which consultant made it rather than anything about the client. So this is **a shortlist that saves scanning 64 products, not an oracle.** It suggests three, never one, and refuses on a thin form. Do not let it be sold to the stylists as certainty. ## 6. Behaviour and design decisions None of §6 is required to make the feature work — §1–§3 are. These are the interaction and visual decisions taken while building the prototype, each with the measurement that prompted it, so you are not re-deciding them from scratch or undoing them by accident. If you are short on time, §6.7 is the one to read: it is the rule that keeps the AI signal meaningful. ### 6.1 After a pick, take the stylist to the rest of the form Choosing a system is what reveals Colour Preference — it is hidden until at least one system is chosen — and Colour Preference is the last required field before Save. So `addSku()` ends with `revealRest()`, which scrolls the page to the bottom. Three things about it worth keeping when you port it: - It waits ~60ms so the chip has landed and the colour question has unhidden before it measures, otherwise it scrolls to a stale height. - It no-ops when the bottom is already within 8px, so adding a second or third system does not jolt the page. - It honours `prefers-reduced-motion` by jumping instead of animating. Both page scrolls go through **`shpScrollTo(target)`**. Smooth scrolling is driven by rAF, which a browser *pauses* when the tab is not visible — the scroll is dropped, not deferred, and the same happens on any engine that ignores the options form of `scrollTo`. The helper therefore checks 220ms later whether the position moved at all and snaps to the target if it never started. One pixel of movement is enough to leave the animation alone. ### 6.2 The accept animation, and why it is handed across the rebuild Accepting a suggestion lifts the card off the surface and sits it back down: up 7px with the shadow growing to match, then a compression on landing (`scale(.994)`) and one soft rebound. 520ms, `shpLiftSit`. The non-obvious part is where it runs. `refreshAI()` rebuilds the whole panel immediately after an accept, so an animation started on the clicked element is destroyed a frame or two later — which is why the old `.row-flash` never actually finished; it was cut off at 90ms by a `setTimeout` that existed to work around the same problem. The engine now sets `SHP_JUST_ACCEPTED` to the sku and the renderer gives the *rebuilt* row the `row-lift` class, clearing the flag as it does so. The animation therefore plays on the element that survives, once, and does not replay on later repaints. Two details worth keeping: - **No `animation-fill-mode`.** The class stays on the row afterwards, and a retained final keyframe outranks normal declarations — it would have killed that row's `:hover` shadow for the rest of the session. 0% and 100% are both the rest state, so there is nothing to retain. - Shadow animates with the lift. A card that rises without its shadow moving reads as flat. `SuggestionPanel.jsx` has the same animation via `justAccepted` state cleared on `onAnimationEnd`. Like the rest of that file it has never been run — see §8. The **chosen chip** under "Your recommendation" has its own landing, `shpChipLand`: it arrives held 9px above the surface with a shadow, is set down with a compression (`scale(.991)`) and settles after one rebound. Two things separate it from the suggestion card's lift: - Its shadow is **neutral**, not indigo. The chip is the stylist's own record; indigo would claim the model chose it (§6.7). - `animation-fill-mode: backwards`, not `none`. The chip is a brand-new element, so without it there is a frame at full opacity before the 0% keyframe applies. `backwards` supplies the start state without retaining the end state — keeping `.sel-row:hover` alive. It rises 9px into a 14px gap below the heading, leaving 5px, and it is fully transparent at that instant — checked, not assumed. ### 6.3 Hierarchy inside System Recommendation The section had four sub-labels at 12.5 / 17 / 18 / 16px, three of them within 2px and all weight 600 — four peers with nothing saying which mattered. Worse, a suggested SKU and a chosen SKU were both 15px/600, so a product the model was *proposing* looked identical to one the stylist had *committed to*. The section produces one thing — the recommendation. Everything else is either machinery for producing it (the suggestions, the search) or an attribute of it (colour, custom made). The type now says so: | | was | now | why | |---|---|---|---| | Chosen SKU | 15px/600 | **16px/700** | the decision | | Suggested SKU | 15px/600 | 14px/600 | a proposal | | "Your recommendation" | 17px/600 | 18px/600 | 17 was off-system; the form uses 24 for sections, 18 for questions | | Add control | 30px, 3-stop gradient, glow ring | 26px, flat `--ai` | it was the loudest thing in the section | The add control mattered most. A gradient with a glow ring made the *helper's* button outrank the output it exists to produce. Flat fill at 26px is still obviously the action without being the headline. The AI badge on a chip is inverted — indigo on `--ai-soft` rather than white on solid indigo. At chip scale a solid block competed with the SKU beside it; the soft form marks provenance without claiming to be the headline. **Keep the rule when you add controls here:** size and weight should track what the thing *is*, not how new it is. The newest feature in the section is the one most tempting to make the loudest, and it is the one that should be quietest. ### 6.4 Colour is a sub-question of the recommendation Colour Preference used to sit *beside* the recommendation as a peer — both labels 18px/600, and an 18px textarea with 20px padding, which made the attribute as loud as the thing it describes. It is now nested inside `#chosenZone`, after the chips, and stepped down: | | was | now | |---|---|---| | Label | 18px/600 | 14px/600 | | Field | 18px text, 20px padding | 15px text, 14px padding | | Position | sibling of `#chosenZone` | child of it, indented 14px behind a hairline | The section divider moved with it. It used to sit on the colour question, which drew a line *inside* the recommendation group; it now sits on `#chosenZone`, so it separates the whole group from Custom Made. When there are no chips the group adds `.is-empty` and drops the divider, otherwise an empty section leaves a stray rule behind. Custom Made is deliberately left as a peer — it is a fulfilment flag, not an attribute of the chosen systems. **Do not let the colour field grow back to full width.** Before it was capped, it was 906px wide against the search box's 922 — and 1.18x the search box *by area*, so the largest, most inviting empty box on the screen was not the one people meant to click. Mehmet kept hitting it when reaching for the product search. It is now capped at 360px (0.47x), and the search box carries a magnifier so it states what it is before anyone clicks. Two plain text boxes of near-identical size, 237px apart, with no icon between them is the bug; keep them visibly different. ### 6.5 The search results must clear the save bar The results list is in-flow and up to 280px tall. Open it under a search bar sitting low in the viewport and most of it lands under the sticky save bar — measured at **21px of 280 visible**, with the page not scrolling at all. `keepResultsInView()` runs at the end of `paintResults()` and scrolls by exactly the overflow — the list's bottom against the save bar's top, less 12px of breathing room — clamped to the remaining scroll. Scrolling *only* that much matters: jumping to the page bottom would push the search bar the stylist is typing in off the screen. Measured after: 280 of 280 visible, search bar still on screen, and it no-ops when the list already fits. The search box sits above the chosen list and stays on screen at the bottom of the scroll, so adding two more systems still works without scrolling back up. ### 6.6 The AI mark is not a vendor logo The panel originally used a four-point sparkle, a large one paired with a small one. That is Google Gemini's mark and should not appear on a Superhairpieces form. What replaced it was researched rather than drawn. The sparkle became the AI convention partly by elimination — Twitter's design team reached for it because the star was already taken by ratings and a robot was too literal. ShapeOfAI's pattern library lists the working set: **sparkles** for generate, **magic wands** for generate and auto-fill, sparkly pencils for inline edits, and — for *Suggest* specifically — a two-star icon. Its guidance is "evolve toward familiarity, not novelty", which is a direct argument against inventing one. The header now uses **Lucide `wand-sparkles`**, copied from Lucide's source. It is a documented convention for an AI action, it cannot be mistaken for Gemini's spark, and it stays legible at 16px where an outlined three-part sparkle goes muddy. Lucide is ISC-licensed and free for commercial use, so shipping it is clean — prefer pulling it from the icon set your app already bundles rather than keeping this inline copy. It is a **stroke** icon, so the `` carries `fill="none" stroke="currentColor" stroke-width="2"` rather than `fill="currentColor"`. Getting that wrong renders a solid blob. The chip badge carries **no glyph at all**. At 9.5px an icon adds nothing the word "AI" does not already say, and the attempts to make one legible at that size are what produced three rounds of rework. Changed in `shp-suggest.js` (header), the prototype (chip), `panel-demo.html` and `SuggestionPanel.jsx`. If you add a mark anywhere else, check it against the current Gemini, OpenAI, Claude and Copilot marks first. Sources: [ShapeOfAI iconography](https://www.shapeof.ai/patterns/symbols), [how the sparkle became the AI symbol](https://jurgengravestein.substack.com/p/how-the-sparkle-icon-became-the-universal), [Lucide](https://lucide.dev/icons/wand-sparkles). ### 6.7 Colour: what indigo is allowed to mean Measured off the live portal, 14 Sep 2026, by cloning its own controls into a detached node (nothing on the live form was clicked): | Portal token | Value | Used for | |---|---|---| | `btn-neutral` | `#2b2b2b` | **a control the user has chosen** | | `btn-primary` | `#422ad5` | accent — links, progress, emphasis | | base / border | `#f8f8f8` / `#e8e8e8` | the unchosen state | So indigo is on-brand, but the portal never uses it to mean *selected*. **In this feature indigo means "the model said so" and nothing else.** Anything the stylist chose is `--ink` (`#2b2b2b`), the same black as the option buttons. Three rules had this backwards and were corrected — `.bcard.on`, `.chk.on` and `.chk.on .box` were painting the stylist's own answers in the AI colour. If you are tempted to accent a new control with indigo, that is the test to apply. One open item: the prototype's `--ai` is `#5b4bd6`, which is close to but not the portal's `#422ad5`. Aligning them is a one-token change and would be strictly closer to live; it is left as Mehmet's call because it shifts the AI badge. ## 7. Known limits - **Colour and stock.** The suggestion can recommend a product you cannot ship in the client's shade. That needs an inventory feed, not a question. Arguably worth more than anything else on this list. - **`base_type` does not exist** in the schema — verified 14 Sep 2026. The rule that depends on it (R-BASE) is written but unreachable until someone decides it is worth a column. - **Density barely moves the answer.** Swept across 24 client profiles and all five values, the shortlist changed on 2 of 24. The engine reads it, but only the `light` end reaches the output. Ask it — it is real information for the fitting — but do not expect it to change the products offered. - **Women's is out of scope.** The matrix is men's only; roughly 15% of consultations are not. - **The men's baldness illustrations are still the women's set.** A live bug independent of this work: a stylist doing a men's consultation is classifying male pattern loss against female diffuse-thinning diagrams. ## 8. What has been verified, and what has not Be sceptical of the parts marked "not run". | Piece | Status | |---|---| | `shp-suggest.js` — the four fixes | **28 tests pass** on a fresh unzip: `node engine.test.js`. No key, no network, no DOM. | | `hairstylist-form-full-prototype.html` | **Checked in a browser**, and the engine inside it is now the same file you ship. Both questions render, values store as slugs, the consequence line appears only for the long options, no stray "undefined", no fill rates on screen, panel suggests, a chosen system carries the AI badge. | | `integration/formToAssessment.js` | Loaded and exercised — booleans pass through raw, form noise is excluded. | | `integration/SuggestionPanel.jsx` | **Not run.** Written against the portal's classes and the `useFieldArray` contract, never compiled or rendered — there is no React app here to mount it in. Expect to fix small things. | | `integration/useSuggestion.js` | **Not run.** Same reason. | | `server/ai-suggest.js` | **Never called the API.** No `ANTHROPIC_API_KEY` was available in any session that wrote it. The prompt, the schema and `enforceRules()` are written; the round trip is untested. | The prototype had been carrying its **own inline copy** of the engine, made before the four fixes. It was found and replaced on 14 Sep 2026, and three things it had been hiding were fixed with it: - `topLength` read only `top_length`, so renaming the question to `hair_length` silently broke the panel — it sat on "Complete length wanted on top" forever. - `engineValues()` in the prototype mapped the retired `top_length` labels and never passed `hair_length` at all. - The dev prefill block still wrote `top_length` / `"Average"`, so a fresh load never reached the seven required inputs. The lesson for your build: **there is one engine, `shp-suggest.js`.** Load it; never paste it. The prototype now embeds it verbatim and its rules block is regenerated from `consultation-rules-source.json`, so both can be re-derived. A third silent-dead-rule bug was found on 14 Sep 2026 by diffing the fields the engine *reads* against the fields `formToAssessment.js` *forwards*. **`base_type` was missing from `FIELD_MAP`, so R-BASE could never fire** — the same class of failure as the boolean mismatch that killed R-SWEAT, and just as invisible: no error, simply a rule that never applies. It is rare (3.2% filled) but it is a hard constraint when answered, which is exactly when it matters. Fixed, along with `hairline_back`, `side_length` and `area_size` — the three proposed questions beyond §2 — which are now mapped in advance so that adding those questions later is a schema change only. **Worth running that diff again after any change to either file.** The engine reads 21 fields; the mapper should forward all of them except `top_length`, which is the deliberate legacy alias. There is also **no parity suite**. An earlier build compared 1,821 generated client profiles against a reference to prove nothing else moved; that reference no longer exists. `engine.test.js` proves the four fixes behave, not that everything else is untouched. Since the engine was patched in place rather than rewritten, the diff is small and readable — `git log` — but that is an argument, not a measurement. ## 9. Files | File | Ship? | |---|---| | `shp-suggest.js` | **yes** — the engine | | `integration/formToAssessment.js` | **yes** — field mapper | | `integration/useSuggestion.js` | **yes** — the hook | | `integration/SuggestionPanel.jsx` | **yes** — the panel | | `server/ai-suggest.js` | optional, step 5 | | `engine.test.js` | run it: `node engine.test.js` | | `CUSTOMER-FORM-HANDOFF.md` | the customer-form half | | `hairstylist-form-full-prototype.html` | **reference only** — see the warning below | | `consultation-rules-source.json` | the rules, as data — the prototype's rules block is generated from this | | `consultation-logic-spec.md` | the same rules in prose, for whoever is not reading JSON | | `panel-demo.html` | a standalone panel demo, no form around it | **The prototype ships a dev-only block.** `DEV_PREFILL`, `DEV_PREFILL_MULTI` and the `Clear form` / `Refill` buttons exist so the panel renders without answering seventeen questions first. They are marked `DEV ONLY` in the source. Delete them along with the `DEV` chip in the header before any of it goes near a real user — they write a complete fake consultation into the form. ## 10. Definition of done Work through this before calling it finished. Each line is checkable — most of them catch a specific failure this codebase has already had. **The engine** - [ ] `node engine.test.js` → `ALL 28 PASSED`, before and after your changes. - [ ] `shp-suggest.js` is loaded from one place. Nothing pasted, nothing inlined. - [ ] `SHP_CONFIG.hairLengthCutover` is the real deploy date, not `2026-09-14`. **The rules actually fire** — all three of these have silently failed here before, with no error of any kind: - [ ] A profile with a **sweaty scalp returns zero SKIN-family products** (R-SWEAT). Check the family on each pick, not just that picks appeared. - [ ] A client who **has worn a system before, self-maintained** ("By Myself") gets a different shortlist from a first-timer (R-EXPERIENCE). - [ ] With **`base_type` answered**, every pick is in that family (R-BASE). **The gate** - [ ] With all seven inputs answered, the panel shows up to three products. - [ ] Remove any one of the seven → panel shows **no products and names the missing field**. It must never guess. - [ ] `hair_length = long` → custom-order message, no stock picks. - [ ] A consultation created **before** the cutover date reads `hair_length` as unanswered rather than reinterpreting the old option set. **The form contract** - [ ] Adding a pick writes through `useFieldArray` on `recommendedProducts` — the "N required left" counter and yup validation both see it. - [ ] The maximum comes from the yup schema, not a hardcoded 3. - [ ] Removing a pick updates both the panel and the counter. **The record** - [ ] Every save POSTs the log, including when the stylist agrees with all three. - [ ] The payload carries suggested SKUs, chosen SKUs, and what was overridden. - [ ] A product added from a suggestion is distinguishable in the record from one the stylist searched for themselves. **Before it reaches a stylist** - [ ] The prototype's dev block is gone: `DEV_PREFILL`, `DEV_PREFILL_MULTI`, the `Clear form` / `Refill` buttons, the `DEV` chip. - [ ] No console errors on the consultation route. - [ ] Indigo appears only on AI surfaces; anything the stylist chose is `#2b2b2b` (§6.7). If you cannot tick the three rule lines, **stop**. A panel that returns plausible products with a dead rule looks exactly like a working one. ## 11. History worth knowing `shp-suggest.js` is the customer quiz's own engine with four fixes applied. Each is a separate commit — `git log` shows them with reasoning. Two were silent failures worth not reintroducing: - It **threw a ReferenceError on every load** (`shpLogFinalChoice` was exported without being defined), which also meant `shpSetGallery` never existed. - The portal stores `sweatsFromScalpOften` and `hasWornHairSystemBefore` as **booleans**; the adapter compared them to the string `'1'`. Against live form state **neither hard rule fired** — a client who sweats was offered a skin base, and every returning client was treated as a beginner. `node engine.test.js` — 28 tests, no key, no network, no DOM — covers both.