Z zexoskit v0.1.0 · unreleased
⌘K
Vanilla JS Native Web Components No UI framework dependency iOS · iPadOS · macOS PWA

Build native-feeling PWAs for Apple platforms

zexoskit is a framework you import, not a CLI that scaffolds — a set of composable building blocks (core, ui, plugins) for shipping installable, offline-capable, native-quality PWAs on iPhone, iPad and Mac, built entirely on standard web APIs.

Installation

zexoskit is consumed via pnpm as a workspace/package import — there is no scaffolding CLI.

pnpm add zexoskit
# optional, only needed if you use the supabase plugin:
pnpm add @supabase/supabase-js

Working inside this repo instead? pnpm install at the root, then pnpm dev to serve the demo and examples via Vite, or pnpm test to run the Vitest suite.

Quick start

The whole public surface is createAppapp.use(plugin, options)app.mount(selector).

import { createApp } from 'zexoskit';
import { supabasePlugin } from 'zexoskit/plugins/supabase';
import { pushPlugin } from 'zexoskit/plugins/push';
import 'zexoskit/ui'; // registers every <zk-*> custom element

const app = createApp({ name: 'CronWeather', platform: 'auto' });

app.use(supabasePlugin, { url: SUPABASE_URL, anonKey: SUPABASE_ANON_KEY });
app.use(pushPlugin, { vapidKey: VAPID_PUBLIC_KEY });

await app.mount('#app');

mount() is async — it registers the service worker, wires the install-prompt and update-check lifecycle, and calls every installed plugin's onReady(). Always await it before assuming app.root or plugin namespaces (app.auth, app.storage, …) are ready.

Project structure

zexoskit/
├── core/          bootstrap, platform detection, router, store, lifecycle
├── ui/            design tokens + native Web Components (zk-*)
├── plugins/       registry + socle plugins (auth, storage, push, supabase) + domain stubs
├── demo/          Vite-served page exercising the public API
├── examples/      real consumer apps built on zexoskit (e.g. PixLearn)
└── index.js       package entry point

Architecture at a glance

Visual guide

Three complementary views explain how zexoskit stays small and composable: independent framework layers, a shared plugin lifecycle, and local-first persistence with an optional remote backend.

Core Architecture

Bootstrap, routing, reactive state, platform detection and PWA lifecycle — everything under core/.

createApp

zexoskitStable

Creates the application instance. This is the framework's single entry point — everything else attaches to the object it returns.

function createApp(options: {
  name: string,                                   // required
  platform?: 'auto' | 'iphone' | 'ipad' | 'mac',   // default: 'auto'
  serviceWorkerPath?: string,                      // default: '/sw.js'
}): App

App instance

MemberSignatureDescription
app.use()(plugin, options?) => appRegisters a plugin. Calls plugin.onInstall(app, options) immediately if plugin.isSupported(app.platform) is true; otherwise skips it silently (debug-logged). Chainable.
app.mount()(selector: string) => Promise<app>Registers the service worker, sets app.root, wires up app.install / app.updates, then calls every plugin's onReady(app).
app.destroy()() => voidCalls every plugin's onDestroy(app) and clears the plugin registry.
app.optionsObjectThe options object passed to createApp.
app.platformPlatformSee Platform.
app.rootElement | nullSet by mount() to document.querySelector(selector).

Plugins attach their own namespaces on install — e.g. app.auth, app.storage, app.push, app.webauthn, app.db.

Router

core/router.jsStable

A small history-API router with dynamic params, wildcards, and navigation guards.

Not re-exported from zexoskit or the package's exports map. Import it by relative/deep path (zexoskit/core/router.js) until it's promoted to a documented subpath export — treat this as an unstable import path.

import { createRouter } from 'zexoskit/core/router.js';

const router = createRouter({
  '/users/:id': (ctx) => renderUser(ctx.params.id),
  '/files/*': (ctx) => renderFile(ctx.params.wildcard),
  '/admin': { handler: renderAdmin, guard: () => isAdmin() || '/login' },
});

