# 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 `