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

BranchWhat it holds
langThe starting language, one of the ten.
desiredVariablesWhich variables to compute. Absent, the measurement computes all it is entitled to.
tagsLabels under which you will find the measurement in the console.
components.introThe opening animation.
components.onboardingThe seven onboarding screens, plus the pager dots.
components.loadStreamThe camera-loading screen and its five error screens.
components.placementThe face-placement screen and how the measurement starts.
components.resultThe result screen and the per-variable cards.
components.errorThe abnormal-end screen.
components.buttonsThe button labels used across the journey.
components.menu, components.langChoice, components.gotItThe toolbar panels.
components.containersThe 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 with ignore. 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:

SlotWhat the widget puts there
check1, check2The two consent checkboxes
termsOfUse, privacyThe two document links on the consent screen
toolbar, layersThe toolbar, and the panels opened over the journey
toolbarPrevious, toolbarMenuThe back arrow and the menu button
gotIt, gotItDismissThe tooltip pointing at the menu, and its dismiss link
menuClose, menuItemsThe menu panel’s close cross and its list
menuItemIcon, menuItemLabelOne 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" }
    }
}
VariableDefault 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 with lang: "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.