npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@unisim/sdk

v0.155.0

Published

Shared React SDK for the Universal Suite — auth, entitlements, usage telemetry, changelog, org admin.

Readme

@unisim/sdk

Shared React SDK for the Universal Suite — Ergo Assess UK, Cyber Assess UK, Workplace Assess, plus the central hub at app.unisim.co.uk. One package for auth, multi-tenant org data, branding, entitlements, changelog, and trial gating, so every product reads the same source of truth.

No product should import @supabase/supabase-js directly — go through the SDK so the underlying backend stays swappable.

Install

npm install @unisim/sdk @supabase/supabase-js react

React 18+ is a peer dependency.

Quick start

Wrap your app in <UniversalProvider> once, then call hooks anywhere.

import { UniversalProvider, useUser, useOrgBranding } from '@unisim/sdk'

const config = {
  supabaseUrl:     process.env.NEXT_PUBLIC_PLATFORM_SUPABASE_URL!,
  supabaseAnonKey: process.env.NEXT_PUBLIC_PLATFORM_SUPABASE_ANON_KEY!,
  product:         'ergo_assess',
  cookieDomain:    process.env.NODE_ENV === 'production' ? '.unisim.co.uk' : undefined,
}

function App() {
  return (
    <UniversalProvider config={config}>
      <Header />
      {/* … */}
    </UniversalProvider>
  )
}

function Header() {
  const { user } = useUser()
  const branding = useOrgBranding()
  return (
    <header>
      {branding.logo_url && <img src={branding.logo_url} alt="" />}
      <span>{user?.email ?? 'Guest'}</span>
    </header>
  )
}

What you get

Auth + session

  • useUniversal() — raw { supabase, session, activeOrgId, … }
  • useUser() — { user, loading }
  • useOrg() — active org + list of orgs the user belongs to
  • signInWithPassword(supabase, email, password)
  • signOut(supabase)
  • continueWithEmail(supabase, { email, password }) — the unified form. Signs in, or creates the account if there isn't one, and reports which happened: signed-in · created · upgraded · unconfirmed · account-exists · trial-collision · error. There is no "does this email exist?" probe — the sign-in is attempted first, so an account's existence is only ever disclosed to someone who already submitted a password for it.
  • abandonTrialAndSignInWithEmail(supabase, { email, password }) — the trial-collision escape hatch: drop the anonymous session and sign in for real, leaving the trial work behind. Only after the user has agreed to that.
  • <SignInDialog /> renders all of the above; apps rarely need the functions. Two tabs, like Universal Family: Email code (type your email, get a 6-digit code; the same code signs in, creates the account, or upgrades a trial in place) and Apple / Google / Microsoft. An email typed on the first travels to the second as a login_hint (Google and Microsoft pre-fill it; Apple has no such parameter). A password is a quiet second route on the code tab: it signs an existing account in and never creates one, and there is no password reset — the code is the way back in. The provider tab appears only for providers the Supabase project has switched on; without it there is no tab bar at all. In a native (Capacitor) shell the buttons use the system browser / Apple's own sheet instead of the web round trip — see Provider sign-in in the phone apps.
  • sendSignInCode(supabase, { email }) / verifySignInCode(supabase, { email, code, flow }) — the code tab's logic, for other sign-in screens (the hub's). A trial session gets an email-change code, verified as email_change, so the user id survives; an address that already has an account quietly becomes an ordinary code, so the form discloses nothing. Offline, the code is 123456.
  • startOAuthSignIn(supabase, provider, { redirectTo, loginHint }) — Apple, Google (google) or Microsoft (azure). A trial session is upgraded in place with linkIdentity, never replaced.
  • fetchEnabledOAuthProviders(supabaseUrl, anonKey) — which providers are on, from /auth/v1/settings; [] on any doubt.
  • startNativeOAuthSignIn(supabase, provider, { supabaseUrl, anonKey, loginHint }) — the same thing inside a Capacitor app; resolves signed-in / cancelled / error when the round trip is over. nativeOAuthSupport() / nativeProvidersToOffer() decide whether to offer it.
  • <OAuthButtons providers={…} /> — the buttons. Without providers it shows Google and Microsoft only, because the hub's labels predate Apple.

Trial mode (anonymous-auth users)

  • useTrialMode() — { isTrial, hasSession, email } for gating exports / multi-user
  • <TrialBadge /> — small "PRO" chip you append to gated buttons
  • <UpgradeWall feature="export PDFs" /> — full feature-replacement card

Suite-wide entities (all org-scoped, RLS-gated)

  • usePeople() + createPerson / deletePerson
  • useTeams() + useTeamMemberships() + createTeam / assignPersonToTeam / …
  • usePlaces() + createPlace / deletePlace
  • useProjects() + createProject / updateProjectStatus / deleteProject
  • useOrgBranding() — logo URL + brand colour
  • useOrgMembers() — org membership list with profile data

