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.

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:

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.

Riverbend Family Medicine
Before your visit

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;
}
Property Default
--smart-checkin-font Inter, then the system font
--smart-checkin-accent, --smart-checkin-accent-hover, --smart-checkin-on-accent #0E6FB8, #094D80, white
--smart-checkin-text, --smart-checkin-text-muted, --smart-checkin-text-faint #1F2933, #4B5563, #7B8794
--smart-checkin-surface, --smart-checkin-row, --smart-checkin-border white, white, #E4E7EB
--smart-checkin-icon-background white, behind wallet icons
--smart-checkin-focus the accent color, for focus rings
--smart-checkin-radius, --smart-checkin-radius-large, --smart-checkin-icon-radius 10px, 14px, 9px
--smart-checkin-success #1A8C76, the check when shared
--smart-checkin-mark-purple #722772 (dark #A04CA0), the starburst's purple petal, lifted in dark themes as in the site's logo
--smart-checkin-mark-muted #B9C2CC (dark #4A5866), the mark's petals when declined or failed
--smart-checkin-motion-speed 1; multiplies every duration. See Motion.
--smart-checkin-card-border, --smart-checkin-card-padding 1px solid the border color, 16px

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.

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.

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:

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,…"
    }
  ]
}

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);

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.

Kiosk hand-off, step by step Three columns: the kiosk page, your mailbox between the devices, and the hand-off page on the patient's phone. Step 1, start: on the kiosk, phone.start(request) makes the request and a key pair, keeps the private key, and posts the request with the public key to the mailbox under a random session id. Step 2, scan: onWaiting gives the kiosk a URL carrying the session id, which it shows as a QR code; the patient scans it, and on the phone fetchHandoff picks the request up from the mailbox and shows what is asked. Step 3, share: the patient picks a wallet in the picker and answerHandoff asks it; the wallet seals its answer to the kiosk's public key, while the kiosk waits on the mailbox. Step 4, return: the sealed answer goes back through the mailbox, which cannot read it; the kiosk opens it with its private key, verifies it and checks it against the request, and start() resolves with a CheckinResult. Kiosk page handoffWallet() Your mailbox sees only public material Hand-off page on the patient's phone, with its wallet 1START 2SCAN 3SHARE 4RETURN phone.start(request) Makes the request and a key pair. The private key stays here. mailbox.post request + public key under a random session id onWaiting({ url }) You show url as a QR code. It carries the session id. the patient scans the code fetchHandoff(mailbox, id) Picks up the request and shows the patient what the kiosk asks for. answerHandoff(…, wallet) The patient picks a wallet in the picker, and the wallet seals its answer to the kiosk's public key. Waits for the answer: mailbox.waitForAnswer Sealed answer mailbox.answer only the kiosk can open it Opens it with its key Verifies it and checks it against the request, as for any check-in. start() → CheckinResult
Kiosk hand-off, step by step Step 1, start, on the kiosk page: phone.start(request) makes the request and a key pair and keeps the private key; the request and public key go to your mailbox under a random session id. Step 2, scan: on the kiosk, onWaiting gives a URL carrying the session id, shown as a QR code; the patient scans it, and on the phone's hand-off page fetchHandoff picks the request up and shows what is asked. Step 3, share, on the hand-off page: the patient picks a wallet in the picker and answerHandoff asks it; the wallet seals its answer to the kiosk's public key. Step 4, return: the sealed answer goes back through the mailbox, which cannot read it; the kiosk opens it with its key, verifies it and checks it against the request, and start() resolves with a CheckinResult. 1 KIOSK PAGE phone.start(request) Makes the request and a key pair. The private key stays here. request + public key, to the mailbox 2 KIOSK PAGE onWaiting({ url }) You show url as a QR code. It carries the session id. the patient scans the code HAND-OFF PAGE, ON THE PHONE fetchHandoff(mailbox, id) Picks up the request and shows the patient what the kiosk asks for. 3 HAND-OFF PAGE answerHandoff(…, wallet) The patient picks a wallet in the picker, and it seals its answer to the kiosk's public key. Sealed answer through the mailbox 4 KIOSK PAGE Opens it with its key Verifies it and checks it against the request. start() → CheckinResult

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 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
};

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(),
  }),
});