router.beforeEach((ctx) => {
  if (!isAuthed() && ctx.path !== '/login') return '/login'; // redirect
});

router.start(); // matches current location + listens for popstate
await router.navigate('/users/42');

Route patterns

PatternCaptures
:nameOne path segment (no /) → params.name
*Everything after, as its own segment (e.g. /files/*) → params.wildcard

API

MethodSignatureDescription
navigate(path)(path) => Promise<boolean>Runs global then per-route guards, pushes history state, invokes the matched handler. A guard returning false cancels; returning a string path redirects.
beforeEach(guard)(guard) => unsubscribeRegisters a global guard, run before any per-route guard.
start()() => voidMatches the current URL once and listens for popstate.
on(event, cb)(event, cb) => unsubscribeOnly 'navigate' is emitted, with the matched { path, params }.

Store

core/store.jsStable

Proxy-based reactive state — mutate nested state directly, subscribe to changes, derive selectors and computed values.

Also not in the package exports map. Import via zexoskit/core/store.js.

import { createStore } from 'zexoskit/core/store.js';

const store = createStore({ cards: [], query: '' });

store.subscribe(() => renderFeed());

const savedCount = store.computed(
  (state) => state.cards.filter((c) => c.saved).length
);
savedCount.subscribe((count) => {
  navButton.textContent = `Saved (${count})`;
});

// direct nested mutation triggers reactivity:
store.state.query = 'cats';
store.state.cards = [newCard, ...store.state.cards];
MemberSignatureDescription
store.stateproxied ObjectThe reactive state tree. Read and mutate directly, at any nesting depth.
subscribe(fn)(fn) => unsubscribeCalled with the full state on every mutation anywhere in the tree.
select(fn)(fn) => { get(), subscribe() }Derived slice; only notifies subscribers when the shallow-compared result actually changes.
computed(fn)(fn) => { value, subscribe() }Built on select(); .value recomputes on every access.

Platform

core/platform.jsStable

Live device/environment detection — device type, input modality, layout breakpoint and standalone (installed) status.

import { platform, detectPlatform, refreshPlatform } from 'zexoskit';

// platform: { type, input, layout, standalone }
// type:       'iphone' | 'ipad' | 'mac'
// input:      'touch' | 'pointer' | 'hybrid'
// layout:     'compact' | 'regular'   (breakpoint: 768px)
// standalone: boolean

console.log(platform.type, platform.layout);

platform is a live singleton, recomputed automatically on resize/orientationchange. Call refreshPlatform() to force a recompute.

createApp({ platform: 'iphone' | 'ipad' | 'mac' }) override is short-lived. It's used for the synchronous isSupported() checks during app.use() calls, but app.mount()'s internal bootstrap() step reassigns app.platform to the auto-detected singleton unconditionally. Don't rely on an explicit platform override surviving past mount().

Lifecycle

core/lifecycle.jsStable

Install-prompt handling and service-worker update flow. Wired up automatically by app.mount() — you only read app.install / app.updates and build UI around them.

app.install

hasNativePrompt()Whether a beforeinstallprompt event was captured (non-iOS browsers).
promptNative()Triggers the native prompt; resolves the user's choice, or null if none captured.
shouldShowIosFallback()True on iPhone/iPad Safari (no native prompt exists there), debounced 14 days after dismissal.
dismissIosFallback()Records the dismissal timestamp to debounce re-showing.

app.updates

availableGetter — true once a new service-worker version is waiting.
onAvailable(fn)Registers a listener, called when an update becomes available.
apply()Activates the waiting worker (SKIP_WAITING) and reloads once it takes control.
await app.mount('#app');

if (app.install.shouldShowIosFallback()) showAddToHomeScreenBanner();

app.updates.onAvailable(() => {
  toast.show('Update available');
  updateButton.onclick = () => app.updates.apply();
});

Plugin System

Every optional capability — auth, storage, push, Supabase, domain logic — is a plugin with the same tiny contract.

definePlugin

zexoskitStable

The contract every zexoskit plugin implements. Use it to build your own plugins.

import { definePlugin } from 'zexoskit';

const myPlugin = definePlugin({
  name: 'analytics',                                  // required
  isSupported: (platform) => true,                     // default: always supported
  onInstall(app, options) { app.analytics = client; },  // called by app.use()
  onReady(app) { /* called once, after app.mount() */ },
  onDestroy(app) { /* called by app.destroy() */ },
});

