@mai/mkit (0.1.33)

Published 2026-09-17 16:25:20 +00:00 by mAi

Installation

@mai:registry=https://mgit.msbls.de/api/packages/mAi/npm/
npm install @mai/mkit@0.1.33
"@mai/mkit": "0.1.33"

About this package

Shared Svelte 5 design kit for m's web apps: tokens, shell, components, markdown.

mkit

Shared Svelte 5 design kit for m's web apps: tokens, shell, components. Consumers: mai2 (m/mAi2, web/), mBrian (~/dev/mBrian), flexsiebels.de (m/flexsiebels.de, website/). The design that produced it is docs/plans/web-unify.md in m/mAi2 (m/mAi2#101); the decisions there are the authority for what the kit looks like.

What the kit is

One place for the concepts every app of m's repeats: a colour system with a light and a dark theme, one type and spacing scale, a three-column desktop shell with a bottom status bar, a phone frame with a five-slot bottom nav and pull-up sheets, and the small set of components every page is made of. An app keeps its domain: mai2 knows agents, mBrian knows nodes, flexsiebels knows rides. The kit knows rows, cards, sheets, chips, tables, tiles and bubbles.

Look: AgentsView's information density (13 px body text on desktop, 2/4/6/8/12/16/24/32 spacing), flexsiebels' palette (green primary on gray-slate neutrals, both themes), mBrian's functionality (command palette, capture sheet, drag-up bottom sheets, resizable sidebar).

What enters the kit, what stays in an app

A thing enters the kit when all three hold:

  1. It carries no domain type. Its props are strings, numbers, booleans, snippets and plain records. ListRow yes; AgentRow no (AgentRow composes ListRow inside mai2).
  2. Two of the three apps use it, or would once they adopt the shell.
  3. It reads only --mk-* tokens and ships no hard-coded colour, size or font.

Everything else stays in the app that needs it. Charts stay in the apps (mai2 draws plain SVG, flexsiebels uses D3); the kit provides the six chart colours and the Tile frame, nothing that draws.