Subscriptions + entitlements

  • useSubscription() — { tier, status, seat_count, current_period_end, credits }
  • useCredits() — convenience wrapper for the metered balance
  • useHasAccess(productCode) — feature/product entitlement check
  • useHasFeature(productCode, feature)
  • useSeat(productCode)
  • useAccountLimits() — the whole entitlement picture in one object: tier, term, the purchased balance, the per-app free-token wall (app_free_tokens) and an admin to ask. A missing token row means available, not "no allowance" — rows are created lazily, so this hook is the one place that rule lives.
  • <AccountLimits /> — the panel that renders it. Mounted for you by <UserProfile /> when its account header is clicked; pass showLimits={false} to the navbar's profile if a product shows entitlements its own way.
  • Delete my account (since 0.141.7) — a row under Sign out in that same account panel, opening <DeleteAccountDialog />: it warns that the Universal ID goes in every UNI·SIM product, needs delete-all typed, calls the platform's delete-account function and signs out locally. App Review 5.1.1(v) and Google Play both require in-app deletion for an app whose sign-in creates accounts, so it is on by default inside a native (Capacitor) shell and off in a browser; showDeleteAccount on <UserProfile /> / <UniversalAppsNavBar /> overrides it either way. ⚠️ An app that draws its own delete row passes false (Universal PDF does, or a phone shows two). deleteMyAccount(), DeleteAccountDialog and deleteAccountCopy() are exported for an app that wants them elsewhere. npm run test:delete-account (39 checks).

Org admin

  • useOrgMembers() / useOrgSeats() / useAuditLog()
  • assignSeat / revokeSeat / reassignSeat

Suite navigation

  • <SuiteSwitcher current="ergo_assess" /> — top-right product switcher with the canonical product list. Since 0.138.0 it also takes an optional currentHref: with the menu open, a pointer click on the identity (mark + name) goes to that URL and closes the switcher instead of toggling. <UniversalAppsNavBar /> passes it for you as productHomeHref or the app's catalogue href. Three gates are deliberate and must survive any refactor — only while the menu is open (on a phone there is no hover, so tap one must still open it), pointer clicks only (e.detail > 0, so Enter/Space still toggles), and navigating to the page you are already on is a close rather than a location.assign (these are SPAs holding live state, and a Universal App's landing page is its root).
  • <CompanyMenu /> — "My Company" dropdown linking to the central hub — but only for a signed-in visitor. Since 0.131.0 <UniversalNavBar /> gives that slot to a "Pricing" link while nobody is signed in: every row in the dropdown needs an organisation, so to a visitor it was five links to a login wall. Override with the navbar's pricingHref, or pass null to keep the old behaviour (on-prem has no subscription page to point at). ⚠️ Since 0.142.7 a native (Capacitor) shell carries neither purchase link. A store app may not send people outside the store to buy a subscription (App Store 3.1.1, Play's payments policy), so there the navbar's default pricingHref is null, a signed-out visitor sees no Pricing and no My Company, and <CompanyMenu /> drops its Billing row (showBilling, default off in a native shell, on in a browser). Both overrides are honoured. A browser is unchanged. npm run test:native-purchase-links (21 checks).
  • "Sign in" opens the in-app <SignInDialog /> on both bars. The Apps bar has done this since 0.141.x; <UniversalNavBar /> only got it in 0.142.9, and until then its Sign in was a plain link to the hub. ⚠️ In a Capacitor app that link LEAVES THE APP — the web view navigates to app.unisim.co.uk and there is no way back to a signed-in app. Reported from Workplace Assess on Android. The row stays an <a href> to hubLoginHref, so middle- and ctrl-click still open the hub in a tab, and a noAccounts product gets no dialog. An anonymous trial is upgraded in place, not replaced (emailCode.ts). npm run test:navbar-sign-in (21 checks).
  • …and in a native shell that row carries no href at all (0.142.10). There is no new tab on a phone, so the attribute buys nothing there — and it is a hazard, because a host app may legitimately intercept an off-origin a[href] in a document-level capture listener, which runs before React sees the click. Universal PDF's externalLinks.ts is exactly that (it keeps an outward link from backgrounding the app and breaking every position: fixed dialog on resume): it preventDefaulted first, the row's own guard stood down on defaultPrevented, and Sign in opened app.unisim.co.uk/login in a Custom Tab. A bare /login honours no ?return= and forwards to the Assess portal, so the tap ended on a website with the app still signed out. Reported on Android, 2026-09-16. With no href, closest('a[href]') misses the row and the click is the dialog's. The web keeps its link, untouched.
  • <UniversalBar /> — the 4 px gradient brand strip across every product header
  • <UKFlag /> — region indicator for UK products
  • The first click on a hover-opened menu leaves it open (since 0.141.9). Every dropdown here — <UserProfile />'s pill, <KnowledgeBaseMenu />, <ChangelogMenu />, <CompanyMenu />, <SuiteSwitcher /> — opens on hover as well as click, and the click used to be a plain toggle: the pointer arriving opened the menu and the click itself shut it, so the first click on "Actions" looked like it did nothing. An open from hover or focus is now provisional and the next click (or Enter) confirms it; the click after that closes. Touch is unchanged (no hover-open there at all). All five share useHoverMenu() in hover.ts — a new dropdown should use it rather than wiring onMouseEnter + a toggle by hand. npm run test:hover-click (77 checks).

