Customisation
Every screen, every string and every colour is an option — change what you need without forking anything
The widget’s whole appearance is an option tree. You pass a partial; it is merged over the defaults and validated. Nothing is templated, nothing is injected, and there is no stylesheet to override — the widget lives in a shadow root precisely so that your page’s CSS cannot reach it.
const instance = SaphereScan.create("#scan", {
lang: "fr",
components: {
onboarding: {
onboarding3: { ignore: false } // ask for personal information
},
placement: {
start: { mode: "manual" } // a start button instead of an auto countdown
}
},
proxy: { retrieveAccessToken: { strategy: "delegate", url: "/api/saphere-token" } }
})
The shape of the tree
| Branch | What it holds |
|---|---|
lang | The starting language, one of the ten. |
desiredVariables | Which variables to compute. Absent, the measurement computes all it is entitled to. |
tags | Labels under which you will find the measurement in the console. |
components.intro | The opening animation. |
components.onboarding | The seven onboarding screens, plus the pager dots. |
components.loadStream | The camera-loading screen and its five error screens. |
components.placement | The face-placement screen and how the measurement starts. |
components.result | The result screen and the per-variable cards. |
components.error | The abnormal-end screen. |
components.buttons | The button labels used across the journey. |
components.menu, components.langChoice, components.gotIt | The toolbar panels. |
components.containers | The frame every screen sits in. |
`desiredVariables` is a cost lever
It is not a display preference. Each requested variable triggers its own processing, so asking for fewer is cheaper. Omit it only if you genuinely want everything the client account is entitled to.Dropping a screen
Most screens carry an ignore flag. Set it and the screen is not merely hidden — it is absent from the routes, so it cannot be reached, and it does not count in the onboarding numbering.
components: {
intro: { ignore: true }, // no opening animation
onboarding: {
onboarding3: { ignore: false }, // personal information — off by default
onboarding5: { ignore: true }, // skip the "uncovered face" guidance
onboarding6: { ignore: true } // skip the lighting guidance
}
}
The seven onboarding screens are, in order: the introduction, the consent gate, the personal-information form, and four guidance screens (posture, uncovered face, lighting, stillness). Only onboarding3 is disabled by default; the rest are on.
Dropping guidance costs signal quality
The guidance screens exist because posture, lighting and stillness determine whether the thirty seconds produce a usable signal. Removing them shortens the journey and raises the rate of measurements that end without a result.Reaching a screen by path is affected too — see the note about screen:enter carrying name as well as path in the events reference.
Two ways a screen is described
Which one a given screen uses is decided by whether it is a form.
A tree of elements, under <screen>.content.<lang>. Onboarding screens 1, 2 and 4–7, the five camera errors, the abnormal-end screen and the language chooser’s header all work this way. You describe the markup itself.
Localised strings, one field per language. Button labels, the toolbar menu, and onboarding 3 work this way. Those screens bind their inputs to a form or build their list from a service, so an arbitrary element tree cannot describe them.
Editing an element tree
An element is { type, ... }, where type is one of div, p, span, text, a, image, svg-component or projection. Containers carry a children array.
components: {
onboarding: {
onboarding4: {
content: {
en: {
type: "div",
attrs: { class: "d-flex-column-center text-center" },
children: [
{ type: "image", src: "001.webp" },
{ type: "p", children: [{ type: "text", value: "Sit like this." }] }
]
}
}
}
}
}
Only div, p, span and a accept children. A type that is not allowed in that position is refused by validation rather than silently dropped.
a is the one element with an attribute of its own: href, restricted to http(s):, mailto: and tel: by validation, and sanitised again by the renderer.
The array merge rule
Options are merged with a deep merge that combines arrays by index. Three consequences:
- Overriding a child works. Give
children[0]and the first default child is replaced. - Appending works. Give more entries than the default has and they are added.
- Shortening does not. You cannot remove a default child by giving a shorter array — the defaults beyond your last index survive underneath.
To change the second child without touching the first, you must leave a hole — and holes have to be explicit:
children: [undefined, { type: "p", children: [{ type: "text", value: "Replaced." }] }]
To remove a node, hide it
Since a tree cannot be shortened, the way to suppress a default node is to give it an empty content — or to drop the whole screen withignore. This is a known limit of the merge model, not an oversight of the schema.Projection slots
Some parts of a screen cannot be described by a tree: anything carrying a click, a signal or a loop. Those appear in the tree as a projection node, and the widget fills the slot with the real control.
{ type: "projection", slot: "toolbarMenu" }
This is how you move the menu button, the consent checkboxes or the back arrow within a layout you describe, without having to reimplement what they do.
The slot list is closed. The ones you are likely to use:
| Slot | What the widget puts there |
|---|---|
check1, check2 | The two consent checkboxes |
termsOfUse, privacy | The two document links on the consent screen |
toolbar, layers | The toolbar, and the panels opened over the journey |
toolbarPrevious, toolbarMenu | The back arrow and the menu button |
gotIt, gotItDismiss | The tooltip pointing at the menu, and its dismiss link |
menuClose, menuItems | The menu panel’s close cross and its list |
menuItemIcon, menuItemLabel | One menu entry’s icon and label |
A slot name that is not in the list is a validation error, not a silent hole on screen.
Colours
Each result variable carries a two-stop gradient, and those are the colours you are most likely to want to align with a brand:
components: {
result: {
hr: { color01: "#22C55E", color02: "#84CC16" },
healthScore: { color01: "#8B5CF6", color02: "#A855F7" }
}
}
| Variable | Default gradient |
|---|---|
hr — heart rate | #22C55E → #84CC16 |
br — breathing rate | #F59E0B → #F97316 |
hrv — heart rate variability | #06B6D4 → #0EA5E9 |
strs — stress | #F59E0B → #EAB308 |
bp — blood pressure | #3B82F6 → #06B6D4 |
bpClass — blood pressure risk | #10B981 → #14B8A6 |
physicalAge — visual age | #14B8A6 → #10B981 |
bmi — body mass index | #6366F1 → #8B5CF6 |
faceBmi — BMI from face | #EC4899 → #D946EF |
healthScore — cardiovascular score | #8B5CF6 → #A855F7 |
The onboarding pager dots have their own pair:
components: { onboarding: { slider: { colors: { activated: "#3B82F6", inactive: "#D1D5DB" } } } }
The health score is deliberately not colour-coded by value
It uses one hue, not a traffic light. The score is an estimate that does not replace a medical examination, and tinting it by value would present it as a verdict.Languages, and reading direction
Ten are built in: Arabic (ar), German (de), English (en), Spanish (es), French (fr), Italian (it), Polish (pl), Portuguese (pt), Brazilian Portuguese (pt_BR) and Turkish (tr).
lang sets the starting language. Absent, the widget reads the browser’s, falling back to English. The user can change it mid-journey from the toolbar, which emits ui:language-changed.
Every text option is a per-language object, so a customisation is written once per language you support:
components: {
buttons: {
title: { en: "Continue", fr: "Continuer", de: "Weiter" }
}
}
Languages you do not override keep their defaults, so you can translate only the strings you actually changed.
Right to left
Arabic renders right to left, and the whole layout mirrors — the toolbar, the back chevrons, the spacing. This follows from a single dir attribute on the shadow host, so it applies to everything at once.
Two things follow for a customisation:
Spacings are logical, not physical. The style options expose marginStart / marginEnd / paddingStart / paddingEnd, never marginLeft. That is deliberate: a customisation has to describe a layout valid in all ten languages, and a physical side would silently break Arabic.
Decorative animations stay left to right. The ECG sweep keeps its direction in every language, because an ECG trace reads left to right by medical convention.
Test in Arabic
If you customise layout at all, load the widget once withlang: "ar". It is the fastest way to catch a hard-coded side.How the merge is validated
The partial you pass is merged over the defaults, then the whole tree is validated at create() time.
- A misspelled key is dropped silently. It will not throw, and it will not take effect — if a customisation seems ignored, check the spelling first.
- A value of the wrong shape throws, with the path to the offending field.
- An element type not allowed in its position is a validation error.
Validation happens synchronously in create(), before anything is mounted, so you find out on your own machine rather than in a user’s journey.