A runtime dependency enters the kit only when it replaces the same functionality in at least two apps (m, m/mAi2#101 d-e62b8fb5: "use synergies, not X times the same functionality"). Today that is four: marked + marked-footnote (the markdown renderer, moved here from @mai/meditor's render.ts; mEditor consumes it back), dompurify (sanitising by default, a trusted flag for the app's own content), @fortawesome/fontawesome-free (the icon set, d-b96fb2b4; icons CC BY 4.0, webfonts OFL 1.1, code MIT — bundled, never the CDN link). Nothing else. Vendored source is recorded the same way: Project Nayuki's QR Code generator (src/qr/qrcodegen.ts, MIT, tag v1.8.0, src/qr/VENDOR.md; the encoder behind QrCode, m/mAi#315) and mAuth's TypeScript adapter (src/lib/auth/mauth/, § Auth).

@mai/mkit/auth's oauth4webapi, @supabase/ssr and @sveltejs/kit are peerDependencies with peerDependenciesMeta.optional: true, not plain dependencies: mBrian, mai2 and flexsiebels install @mai/mkit for browser chrome and must not pay for a server auth stack they never import. Only an app that configures oidc or gotrue installs the matching peer; oauth4webapi/@supabase/ssr load lazily and throw a named error naming the missing package otherwise. @sveltejs/kit is marked optional the same way but imported directly (not lazily): every consumer of @mai/mkit/auth is a SvelteKit app by definition, so it costs nothing a browser-chrome-only consumer of plain @mai/mkit does not already avoid — the optional flag only keeps that consumer's install from warning about an unmet peer it never triggers.

Package layout

This package lives at packages/mkit/ in the m/mkit workspace, alongside packages/meditor/ (@mai/meditor); the root package.json, bunfig.toml and .gitea/workflows/publish.yml are shared across both, see the workspace root README.

packages/mkit/
├── README.md
├── package.json            name @mai/mkit, peerDependency svelte ^5, exports below; svelte-package builds src/ into dist/
├── src/
│   ├── tokens.css          every --mk-* token, both themes, the touch scale, the base reset
│   ├── fonts.css           Inter + JetBrains Mono + Symbols Nerd Font Mono (@font-face), fonts/ holds the woff2, OFL.txt and NerdFonts-MIT.txt
│   ├── icons.css           Font Awesome Free 6: @font-face on ./webfonts/ plus fa-classes.css, both generated from @fortawesome/fontawesome-free by scripts/webfonts.ts (gitignored)
│   ├── markdown/           render.ts (marked + marked-footnote + DOMPurify, from mEditor; block-lines.ts and block-ids.ts serve its options), headless.ts, Markdown.svelte, prose.css (.mk-prose), tasks.ts + tasks.css (the task states and their icon, § Task states)
│   ├── qr/                 encode.ts (encodeQr, qrRows, qrSvgPath, qrSvg over the vendored qrcodegen.ts — VENDOR.md), headless.ts, QrCode.svelte (§ QR code)
│   ├── index.ts            re-exports the six group indices below; a group adds its exports to its own index, never here
│   ├── shell/              Shell, Sidebar, SidebarHead, LanguageSwitch (a DE/EN-style toggle in SidebarHead's brand row, rendered only when `languages` is given), NavSection, NavItem, Tree, TreeGroup, TreeRow, PageHead, ContextPanel, StatusBar,
│   │                       PhoneTop, BottomNav, BottomSheet, MenuSheet; types.ts (NavSlot, NavCentre, MenuGroup), context.ts (what Shell tells its children)
│   ├── components/         Badge, Button, Card, Checkbox, Chip (removable, `removeLabel` prop on the close control, default 'Remove'; `wrap` prop, default false, wraps long text instead of overflowing), CommandPalette (Ctrl/Cmd K or commands.palette.open(), mBrian's QuickSwitcher shape;
│   │                       + search.ts, pure ranking/recents), CollapsibleSection (a titled page section that folds on its header button; `count` Badge, `summary` while folded, `actions` snippet outside the toggle; with an `id` the fold persists under `mk:sections`, m/mkit#19), Dialog (`size` sm 480 / md 720 / lg 960 / full, at least min(720px, 92vw) on a tablet, 768–1199 px; the body scrolls under a fixed head and footer, m/mkit#43), EmptyState, Field, Icon, IconButton, Kbd, ListRow,
│   │                       SearchResultRow, SegmentedControl, Select (native `<select>`, m/mAi2#74), Sheet (a thin wrapper on the shell's BottomSheet, desktop="modal", the same `size` sm / md / lg for the desktop dialog),
│   │                       Spinner, StatusDot, Table, TableHeaderCell, TextInput, Textarea, Tile, Toast (+ toast.svelte.ts, one queue for the kit), Toggle;
│   │                       calendar: CalendarMonth (the 6×7 month grid, § Calendar) + calendar.ts (the headless date arithmetic on `YYYY-MM-DD` strings: monthGrid, addDays, addMonths, startOfWeek, weekdayLabels, …); TimeGrid (the hour grid of a day or a week, § Calendar) + timegrid.ts (the pure geometry: segmentsFor, allDayFor)
│   │                       TextInput, Textarea, Select and Composer all take a bindable `element` prop bound to their native input/textarea/select;
│   │                       chat: Bubble (me/them, the timestamp under the text as 24-hour HH:MM via `formatTime` or a `timeText` of the consumer's own, an optional pin marker, swipe-to-reply armed on every bubble, `--mk-bubble-max` overrides its width cap), Composer
│   │                       (leading and trailing action slots, auto-growing input, send), Chips (a decision's options, faded once one is picked; wraps each option's Chip by default, since option labels are free text), DecisionForm (a
│   │                       decision row's `options.fields` on Field and the primitives);
│   │                       auth (m/mkit#26, § Auth below): LoginCard, MethodTabs, PasswordForm, OidcButton, MagicLinkForm, PinInput, RedeemPanel, AuthNotice
│   ├── stores/             theme.svelte.ts (system/light/dark, data-theme on <html>), shell.svelte.ts (sidebar open/width/rail, context open per page/width, nav sections folded, phone sheet), sections.svelte.ts (CollapsibleSection open per id, `mk:sections`),
│   │                       commands.svelte.ts (register/unregister command lists for the palette), context.svelte.ts (register/unregister the context column's content, a route-owned stack ContextPanel reads via activeContext()),
│   │                       strings.svelte.ts (the kit's own chrome text, English defaults, setStrings merges an override — see Strings & language below),
│   │                       viewport.svelte.ts (--mk-app-h from visualViewport, keyboard-up), storage.ts (mk:-prefixed localStorage)
│   ├── actions/            swipe.ts (swipe-to-reply), slideUp.ts (drag-up on a nav slot opens a sheet), focusTrap.ts (a custom overlay's own trap; a native `<dialog>` traps focus itself), longPress.ts, pullToRefresh.svelte.ts (+ PullIndicator.svelte, the phone pull-down on a scroller — § Actions), interactive.ts (shared guard)
│   └── lib/auth/           @mai/mkit/auth (server, m/mkit#27): configureAuth, resolveUser, createAuthHandlers, provider.ts + oidc.ts + gotrue.ts, mauth/ (mAuth's TS adapter, vendored — see Auth below); svelte/ is @mai/mkit/auth/svelte, the SignIn card
├── scripts/                webfonts.ts (the Font Awesome copy above); publish.ts lives at the workspace root, one script for every package
├── demo/                   a Vite app (vite.config.ts at the package root) that renders the kit with fixture data; ?page=<name>&theme=<light|dark>&overlay=<menu|sheet>
│   ├── index.html, main.ts, App.svelte, demo.css
│   ├── pages/*.svelte      one file per page; a page registers itself by existing here (import.meta.glob). components, icons, tokens show the primitives and the tokens; language shows the strings store and LanguageSwitch (DE/EN, a demo fixture translation); chat, lists, today, saved, board, agent, analytics, settings are the page groups of web-unify.md § 5, each a thin `<ShellDemo group="…" />`
│   ├── ShellDemo.svelte, shell-data.ts   the shell chrome and the fixture-derived data shared by the eight page groups; Sidebar/StatusBar and PhoneTop/BottomNav each render only on their own breakpoint, so one component serves both shells
│   ├── fixtures.ts         imports mockups/data/*.json in place; fixture('projects_mai2_agents')
│   └── tokens.ts, tokens-parse.ts   src/tokens.css parsed into rows for the tokens page and the spec
├── tests/                  playwright.config.ts at the root; components.spec.ts, demo.spec.ts, language.spec.ts and shell.spec.ts run chromium against the demo, components.spec.ts webkit as well; shots/ holds the screenshots (gitignored)
└── mockups/                the static HTML sketch of the shell from #101, kept as the visual reference until the components replace it
    ├── index.html, shell.css, shell.js, data/   render with any static server; ?page=…&theme=…&overlay=…
    └── shoot.mjs           screenshots every page at both breakpoints

Exports (package.json exports):

  • @mai/mkit/tokens.css, @mai/mkit/fonts.css, @mai/mkit/icons.css
  • @mai/mkit — every component and store
  • @mai/mkit/shell, @mai/mkit/stores, @mai/mkit/actions, @mai/mkit/markdown — the same, grouped
  • @mai/mkit/auth — server-only, the plain condition (no svelte condition: it is imported from $lib/server/ or a +server.ts, never a component); @mai/mkit/auth/svelte — the SignIn card

@mai/mkit and @mai/mkit/shell resolve through the svelte export condition only, because a .svelte file loads in nothing else. @mai/mkit/markdown carries a default condition too, pointing at headless.ts: renderMarkdown without the component, for a bun test, a node script or a server render.

Tokens

All tokens carry the --mk- prefix, so the file can sit beside an app's own variables while that app migrates page by page. Theme is data-theme="light|dark" on <html>; no attribute means the system preference. Read src/tokens.css; it is the spec.

Shell chrome (nav items, tree rows, palette rows, SearchResultRow, ContextPanel's head, MenuSheet rows) reads --mk-text-nav (14 px desktop, 16 px on a coarse pointer); section labels, hints and tree meta read --mk-text-nav-meta (12 px desktop, 15 px on a coarse pointer). Body text stays on --mk-text-md (13 px, d-37be501a).

--mk-font-mono is "JetBrains Mono", "Symbols Nerd Font Mono", …: the mono face sets the cell metrics and the symbols face (m/mAi#284) fills the nerd glyphs behind it — powerline separators, devicons, octicons, Material Design icons, every glyph a tmux status line or a shell prompt uses. Its @font-face carries a unicode-range of the private-use blocks (U+E000-F8FF, U+F0000-FFFFD), so it never shadows a letter and the browser fetches the 1.2 MB file only for text in those blocks. A canvas-measured surface (xterm.js) takes the same literal stack, since a CSS variable does not resolve there, and waits for document.fonts.load('13px "Symbols Nerd Font Mono"', '\uE0B0') — a range-limited face only loads for a sample inside its range — before it measures its cell.

How an app consumes the kit

Published as @mai/mkit on mgit's own npm registry under the mAi account, the way @mai/meditor already is (m/mAi2#101 d-56e153a2): a push to main publishes the next version. No public npm registry is involved.

bun add @mai/mkit

The @mai scope points at the registry in a .npmrc (project root or user level). The packages are readable without a credential, so an install needs the scope line only:

@mai:registry=https://mgit.msbls.de/api/packages/mAi/npm/

Publishing adds the token line; never ship it in a consumer, because with GITEA_NPM_TOKEN unset bun sends an empty bearer and Gitea answers 401:

//mgit.msbls.de/api/packages/mAi/npm/:_authToken=${GITEA_NPM_TOKEN}

GITEA_NPM_TOKEN is a Gitea personal access token with write:package; this repo's publish workflow carries it as the repo secret NPM_TOKEN. A consumer declares "@mai/mkit": "^0.x"; bun update @mai/mkit pulls the newest published version and the lockfile pins the one a build used. Versioning (d-880df9d8): 0.x, a patch for every fix and every addition, a minor only for a change that breaks a consumer, 1.0 when m calls the kit stable. The registry was reset to 0.1.0 on 2026-09-15; the numbers before that carry no meaning. For two repos changing together, bun link here and bun link @mai/mkit in the consumer replace the registry locally; that state is not committed.

Entry point in a consumer:

import '@mai/mkit/fonts.css'
import '@mai/mkit/icons.css'
import '@mai/mkit/tokens.css'
import { Shell, Sidebar, ContextPanel, StatusBar, BottomNav } from '@mai/mkit/shell'

mai2 is the first consumer (m/mAi2#101, slices 1–5). mBrian and flexsiebels adopt tokens first, then the shell, each in their own repo and issue.

Shell

Shell takes the frame's parts as snippets — sidebar, head, context, status, phoneTop, nav — and the page body as children; the body is the only vertical scroll container in the content column, and flush hands scrolling to the page (chat, terminal). page is the id the context rail's pinned state persists under. Desktop (≥ 768) needs a full-height parent (html, body and the mount node at height 100 %); on the phone (< 768) the shell fixes itself to the visual viewport through --mk-app-h. Sidebar, StatusBar, PhoneTop and BottomNav render only on their breakpoint, decided by the viewport store, which Shell starts.

Sidebar takes SidebarHead as its head snippet and the nav sections and Tree as children; the children sit in a scroll container of their own, so a nav taller than the viewport scrolls under a fixed head (m/mkit#18). A SidebarHead rendered as a child still works and scrolls with the nav.

SidebarHead's brand mark and name link to homeHref (default /, m/mkit#22) as one anchor — a plain <a> works for a SvelteKit route or a hash-router path (#/) alike; a consumer that must intercept navigation does it on the anchor via its own router. In the rail state the mark alone is the link. SidebarHead's own buttons only collapse the sidebar to its 56 px rail (shell.setSidebarRail) — there is no button to fully hide it. shell.setSidebarOpen(false) stays on the store for a consumer that wants a full hide by code, and Shell renders a left-edge stub with its own "Show sidebar" button whenever sidebarOpen is false on desktop, regardless of whether the consumer passes a head snippet (m/mkit#13) — PageHead's own "Show sidebar" button (shown under the same condition) stays alongside it. The actor row takes an optional avatar (an image URL, m/mkit#15, moved off the brand mark onto the actor row in m/mkit#22): a --mk-mark-size circle before the actor name, with initials from actor as the fallback shown until the image loads and again if it errors, so a broken or slow Gravatar URL never leaves the circle blank. The search row renders only when onSearch is set (m/mkit#16) — there is no separate boolean, the callback's presence is the signal.

State lives in the shell store (@mai/mkit/stores): sidebar open / width (200–420) / rail, context width (220–480) and pinned per page, nav sections open per title (isNavOpen/setNavOpen, default open), sheet for the phone sheet that is up (menu, context, or the app's own id). A titled NavSection is collapsible (m/mkit#30): its title row is a button with a chevron, open is bindable, and the consumer binds it to the store (bind:open={() => shell.isNavOpen(title), (o) => shell.setNavOpen(title, o)}) so the fold persists; in the rail the items show whatever open says. A MenuGroup with ontoggle folds the same way on the phone, from the same record. ContextPanel is mWiki's right-rail model on desktop (m/mkit#12), not an open/closed pane: it stays mounted, collapsed to a --mk-rail-w (56 px) icon strip by default. Hovering it for 150 ms peeks it open; a click on the strip's own icon (or on the fallback 'show context' icon when the page passes no rail snippet) opens it at once; a pin control in its head keeps it open across a mouse-away, and unpinning collapses it immediately even with the pointer still on it. The rail snippet gets { expand } to call on a section icon's click, the same shape as Field's scoped children. The resize handle (220–480) only exists while open. On the phone, ContextPanel is unchanged: a BottomSheet while shell.sheet === 'context'; a page opens it with shell.openSheet('context') on a row tap. BottomSheet and MenuSheet take open and onclose and never flip open themselves; the Sheet primitive (@mai/mkit) is a thin wrapper on BottomSheet with desktop="modal", so a form sheet is a phone sheet below 768 px and a centred dialog above it through the one implementation. theme writes data-theme on <html> and persists under mk:theme; every persisted key carries the mk: prefix. The demo's eight page groups (?page=chat|lists|today|saved|board|agent|analytics|settings&theme=&overlay=menu|sheet) render every shell component over the mockup fixtures, board and lists with their own rail sections; tests/shell.spec.ts asserts the behaviour above and screenshots every group at 1440 / 1100 / 390 px in both themes.

Shell takes onrefresh (m/mkit#20): when set, the body carries the pullToRefresh action and a phone pull-down on it calls the callback; a flush page attaches the action to its own scroller instead (§ Actions).

Two ways to fill the context column (m/mkit#14). A single-SPA app that already switches ContextPanel's content on its own router state (mai2) passes context to Shell as a snippet and gives ContextPanel title/rail/children itself, same as always. A SvelteKit app mounts Shell once in +layout.svelte, gives it no context snippet, and each route registers its own content instead: registerContext({ title, rail?, body }) from $effect, returning the unregister function as the effect's cleanup (the same shape as registerCommands, and it needs the same $effect wrapping — untracked writes, cleanup on teardown). Shell then renders a bare ContextPanel itself, which reads activeContext(). Registrations stack: the most recent one wins, and unregistering it restores whichever was active before, so a route can register a base context and push a nested one on top (a selected node, then one of its outputs) without the two coordinating. With nothing registered, ContextPanel renders nothing at all, not even the collapsed rail, so the column takes zero width. demo/pages/editor.svelte (?page=editor) is the registry-driven example; the ShellDemo-based groups keep the snippet path. tests/context.spec.ts covers both the rail/pinned-panel rendering and the stack's register/unregister/nesting behaviour.

Actions

@mai/mkit/actions — each is a Svelte action with a params object, disabled on every one, and update/destroy so a changed param takes effect and the listeners leave with the element.

Action On Params Fires
swipe a row or bubble onRight, onLeft, threshold (48), max (96) a horizontal touch/pen drag past threshold; data-swipe while the finger is down
slideUp a nav slot onSlideUp, threshold (50) a drag up past threshold, once; the click after it is swallowed
longPress a row onLongPress, ms (500), tolerance (10) a pointer held ms without moving; a mouse right-click too; data-pressing after 200 ms
focusTrap a custom overlay — keeps Tab inside; a native <dialog> needs none
pullToRefresh a scroll container onrefresh, threshold (80) a touch pull down past threshold with the container at scrollTop 0, once per release

pullToRefresh (m/mkit#20) is touch-only — touch events plus a (pointer: coarse) check on the first touch — so a mouse or trackpad never arms it. It gives the gesture back to the scroller when the first movement is more sideways than down (a swipe on a row inside) or when the container scrolls under the finger. On the first armed touch it mounts a PullIndicator as the container's first child: a Spinner in a --mk-* pill that follows the pull up to 1.5 × threshold, turns with the distance, marks data-state="ready" past the threshold and spins while onrefresh()'s promise is pending; its label reads strings.pullToRefresh / strings.refreshing. prefers-reduced-motion drops the pill's spring-back and slows the ring the way Spinner already does. The action sets overscroll-behavior-y: contain on the container unless the consumer set one, so the browser's own pull-to-refresh never fires under it. The 80 px threshold is mai2's legacy/pull-to-refresh.js, the old PWA's. Shell's onrefresh puts it on the content column; a flush page uses use:pullToRefresh={{ onrefresh }} on its own scroller. tests/pull-to-refresh.spec.ts drives it with CDP touch events on the lists page at 390.

Auth

LoginCard (frame: brand/error/footer slots, a method area), MethodTabs (renders only the methods the app passes — password/oidc/magiclink/pin — one method renders no tab bar, each method's content is its own snippet), PasswordForm (email/password, onsubmit), OidcButton (one button per provider, onselect(id)), MagicLinkForm (idle/sent/expired phase, onsubmit/onresend, the resend cooldown is the app's own resendIn), PinInput (per-digit boxes, paste, arrow/backspace navigation, oncomplete), RedeemPanel (never acts on mount, never auto-submits — only its button's click calls onconfirm), AuthNotice (a standing state with no form: mail sent, link expired, signed out, no access). All eight are presentational (m, d-75b718ec): no fetch, no cookie, no Supabase import, no knowledge of an endpoint shape. docs/standards/auth.md owns the seam — the app keeps its +page.server.ts and form actions, m/mAuth owns mint/compose/redeem, and RedeemPanel follows that doc's rules 1 and 2 (GET renders and consumes nothing, no JS auto-submit) because a mail scanner that opens the link may run JavaScript. Their own text reads through the strings store below (§ Strings & language) the same way the shell does. demo/pages/auth.svelte renders every method alone and all four behind MethodTabs; tests/auth.spec.ts covers the behaviour above.

Strings & language

The kit ships English only: every user-visible chrome string (button labels, aria-labels, the command palette's group headings) has an English default and lives in the strings store (@mai/mkit/stores). The kit holds no language state of its own — an app that wants another language keeps its own current language and translations, and calls setStrings(partial) to merge an override over the current strings:

import { strings, setStrings, defaultStrings, type Strings } from '@mai/mkit/stores';

setStrings({ close: 'Schließen', menu: 'Menü' }); // any subset; keys left out keep their current value
setStrings(defaultStrings); // back to English

Keys: sidebarShow, sidebarExpand, sidebarCollapse, sidebarResize, sidebarSearch, live, offline, theme, signOut, contextShow, contextResize, contextPin, contextUnpin, close, dismiss, send, sheetDragClose, commandPalette, search, searchPlaceholder, commandPlaceholder, paletteActions, paletteGoTo, paletteRecent, paletteRecentSearches, paletteNoResults, menu, authMethodPassword, authMethodOidc, authMethodMagiclink, authMethodPin, authEmailLabel, authPasswordLabel, authSignIn, authContinueWith, authSendMagicLink, authMagicLinkSentTitle, authMagicLinkSentDescription, authMagicLinkExpiredTitle, authMagicLinkExpiredDescription, authResend, authPinDigitLabel, authConfirmSignIn, authConfirm (the auth components, m/mkit#26), pullToRefresh, refreshing (the pullToRefresh indicator, m/mkit#20), authExternalTitle, authExternalDescription, authNoMethodTitle, authOr (the SignIn card, m/mkit#27, #29), locale (a BCP 47 tag the kit formats dates and weekday names in; empty means the browser's own, m/mkit#37), calendarPrevMonth, calendarNextMonth (CalendarMonth's nav row), timeGridAllDay (TimeGrid's all-day lane label, m/mkit#38).

A component's own per-string prop (SidebarHead's liveLabel/searchLabel, MenuSheet's label) still wins over the store when a consumer sets it — the store only supplies the default. setStrings merges under untrack, the registerCommands/registerContext shape: calling it from a consumer's own $effect is safe, it never loops.

LanguageSwitch (@mai/mkit/shell) is chrome for a language toggle, not state: props languages: {id, label}[], current: string, onselect: (id: string) => void. It renders in SidebarHead's brand row beside the theme cycle only when languages is passed, as one button that toggles straight to the other language when exactly two are given, or opens a small menu for three or more. The app owns current, persistence and its own translations, and calls setStrings itself from onselect — demo/pages/language.svelte (?page=language) is the worked example, DE/EN with a demo-fixture German translation.

Markdown

renderMarkdown(markdown, resolveLink?, options?) is mEditor's function with the same signature and the same sourceLines / blockIds options, plus DOMPurify on the output. It is sanitised unless options.trusted is set; trusted is for markdown the app produced itself, never for what a user or a remote system wrote. In a browser DOMPurify uses the page's window. Outside one (a server render, bun test) the untrusted path throws until setSanitizerWindow(new JSDOM('').window) has been called; jsdom is the DOM DOMPurify supports there, happy-dom is not. Markdown (props source, trusted, breaks, resolveLink, options) renders into a div.mk-prose; prose.css styles that class on --mk-* tokens only.

breaks decides what a newline inside a paragraph means. It defaults to true, a <br> per newline, which is the chat bubble: a human types one thought per line. A view showing a markdown file passes breaks={false} (or options.breaks) and gets GFM paragraph semantics, so a hard-wrapped source reads as paragraphs instead of one short line per source line. Every table is wrapped in div.mk-prose-table, the scroll container prose.css gives it: a table wider than the prose column scrolls there and the page keeps its width, a narrower one keeps its own. demo/pages/markdown.svelte (?page=markdown) shows both halves.

Task states

A list item takes one of Obsidian's nine states (m/mkit#51) and renders as an icon: - [ ] to do, - [x] done, - [/] in progress, - [?] question, - [!] important, - [-] cancelled, - [>] forwarded, - [<] scheduled, - [*] starred. The character between the brackets is the contract with the file and is never rewritten; the icon is display only, and - [ ]/- [x] render as that same icon rather than as the <input type="checkbox"> marked emits. A character outside the set — - [a], - [ref](url), - [[wikilink]] — stays the plain list item it was.

The item carries data-task="<name>" (todo, done, doing, question, important, cancelled, forwarded, scheduled, star) and holds one <span class="mk-task" data-task="<name>" role="img" aria-label="…">. The name, not the character: DOMPurify trims an attribute value that is only a space, which is what - [ ] would write.

tasks.css (@mai/mkit/tasks.css, imported by prose.css and by mEditor) draws that span from --mk-task-glyph-<name> and --mk-task-color-<name> in tokens.css, plus --mk-task-font, --mk-task-weight and --mk-task-ink — a consumer re-themes the set by overriding those and nothing else. Every token has a fallback, so an app that ships no tokens.css still sees the set in the text colour around it.

tasks.ts carries the table headless, exported from @mai/mkit/markdown: TASK_STATES (the set, in the order the editor's task key steps them), taskState(char), nextTaskState(state) and matchTaskLine(line) ({ bulletEnd, state? }, undefined for a line that is no list item). mEditor's task key and its icon overlay read the same table, so the editor and a published page can never disagree about what - [?] is.

QR code

QrCode (props value, ecc = low | medium | quartile | high, default medium; quiet, the quiet zone in modules, default 4; label, the accessible name, default the value) renders one inline <svg class="mk-qr"> sized by its container (width: 100%, aspect-ratio: 1), black modules on a white rectangle in both themes: a scanner reads contrast, and a light-on-dark code on a dark surface is what fails in the field, so this is the one kit component that ships hard-coded colours. data-version carries the symbol version. The encoder is Project Nayuki's qrcodegen.ts vendored under src/qr/ (MIT, VENDOR.md); encodeQr(text, { ecc }) returns { size, version, modules }, qrRows the #/. row strings a fixture compares against, qrSvgPath the <path d> of the dark modules offset by the quiet zone, and qrSvg a standalone SVG document for a download or a share. @mai/mkit/qr's default condition is headless.ts, the encoder without the component, so bun test and a server can encode. src/qr/encode.test.ts pins https://mai.msbls.de/s/abc to its 25×25 matrix, the same rows mai's Go twin (mai qr, skip2/go-qrcode) produces.

Calendar

CalendarMonth (m/mAi#316 § 4, #37) is the 6×7 month grid: always six rows so the height never jumps between months, a weekday header in strings.locale (or the locale prop), role="grid" with a roving tabindex. Props: month (YYYY-MM), weekStart (0 | 1, default 1), today (YYYY-MM-DD, default the browser's date), selected, onSelect(date), onNavigate(month), compact, cell (a snippet over { date, inMonth, isToday, selected }; the consumer renders what a cell holds, the kit renders the day number and the states), marks ({ [date]: number[] }, chart colour indexes 1..6 for compact: three dots then "+N"), nav (the prev/next row, default true; off when the consumer has its own range header), locale. Keys: arrows move the focus, Home/End the week, PageUp/PageDown the month, Enter/Space selects; a move past the grid's edge, PageUp/PageDown and a click on an outside-month cell call onNavigate with the neighbour month and the focus lands on the target cell once the consumer renders that month. The today ring, the selection and the focus ring read --mk-primary, the dots --mk-chart-1..6; --mk-calendar-cell-min on the component sets the row height (88 px, compact 44 px). Every date on the wire is a string; the kit never hands out a Date. calendar.ts carries the arithmetic headless (monthGrid, addDays, addMonths, daysInMonth, weekday, startOfWeek, monthOf, isoToday, weekdayLabels, monthLabel, dateLabel), exported from the root for WeekStrip, DatePicker and a consumer's own range maths; src/components/calendar.test.ts pins it. demo/pages/calendar.svelte, tests/calendar.spec.ts.

DatePicker (m/mAi#316 § 4, #40) is the date field over that grid, YYYY-MM-DD on the wire. Props: value (bindable), min/max, weekStart, onChange(date), native, invalid, locale, today, and name/id/disabled/placeholder as TextInput takes them; it renders inside Field the same way (spread controlProps). Desktop (≥ 768): a text field reading the date as YYYY-MM-DD in every locale (formatDate; d-908e4855, the one form m chose for mai in m/mAi#342, #48), placeholder defaulting to YYYY-MM-DD, a calendar button at its right edge; a typed date commits on Enter and on blur, ISO or the locale's own order with any separator (parseDate, four-digit year; 16.9.2026 under de-DE), and the field re-renders it as ISO; an entry that does not parse or lies outside min/max marks the field (--mk-danger border, aria-invalid) and keeps the last value, Esc restores it. The button or ArrowDown opens a CalendarMonth (compact, 36 px cells, --mk-datepicker-popover-w 320 px) in a role="dialog" popover under focusTrap, the selected cell focused; a pick, Esc and a click outside close it and the focus returns. Phone: the field is a button that opens a Sheet holding the grid; a pick closes it. The server-rendered and pre-hydration markup is a native <input type="date"> with the value and the bounds, so a page without JS keeps a working field; native renders only that. prefers-reduced-motion drops the popover's fade and changes nothing else. name lands in a hidden input carrying the ISO value on both the desktop and the phone form. CalendarMonth takes min/max for it: a cell outside is dimmed and aria-disabled and does not select, the month buttons stop at the bound's month. isDate, inRange, formatDate and parseDate are exported from calendar.ts; formatDate(date, locale?) returns ISO and does not read locale, which stays in the signature for 0.x callers, while the prose labels (monthLabel, dateLabel, spanLabel, weekdayLabels) follow the locale; src/components/DatePicker.ssr.test.ts renders the server markup through the demo's Vite config. demo/pages/datepicker.svelte, tests/datepicker.spec.ts.

TimeGrid (m/mkit#38, from m/mAi#316 docs/plans/calendar.md § 4) is the hour grid of a day or a week: one column per entry of days (YYYY-MM-DD, local; one for the day view, seven for the week), the hour axis on the left, an all-day lane above the columns, the now-line in today's column, and the items laid out into overlap columns — two items at one hour split the width in half, three in thirds (segmentsFor in timegrid.ts, pinned by timegrid.test.ts). items are { id, start: Date, end: Date, allDay?, color? (1..6, the --mk-chart- series), label } in the browser's zone; the kit never loads data. An item across midnight renders in every day it touches, each slice squared off on the side it continues. hourStart/hourEnd (0/24) clip the window, slotMinutes (30) is the snap for slot taps, drag and resize, scrollTo (07:00) the initial scroll, now a fixed clock (absent, the browser's, ticking once a minute), locale the header's weekday form (defaults to strings.locale, then the browser's, like CalendarMonth). The consumer gives the grid its height (the parent's); the body scrolls.

Callbacks: onSlot(date, 'HH:MM') for a tap on empty grid, onItem(id) for a tap or Enter on an item (items are buttons, so Tab walks them), onMove(id, start) on the release of a pointer drag and onResize(id, end) on the release of the bottom handle, each once, snapped. onMove and onResize are opt-in: without the handler there is no drag and no handle, so a phone can show a read-only week. The consumer applies the change to items (keeping the duration on a move); until it does, the item springs back. On a mouse or pen the drag starts at once; on touch a hold of about half a second arms it and the page keeps scrolling from an unarmed touch (touch-action: pan-y), so a dense week still scrolls by finger. In the seven-column form a drag sideways moves the item to that day. The density follows the viewport store: on a phone the hour is 56 px and the weekday stacks over the day number; --mk-timegrid-hour on an ancestor overrides the hour height either way. Item colour is --mk-chart-<n> with --mk-text-inverse as the label colour, which is the contrasting text in both themes. ?page=timegrid renders the day, the week and a read-only 8–20 grid around a fixed clock; tests/timegrid.spec.ts asserts the now-line offset, the overlap widths, one drag with onMove's argument, a resize, the read-only form and the slot tap at 390 and 1280 px in both themes.

Auth

@mai/mkit/auth (m/mkit#27, m's decision d-f9a25991) is the shared sign-in for a SvelteKit app: email + password and magic link against ydb's GoTrue (gotrue), OIDC (oidc, mgit today), a trusted-proxy header (proxy), or one fixed address for a single-operator deployment (local). proxy and local each authenticate every request as one address and cannot be combined with another provider; gotrue and oidc can share one login page. The instance/tenant allow-list that decides who may read what stays entirely in the app — this module only ever produces an address.

Server-only. Import it from $lib/server/ or a +server.ts / +page.server.ts, never from a .svelte file: oidc.ts signs cookies with the OIDC client secret and gotrue.ts can carry a service_role key, the same discipline m/mAuth's own README states for the adapter this module wraps.

// src/hooks.server.ts
import { configureAuth } from '@mai/mkit/auth';
import { env } from '$env/dynamic/private';
import { env as publicEnv } from '$env/dynamic/public';

configureAuth({
	providers: env.APP_AUTH_PROVIDER, // 'gotrue', 'oidc', 'gotrue,oidc', 'proxy', or 'local'
	siteUrl: publicEnv.PUBLIC_SITE_URL,
	gotrue: { url: env.SUPABASE_URL, anonKey: env.SUPABASE_ANON_KEY, serviceRoleKey: env.SUPABASE_SERVICE_ROLE_KEY, deliverLink: sendMagicLinkMail },
	oidc: { issuer: env.OIDC_ISSUER, clientId: env.OIDC_CLIENT_ID, clientSecret: env.OIDC_CLIENT_SECRET, onSignedIn: seedAndAllow }
});

Every app reads its own env under its own variable names and passes the values in — the module never reads process.env or SvelteKit's $env itself, so two apps on one page never fight over MWIKI_AUTH_PROVIDER-shaped globals. The table below names the concept each configureAuth() field needs, not a variable name to copy:

Field Needed by What it is
providers always one provider, or a comma-separated list / array — 'gotrue', 'oidc', 'gotrue,oidc', 'proxy', 'local'
siteUrl always this app's own public origin, no trailing slash — the oidc redirect URI and the mAuth magic-link redirect target are built from it
gotrue.url, gotrue.anonKey gotrue the GoTrue / Supabase gateway, e.g. https://ydb.youpc.org, and its anon key
gotrue.serviceRoleKey requestLink only mAuth's admin generate_link call — password/logout do not need it
gotrue.deliverLink requestLink only composes and sends the magic-link mail — the module mints, the app sends
oidc.issuer, .clientId, .clientSecret oidc the issuer's discovery URL and this app's OAuth2 client
oidc.scopes oidc, optional default openid profile email
oidc.displayName oidc, optional the "Sign in with …" label; defaults to the issuer's own host
oidc.onSignedIn oidc, optional runs once per completed sign-in, before the session cookie is set — the app's allow-list check and any profile seeding (m/mWiki#163); returning false refuses the sign-in
proxy.header, .nameHeader proxy the header the proxy puts the verified address (and, optionally, display name) in
local.email, .name local the one address this deployment runs as
cookies optional sameSite/secure/path overrides for every cookie the module sets

resolveUser(event) tries every configured provider in list order, the first to yield an identity winning — a request carrying both a gotrue cookie and an oidc cookie resolves to whichever is listed first. createAuthHandlers({ base }) builds the handlers an app mounts at its own routes; base is the app's own paths.base when it is not empty. Conventional, fixed paths (so a migration keeps its registered OIDC redirect URI and GoTrue GOTRUE_URI_ALLOW_LIST entries):

Handler Shape Method Path (relative to base)
password RequestHandler POST /api/auth/password
requestLink RequestHandler POST /api/auth/request-link
verifyCode RequestHandler POST /api/auth/verify-code
gotrueCallbackLoad + gotrueCallbackAction load + form action GET + POST /api/auth/callback
oidcLogin RequestHandler GET /auth/oidc/login
oidcCallback RequestHandler GET /auth/oidc/callback — registered verbatim on the issuer's OAuth2 app; do not rename without updating that registration
logout RequestHandler POST /api/auth/logout

Six of the seven render nothing and mount as a +server.ts:

// src/routes/auth/oidc/callback/+server.ts
export { oidcCallback as GET } from '@mai/mkit/auth';

gotrueCallback is different, and is the one place @mai/mkit/auth needs @sveltejs/kit's own load/action shape rather than a plain RequestHandler: it is the page a mailed magic link points at, and docs/standards/auth.md (m/mkit#26) rules 1-2 — mAuth's own contract §5.1 — require that page to render on GET and consume nothing, with only an explicit POST the user triggers redeeming, and no client-side auto-submit (a corporate mail scanner that follows every link in a message must not spend the token). That is a SvelteKit load + form action, not a +server.ts — mounting it as a bare endpoint would mean either auto-redeeming on the mail scanner's GET or hand-rolling HTML no consumer could theme, which is the one auth surface a signed-in-out user sees outside the login route and therefore the one place it matters most that it carries the app's own shell and tokens:

// src/routes/auth/gotrue/callback/+page.server.ts
export { gotrueCallbackLoad as load, gotrueCallbackAction as actions } from '@mai/mkit/auth';
<!-- src/routes/auth/gotrue/callback/+page.svelte -->
<script lang="ts">
	import { enhance } from '$app/forms';
	import { RedeemPanel } from '@mai/mkit';
	let { form }: { form?: { error?: string } } = $props();
</script>
<form method="POST" use:enhance>
	<RedeemPanel onconfirm={() => {}} error={form?.error} />
</form>

Magic link mints through mAuth's TypeScript adapter (m/mAuth), not GoTrue's own signInWithOtp: GoTrue's SMTP is global to the Supabase instance, so letting it send the mail puts every product's auth mail under whichever product configured the instance. requestLink mints and hands the app's deliverLink a URL on the app's own domain, and optionally a code (gotrue.deliverEmailOtp: true, for a mail that also offers "or enter this code" through PinInput — verifyCode, a plain JSON RequestHandler, checks that code directly against GoTrue, independently of mAuth's redeem, which does not accept one).

m/mAuth's ts/src is vendored under src/lib/auth/mauth/ — a private repo with no npm package (its own ts/README.md: "copying ts/src… works too"). VENDOR.md there names the source commit; scripts/sync-mauth.ts re-copies it byte for byte from a local m/mAuth checkout (--path, default ~/dev/mAuth; --ref, default that checkout's HEAD) and prints a diff. mAuth is normative for everything under mauth/: never hand-edit a file there, file a bug against m/mAuth instead and resync.

@mai/mkit/auth is server-only and never reachable from the kit's browser entry points: package.json's "."/"./shell"/"./stores"/"./actions"/"./markdown" exports build from src/index.ts and its group indices, none of which import anything under src/lib/auth/; "./auth" itself carries no svelte export condition (only types/default), so a bundler resolving a component import never routes through it. Verified 2026-09-15: grep for lib/auth across every group index and the built dist/index.js/dist/shell/*.js/dist/stores/*.js/dist/actions/*.js/dist/markdown/*.js/dist/components/*.js returns nothing.

SignIn

@mai/mkit/auth/svelte's SignIn is the card an app's login page renders over the providers configureAuth() resolved: providers (the same list), oidcProviders (one OidcProvider per issuer, mgit today), and one callback per path — onPasswordSubmit, onOidcSelect, onMagicLinkSubmit/onMagicLinkResend with magicLinkPhase/magicLinkEmail/magicLinkResendIn, onCodeComplete with codeError — plus error and busy. It composes § Auth's presentational components and, like them, never fetches: every callback is the app's own wiring to the handlers above. Its text reads through the strings store (setStrings, § Strings & language), never through label props.

The shape (m/mkit#29): the oidc path is an OidcButton at the top of the card, then a divider carrying strings.authOr ("or"), then MethodTabs with the gotrue methods (password, magiclink). oidc alone renders the button and no tab list; gotrue alone renders the tabs and no button. oidcAsTab (default false) keeps the older three-tab shape, oidc as the first tab labelled strings.authMethodOidc ("SSO"). proxy/local render an AuthNotice and no form. signInViewModel(providers, { oidcAsTab }) is the pure mapping behind it, exported for a consumer that composes its own card from the same decision. demo/pages/signin.svelte renders every shape; tests/signin.spec.ts covers them.

Develop

Run from the workspace root (bun install there covers both packages) or from here; both work.

bun install
bun run dev         # the demo on http://127.0.0.1:5180/?page=tokens&theme=dark
bun run build       # scripts/webfonts.ts, svelte-package into dist/, publint
bun run test:unit   # bun test src — the renderer
bun run test        # the Playwright spec against the demo (always starts its own server); screenshots in tests/shots/
bun run check       # svelte-check

The demo and the Playwright suite bind 127.0.0.1:5180 by default; set MKIT_PORT to run on another port, e.g. when a foreign server already holds 5180 or a second worktree runs the suite at the same time:

MKIT_PORT=5181 bun run test

reuseExistingServer is false, so a port already in use fails the run fast instead of testing whatever is listening there.

Every script runs on bun ([run] bun = true in the root bunfig.toml and again in this package's own, because bun reads the file from the cwd of the bun run and a bun run test from here never sees the root's): the publish container has no node, and node 18 on the desktops cannot load vite-plugin-svelte. A SyntaxError: ... 'node:util' does not provide an export named 'styleText' from vite means the script ran on the host's node; check that packages/mkit/bunfig.toml is present.

Publishing is a workspace-level concern: see the root README's Publishing section. The one relevant fact here is that @mai/mkit is versioned independently of @mai/meditor — a push that bumps only this package's version publishes only this package.

Status

  • Tokens, fonts and the shell mockup exist (from #101).
  • Package, build, publish workflow, icons.css + Icon, markdown/, the demo harness and the first Playwright spec exist (#1, slice 1a).
  • The primitives, Button through Field, and their demo page exist (#1, slice 1c).
  • The shell components, the four stores, the four actions, the shell demo pages and tests/shell.spec.ts exist (#1, slice 1b).
  • Slice 1 is complete (#1, slice 1d): Sheet reconciled onto the shell's BottomSheet; the demo renders every page group of § 5 (chat, lists, today, saved, board, agent, analytics, settings) in both shells over the mockup fixtures; tests/shell.spec.ts screenshots every group at 1440 / 1100 / 390 px in both themes and asserts the scroll container, the status bar, the phone nav, the sheets and the theme tokens. 0.1.0 is on the registry under the latest dist-tag.
  • The chat components — Bubble, Composer, Chips, DecisionForm — and the swipe action exist (#5, slice 3 of docs/plans/web-unify.md in m/mAi2). The chat demo page renders them over mockups/data/pwa_chat_last_25.json; tests/chat.spec.ts covers the timestamp placement, the pin marker, swipe-to-reply, chips fading and the form's required-field guard.
  • The strings store and LanguageSwitch exist (#25): every hardcoded English chrome literal in src/shell and src/components reads strings instead, SidebarHead carries the switch, demo/pages/language.svelte exercises DE/EN over a demo-fixture translation; tests/language.spec.ts covers setStrings, a per-instance prop overriding the store, and the switch's own render/onselect contract.
  • The auth components exist (#26): see § Auth above. Their text reads through the strings store too. demo/pages/auth.svelte, tests/auth.spec.ts.
  • The pullToRefresh action and Shell's onrefresh exist (#20, web-unify.md slice 4): the lists demo page pulls at 390; tests/pull-to-refresh.spec.ts.
  • @mai/mkit/auth exists (#27): configureAuth, resolveUser, createAuthHandlers (gotrue password + mAuth-minted magic link, oidc, proxy, local), moved up from m/mWiki's auth-provider.ts/oidc.ts and made config-driven; the SignIn card (@mai/mkit/auth/svelte) composes #26's presentational components.
  • CollapsibleSection and the sections store exist (#19): the header is a <button aria-expanded> inside an h3, the chevron is the kit's Icon, open is bindable, count renders a Badge, summary shows while folded, actions sits outside the button; with an id the user's fold persists under mk:sections. Replaces mai2's and flexsiebels' own CollapsibleSection and mBrian's FilterSidebar section headers. demo/pages/components.svelte, tests/collapsible.spec.ts.
  • CalendarMonth and the headless calendar.ts exist (#37): see § Calendar. strings gains locale, calendarPrevMonth, calendarNextMonth. demo/pages/calendar.svelte, tests/calendar.spec.ts.
  • DatePicker exists (#40): see § Calendar. CalendarMonth gains min/max; strings gains datePicker, datePickerOpen. demo/pages/datepicker.svelte, tests/datepicker.spec.ts, src/components/DatePicker.ssr.test.ts.

Dependencies

Dependencies

ID Version
@fortawesome/fontawesome-free ^6.7.2
dompurify ^3.2.4
marked ^17.0.3
marked-footnote ^1.4.0

Development Dependencies

ID Version
@playwright/test ^1.63.0
@supabase/ssr ^0.5.2
@sveltejs/kit ^2.15.0
@sveltejs/package ^2.3.0
@sveltejs/vite-plugin-svelte ^6.2.4
@types/bun ^1.3.9
@types/jsdom ^30.0.0
jsdom ^30.0.1
oauth4webapi ^3.8.8
publint ^0.3.0
svelte ^5.57.0
svelte-check ^4.0.0
typescript ^5.0.0
vite ^7.3.6

Peer Dependencies

ID Version
@supabase/ssr ^0.5.2
@sveltejs/kit ^2.0.0
oauth4webapi ^3.8.8
svelte ^5.0.0
Details
npm
2026-09-17 16:25:20 +00:00
1
MIT
1.7 MiB
Assets (1)
mkit-0.1.33.tgz 1.7 MiB
Versions (236) View all
0.2.78 2026-10-01
0.2.77 2026-10-01
0.2.76 2026-10-01
0.2.75 2026-10-01
0.2.74 2026-10-01