Wallet picker
In this section
A wallet is anything that can answer a check-in request: the phone's own wallet, a wallet on the web, a kiosk hand-off, or a mock. Your page decides which to offer; the patient picks one.
- Most pages: drop in the picker. It lists the wallets, runs the check-in, and hands you the result.
- Your own buttons: start the wallets yourself; see Without the picker.
The picker
<smart-checkin-picker> shows the patient the wallets they can use, runs the check-in with the one they pick, and tells your page what came back.
<script type="module" src="https://smart-health-checkin.org/client/lib/0.4.4/ui.js"></script>
<smart-checkin-picker registry="/wallets.json"></smart-checkin-picker>
<script type="module">
const picker = document.querySelector("smart-checkin-picker");
picker.request = myRequest;
picker.addEventListener("smart-checkin-response", (e) => prefillMyForm(e.detail.response));
</script>In a bundled app, import "@smart-health-checkin/client/ui" registers the element instead of the script tag. Install covers both ways of loading the library.
What it does:
- The phone's own wallet leads when the browser can reach it. When it can't, that option is hidden.
- Web wallets from your registry follow, in registry order. With more than five, the first four show and the rest open in a searchable list.
- It opens a web wallet's tab inside the patient's click, so browsers don't block it.
This is the real picker, but the wallets are simulated and none opens. Choose one, and after a moment it ends with the outcome set here. Shared answers with made-up data. For a real check-in, use the clinic check-in demo.
Choose a wallet to see what your page gets.
In React
import { CheckinPicker } from "@smart-health-checkin/client/react";
<CheckinPicker
request={myRequest}
registry="/wallets.json"
onResponse={({ response }) => setResponse(response)}
onDeclined={() => setNote("Nothing was shared.")}
/><CheckinPicker> renders the same element, and its props are CheckinPickerProps. React is needed only for this module.
In Angular, Vue, and others
Import @smart-health-checkin/client/ui once, allow the element, and set request as a property, not an attribute.
Angular (standalone component):
import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import type { CheckinResponse } from "@smart-health-checkin/client";
import "@smart-health-checkin/client/ui";
@Component({
selector: "app-checkin",
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `<smart-checkin-picker registry="/wallets.json" [request]="request"
(smart-checkin-response)="onResponse($event)"></smart-checkin-picker>`,
})
export class CheckinComponent {
request = myRequest;
onResponse(e: Event) {
const { response } = (e as CustomEvent<{ response: CheckinResponse }>).detail;
prefillMyForm(response);
}
}Vue: tell the compiler the tag is a custom element (isCustomElement: (tag) => tag === "smart-checkin-picker" in the Vue plugin's template.compilerOptions), then:
<smart-checkin-picker registry="/wallets.json" :request.prop="request"
@smart-checkin-response="(e) => prefillMyForm(e.detail.response)" />TypeScript and the element
Importing @smart-health-checkin/client/ui also tells TypeScript about the tag. document.querySelector("smart-checkin-picker") returns a SmartCheckinPicker, and its addEventListener types each event's detail from SmartCheckinPickerEventMap:
import "@smart-health-checkin/client/ui";
const picker = document.querySelector("smart-checkin-picker")!;
picker.request = myRequest;
picker.addEventListener("smart-checkin-response", (e) => {
if (e.detail.response) prefillMyForm(e.detail.response);
});response is optional in the type because a server that keeps the data sends none. Importing @smart-health-checkin/client/react types the element's attributes in JSX as well, though <CheckinPicker> is simpler there.
Attributes
| Attribute | What it does |
|---|---|
registry |
URL of a wallet registry. Omit it for no web wallets. |
platform="off" |
Don't offer the phone's own wallet. |
remember |
Remember the last app used on this site, in this browser. Off unless present. |
mock |
Offer a simulated response. Development only. |
mode="pick" |
Only choose; your page runs the check-in. See Pick only. |
theme |
light (default), dark, or auto to follow the device. |
appearance="flat" |
No card border or background. |
footer="off" |
Hide the SMART Health Check-in mark. |
motion |
subtle (default) or none. See Motion. |
heading, description |
Replace the two lines at the top. |
Properties
| Property | What it does |
|---|---|
request |
What to ask for. Required unless mode="pick". |
motion |
"subtle" or "none"; reflects the motion attribute. |
wallets |
A list from wallets(), used instead of registry, platform, and mock. Use it to add a kiosk hand-off or your own wallets. |
checkinOptions |
Passed to runCheckin: keys for server-held keys, healthCards for card trust. |
strings |
Replace any text, for wording or translation. See DEFAULT_STRINGS. |
Events
All events bubble and cross shadow roots. SmartCheckinPickerEventMap types each one's detail.
| Event | detail |
When |
|---|---|---|
smart-checkin-response |
{ wallet, response?, result } |
The check-in finished. response is a CheckinResponse, absent only when a key server kept the data. |
smart-checkin-declined |
{ wallet, result } |
The patient closed the app or said no. |
smart-checkin-error |
{ wallet?, code?, message, result? } |
Anything else went wrong, including a registry that wouldn't load. Codes. |
smart-checkin-choose |
{ wallet, session? } |
The patient picked a wallet. In pick mode, session is the opened wallet. |
Styling
Set custom properties on the element or any ancestor. They are enough to match a clinic's own site: each set below restyles the whole picker. Its [theme="dark"] rule gives the colors for when your page sets theme="dark"; with theme="auto", put them in a prefers-color-scheme: dark media query instead.
The wallets are simulated, as in the example above: choosing one opens nothing and ends as shared.
The defaults: nothing to set.
/* Portal blue */
smart-checkin-picker {
--smart-checkin-font: "Source Sans 3", sans-serif;
--smart-checkin-accent: #205E9B;
--smart-checkin-accent-hover: #184A7B;
--smart-checkin-border: #E3DDD7;
--smart-checkin-radius: 24px;
--smart-checkin-radius-large: 8px;
--smart-checkin-icon-radius: 50%;
}
smart-checkin-picker[theme="dark"] {
--smart-checkin-accent: #6FA8DC;
--smart-checkin-accent-hover: #8FBDE6;
--smart-checkin-border: #3A4150;
}/* Clinic teal */
smart-checkin-picker {
--smart-checkin-font: "Nunito Sans", sans-serif;
--smart-checkin-accent: #00735F;
--smart-checkin-accent-hover: #005A4A;
--smart-checkin-surface: #F2F7F6;
--smart-checkin-border: #C9D6D4;
--smart-checkin-text: #0F2A2A;
--smart-checkin-text-muted: #4A6363;
--smart-checkin-radius: 3px;
--smart-checkin-radius-large: 4px;
--smart-checkin-icon-radius: 3px;
}
smart-checkin-picker[theme="dark"] {
--smart-checkin-accent: #3DD2B4;
--smart-checkin-accent-hover: #6ADFC6;
--smart-checkin-surface: #0F2322;
--smart-checkin-row: #16302E;
--smart-checkin-border: #2A4543;
--smart-checkin-text: #E1F0ED;
--smart-checkin-text-muted: #A5C2BE;
}
For anything else, style these parts with ::part(): container, card, title, description, primary, list, row, more, icon, status, mark (the starburst beside a status), check (on the mark when shared), footer, dialog.
Motion
With motion="subtle", the default, the picker moves a little:
| State | What moves |
|---|---|
| Choosing | The choices fade up in turn (180 ms each, 40 ms apart). A press shrinks one slightly. |
| Waiting for the wallet | A soft dim wedge turns round the starburst, once every 2.6 s, until the wallet answers. The petals stay bright. |
| Shared | The check and the message fade in (240 ms). |
| Declined or failed | The petals fade to gray (320 ms) and the message fades in. |
motion="none"stops all of it: the mark is still, and state changes happen at once.- A device set to reduce motion (
prefers-reduced-motion: reduce) always getsnone, whatever the attribute says. --smart-checkin-motion-speedscales every duration:2is half as fast,0.5twice as fast.
The wedge is drawn in --smart-checkin-surface, so it matches the card in light and dark themes. Set that property if your card has another background.
smart-checkin-picker { --smart-checkin-motion-speed: 1.5; }
smart-checkin-picker::part(mark) { width: 36px; height: 30px; }Pick only
Use mode="pick" when your page runs the check-in itself, for example to inspect the raw response.
- Listen for
smart-checkin-choose. Itssessionis the chosen wallet, already opened inside the click. - Run the check-in with it:
runCheckin(myRequest, { wallet, session }). - Tell the picker how it ended with
setOutcome:picker.setOutcome({ status: "completed" }),{ status: "declined" }, or{ status: "failed", message, code }.
Without the picker
You can leave the picker out and start a check-in from buttons you draw yourself. You might do that when your site has its own design system and the picker doesn't fit it even after styling. It also makes sense when you offer only one wallet, because a list with a single choice only adds a step, and when the check-in should feel like part of your own page, such as one "Fill in from my health app" button beside the form it fills.
The library does the same work either way: it opens the wallet, decrypts and checks the answer, and gives you a CheckinResult. What the picker did around that becomes your code:
- Availability. Offer only the wallets this browser can use.
wallets()leaves out the rest, and a single wallet'savailablesays whether it can run here. The phone's own wallet needs the Digital Credentials API, which not every browser has. - Starting inside the click. Call
wallet.start()as the first thing in your click handler, before anyawait, so a web wallet's tab can open. Starting inside the click explains why, and how to stop waiting. - Waiting. While the wallet is open, say so, and don't let a second click start another check-in.
- Every ending.
start()doesn't throw when a check-in doesn't complete. It resolves with astatusof"completed","declined"when the patient said no or closed the wallet, or"failed"with anerror.code. Declined and failed check-ins go back to your own form (Fallback).
One button for the phone's wallet
This button starts the patient's own wallet through platformWallet(). It stays hidden in a browser without the Digital Credentials API, so those patients see only your form. On a desktop, the browser shows a QR code and the patient answers on their phone.
import { platformWallet, type CheckinResult } from "@smart-health-checkin/client";
const button = document.querySelector<HTMLButtonElement>("#fill-from-app")!;
const note = document.querySelector<HTMLElement>("#checkin-note")!;
const phone = platformWallet();
button.hidden = !phone.available;
button.onclick = async () => {
const running = phone.start(myRequest); // first, inside the click
button.disabled = true;
note.textContent = "Waiting for your health app…";
showOutcome(await running);
button.disabled = false;
};
function showOutcome(result: CheckinResult) {
if (result.status === "completed") {
prefillMyForm(result.response);
note.textContent = "Filled in from your health app. Please check it.";
} else if (result.status === "declined") {
note.textContent = "Nothing was shared. Please fill in the form.";
} else if (result.status === "failed") {
note.textContent = "That didn't work. Please fill in the form.";
console.warn("check-in failed:", result.error.code, result.error.message);
}
}This is the first example running, with a simulated wallet in place of the phone's, so it works in any browser and nothing opens. Choose what the wallet does, then press the button. Shared answers with made-up data.
A button for each wallet
To offer web wallets as well, list them with wallets() and draw a button for each. The list comes in order, with the phone's own wallet first when this browser can reach it. It's empty when nothing is available, and wallets() throws when the registry can't be loaded, so both cases leave the patient with your form. showOutcome is the function from the first example.
import { wallets } from "@smart-health-checkin/client";
const list = document.querySelector<HTMLElement>("#checkin-wallets")!;
const offered = await wallets({ registry: "/wallets.json" }).catch(() => []);
for (const wallet of offered) {
const button = document.createElement("button");
button.textContent = wallet.name;
button.onclick = async () => {
const running = wallet.start(myRequest); // a web wallet's tab opens here
for (const b of list.querySelectorAll("button")) b.disabled = true;
showOutcome(await running);
for (const b of list.querySelectorAll("button")) b.disabled = false;
};
list.append(button);
}Each wallet also has a description and an iconUrl if you want to show more than the name. @smart-health-checkin/client/picker has the picker's own logic for a longer list:
| Function | What it does |
|---|---|
arrangeWallets(list) |
Returns { primary, inline, more }: what leads, what's listed, and what's behind "more". |
rememberChoice(id), recallChoice() |
Keep the last choice for this site, if you want that. |
monogram(name) |
The letter and color for a wallet without an icon. |
In React
The useCheckin hook lists the wallets and gives you start for your own buttons. Its status is "waiting" while a wallet is open.
import { useCheckin } from "@smart-health-checkin/client/react";
function CheckinButtons() {
const { wallets, start, status } = useCheckin(myRequest, { registry: "/wallets.json" });
return wallets.map((wallet) => (
<button key={wallet.id} disabled={status === "waiting"}
onClick={() => start(wallet).then((result) => result && showOutcome(result))}>
{wallet.name}
</button>
));
}Kinds of wallet
| Kind | What it is | How you get one |
|---|---|---|
platform |
A wallet on the patient's device, reached through the browser's Digital Credentials API. On a desktop, the browser shows a QR code and the phone answers. | platformWallet(), or included by wallets() |
web |
A wallet that's a website. It opens in a tab; the patient chooses there. | webWallet(entry), or from a registry |
handoff |
A kiosk's "use your phone" option. | handoffWallet(...); see Kiosk hand-off |
mock |
Answers at once with made-up data. Development only. | mockWallet() from /testing |
custom |
Any transport you write. | customWallet(...); see Custom transports |
wallets() returns what to offer, in order: the phone's app, the registry's web wallets, then extra.
import { wallets } from "@smart-health-checkin/client";
const options = await wallets({ registry: "/wallets.json" });| Option | Default | What it does |
|---|---|---|
platform |
true |
Offer the phone's own app. It's left out when the browser can't reach one. |
registry |
none | A wallets.json URL, a registry object, or a list of entries. With none, no web wallets. |
extra |
none | More wallets after the registry's, such as a hand-off or mockWallet(). |
includeUnavailable |
false |
Keep wallets this browser can't use, marked available: false with unavailableReason. For developer tools. |
Each Wallet has id, kind, name, description, iconUrl, available, and the two methods below, start and open.
Registries and icons
A registry is a wallets.json file listing the web wallets your page offers. You decide which websites your patients' data can be sent to, so the list is yours.
{
"wallets": [
{
"id": "smart-testing-wallet",
"name": "SMART Testing Wallet",
"walletUrl": "https://smart-health-checkin.org/connectathon/testing-wallet/",
"description": "Reference wallet with synthetic patients",
"iconUrl": "data:image/svg+xml,…"
}
]
}- Every field is in Registry format.
- A registry that can't be loaded or is malformed throws. Which apps patients are sent to shouldn't be decided by accident.
- Icons: prefer
data:URLs. An icon loaded from a wallet's server tells that server someone is on your check-in page. Wallets without an icon get a colored letter tile. - The connectathon registry is at smart-health-checkin.org/connectathon/wallets.json. It inlines every icon, and lists the SMART Testing Wallet first.
Starting inside the click
Browsers only let a page open a new tab during a click. A web wallet's tab has to open before anything slow happens.
button.onclick = () => wallet.start(myRequest).then(handleResult);- Call
wallet.start(request)directly in the click handler, before anyawait.runCheckin(request, { wallet })works the same way. startopens the tab first, then builds the request. The hand-off copes with the wallet being ready before the request is.- To stop waiting, pass an
AbortSignalassignal:wallet.start(request, { signal }). The tab closes and the check-in ends asdeclined. - If the tab is blocked anyway, the result is
failedwith codeblocked.
wallet.open() connects without running a check-in, for pick-mode UIs. It has the same rule: call it inside the click.
Kiosk hand-off
A kiosk or front-desk screen has no wallet, but the patient's phone does. The screen shows a QR code; the phone opens a small page, asks its wallet, and sends the sealed answer back. Only the screen can read it.
The kiosk
The phone is just another wallet. handoffWallet takes the HandoffOptions:
import { handoffWallet } from "@smart-health-checkin/client/handoff";
const phone = handoffWallet({
mailbox: myMailbox, // yours; see "The mailbox" below
handoffUrl: "/checkin/handoff.html", // the page the phone opens
onWaiting: ({ url }) => drawMyQrCode(url),
});
const result = await phone.start(myRequest);- The key stays on the kiosk, bound to the hand-off page's origin: the wallet answers the page that asked, and that page is on the phone.
- The result is the usual one, after the same checks as any check-in.
- Offer it next to other wallets with
wallets({ extra: [phone] }), or the picker'swalletsproperty.
The phone page
The page reads the session id with sessionIdFromHash, and fetchHandoff picks up the request so you can show what the kiosk asks for.
import { answerHandoff, fetchHandoff, sessionIdFromHash } from "@smart-health-checkin/client/handoff";
const sessionId = sessionIdFromHash(location.hash)!;
const { envelope, request } = await fetchHandoff(myMailbox, sessionId);
showMyConsentScreen(request);
myShareButton.onclick = () => answerHandoff(myMailbox, sessionId, envelope);answerHandoff asks the phone's own wallet unless you pass another wallet. Call it inside the click.
With the picker in pick mode, let the patient choose the wallet, then pass the session the picker opened:
picker.addEventListener("smart-checkin-choose", ({ detail: { wallet, session } }) =>
answerHandoff(myMailbox, sessionId, envelope, wallet, { session }));The kiosk's phone page works this way.
The mailbox
You provide the mailbox, a HandoffMailbox somewhere both devices can reach: a realtime database, a WebSocket relay, or two endpoints on your API.
type HandoffMailbox = {
post(sessionId, envelope): Promise<void>; // kiosk → phone
fetch(sessionId): Promise<HandoffEnvelope>; // phone ← kiosk
answer(sessionId, answer): Promise<void>; // phone → kiosk
waitForAnswer(sessionId, { signal }?): Promise<HandoffAnswer>; // kiosk ← phone
};- Session ids are secrets. Whoever has one can read the request and post an answer. The library makes 192 random bits; don't shorten them.
- An answer is written once. The first one wins.
- Sessions expire.
fetchHandoffrefuses a session older than the kiosk'sttlMs, ten minutes unless you change it. The mailbox should delete sessions after the same time. - The mailbox needs no keys of its own. It carries the request, the kiosk's public key, and the sealed answer, and it can't open the answer. Serve the hand-off page from an origin you control.
The demo's mailbox uses InstantDB: mailbox-instant.ts. Try it: the kiosk demo.
Custom transports
customWallet wraps anything that can turn the Digital Credentials API argument into a wallet's answer.
import { customWallet } from "@smart-health-checkin/client";
const myWallet = customWallet({
id: "my-app",
name: "My Health App",
open: () => ({
getCredential: (navigatorArgument) => myTransport.ask(navigatorArgument),
cancel: () => myTransport.close(),
}),
});openruns inside the click and returns aWalletSession; do anything that needs the click there.- Throw
WalletDeclinedErrorwhen the patient says no, andCheckinErrorwith acodefor failures. - Web wallets use a documented hand-off between two pages. Web wallets describes it.