Changelog

  • useChangelog() — fetches the suite-wide changelog feed (defaults to https://changelog.unisim.co.uk/changelog.json)

Usage telemetry

  • <UsageTracker /> (since 0.140.0) — mount it once inside <UniversalProvider>. It starts the batcher and sends exactly one session.opened row per page life for a signed-in visitor with an org, which is what fills god-mode's "last product used". Signed-out visitors send nothing. Import it; do not copy it — it replaced a 20-line UsageTracker.tsx that had been pasted into nineteen apps.
  • It takes no product prop: the row's product is config.product, already typed ProductCode, and a second place naming it could only disagree with the first. What the compiler cannot check is the Postgres product_code enum — if the code is missing there, every signed-in insert fails with 22P02, and the batcher's one error now names the code so you know which migration to write (Docs_UNI_SIM/new-universal-app.md §1).
  • useUsageTracker() / track(name, props) — the parts, for a product that needs its own events. ⚠️ Track that the app was opened, never what was in it: several Universal Apps promise we never see the file, and an event carrying a byte count breaks that promise.
  • npm run test:usage-tracker — one row signed in (plain and StrictMode), none signed out, a negative control with no tracker mounted, and the 22P02 message.

Tune this app, and Global Tuning (since 0.143.0, renamed 0.148.0)

⚠️ The per-app row is called "Tune this app" since 0.148.0, and the app's own settings page (settingsHref) is the last LINK inside that dialog rather than a second "App settings" row in the menu (James, 2026-09-17: "Can we combine app settings with app preferences?"). A host still passes settingsHref exactly as before — the SDK moved where it renders, not what the host provides — and showAppPreferences={false} no longer hides the row when there is a settingsHref, or the settings page would have nowhere to live.

James, 2026-09-17: per-app "Settings" is now App preferences, owned by the SDK, and the things nobody wants to set once per app — Language and Colour scheme — are Global preferences that each app can override.

  • The menu. <UserProfile> (and so both navbars) has a Global Tuning row where the language row used to be — in the account panel for a signed-in user, in the list for a guest — and an App preferences row directly under the host's actions, for everyone. Each opens <PreferencesDialog>; the menu closes first.

  • Global Tuning: Language (a <select> of native names, LANGUAGE_LABELS) and Colour scheme (Light — the default — / Dark / System, a radio group). When the current app overrides either, the dialog says so ("Universal PDF uses its own language: Français").

  • App preferences: Language, first option "Follow global: English (US)" naming the current global value; Colour scheme the same way when the app passes its theme store, or "{app} is always light/dark" when it does not; then the app's own rows. Choosing "Follow global" removes the override rather than storing today's global value.

  • New props on <UniversalAppsNavBar>, <UniversalNavBar> and <UserProfile>:

    • appPreferences?: ReactNode — the app's own rows, wrapped in AppMenuProvider, so useCloseAppMenu() closes the dialog. Style them for theme, as with actions.
    • themeStore?: ThemeStore — pass your createThemeStore(...) to offer a colour scheme override.
    • fixedColorScheme?: 'light' | 'dark' — the one look of an app with no store. Defaults to theme.
    • appName?: string — defaults to the catalogue name for product.
    • showAppPreferences?: boolean — default true.
  • ⚠️ showLanguageSelector={false} no longer removes a row. The Global Tuning row also holds the colour scheme, so it stays; false leaves the Language sections out of both dialogs.

  • <PreferencesDialog kind="app" | "global" open onClose theme themeStore fixedColorScheme appName showLanguage>{rows}</PreferencesDialog> is exported for a host that opens one from elsewhere. It must render inside <UniversalProvider>.

  • Storage (all strings exact):

    | What | Where | Absent means | |---|---|---| | Global language | universal:language — .unisim.co.uk cookie on Assess, localStorage on a Universal App (unchanged) | browser locale, else en | | Global colour scheme | universal:color-scheme — same store as the language, and always mirrored to localStorage | light | | An app's language override | localStorage universal:language:<config.product> | follow global | | An app's colour scheme override | localStorage under the app's own theme-store key (unisim-<app>-theme) | follow global |

  • Signed in, the global pair follows the account: the suite row of user_app_prefs (migration 0151) holds { language, colorScheme }. On sign-in the row wins and is applied without being written back; with no row, one is seeded only from values this device explicitly stored (never defaults or the browser locale); every global change upserts the pair. Fire-and-forget and silent on failure; skipped in mock mode. App overrides are never synced. ⚠️ This deliberately lets a signed-in person's language cross between the Universal Apps and Assess — the separation below exists for anonymous visitors on a shared machine, which a row keyed on the user's id cannot affect.

  • npm run test:preferences (51 checks) and npm run test:profile-menu-order (53).

One app: no Global Tuning (since 0.144.0)

James, 2026-09-17: "start with app and show global once they start using a second app, or if they are part of an enterprise? It's kinda pointless if they only use one app".

  • Until the split, the menu has no Global Tuning row, and App preferences shows Language and Colour scheme (radios) without "Follow global". They show what the app is using (a leftover override included) and a change sets the global value and clears any override. So when the split does appear, the choice already made is the global one and every app follows it — nothing to migrate, nothing that looks different.
  • The split (useUniversal().splitPreferences, useGlobalPreferences().split) turns on, and stays on (universal:preferences-split = 1, same store as the global language), when any of these is true:
    • this device has opened two apps: universal:apps-seen, a JSON array of product codes in the global prefs' store (the .unisim.co.uk cookie across Assess, localStorage per origin on a Universal App);
    • the account has: apps in the suite row of user_app_prefs. Each signed-in app adds itself (not the device's list, which may be someone else's on a shared machine), and apps is carried on every global-prefs upsert;
    • the account has any org_members row (one limit(1) read on sign-in; skipped in mock mode).
  • central (the hub and the Assess portal) never counts as an app.
  • ⚠️ Blind spots, accepted: a signed-out person on two Universal Apps on different origins, or on two native apps, stays in the one-app view. Their apps cannot see each other, so there is no global value that could carry between them anyway.
  • <PreferencesDialog kind="app" combined> is the one-app dialog. An app drawing its own appearance control (Jukebox) should check useGlobalPreferences().split and, when false, offer plain Light / Dark / System bound to setColorScheme with the override cleared.

"There are X total users (Y live)" (since 0.145.0)

Every app shows how many people use it, and how many are using it right now, as the last line of the navbar's profile menu. Nothing to wire up: the provider reports the app as in use (guests included) and the menu reads the figure.

  • Counting (migration 0175): the provider calls app_presence_beat(product, install_id) on load, on sign-in/out and every 45 s while the page is visible. The install id is a random UUID in localStorage (unisim:install-id). An account is one person across all its devices and platforms; a guest install is one person unless somebody signed in on it. "Live" = a beat in the last 2 min.
  • Reading: app_user_counts(product) → { total, live }, only while the menu is open. The line hides until a real number arrives.
  • It opens on the WHOLE SUITE since 0.150.0 — suite_user_counts(), "There are a total of 1,234 users across all UNI·SIM apps (12 live)" (migration 0177) — and a tap switches to this app's own figure and back. An account is one person suite-wide; a guest is one person per browser origin, so the suite total is a utilisation figure, not a headcount. ⚠️ The tap is NOT remembered (since 0.152.0). It used to be, in unisim:user-count-scope, and one tap months earlier is what made every app on a browser open on its own figure with the suite hidden behind a click. The key is now deleted on mount; COUNT_SCOPE_KEY is exported only so it can be cleared. [Correction 2026-09-19: this bullet still described the remembered choice.]
  • Press and hold for the breakdown (since 0.154.0, migration 0184): suite_user_counts_by_product() → one row per app, in a popup over the menu. Right-click and Shift+Enter open it too, so the gesture is not pointer-only. ⚠️ The rows do not add up to the suite total and are not meant to — an account using four apps is four rows there and one person in the suite figure. The popup says so; never sum them to produce a total. App names come from the switcher catalogue (productDisplayName), and a product code this build has never heard of is listed under a prettified name rather than dropped, because the database enum gains values without the SDK being rebuilt. A database without 0184 shows "not available" and nothing else breaks. Turn it off with <UserCountLine breakdown={false} />.
  • Off switches: presence: false in UniversalConfig; showUserCount={false} on <UserProfile>. The hubs (product: 'central') never beat or show it.
  • Elsewhere: <UserCountLine /> / useUserCounts() for a home screen, and <UserCountBreakdown /> / useUserCountsByProduct() for the per-app list. Apps without <UniversalProvider> use startPresence(supabase, product), fetchUserCounts, fetchUserCountsByProduct and formatUserCounts(language, counts) directly.

Languages

  • SUPPORTED_LANGUAGES: en, en-gb, fr, es, it, de, pt-BR, pt-PT, tr. The language pickers in Global Tuning and App preferences offer both Portuguese options, "Português (Brasil)" and "Português (Portugal)".

  • Three values since 0.143.0. useLanguage() returns { language, setLanguage, globalLanguage, appLanguage, setGlobalLanguage, setAppLanguage }. language is what the app shows (appLanguage ?? globalLanguage), so everything that already reads it keeps working. ⚠️ setLanguage means setGlobalLanguage — what it always did. setAppLanguage(null) removes the override. useGlobalPreferences() returns the global values only: { language, setLanguage, colorScheme, setColorScheme }.

  • ⚠️ There has been no pt since 0.141.6. The old pt was European Portuguese, so it is now pt-PT. resolveLanguage('pt') returns pt-PT, so a stored pt preference keeps showing what it showed before. A browser locale of pt-BR resolves to pt-BR; every other pt-* (pt-PT, pt-AO, …) resolves to pt-PT. setLanguage() normalises its argument the same way. The shared cookie is also safe with apps on older SDKs: they read pt-BR / pt-PT as pt.

  • Your app's own dictionaries. The SDK only translates its own chrome; each app's strings are its own. Look them up with pickTranslation(dicts, language), or walk languageFallbacks(language) yourself. That way an app with only a pt dictionary keeps working under both codes:

    • pt-BR → pt-PT → pt → en
    • pt-PT → pt → pt-BR → en

    The "exact code, else base language, else English" lookup that Cyber Assess and unisim-central already use maps both codes to pt as well. ⚠️ A map typed Record<Language, …> with the SDK's Language now needs pt-BR and pt-PT keys, not pt.

  • <PrivacyNote> is translated, and so is the About dialog's privacy section: the headline, the claim, the caveat, the link and the tooltips. Apps keep passing English: every subject / except / headline string an app used before 0.141.6 is catalogued by its exact English text in src/privacyCopy.ts. A string that is not catalogued renders the whole note in English, never half-translated. Alternatively pass a map (LocalizedText), e.g. except={{ en: 'backup', fr: 'la sauvegarde' }}. ⚠️ Read the rules at the top of privacyCopy.ts before changing any wording. This is the suite's legally load-bearing sentence, and no translation may promise more than the English does.

  • npm run test:i18n (plain Node) and npm run test:privacy-note (in a browser).

Brand colour on a dark ground

  • useOrgBranding().brand_color_accessible is legible on white only. For a dark theme, call brandColorOn(branding.brand_color, 'dark') (since 0.141.6). It measures against DARK_THEME.surface (#0a0e16), or pass your own surface's hex instead of 'dark'. Same hue; only the lightness moves.
  • Use the default 4.5:1 for text. Use { minRatio: 3 } for a fill whose label is picked by textOn(). The SDK already does this in dark theme for the navbar's company tile and for the company switcher's tiles.

Chips

Two styles, one rule (owner decision 2026-09-14; BRANDING.md, Components): Orbit for anything you click or promote, Value for anything that carries a value.

import { Chip, ChipToggle, ValueChip } from '@unisim/sdk'

<Chip icon={<Lock />}>Nothing uploaded</Chip>
<ChipToggle selected={on} onClick={() => setOn(!on)}>Lighting</ChipToggle>
<ValueChip label="HSE">DSE Regs 1992</ValueChip>
<ValueChip label="REBA 6" tone="warn">Medium risk</ValueChip>
  • The components inject their own CSS: no import, no Tailwind @source. On plain markup, use the u-chip / u-vchip classes and call installChipStyles() once. A page with no build step pastes CHIP_CSS.
  • Light by default. ground="dark", or data-u-ground="dark" on any ancestor, switches a chip to the dark ground. So does a dark class (what createThemeStore sets on <html>) or data-theme="dark" on an ancestor (since 0.142.1), which means an app's dark mode needs no wiring. A chip's own ground="light" beats a dark ancestor.
  • --u-chip-accent / --u-chip-accent-text (and their -dark twins) re-tint the arc and the selected label for a tenant. Pass brand_color_accessible, never brand_color.
  • size="sm" for tight spots: table cells, card corners.
  • ⚠️ The chip CSS is unlayered and injected after the app's own CSS, so in a Tailwind v4 app it beats utilities on the properties it sets. px-4, text-sm or hidden sm:inline-flex on a chip do nothing. Put layout and visibility classes on a wrapper. Margins (since 0.142.2) and the hidden attribute (since 0.142.4) do work. chipStyles.ts explains why a cascade layer isn't the fix.
  • npm run test:chips measures every label's contrast on both grounds and checks the sweep, the grounds and the geometry. A negative control proves the ring check can fail.

Theme (light / dark / system)

  • createThemeStore(storageKey) (since 0.140.0) — an app's theme preference, as a hook. Call it once at module level: export const useThemeStore = createThemeStore('unisim-<app>-theme'), then useThemeStore((s) => s.pref), s.effective ('light' | 'dark', with 'system' resolved), s.setPref(p), and useThemeStore.getState() outside React — the same shape the zustand copies had, without zustand.

  • It toggles the dark class on <html> (Tailwind's dark: variant) and sets color-scheme, at import time, so a dark-mode user never sees a light frame.

  • ⚠️ Opens LIGHT and stays light until the user chooses otherwise — the suite rule, and deliberately stronger than "respect the OS". 'system' follows the OS live, but only once chosen.

  • ⚠️ The key is the user's saved choice. Changing it on a shipped app silently resets everyone to light. Existing apps keep the key their old copy used.

  • Since 0.143.0 that key is the app's OVERRIDE of a global scheme. Absent, the store follows localStorage['universal:color-scheme'] (exported as GLOBAL_COLOR_SCHEME_KEY, default light), which Global Tuning sets. The state gains override: ThemePref | null, global: ThemePref and setOverride(pref | null) (null removes the key); pref is override ?? global, and setPref(p) is setOverride(p). The store repaints on the provider's universal:color-scheme window event (GLOBAL_COLOR_SCHEME_EVENT, CustomEvent with the pref as detail) and on storage events for either key. Existing choices are not migrated: a stored dark now reads as "this app overrides with Dark", which is what the user chose. Pass the store to the navbar as themeStore to offer the override in App preferences.

  • ⚠️ Update your pre-paint script in index.html, or someone whose only choice is the global one gets a light first frame and then a flip:

    <script>
      try {
        var p = localStorage.getItem('unisim-<app>-theme')
          || localStorage.getItem('universal:color-scheme') || 'light'
        var d = p === 'dark' || (p === 'system' && matchMedia('(prefers-color-scheme: dark)').matches)
        document.documentElement.classList.toggle('dark', d)
        document.documentElement.style.colorScheme = d ? 'dark' : 'light'
      } catch (e) {}
    </script>
  • npm run test:theme-store (34 checks).

Collapsibles that show what is inside them

Opening a fold low on the page used to leave its contents below the bottom of the screen: the row you clicked stayed put and you had to scroll to find out whether anything had happened. Every product inside <UniversalProvider> now scrolls an opening fold into view automatically — no per-app wiring, and nothing to remember in a product written later. It moves by the smallest amount that brings the contents on screen, never scrolls the row you clicked off the top (so contents taller than the window read from the top down), and does nothing at all when the fold already fits.

  • Works out of the box for <details>/<summary>, and for a state-driven collapsible whose trigger carries aria-expanded and aria-controls="<panel id>". A trigger with no aria-controls is deliberately left alone — nothing on the page says which box is its panel. Adding it is a one-line a11y fix that opts the fold in.
  • revealOnExpand={false} on the provider config turns it off; pass { topOffset: 72 } (etc.) to nudge the geometry. A pinned header's height is measured, not configured, so a sticky navbar needs nothing.
  • installRevealOnExpand() / revealExpanded(panel, header?) are exported for an app that never mounts the provider, or a collapsible the document listeners cannot see.
  • npm run test:reveal-on-expand — geometric browser checks, including a negative control that proves the harness catches the original bug.

QR codes

  • <UnisimQr value={url} size={176} label="the mobile signing link" /> — the house-style code: ink modules, orange finder eyes, the UNI·SIM mark in the centre, error correction pinned at H. Click (or Enter) enlarges it full-screen on a dimmed backdrop — that's on by default, since the reason a code is on screen is that someone wants to point a phone at it. Pass enlargeable={false} for a plain image.
  • <QrLightbox value={url} onClose={…} /> — the enlarged view on its own, for a code drawn by something else. title, hint and actions override the caption and hang buttons (Copy PNG, Download) below the plate; <UnisimQr lightbox={{ … }}> passes them through.
  • unisimQrPngDataUrl(value, size) / unisimQrPngBlob(value, size) — the same code as a PNG, for an <img>, a clipboard write, a download, or a stamp drawn into a generated PDF.

The colours are measured, not chosen: brand orange modules are 2.34:1 on white, below the ~3:1 a decoder's binariser needs, and the light-on-dark version is an inverted code that strict readers refuse. The orange goes on the finder eyes instead. Don't restyle without re-running a decode check against zxing (Universal_Beam/e2e/beam.e2e.ts does).

qr-code-styling is a normal dependency but is imported dynamically, so it stays out of the bundle — and out of any server render — of an app that never draws a code.

Where the session is stored

UniversalProvider picks the store for the platform it is running on — you do not configure this beyond passing cookieDomain:

| Platform | Store | Carries across | | --- | --- | --- | | Browser on *.unisim.co.uk | cookie scoped to the parent zone | every suite subdomain | | Browser on localhost / Electron | localStorage | nothing (origin-scoped) | | Browser on a product's own domain (ergoassess.app) | localStorage | into the zone on a click — see below | | Native (Capacitor), plugin present | shared suite store (see below) | every suite app on the device | | Native, plugin missing | localStorage | nothing, but it does persist |

⚠️ A Capacitor app cannot use the cookie. It runs at capacitor://localhost, where a cookie carrying Domain=.unisim.co.uk is rejected outright by the domain-match rule — the write silently does nothing and the read returns null. Every product computes cookieDomain from import.meta.env.PROD, which is true in the native bundle too, so before 0.123.0 the native builds could not persist a session at all: sign in, force-quit, signed out again, nothing logged. npm run test:session-storage pins that behaviour.

A product served from its own domain as well (Ergo at ergoassess.app, beside assess.unisim.co.uk/ergo) passes the same cookieDomain on both. From 0.155.0 the SDK checks the page really sits under the zone before using the cookie; before, every write on ergoassess.app was refused, so no session survived a reload and each page load made a new anonymous user. There the session is app-local, and links into the zone carry it across (handoff.ts): a click on a *.unisim.co.uk link by a signed-in, non-anonymous visitor asks the session-handoff edge function for a single-use magic-link code, goes to the Assess portal with it in the fragment, and the portal redeems it into the .unisim.co.uk cookie before going on to the link. It is a NEW session, never a copy of the refresh token (two stores spending one token get the whole family revoked), and a real sign-in already in the zone is never replaced. Only redeemers on 0.155.0+ understand the fragment, which is why there is one: the portal. npm run test:handoff.

⚠️ A shared store does not by itself give you a shared SIGN-OUT. supabase-js announces a session it finds on resume, and announces nothing when it finds the store emptied — so an app already running keeps showing a signed-in UI after you sign out somewhere else. UniversalProvider closes that by re-reading the store on every resume; npm run test:shared-signout pins both legs.

The shared store is two mechanisms behind one name

UnisimSuiteAuth presents the same three calls on both platforms, but what is underneath could hardly be less alike, and the difference decides what can go wrong:

| | iOS | Android | | --- | --- | --- | | Mechanism | one Keychain access group | a ring of ContentProviders, one per app | | Shared by | the OS, for apps of the same team | the apps themselves, gated on a signature permission | | Written on sign-in | once | to this app, then pushed to every installed peer | | Fails to share when | the entitlement is missing | the peer list is empty, or signatures differ |

Android has no shared box to put anything in — sharedUserId was deprecated in API 29 and is unusable for anything new — so there is nowhere central to write. Each app therefore hosts SuiteAuthProvider behind uk.co.unisim.suite.permission.SUITE_AUTH (protection level signature, so only same-key apps get through), and reads and writes fan out across the installed peers. Sources: android/src/main/java/uk/co/unisim/sdk/.

⚠️ On Android the peer list is the thing that breaks. API 30+ hides packages you have not declared an interest in, so the <queries> block in the module's AndroidManifest.xml is load-bearing: without an app listed there it is invisible, keeps a private session, and nothing reports a problem. Its <provider> authorities are the single canonical list of participating suite apps — the Java side keeps no second copy, it filters what the package manager admits to by the .unisimsuiteauth suffix. Adding an app to the suite means adding one line there. (The <package> list below it is a different list for a different job — see the switcher section.)

⚠️ Same key, in practice, means the same BUILD TYPE. Debug builds are signed with ~/.android/debug.keystore and release builds with the upload key. Install a mix on one phone and the platform treats them as two different vendors: access is refused, every app keeps its own session, and a refused peer is a normal enough thing to meet that nothing shouts. Test all-debug or all-release.

Turning on the shared store for a native app

The SDK ships both native halves itself (ios/Sources/UnisimSuiteAuthPlugin and android/), so npx cap sync picks them up with no Xcode or Gradle surgery. Android needs nothing further — the permission, the <queries> list and the provider all arrive through manifest merging.

iOS has one thing the SDK cannot do for you, the entitlement:

node ../universal-platform/scripts/add-suite-keychain.mjs <path-to-app-repo>

That writes ios/App/App/App.entitlements declaring $(AppIdentifierPrefix)co.uk.unisim.suite and points CODE_SIGN_ENTITLEMENTS at it in both configurations. Then npx cap sync ios and rebuild.

⚠️ An app with no entitlements file cannot use the Keychain at all — every call returns errSecMissingEntitlement (-34018), not just the shared ones. An unsigned simulator build (CODE_SIGNING_ALLOWED=NO) gets the same, which is worth knowing before you go hunting for a bug in the plugin.

hasSharedSuiteStore() (and chooseSessionStorage().kind) report which store an app actually got — a build that quietly landed on native-local still signs in, it just does not carry to the other apps, and nothing about the behaviour makes that visible.

Provider sign-in in the phone apps (Apple / Google / Microsoft)

<SignInDialog /> offers the Apple / Google / Microsoft tab inside a Capacitor app too (since 0.141.8). The web round trip cannot work there — it returns to capacitor://localhost, which is on no redirect allowlist; Google refuses sign-in inside an embedded web view (403 disallowed_useragent); and navigating the app's only web view away unloads the app — so a native shell does it the way native apps must:

| | iOS | Android | | --- | --- | --- | | Apple | the OS's Sign in with Apple sheet → Supabase id_token grant | the system browser, like the others | | Google / Microsoft | ASWebAuthenticationSession → Supabase /authorize | a Custom Tab → Supabase /authorize | | Comes back to | the session's own completion handler | OAuthCallbackActivity, merged in from the SDK's manifest | | Callback address | <bundle id>://auth-callback | <applicationId>://auth-callback | | Per-app config | the com.apple.developer.applesignin entitlement | none |

Source: src/nativeOAuth.ts, ios/Sources/UnisimSuiteAuthPlugin/SuiteOAuth.swift, android/src/main/java/uk/co/unisim/sdk/OAuthCallbackActivity.java. The three native methods (oauthSupport, oauthStart, appleSignIn) live on the existing UnisimSuiteAuth plugin, for the same reason the app-opening methods do: a new plugin class is absent from every app until each one re-syncs.

What it guarantees, each pinned by npm run test:native-oauth:

  • The session is suite-wide. It is installed with supabase.auth.setSession, which writes through the shared suite store above — one sign-in on the phone signs in every suite app, exactly like the email code.
  • A trial session is linked, never replaced — the browser leg goes through /user/identities/authorize with the trial's bearer token, the Apple leg through the id_token grant with link_identity, and a failed link is returned, never retried as a sign-in. identity_already_exists and manual_linking_disabled arrive with the web flow's codes, so the dialog's collision panel works unchanged. (The native "leave my trial behind" path does not sign out first: the new session replaces the trial only when it lands, so cancelling keeps the trial.)
  • PKCE, and only a code is accepted back. S256 where WebCrypto exists, plain otherwise (supabase-js's own fallback). Tokens in a fragment, or a callback on any address but this app's own, are refused — on Android any app can fire a deep link at us, and a code is worthless without the verifier.
  • Nothing appears until it can work. The tab needs the provider switched on in Supabase and the plugin's native half (an app synced against an older SDK shows no tab). On iPhone it also needs Apple switched on: App Review 4.8 will not take Google or Microsoft in an iPhone app without Sign in with Apple beside them, so with Apple off the app offers the email code alone.

Turning it on for an app

  1. Depend on @unisim/sdk ≥ 0.141.8 and npm run cap:sync. Android needs nothing more.
  2. iOS: node ../universal-platform/scripts/add-sign-in-with-apple.mjs <path-to-app-repo> (after add-suite-keychain.mjs, which creates the entitlements file), then rebuild. The App ID needs the Sign in with Apple capability as well.
  3. The project — console settings, recorded nowhere in code:
    • every <bundle id>://auth-callback on the Supabase redirect allowlist;
    • every iOS bundle id in the Apple provider's Client IDs (beside the web Services ID) — the sheet's token is issued to the bundle id;
    • Allow manual linking on (trials link);
    • the providers themselves switched on.

⚠️ Every one of those fails far from its cause:

| Missing | Looks like | | --- | --- | | the callback on the allowlist | GoTrue silently sends the browser to the Site URL; the sheet sits on the website and never comes back | | the bundle id in Apple's Client IDs | the Apple sheet succeeds, then Supabase refuses the token's audience | | the entitlement | the Apple sheet fails at once with ASAuthorizationError 1000 — the same code as a device with no Apple ID | | the App ID capability | a device build fails to provision; a simulator build is fine, so it proves nothing | | manual linking | every trial visitor gets "can't link" | | a re-sync against this SDK | no provider tab at all |

Known limit: on Android, if the system kills the app while the browser is up, the callback arrives with nobody holding the PKCE verifier; the app comes back to the front and the person signs in again.

Opening a suite app instead of its website

On a phone, a switcher row for a product you have installed opens the app, and says so with an Installed badge. In a browser nothing changes at all.

⚠️ An https link cannot do this, and adding associated domains would not help. A WKWebView does not honour universal links for navigation that happens inside it, so a Capacitor app tapping https://opensource.unisim.co.uk/pdf loads the website in the webview it is already in, however the domains are configured. Handing off needs the platform's app-to-app channel: a custom URL scheme on iOS, a launch intent on Android.

SUITE_NATIVE_APPS in src/suiteApps.ts is the source list — id, scheme, iOS bundle id, Android package. A product missing from it (Ergo Assess, Exports, anything web-only) keeps today's link, which is the right answer for a product with no app.

| | iOS | Android | | --- | --- | --- | | Matches on | the app's custom URL scheme | the app's package | | Asked with | canOpenURL / UIApplication.open | getLaunchIntentForPackage | | Needs, per app | CFBundleURLTypes and every other app's scheme in LSApplicationQueriesSchemes | nothing — the SDK's manifest merges in | | Wrong list looks like | "not installed" | "not installed" |

⚠️ Both platforms fail the same silent way, and it is the reason this is tested. An undeclared scheme or package is not an error: it is the ordinary answer for an app you do not have. A drifted list does not break the switcher, it just quietly stops offering one product on one platform. npm run test:suite-apps pins the registry against the Android manifest and against the switcher's own catalogue, and pins the browser case to today's behaviour.

The iOS half is per app and generated, never hand-edited:

node ../universal-platform/scripts/add-suite-app-links.mjs <path-to-app-repo>

Add --check to fail instead of write. Then npx cap sync ios and rebuild — Info.plist is compiled into the bundle, so running the script is not shipping the change.

⚠️ Adding a new native app means three edits, not one: the registry, the <package> list in android/src/main/AndroidManifest.xml, and a run of the script above in every app repo (they all need the new scheme in their queries list, not just the new app).

⚠️ An app that gains a native build later is the same three edits, and nothing will tell you. Ergo Assess shipped a SwiftUI/Compose pair while the registry still listed it as web-only; its switcher row went on loading the website inside the caller's webview, which is indistinguishable from an app you have not installed. test:suite-apps now names Ergo specifically, because a category ("web-only products") cannot be tested and a named product can.

Not every suite app is a Capacitor app. The script tries ios/App/App/ first, then falls back to the single Info.plist under ios/ — which is how it finds Ergo's ios/ErgoAssessCapture/Info.plist — and reads the bundle id from ios/project.yml rather than the Capacitor config in that case. It refuses to choose when a repo has more than one iOS target.

The row stays a real <a href> throughout — the handoff is a click handler over the top, so a launch that fails still navigates and long-press/copy-link keep working. openSuiteApp() resolving false is the fallback signal, not an error. A comingSoon product never launches even when installed, because its row does not navigate either.

…and where to GET one you have not got

The same row, the other answer. In a native shell a product you do not have installed shows an App Store ↗ / Google Play ↗ chip and its row points at the store, once — and only once — that listing is actually live:

| Registry field | Set it when | | --- | --- | | appStoreId | Apple's numeric id, the day the listing goes on sale. Not the bundle id, and not the day you submit | | playListed | true the day the Play listing is public. There is no id to record — the URL is just androidPackage |

Ergo Assess was the first to get one (appStoreId 6811535068, 2026-09-15); every other field is absent today, and absent means the row keeps its website link. That is the whole safety property: this ships inert and lights up one product at a time as each store approves it, with no second place to edit.

⚠️ The two URLs per platform are not interchangeable. An https://apps.apple.com/… link tapped inside a Capacitor webview loads the App Store's web page in that webview — the same "website inside the app you were already in" this whole feature exists to end. itms-apps: and market: are not http(s), so the webview hands them to the OS instead of navigating:

| | in the shell | in a browser | | --- | --- | --- | | iOS | itms-apps://itunes.apple.com/app/id<ID> | https://apps.apple.com/app/id<ID> | | Android | market://details?id=<pkg> | https://play.google.com/store/apps/details?id=<pkg> |

Getting those two rows the wrong way round reintroduces the original bug wearing a badge that says it fixed it, so test:suite-apps pins all four by construction and test:suite-store drives the iOS and Android ones in a real Chromium against a stubbed bridge.

⚠️ The switcher offers this in the native shell only, and that is deliberate rather than unfinished. On the mobile web the https link is the product working correctly — someone on opensource.unisim.co.uk tapping Universal QR wants the QR tool, not a store page — so retargeting there would hide a working web app behind a download. suiteAppStore() handles the browser case and is exported for a product that wants to offer it behind its own affordance; the switcher just does not make that call on everyone's behalf.

An installed app is never offered a download (installed wins), and neither is a comingSoon one — whatever the badge says is what the row does.

Multi-tenant model

This SDK is the client side of a Supabase-backed multi-tenant schema (see universal-platform/supabase/migrations). Every read/write is scoped to the user's active org via the is_org_member() helper in RLS policies. Anonymous-auth users get the same hooks; trial caps (3 people / 1 team / 2 places / 1 project) are enforced server-side by the enforce_anonymous_trial_caps() trigger.

Publishing

cd packages/sdk
./publish.sh patch       # or minor / major

This runs npm version, builds, publishes, commits the version bump, and pushes — see publish.sh for the exact sequence. prepublishOnly runs typecheck && build as a safety net, and the dist/ folder is the only thing shipped (per files).

License

MIT © Universal Simulation Ltd