app.use(myPlugin, { apiKey: '...' });

Built-in plugins

Ready-made plugins, importable from documented subpaths per package.json's exports map:

ImportExportsNamespace
zexoskit/plugins/authcreateAuthPlugin(adapter)app.auth
zexoskit/plugins/webauthnwebauthnPluginapp.webauthn
zexoskit/plugins/storagestoragePluginapp.storage
zexoskit/plugins/pushpushPluginapp.push
zexoskit/plugins/supabasesupabasePluginapp.db, app.auth, app.realtime
zexoskit/plugins/supabase/auth-adaptercreateSupabaseAuthAdapter(client)— (feeds createAuthPlugin)
zexoskit/plugins/supabase/storage-adaptercreateSupabaseStorageAdapter(client, table?)— (feeds storagePlugin's remote option)
zexoskit/plugins/domain/*e.g. calendarPlugin, chatPluginstubs — see Domain plugins

Auth

plugins/authStable

A generic authentication contract — bring your own adapter (Supabase, a REST backend, a local mock, …).

// AuthAdapter contract:
{
  login(credentials: Object): Promise<Object>,
  logout(): Promise<void>,
  getSession(): Object | null,
  onAuthChange(callback: (session) => void): unsubscribe,
}
import { createAuthPlugin } from 'zexoskit/plugins/auth';

app.use(createAuthPlugin(myAdapter));

app.auth.onAuthChange((session) => updateAuthUi(session));
loginForm.addEventListener('zk-submit', (e) => {
  app.auth.login({ email: e.detail.email, password: e.detail.password });
});

WebAuthn

plugins/webauthnStable

Passkey registration/authentication ceremonies. Only wraps the browser API — challenge issuance and attestation verification stay on your backend.

MethodSignatureDescription
isPlatformAuthenticatorAvailable()() => Promise<boolean>Face ID / Touch ID availability check.
register(publicKeyOptions)(opts) => Promise<Object>navigator.credentials.create(), base64url in/out.
authenticate(publicKeyOptions)(opts) => Promise<Object>navigator.credentials.get(), base64url in/out.
import { webauthnPlugin } from 'zexoskit/plugins/webauthn';
app.use(webauthnPlugin);

const options = await fetch('/webauthn/register/options').then((r) => r.json());
const credential = await app.webauthn.register(options);
await fetch('/webauthn/register/verify', { method: 'POST', body: JSON.stringify(credential) });

Storage

plugins/storageStable

IndexedDB-backed local cache with an optional remote adapter and an offline write queue — flushed automatically on online and when the app returns to the foreground.

MethodSignatureDescription
get(key)(key) => Promise<any>Tries remote first (if online + configured), falls back to local cache.
set(key, value)(key, value) => Promise<void>Always writes locally; syncs remotely if online, else enqueues for later.
delete(key)(key) => Promise<void>Local cache only.
setRemote(adapter)(adapter) => voidAttach/replace the remote adapter post-install.
flushQueue()() => Promise<void>Replays queued offline writes in order.
import { storagePlugin } from 'zexoskit/plugins/storage';
import { createSupabaseStorageAdapter } from 'zexoskit/plugins/supabase/storage-adapter';

app.use(storagePlugin, { remote: createSupabaseStorageAdapter(supabaseClient) });

await app.storage.set('cards', cards);
const cards = await app.storage.get('cards');

Push

plugins/pushStable

Web Push subscribe/unsubscribe flow via VAPID. Only supported for installed (standalone) PWAs — matches iOS 16.4+'s own push requirement.

MemberSignatureDescription
permissionstringNotification.permission, kept in sync.
requestPermission()() => Promise<string>Prompts the OS permission dialog.
subscribe()() => Promise<PushSubscription|null>Requests permission, then subscribes with the configured vapidKey.
unsubscribe()() => Promise<boolean>Unsubscribes the current subscription, if any.
import { pushPlugin } from 'zexoskit/plugins/push';

app.use(pushPlugin, {
  vapidKey: VAPID_PUBLIC_KEY,
  onSubscribe: (subscription) => fetch('/push/subscribe', { method: 'POST', body: JSON.stringify(subscription) }),
});

await app.mount('#app'); // app.serviceWorkerRegistration must exist before subscribe()
await app.push.subscribe();

Supabase

plugins/supabaseStable

Wires up a Supabase client as app.db, plus a matching app.auth adapter and app.realtime.

import { supabasePlugin } from 'zexoskit/plugins/supabase';

app.use(supabasePlugin, { url: SUPABASE_URL, anonKey: SUPABASE_ANON_KEY });
// app.db, app.auth, app.realtime become available once the plugin's
// async onInstall() resolves — see the caveat below.

Async install, not awaited by app.use(). supabasePlugin.onInstall dynamically imports @supabase/supabase-js and is itself async, but app.use() doesn't await it. app.db / app.auth / app.realtime may not exist yet immediately after app.use(supabasePlugin, …) returns — guard accordingly, or wait until after app.mount().

Two standalone adapters build on it:

createSupabaseAuthAdapter(client)Implements the generic AuthAdapter contract on a Supabase client.
createSupabaseStorageAdapter(client, table = 'zexoskit_kv')Implements storagePlugin's remote adapter contract against a simple key/value jsonb table.

Domain plugins

plugins/domain/*Coming soon

Structurally valid, deliberately unimplemented. Their business logic is deferred until a first real consumer app picks one, so today they only log a debug message identifying themselves.

PluginImportIntended purpose
chatPluginzexoskit/plugins/domain/chat-pluginAgentic orchestration, streaming, conversation history.
mediaPlayerPluginzexoskit/plugins/domain/media-player-pluginShared audio/video player (Media Session API, PiP).
contentSearchPluginzexoskit/plugins/domain/content-search-pluginGeneric full-text search engine.
richEditorPluginzexoskit/plugins/domain/rich-editor-pluginMarkdown / WYSIWYG editor.
calendarPluginzexoskit/plugins/domain/calendar-pluginCalendar view, recurrence, reminders.
trackingPluginzexoskit/plugins/domain/tracking-pluginGeneric daily tracking (values/goals/history) — no HealthKit access from a pure PWA.

UI · Foundations

Design tokens and theming — the base every zk-* component builds on.

Design tokens

ui/tokens.js

Plain CSS custom properties, framework/bundler-agnostic. Every component reads them from :root, so shadow DOM inherits them for free.

CategoryTokens
Color — base--zk-color-bg, --zk-color-surface, --zk-color-surface-elevated, --zk-color-glass-bg, --zk-color-text, --zk-color-text-secondary, --zk-color-text-tertiary, --zk-color-primary, --zk-color-primary-hover, --zk-color-primary-subtle, --zk-color-border, --zk-color-border-subtle
Color — semantic--zk-color-danger, --zk-color-danger-subtle, --zk-color-success, --zk-color-success-subtle, --zk-color-warning, --zk-color-warning-subtle
Radius--zk-radius-sm (8px), --zk-radius-md (14px), --zk-radius-lg (20px), --zk-radius-pill (9999px)
Spacing--zk-space-xs (4px), --zk-space-sm (8px), --zk-space-md (16px), --zk-space-lg (24px)
Elevation--zk-shadow-sm, --zk-shadow-md, --zk-shadow-lg, --zk-blur-backdrop (blur(20px) saturate(180%), used for glass surfaces)
Motion--zk-transition-fast (0.15s), --zk-transition-normal (0.25s) — both cubic-bezier(0.4, 0, 0.2, 1)
Typography--zk-font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif

Light values include bg #ffffff, primary #007aff; dark values include bg #000000, primary #0a84ff — see ui/tokens.js for the full light/dark maps. Override any token with normal CSS cascade, or target a component's internal parts with ::part(...) for deeper overrides.

Icon

<zk-icon>

A pro-grade SVG vector icon set (SF Symbols / Lucide-style stroke design, 24×24 viewBox) as a single lightweight custom element — no icon font, no external requests.

Live Interactive Component
AttributePropertyDefaultDescription
name.name'info'Icon key — see gallery below.
size.size'20'Pixels — sets both width/height on :host.
color.color'currentColor'Maps to the SVG's stroke (and fill when filled).
stroke-width'2'Attribute-only, no JS property setter.
filled.filledfalseBoolean — fills the shape with color instead of stroking it (stroke width forced to 0).
<zk-icon name="bell" size="20"></zk-icon>
<zk-icon name="heart" size="20" color="var(--zk-color-danger)" filled></zk-icon>

Registered on the barrel import (import 'zexoskit/ui'), or standalone via import { Icon } from 'zexoskit/ui'. The raw path lookup table is also exported directly from the source module: import { ICONS } from '../ui/icon.js' (not re-exported from the ui barrel).

Icon gallery (Click to copy name)

ThemeProvider

<zk-theme-provider>

Injects the active token set as global CSS custom properties and tracks OS prefers-color-scheme when in auto mode.

AttributeValuesDescription
themeauto | light | darkReactive — changes are applied immediately, including OS-level changes while auto.
<zk-theme-provider theme="auto">
  <div id="app"></div>
</zk-theme-provider>

UI · Layout & Navigation

Adaptive shells and navigation — compact (iPhone) vs. regular (iPad/Mac) layouts, resolved purely via CSS media queries.

Shell

<zk-shell>

Root layout container: bottom nav on compact layout, left sidebar on regular layout.

Slots

navNavigation element (e.g. <zk-sidebar-collapsible>).
contentMain page content.
<zk-shell>
  <zk-sidebar-collapsible slot="nav" active-id="feed">
    <button data-id="feed">Discover</button>
  </zk-sidebar-collapsible>
  <div slot="content">...</div>
</zk-shell>

NavBar

<zk-nav-bar>

A large title that condenses into a compact centered title on scroll (iOS-style).

AttributeDescription
scroll-targetCSS selector to watch for scroll; defaults to window.

Slots

leadingtitletrailing
<zk-nav-bar scroll-target="#content">
  <span slot="leading"></span>
  <span slot="title">Feed</span>
  <span slot="trailing"></span>
</zk-nav-bar>

TabBar

<zk-tab-bar>

Safe-area-aware bottom navigation for compact layouts.

AttributeDescription
active-idMatches a child's data-id.

Events

zk-navigate{ id } — dispatched on tab click.
<zk-tab-bar active-id="home">
  <button data-id="home">Home</button>
  <button data-id="settings">Settings</button>
</zk-tab-bar>

SidebarCollapsible

<zk-sidebar-collapsible>

Adaptive nav: a bottom tab bar on compact layout, a collapsible left sidebar on regular layout.

Attribute / PropertyDescription
active-id / .activeIdMatches a child's data-id.
collapsed / .collapsedBoolean; toggled by the built-in collapse button on regular layout.

Events

zk-navigate{ id }
zk-toggle-collapse{ collapsed }
<zk-sidebar-collapsible slot="nav" active-id="feed">
  <button data-id="feed">Discover</button>
  <button data-id="saved">Saved</button>
  <button data-id="settings">Settings</button>
</zk-sidebar-collapsible>
nav.addEventListener('zk-navigate', (e) => showPage(e.detail.id));

Breadcrumb

<zk-breadcrumb>

macOS/iPad windowed-mode navigation aid. Hidden entirely on compact layout — not part of the iOS nav vocabulary.

Live Interactive Component
<zk-breadcrumb>
  <a href="/">Home</a>
  <a href="/section">Section</a>
  <span>Current page</span>
</zk-breadcrumb>

UI · Feedback & Overlays

Modals, alerts, action sheets and toasts — every event bubbles with zk- prefixed names, composed across shadow boundaries.

Modal

<zk-modal>

Full-screen slide-up on compact layout, centered panel on regular layout.

Attribute / PropertyDescription
open / .openBoolean, controls visibility.

Methods

close()Sets open = false and dispatches zk-close.

Events

zk-closeBackdrop click, Escape, or .close().

Slots

content
<zk-modal id="login-modal">
  <div slot="content"><zk-login-form></zk-login-form></div>
</zk-modal>
openButton.addEventListener('click', () => { modal.open = true; });
modal.addEventListener('zk-close', () => { modal.open = false; });

Alert

<zk-alert>

Centered alert dialog — never full-screen, on any platform.

Live Interactive Component
AttributeDescription
openBoolean.

Events

zk-close{ id } — the clicked action's data-id, or null for backdrop/Escape.

Slots

titlemessageactions (repeatable — data-id required, data-role="cancel" / data-destructive optional)
<zk-alert id="confirm-delete">
  <span slot="title">Delete?</span>
  <span slot="message">This action can't be undone.</span>
  <button slot="actions" data-id="cancel" data-role="cancel">Cancel</button>
  <button slot="actions" data-id="confirm" data-destructive>Delete</button>
</zk-alert>

ActionSheet

<zk-action-sheet>

Contextual choice list — bottom sheet on compact layout, small centered panel on regular layout.

AttributeDescription
openBoolean.

Methods

close()

Events

zk-select{ id } — clicked item's data-id (excludes cancel items).
zk-closeBackdrop click, Escape, or cancel item.
<zk-action-sheet id="post-actions">
  <button data-id="reply">Reply</button>
  <button data-id="delete" data-destructive>Delete</button>
  <button data-id="cancel" data-role="cancel">Cancel</button>
</zk-action-sheet>

Toast

<zk-toast>

Non-blocking, auto-dismissing feedback message — purely imperative API.

Methods

show(message, { duration = 3000, icon? })Displays a message for the given duration (ms). icon is an optional icon name shown before the text.
hide()Dismisses immediately, dispatches zk-close.
<zk-toast id="toast"></zk-toast>
toast.show('Card saved!', { icon: 'check' });

PushNotificationPrompt

<zk-push-notification-prompt>

Soft pre-permission prompt shown before the real OS dialog — since a denied OS prompt on iOS can't be re-triggered without a manual Settings visit.

AttributeDescription
openBoolean.

Events

zk-acceptComponent stays decoupled — the app calls app.push.subscribe() itself.
zk-dismiss

Slots

titlebody
prompt.addEventListener('zk-accept', () => app.push.subscribe());

UI · Forms & Input

Form components deliberately stay decoupled from plugins — they emit zk-submit/zk-change events and let the app wire them to app.auth, app.storage, etc.

LoginForm

<zk-login-form>

Email/password fields. Does not call any auth adapter itself.

Events

zk-submit{ email, password }

Slots

oauthe.g. "Sign in with Apple" buttons.
loginForm.addEventListener('zk-submit', (e) => {
  app.auth.login({ email: e.detail.email, password: e.detail.password });
});

SettingsForm

<zk-settings-form>

Grouped list, Settings.app style. Fully data-driven via the .sections property (not an attribute). Toggle rows render as a real <zk-switch> internally rather than a native checkbox.

PropertyShape
.sections{ id, title, rows: [{ id, label, icon?, type: 'toggle'|'link'|'text', value }] }[]

Each row accepts an optional icon field (an icon name), rendered before the label for any row type.

Events

zk-change{ sectionId, rowId, value }
zk-select-link{ sectionId, rowId }
settingsForm.sections = [
  { id: 'appearance', title: 'Appearance', rows: [
    { id: 'dark-mode', label: 'Force dark mode', icon: 'moon', type: 'toggle', value: false },
  ]},
  { id: 'account', title: 'Account', rows: [
    { id: 'logout', label: 'Sign out', icon: 'user', type: 'link' },
  ]},
];

settingsForm.addEventListener('zk-change', (e) => {
  if (e.detail.rowId === 'dark-mode') {
    themeProvider.setAttribute('theme', e.detail.value ? 'dark' : 'auto');
  }
});

CodeValidationForm

<zk-code-validation-form>

OTP input with separated boxes — auto-advance focus, backspace navigation, full-code paste support.

AttributeDescription
lengthNumber of boxes, default 6.

Events

zk-change{ code } — on every edit.
zk-submit{ code } — once all boxes are filled.
<zk-code-validation-form length="6"></zk-code-validation-form>

SearchEngine

<zk-search-engine>

Debounced search bar with a leading search icon. On compact layout, focus expands it into a full-screen overlay with a Cancel button; once there's a value, an inline clear button also appears (both dispatch zk-search with an empty query).

Attribute / PropertyDescription
placeholderRead once at connect.
debounceMilliseconds, default 250.
expanded / .expandedBoolean, auto-toggled on focus (compact layout).

Events

zk-search{ query } — after debounce, or immediately with '' on Cancel.

Slots

results
<zk-search-engine placeholder="Search a topic…"></zk-search-engine>
search.addEventListener('zk-search', (e) => { store.state.query = e.detail.query; });

UI · Selection & Value Controls

iOS-styled input primitives — all mirror state as both a reflected attribute and a JS property.

Picker

<zk-picker>

iOS-style wheel picker built on CSS scroll-snap.

Live Interactive Component

Events

zk-change{ value }
<zk-picker>
  <option value="a">Option A</option>
  <option value="b" selected>Option B</option>
</zk-picker>

Slider

<zk-slider>

Styled wrapper around native <input type="range">.

Live Interactive Component
Attributesmin, max, step, value, disabled

Events

zk-change{ value: number }
<zk-slider min="0" max="100" value="40"></zk-slider>

Switch

<zk-switch>

iOS-style toggle switch.

Live Interactive Component
Attributes / Propschecked, disabled

Events

zk-change{ checked }
<zk-switch checked></zk-switch>

SegmentedControl

<zk-segmented-control>

iOS-style segmented control with a sliding highlight.

Live Interactive Component
Attribute / Propertyvalue / .value — matches a child button's value.

Events

zk-change{ value }
<zk-segmented-control value="a">
  <button value="a">A</button>
  <button value="b">B</button>
</zk-segmented-control>

UI · Data Display

Cards, logs and placeholder states for showing (or waiting on) data.

ImageCard

<zk-image-card>Community

Flippable image/fact card. Added while building the PixLearn example, promoted to the shared catalog as broadly reusable.

Live Interactive Component
Attribute / PropertyDescription
image, title, tag, factContent fields.
likes / .likesNumber.
liked, saved, flippedBooleans, each with a matching JS property.

Events

zk-flip{ flipped }
zk-like{ liked, likes } — component increments/decrements likes itself.
zk-save{ saved }
const el = document.createElement('zk-image-card');
el.setAttribute('image', card.image);
el.setAttribute('title', card.title);
el.setAttribute('fact', card.fact);
el.setAttribute('likes', String(card.likes));
el.toggleAttribute('liked', card.liked);

LogsViewer

<zk-logs-viewer>

In-app scrollable log panel mirroring console.log/info/warn/error/debug — useful since installed iOS PWAs have no devtools access. Caps at 200 entries.

<zk-logs-viewer></zk-logs-viewer>

EmptyState

<zk-empty-state>

Illustrated empty-state placeholder. The icon slot defaults to <zk-icon name="inbox" size="36"> inside a circular badge when the consumer doesn't supply their own.

Live Interactive Component

Slots

icontitlemessageaction
<zk-empty-state>
  <zk-icon slot="icon" name="bookmark" size="36"></zk-icon>
  <span slot="title">No saved cards</span>
  <span slot="message">Save cards from Discover to find them here.</span>
</zk-empty-state>

LoadingSkeleton & Spinner

<zk-loading-skeleton><zk-spinner>

Shimmering placeholder block, and an indeterminate rotating spinner.

Live Interactive Component
ComponentAttributes
zk-loading-skeletonwidth, height (CSS lengths — default 100% / 1em)
zk-spinnersize (px, default 24)
<zk-loading-skeleton width="240px" height="14px"></zk-loading-skeleton>
<zk-spinner size="20"></zk-spinner>

UI · Onboarding & Reliability

First-run flows and app-wide error handling.

ErrorBoundary

<zk-error-boundary>

Catches uncaught errors app-wide via window error/unhandledrejection listeners (Web Components have no render tree to wrap in try/catch), swapping to the fallback slot.

Methods

reset()Restores normal content.

Events

zk-error{ error }

Slots

defaultfallback
<zk-error-boundary>
  <div id="app">...</div>
  <div slot="fallback">
    <p>Something went wrong.</p>
    <button onclick="location.reload()">Reload</button>
  </div>
</zk-error-boundary>

Examples

Real apps built entirely on zexoskit — the best place to see everything wired together.

PixLearn

examples/pixlearn

A self-contained image-sharing / learning app: browse a feed of flippable image cards, reveal an educational fact, like/save/create cards, search, and manage settings — no backend, all local (IndexedDB via storagePlugin, a mock auth adapter).

What it exercises

createApp / .use() / .mount()
createStore — reactive state + .computed() for a live saved-count badge
storagePlugin, createAuthPlugin (with a demo-only local adapter)
ThemeProvider, ErrorBoundary, LoginForm, Shell + SidebarCollapsible, SearchEngine, LoadingSkeleton, EmptyState, SettingsForm, Modal, Toast

Run it from the repo root: pnpm dev, then open /examples/pixlearn/index.html. Sign in with any email/password (mock auth, no validation).

Open PixLearn →

Reference

Sharp edges worth knowing before you build on top of them.

Known gaps & caveats

Surfaced while documenting the current source — not bugs the docs paper over, but real behavior to design around today.

Router & Store have no public import path. Neither core/router.js nor core/store.js is listed in package.json's exports map or re-exported from index.js. Import them via the deep path (zexoskit/core/router.js, zexoskit/core/store.js) until this is formalized.

Explicit platform override doesn't survive mount(). createApp({ platform: 'iphone' }) only affects app.platform until bootstrap() runs during mount(), which reassigns it to the live auto-detected singleton. Useful for synchronous plugin isSupported() checks pre-mount, not as a lasting override.

supabasePlugin's install is async but unawaited by app.use(). app.db / app.auth / app.realtime may not exist immediately after app.use(supabasePlugin, …) returns.

Install-prompt debounce isn't configurable. setupInstallPrompt's storageKey/reshowAfterDays exist in core/lifecycle.js but aren't threaded through createApp/mount — only the hardcoded defaults (14 days) are reachable today.

Event convention. Every zk-* component dispatches events prefixed zk- (e.g. zk-change, zk-close, zk-submit) with { bubbles: true, composed: true }, so you can listen at document level across shadow boundaries.

Changelog

Full history lives in CHANGELOG.md at the repo root — Sprint 1 (core skeleton + first working slice), Sprint 2+3 (full UI catalog, router, store, remaining plugins), and the PixLearn example app with two real bugs fixed along the way.

Read the changelog →