Scaffolding tooltips
New staff learn the Till the same way every staff before them did — by asking the shift supervisor what a button does. Scaffolding tooltips replace that ask with a hover hint that fades as the staffer uses the feature and comes back if they stop using it long enough to forget. No wizard, no tutorial mode, no "help" tab. Just the hint appearing where the operator needs it, then quietly going away.
| Shipped | v0.7.297 (per-staff control) → v0.7.303 (engine) → v0.7.318 (final gate) |
| Substrate | src/core/scaffold.ts + migrations/043_tooltip_mode.sql (per-staff decay state) + migrations/044_feature_registry.sql (129 seeded hints) |
| Endpoints | /api/scaffold/states (read a staffer's decay map), /api/scaffold/use (bump on interaction) |
| Client | window.kbScaffold — every module's client wires the elements it wants hinted |
Why this exists
The Sales Till is a lot of screen. Every pill, every keyboard shortcut, every column-header behaviour, every silent affordance (click-the-tax-line, click-the-customer-name-on-a-historic-transaction) is teachable — but printing that documentation onto the screen forever is visual noise for the operator who already knows. The doctrine: a beginner sees hints; a veteran doesn't; a veteran-who-took-two-weeks-off sees them again.
The three modes (per-staff)
Each staffer picks their mode in the Employee modal (Settings → Staff → <name>):
| Mode | Behaviour |
|---|---|
| On | Always show every registered hint. For a brand-new hire the first shift. |
| Off | Never show hints. For the owner who wrote the software. |
| Heuristic | Hints fade with use, come back after decay. Default for everyone else. Picking Heuristic wipes the staffer's staff_feature_state after a confirm so the count restarts. |
Migration 043 adds staff.tooltip_mode (on / off / heuristic) and the per-staff staff_feature_state table (one row per staffer × feature, with use_count + last_used_at).
The registry (129 hints seeded)
Migration 044 seeds feature_registry with a hint per teachable element across every module — 129 rows total:
| Module | Hints |
|---|---|
| Sales | 25 |
| Customers | 20 |
| Inventory | 19 |
| Purchases | 21 |
| Settings | 25 |
| Service | 19 |
Each row: element_key (a stable identifier the client references), hint_text, and a frequency_class that governs the decay curve.
Decay curves — the frequency classes
| Class | Hint decays after | Re-teach after |
|---|---|---|
| high | 15 uses | 30 days idle |
| medium | 30 uses | 60 days idle |
| low | 8 uses | 120 days idle |
| rare | 4 uses | 180 days idle |
A pill the operator taps every shift (medium — auto-park, quick-tender presets) fades quickly and stays faded. A pill the operator uses maybe once a month (rare — Adjust-tax modal, Reset Owner permissions) has a bigger re-teach window because forgetting it is more likely between uses.
Idle past the re-teach window RESETS use_count to 1 (v0.7.313). The staffer sees the hint again, uses the feature once, count goes back up, hint fades again. The system self-heals: a staffer who took two weeks off the till doesn't come back to a wall of hints, but they also don't come back to a silent register where they've forgotten what half the pills do.
What triggers a hint
- Hover on a registered element — always (subject to decay).
- Focus via keyboard Tab within 700 ms — the last-hint-fired vs the Tab timestamp. This is the gate added in v0.7.318 that kills programmatic-focus firing — a
.focus()call from the client on a modal open no longer counts as the staffer having noticed the element, so theiruse_countdoesn't inflate on things they never actually looked at. - Never fires on click (a click is a use, not a first-encounter).
The hint replaces the control's own native title attribute (v0.7.306) so a browser tooltip and a scaffolding tooltip don't stack.
Rendering
Pure hovercard, no ⓘ marker (v0.7.305 dropped it). The pill or button looks exactly like it does with scaffolding off; the hint only appears when the operator hovers. That means the surface stays uncluttered — a Till full of pills reads clean, and hints appear only under the pointer.
How a module wires an element
Every module's client calls window.kbScaffold(el, key) on the elements it wants hinted; the engine looks up the hint text, tracks the interaction, decides whether to render:
// Inside Sales' client.ts
window.kbScaffold(document.getElementById('sc-attach-cust'), 'sales.attach_customer');
window.kbScaffold(document.getElementById('sc-charge'), 'sales.charge_button');
Adding a new hint = one row in feature_registry + one kbScaffold(...) call in the module. No design system pass, no doc write.
Endpoints
GET /api/scaffold/states— returns the staffer's decay map ({ feature_key: { use_count, last_used_at } })POST /api/scaffold/use— bumpsuse_count+last_used_atfor one feature
Both are cheap; the client caches states in memory for the session and posts uses in the background without awaiting.
What NOT to do
- Don't print a hint on the surface permanently. That's the tutorial pattern this doctrine replaces.
- Don't wire hints on non-teachable UI (labels, static text, the shop logo). The registry is for interactive affordances only.
- Don't count programmatic focus as a use. The 700-ms Tab gate exists precisely to prevent this.
- Don't stack an ⓘ marker next to a hinted pill. The pointer discovers it; the marker is noise.
See also
- Three strata —
core/scaffold.tsin the Kernel - Sales module — the first module to wire the hints
- Settings module — the Employee modal's tooltip-mode control