@mai/mkit (0.1.72)
Installation
@mai:registry=https://mgit.msbls.de/api/packages/mAi/npm/npm install @mai/mkit@0.1.72"@mai/mkit": "0.1.72"About this package
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:
- It carries no domain type. Its props are strings, numbers, booleans, snippets and plain records.
ListRowyes;AgentRowno (AgentRowcomposesListRowinside mai2). - Two of the three apps use it, or would once they adopt the shell.
- It reads only
--mk-*tokens and ships no hard-coded colour, size or font.
Everything else stays in the app that needs it, with one exception the census settled: charts draw here. @mai/mkit/chart is the five kinds at least two apps each already draw — sparkline, bar list, bar chart, line chart and heatmap (docs/plans/chart.md § 2; § Chart below says which of them have shipped) — over a pre-projected ChartSeries that carries no domain field, so an app maps its own rows once and a mapping like kind × status → shape never runs inside the kit. What stays in an app is the picture one app draws and the behaviour the app must own: donut and pie, treemap and bubble pack, brush, and chart export to PNG (§ 9). The force-directed graph § 9 left out came back through its own census, docs/plans/graph.md: four apps draw one, so @mai/mkit/graph draws it (§ Graph below).
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: 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), d3-scale + d3-shape + d3-array + d3-time (the chart group's maths — the band and value scales, the stack, line and area generators, and utcTicks for tick selection; ISC, m's d-49f72230 on docs/plans/chart-library.md), and d3-force (the graph group's layout, run to convergence with no timer; ISC, m's d-4c3307a9 on docs/plans/graph.md, 5.7 KB gzipped). Nothing else, and nothing else from d3 — not d3-zoom and d3-drag, whose arithmetic the graph group writes itself: d3-selection never enters the kit, because a renderer that owns the DOM costs server rendering, keyed updates and scoped CSS (docs/plans/chart.md § 4.1). A bar chart on a page costs 20.9 KB gzipped over an app shell, of which 13.2 KB is d3 — measured before axis.ts moved from scaleUtc to d3-time's utcTicks, which took another 3,064 B out of the demo build by leaving d3-time-format, d3-format, d3-interpolate and d3-color behind. 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), struck.css (the drawn strikethrough, § Struck text)
│ ├── qr/ encode.ts (encodeQr, qrRows, qrSvgPath, qrSvg over the vendored qrcodegen.ts — VENDOR.md), headless.ts, QrCode.svelte (§ QR code)
│ ├── chart/ types.ts (ChartSeries, ChartPoint — pre-projected, no domain field), scale.ts (the zero floor, the polyline projection, the colour index), axis.ts (span detection, tick selection on d3-time, labels on Intl), layout.ts (bars, stacks, lines and areas on d3-scale + d3-shape), summary.ts, headless.ts, Sparkline.svelte + BarList.svelte + BarChart.svelte + LineChart.svelte + SummaryTable.svelte (§ Chart)
│ ├── graph/ types.ts (GraphNode, GraphEdge, GraphViewport — pre-projected, no domain field), layout.ts (graphLayout, ringSeed and the simulation builder the live mode restarts, on d3-force), geometry.ts (nodeRadius, edgeWidth, edgePath, paletteColor), viewport.ts (fitViewport, zoomAt, panBy), summary.ts (degrees, neighbours, the hidden list's model), headless.ts, NetworkGraph.svelte (§ Graph)
│ ├── dataview/ types.ts + registry.ts (the field registry and its accessors), codec.ts (the standard's query keys and the validator), reduce.ts (search, filter, sort, group), pager.ts (the numbered-page arithmetic), chrome.ts (which filter surface, which chips, which card fields), focus.ts + rowfocus.svelte.ts (the row keyboard), selection.ts, board.ts (the lane step, the drag effect, the move), labels.ts, headless.ts, DataView.svelte + DataTable.svelte + DataList.svelte + DataCards.svelte + DataCard.svelte + DataBoard.svelte + DataGroupHead.svelte + DataFilterPanel.svelte + DataFilterSheet.svelte + DataColumnPicker.svelte (§ DataView)
│ ├── index.ts re-exports the six group indices below; a group adds its exports to its own index, never here. The graph group is reached through `@mai/mkit/graph` only: its `summary` would collide with the chart group's under one `export *`
│ ├── 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 (`indeterminate` for the mixed state of a set), 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; the kind filter chips read each group's own `label`, the Esc hint hides on a coarse pointer, m/mkit#49;
│ │ + 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), Combobox (the typeahead multi-select on Popover, § Combobox; + combobox.ts, the pure filter and highlight), 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), ConfirmHost (+ confirm.svelte.ts: `confirm()` as a promise over Dialog, § Confirm), EmptyState, Field, Icon, IconButton, Kbd, ListRow, Menu (the anchored action menu on Popover, § Menu; + menu.ts, the pure list and its focus arithmetic), Popover (the anchored overlay, § Popover; + popover.ts, the pure placement),
│ │ SearchResultRow, SegmentedControl, Select (native `<select>`, m/mAi2#74), Slider (native `<input type="range">`, § Slider), Tabs (the APG tabs pattern, § Tabs; + tabs.ts, the item type and the pure key arithmetic; roving.ts is the step over a list that tabs.ts, menu.ts, keyboardMode.ts and dataview/focus.ts share), 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, § DecisionForm; + decisionform.ts, the field-type map headless);
│ │ 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), inView.ts (an IntersectionObserver on a sentinel — § 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/tasks.css,@mai/mkit/struck.css@mai/mkit— every component and store@mai/mkit/shell,@mai/mkit/stores,@mai/mkit/actions,@mai/mkit/markdown,@mai/mkit/qr,@mai/mkit/dataview,@mai/mkit/search— the same, grouped@mai/mkit/auth— server-only, the plain condition (nosveltecondition: it is imported from$lib/server/or a+server.ts, never a component);@mai/mkit/auth/svelte— theSignIncard
@mai/mkit and @mai/mkit/shell resolve through the svelte export condition only, because a .svelte file loads in nothing else. @mai/mkit/markdown, @mai/mkit/qr and @mai/mkit/dataview each carry a default condition too, pointing at their own headless.ts: the module without its components, for a bun test, a node script or a server render. @mai/mkit/search is components/search.ts itself, which imports nothing at all, so the same three settings reach the ranker without loading a .svelte file.
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 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. All three take a footer snippet rendered outside the scroll, the foot Dialog has had since m/mkit#43: the body alone scrolls under it, so a menu's Sign out stays on screen however long the list is (m/mkit#50), and the body fades out at its bottom edge while there is more below it. A row in a MenuSheet footer wears class="mk-menu-item" (its label mk-menu-label, its Icon class="icon") for the look of the rows above; the sheet's own parts carry mk-sheet-body and mk-sheet-foot. theme writes data-theme on <html> and persists under mk:theme; every persisted key carries the mk: prefix. Whether the sidebar and the right column are open is not the shell store's but the layout record's (m/mkit#94): the areas sidebar, context and bottom (AREAS) are always present and never off, at most collapsed to an edge stub — layout.collapse(area) / expand / toggleArea / isCollapsed, undone and reset with every other layout write, persisted wherever the consumer's layout.connect(storage) puts the record. shell.sidebarOpen stays as a getter and setter over it, so a consumer reading or toggling it changes nothing; a sidebarOpen: false still under mk:shell from before is read once into the record and the key dropped. EditMode registers a palette command per area (Show sidebar / Hide sidebar, the same for the right column and the bottom) and its tray carries a Customize layout checklist of the areas and every registered panel, read from the registry so a panel is listed whether or not the record names it. Outside the mode every panel carries a put-away control on its head: the kit's Panel renders it itself, and a panel with a head of its own takes hide from its body snippet's argument (PanelBodyArgs). 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 |
inView |
a sentinel, an image, a section | onEnter, onLeave, root, rootMargin, threshold; or the bare callback |
onEnter when the element comes into the root, onLeave when it goes out after that |
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.
inView (m/mkit#73) is one IntersectionObserver per element. use:inView={loadMore} is the sentinel after a list; use:inView={{ onEnter: loadMore, rootMargin: '200px', disabled: loading || done }} is the same with the observer's own options and the gate. root, rootMargin and threshold pass through to the observer unchanged, and a change to one of them rebuilds it (an observer cannot change them once made); a changed callback or disabled does not. The flip of disabled back to false observes the element again, and observe() reports its state at once, so a sentinel still in view after an append fires onEnter a second time and a list shorter than the viewport keeps loading until it fills or done — the case mai2's ChatView, mBrian's nodes page and flexsiebels' ImageGallery each got wrong with their own observer. onLeave fires only after an enter: the first report of an element out of view is not a leave. There is no once; an onEnter that wants one shot sets its own disabled. Without IntersectionObserver (a server, jsdom) the action is a no-op. src/actions/inView.test.ts covers the bookkeeping against a stub observer under bun test src; tests/inview.spec.ts the geometry on the inview demo page.
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.
Struck text
~~text~~ renders as <del class="mk-struck">; del, s and .mk-struck all carry a drawn stroke instead of text-decoration: line-through (m/mkit#54). struck.css (@mai/mkit/struck.css, imported by prose.css and by mEditor) draws it as two radial-gradient rims in currentColor — each the top edge of a very flat ellipse whose apex sits past the right end of the text, so the line rises and thins across the span, the second rim a little lower and lighter. Every length is in em; the ink follows the text colour, so a cancelled item's --mk-text-3 and a heading's --mk-text both come out right.
The stroke is a background, not a pseudo-element, and that is the whole design: a pseudo-element is one box, so over a span that wraps it draws one line across both lines of text — in Chromium, none at all, because width: 108% resolves against a degenerate containing block. A background is painted per box fragment, so with box-decoration-break: clone every line of a wrapped span carries its own stroke. For the same reason the rise is a length (0.19 em) rather than an angle: an angle multiplies with the width of the text, and -8° over a 651 px line is 91 px of rise.
.mk-struck is for an app striking its own element, inside rendered markdown or outside it; the renderer stamps it on its own output, which is how the stroke reaches a container that is not .mk-prose — mEditor's preview imports struck.css and nothing else. A cancelled task item carries its text in an <s class="mk-struck"> the renderer writes, which keeps the state icon, a nested list and a second paragraph outside the stroke; .mk-prose li[data-task="cancelled"] only sets the colour. demo/pages/markdown.svelte shows a word, a phrase, a wrapping span and both task cases; tests/markdown.spec.ts counts the stroke's own ink per line fragment.
Command palette
CommandPalette (Ctrl/Cmd K, or palette.open() from @mai/mkit/stores) reads the registry registerCommands fills. Its ranking and its recents are components/search.ts, pure and beside the component, exported from the root and from @mai/mkit/search for an app that offers a list of commands somewhere else — a slash picker in a text field, a jump list, a menu with a filter box.
rankCommands(commands, query) returns the commands the query names, best first, the catalog's own order kept inside a tier and a non-match dropped. An empty query returns the catalog unchanged. The tiers, strongest first:
- the query is a prefix of the id or of the label
- it is a prefix of a word in the label (split on whitespace,
/,:,.,_,-) - a keyword starts with it or contains it
- the label contains it anywhere
- its letters run in order through the label or the id, from three letters up
The id is read beside the label because an app whose labels are translated still types its commands by id: a matcher reading labels alone finds /sort in English and misses it in German (m/mAi#457). It sits with the label prefix and not above it, so an id match never demotes a command that already matched by its label. The last tier starts at three letters because two run in order through nearly every name a catalog holds.
Text that is neither the id nor the label goes in keywords — a synonym, a description, a hint. The ranker reads no other field, so a consumer whose rows carry their own prose decides for itself what of it is searchable.
RankableCommand is what the ranker needs: id, label, optional keywords. Command satisfies it, and so does an app's own row with no group and no run; the return type is the argument's, so a palette still gets Command[] back.
EMPTY_RECENTS, pushRecentQuery(recents, query) and pushRecentEntity(recents, entity) keep a most-recently-used list in a PaletteRecents ({ queries, entities }): moved to the front, de-duplicated (by string, and by id for an entity), capped at 8 each. Every one returns a new object and none of them touches storage — the palette persists its own under mk:palette, a consumer persists where it likes.
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.
Popover
Popover (m/mkit#65) is the anchored overlay: the panel a field, a button or a row opens beside itself. It is the one place in the kit that knows how to put a panel next to an element, and DatePicker's calendar, TimePicker's dropdown and LanguageSwitch's menu are the three call sites.
The panel is a popover="manual" element, which puts it in the top layer — an ancestor's overflow: hidden cannot clip it, and nothing on the page can cover it whatever its z-index. That is the bug the hand-rolled panels each had. manual rather than auto because auto's light dismiss fires before the trigger's own click handler, so clicking an open trigger would close the panel and immediately reopen it; the component closes on Escape and on a pointer outside itself and its anchor instead.
The placement is JS, not CSS: position-try-fallbacks (the declarative flip) has not shipped in Firefox in any version, and a panel that flips only in Chrome and Safari has not fixed anything. placePopover(anchor, panel, viewport, { placement, offset, pad }) in popover.ts is pure — rects in, { placement, x, y } out, no DOM — so src/components/popover.test.ts pins the flip and the shift without a browser. The requested side wins while the panel fits there, the other side wins when it does not, and the side with more room wins when the panel fits on neither; along the anchor the panel then slides to stay inside the viewport's padded box. PopoverPlacement is vertical only (bottom-start | bottom | bottom-end | top-start | top | top-end): the flip stays on one axis, and no call site opens a panel beside its anchor.
Props: open (bindable), anchor (the element the panel is placed against, usually the field wrapper), placement (bottom-start), offset (4 px, the gap the kit's panels have always left), pad (8 px to the viewport's edge), matchWidth (the panel takes the anchor's width — a field's dropdown), trap, role, label, haspopup, onClose, triggerProps (bindable, written by the component), class, and the panel as children.
triggerProps carries aria-haspopup / aria-expanded / aria-controls for the control that opens the panel; the caller spreads them onto its own button the way a control spreads Field's controlProps, because the anchor is a wrapper and only the caller knows which element inside it is the trigger. trap is off by default: a combobox's listbox keeps focus on the input (TimePicker), and a trap there breaks the keyboard model. With it on, focus moves into the panel and returns to the trigger on close.
The panel ships its own surface — border, radius, background, shadow, padding — overridable per call site through --mk-popover-pad, --mk-popover-border, --mk-popover-radius, --mk-popover-bg, --mk-popover-shadow and --mk-popover-max-w. A caller's class may set width, height and colour, but must not set display unless the rule is guarded with :popover-open: an author display rule overrides the UA rule that hides a closed popover. Wrap content that needs a layout in an element of your own. demo/pages/popover.svelte, tests/popover.spec.ts.
Menu
Menu (m/mkit#67) is the anchored action menu on top of Popover: the list of things a button, a row or a bubble offers. It renders no trigger — the trigger stays the caller's own button, which spreads the bindable triggerProps (§ Popover) — and it holds no domain field: an item carries a label, an optional icon, an optional kbd hint, disabled, destructive and an onSelect the app closes over.
Props: open (bindable), anchor, items, placement (bottom-start), label (the screen reader's name for the menu, and the sheet's title on the phone; strings.menu by default), onClose, triggerProps (bindable), class. --mk-menu-min-w (11rem) sets the panel's floor.
items is one flat MenuEntry[] that renders in document order: an action, { separator: true } for a rule, { title } for a heading over the rows that follow. Flat rather than a list of groups, so "two rows, a rule, three rows" needs no group with an empty title, and a caller with no groups passes the array it already has. The action type is MenuAction; the shell's MenuItem is a different thing, the nav row MenuSheet takes.
The keyboard is the whole point: the panel is a role="menu" of role="menuitem" rows under a roving tabindex, ArrowDown / ArrowUp / Home / End move it, Enter and Space run a row, Escape and Tab close and hand the focus back to the trigger. A disabled row is announced, skipped by the arrows, and not activatable. trap is on — a menu owns the keyboard while it is open, unlike the combobox case Popover defaults to. menu.ts keeps the list types and the focus arithmetic (moveFocus, firstAction, lastAction, the isAction / isSeparator / isTitle / isFocusable guards) pure, so src/components/menu.test.ts pins the arrows without a browser.
On the phone (viewport.phone) the same rows rise as a Sheet, titled with label — DatePicker's and TimePicker's split. MenuSheet is not that sheet: it is the shell's nav, whose rows take href, badge and active and whose groups fold into shell.setNavOpen, and which carries none of disabled, destructive or kbd. The sheet keeps the menu roles, so the phone form is the same widget rather than a list of buttons. demo/pages/menu.svelte, tests/menu.spec.ts.
Tabs
Tabs (m/mkit#75) is the APG tabs pattern: a role="tablist" of role="tab" buttons over one role="tabpanel", and it replaces the four hand-rolled tablists in mai2 (AgentWorkspaceView, VoiceCapture) and flexsiebels (CompareSort, imagen/source), none of which had a keyboard path and two of which announced a toggle button in place of a tab. SegmentedControl stays the radiogroup for a filter; MethodTabs is Tabs with the auth methods' labels and the sign-in card's equal-width look, its own props unchanged.
Props: tabs (TabItem[]: id, label, an optional icon — a Font Awesome name — an optional badge shown as a Badge, disabled), value (bindable, the selected id; the first enabled tab by default), onChange, label (the tablist's name for a screen reader), panel, class; the rest lands on the root. id is generic over T extends string, so a consumer's union survives bind:value. panel is one snippet receiving the active TabItem, rendered inside the tabpanel with aria-labelledby the tab, which in turn carries aria-controls: tabs is a runtime list, so a snippet per tab would put a Snippet in the item type. Without panel only the list renders and no tab claims aria-controls, for a consumer that draws its content elsewhere. Only the active panel is mounted.
The list has one tab stop, on the selected tab: Tab from before the list lands there and Tab again leaves it into the panel (tabindex="0", so a panel with no control of its own is still reached). ArrowLeft, ArrowRight, Home and End move the selection itself and the panel follows — the pattern's automatic activation — wrapping at both ends and stepping over a disabled tab, which is announced, not selectable and never the tab stop. tabs.ts keeps the item type and the arithmetic (stepTab, tabStop, firstTab, lastTab, isTabKey, isSelectable) pure, so src/components/tabs.test.ts pins the keys without a browser; tests/tabs.spec.ts asserts the roles, the tab stop and the arrows on demo/pages/tabs.svelte. Vertical, closable and overflowing tabs are not built; no consumer has one.
Confirm
confirm({ title, message, confirmLabel?, cancelLabel?, danger? }) (m/mkit#72) is the promise the estate called window.confirm for: it resolves true on the confirm button and false on cancel, on the head's close button, on Escape and on a backdrop click, so a call site reads if (!(await confirm({ title, message, danger: true }))) return. It is mBrian's dialog.confirm shape on the kit's own Dialog, and the Toast pattern for the mount: confirm.svelte.ts is the queue, ConfirmHost renders its head and settles it. Mount <ConfirmHost /> once per app, beside <Toast /> — the app and not Shell carries it, because Toast, KeyboardMode and EditMode are mounted the same way and a sign-in page has no shell. A call with no host mounted rejects at once with an error naming ConfirmHost, rather than leaving a click that does nothing; a call made while one is open queues behind it, in call order.
danger styles the confirm button as Button's danger variant; the labels default to strings.confirm / strings.cancel (§ Strings & language), and a call's own confirmLabel / cancelLabel win over them. Focus starts on the cancel button, so Enter never confirms by accident. confirms (the queue) and settleConfirm are exported for an app that renders its own host over the same store. Neither prompt() nor alert() has an equivalent: Dialog is there for the general case. demo/pages/confirm.svelte, tests/confirm.spec.ts, src/components/confirm.ssr.test.ts.
Combobox
Combobox (m/mkit#69) is the typeahead multi-select: a text input with a filtered listbox in a Popover. It replaces three hand-rolled ones — mBrian's NodePicker and DefaultRelationPicker, mWiki's TagInput. Select stays a native <select> (m/mAi2#74) and this does not change that: a typeahead multi-select has no native element, so there is nothing for selectOption to drive, and its spec is written against roles and keys.
The focus never leaves the input. The input is the role="combobox" carrying aria-expanded, aria-controls and aria-autocomplete="list", the panel holds a <ul role="listbox"> of role="option" rows, and the highlight travels as aria-activedescendant rather than as real focus — the case Popover keeps trap off by default for. ArrowDown and ArrowUp move the highlight and wrap at both ends, Enter takes it, Escape closes the panel and keeps the typed text (stopping there, so a Dialog around the field stays open), Tab closes and commits nothing. With multiple the picked values render as removable Chips and Backspace on an empty input drops the last.
Both option sources. By default the kit filters options with filterOptions (combobox.ts), which is rankCommands — the command palette's matcher, not a second one. Pass onQuery and the consumer owns the search instead: the kit renders options as they arrive, filters nothing, and loading puts a spinner in the field. onQuery fires on every keystroke and the debounce is the consumer's; the local filter runs in about a millisecond over four hundred options and wants none.
filterOptions ranks the label and the option's own keywords, never its value. rankCommands runs a subsequence tier over its id for any query of three characters or more, and an opaque value carries nearly every three-letter run of hex digits as a subsequence: ranked on a UUID, abc returns the whole catalog, so the list never narrows and neither the empty row nor the create row can be reached. A value worth searching goes in keywords.
create offers the typed text as a value of its own while no option carries it as a label or a value and it is not picked already. Without onCreate the kit commits the text itself (mWiki's case); with it the kit emits the text and commits nothing, for a consumer whose new value is minted elsewhere — mBrian POSTs /api/nodes and keeps the slug it gets back.
options is every option the field may need to name, not only the ones it may offer: a picked value reads its label from there, so a consumer owning the search keeps the picked options in the array beside its results, and a form loading a stored value supplies the option behind it. A value no option names falls back to showing itself. value (single) and values (multiple) stay plain strings, which is what the form submits.
Pair with Field like TextInput does — spread controlProps. An option carries no domain field: a value, a label, an optional hint, an optional icon, optional keywords. demo/pages/combobox.svelte, tests/combobox.spec.ts.
Slider
Slider (m/mkit#74) is the one-value native <input type="range">, styled on the tokens where fourteen call sites in three apps each styled their own — three with accent-color alone, which leaves the track platform-grey on the dark theme, ten with nothing. It stays the native element for the reason on Select.svelte:2: fill(), getByLabel, the arrow keys, Home/End and aria-valuenow all come from the platform, and a div with pointer handlers would have to rebuild every one. The track reads --mk-border, the filled part and the thumb --mk-primary (the fill is one gradient split at the value, since Chromium has no progress pseudo-element), the focus ring sits on the thumb, and the thumb grows under (hover: none) and (pointer: coarse) the way Toggle does.
Props: value (a number, bindable), min (0), max (100), step (1), disabled, label (the aria-label for a slider that stands alone), showValue (the value beside the track in an <output for> the input's id, off by default), format ((value: number) => string, the shown value and aria-valuetext; without it the number itself is shown and the platform reads it), invalid (the fill and the thumb on --mk-danger), element (bindable, the input), and the rest of HTMLInputAttributes. Pair with Field like TextInput does — spread controlProps; a <label> wrapping it names it too. The two-thumb range form waits: mai2's RhythmSlider is the only one in the estate, and one app does not enter the kit. demo/pages/slider.svelte, tests/slider.spec.ts.
DecisionForm
DecisionForm (m/mkit#5, the field set of m/mkit#86) renders a decision's options.fields — the {"fields": [...]} form of core.decisions.options — on Field and the primitives, and refuses to submit while a required field is empty. It carries no domain type: fields is DecisionFormField[], a name, a label, a type, required, options for the two pick types, min/max/step for the two number types, placeholder and hint; onsubmit receives Record<string, DecisionFormValue> keyed by name. Ten types, one primitive each: text (TextInput), textarea, select (the native Select), multiselect (Combobox with multiple, § Combobox), number (TextInput type="number"), range (Slider, § Slider, the value shown), date (DatePicker, § Calendar), datetime (a DatePicker and a TimePicker in one Field), checkbox and toggle (a label-beside row, since both are buttons and not labelled inputs). A type it has no primitive for is not in the list: mBrian's dict (a key/value map, TypedFieldEditor.svelte) waits for a second app.
What a submit carries is the type's own value, so a consumer reads an answer without parsing: the typed string for text, textarea and select; a number for number and range, null while an optional number is empty; a boolean for checkbox and toggle; the picked values as a string[] for multiselect; YYYY-MM-DD for date; YYYY-MM-DDTHH:MM for datetime (the datetime-local wire form), '' until both parts are set. required refuses a blank string, a boolean that is off, a number field without a number (0 is a number), a multiselect with nothing picked, and a date or datetime off its wire form; a range always holds a number and starts at min. fields is read once at mount: a caller renders a fresh form per decision and does not swap fields under a live instance. decisionform.ts holds the map headless — emptyValues, isEmptyValue, missingFields, isDateTime, splitDateTime, joinDateTime, exported from the root — so a consumer holding the same fields reads or validates an answer the way the form writes it; src/components/decisionform.test.ts pins it. demo/pages/decisionform.svelte, tests/decisionform.spec.ts; the chat demo's form stays in tests/chat.spec.ts.
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 (§ 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.
formatRelativeDay(date, today?, locale?) and formatAge(at, now?, locale?) (m/mkit#76) are the relative forms, headless in calendar.ts and exported from the root. The day form names yesterday, today and tomorrow in the locale's own words — Intl.RelativeTimeFormat with numeric: 'auto' carries them, so there is no strings key and an app translates nothing — and writes any other day as formatDate does (YYYY-MM-DD, d-908e4855); the named window is one day either side in every locale, so a list's shape does not depend on the reader's language even where the locale has a word for two (de "vorgestern"). today is a YYYY-MM-DD string defaulting to the browser's civil date, the same fixed-clock seam as CalendarMonth's prop; daysBetween(today, date) is the signed difference underneath. The age form takes an instant like formatTime and climbs minutes, hours, days, weeks (from 7 days), months (from 30) and years (from 365) as numeric counts — "2 hours ago", "vor 3 Tagen", "in 5 minutes", "now" under a minute; the counts stay numeric because an elapsed day is not a calendar day (40 hours is "1 day ago", never "yesterday"). One form each, as with formatDate and formatTime; an app that wants a compact column form (mai2's 3d) keeps its own, since Intl writes "3 T" / "2 Std." for it in German. src/components/calendar.test.ts pins both against fixed clocks in en-US and de-DE.
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.
Chart
@mai/mkit/chart is the design docs/plans/chart.md; slices 1 (#63), 2 (#64), 3 (#66), 4 (#68), 5 (#98) and 6 (#70) have shipped; the apps' adoption (7) has not. Its contract is pre-projected and holds no domain field (m's d-269e403c): a ChartSeries is { key, label?, color?, points: ChartPoint[] } and a ChartPoint is { x, y, label? }, where x is an ISO day, an ISO instant or a plain name and the kit never reads it as a domain value. color is the index 1..6 into --mk-chart-<n>, the same contract timegrid.ts:10 uses; no prop takes a colour string, a series that names none takes its own position in the list, and an index past the sixth cycles. An app maps its own rows into those two shapes once, and a mapping like kind × status → shape never runs inside the kit.
Headless. seriesMax carries the zero floor — the maximum is the series' own and is floored to 1 only when no value is above zero, never to a fixed unit, because a cost series in dollars sits well under 1 and a unit floor flattens every bar. barFraction, linePoints and chartColor are the rest of the arithmetic. axisSpan reads a bucket list's granularity (category | hour | day | month | year), axisTicks picks which buckets carry a label and axisFormat prints one: d3-time's utcTicks selects round instants, Intl.DateTimeFormat on the consumer's locale does the wording, and a label that would repeat one already on the row is dropped rather than printed twice. Dates are read and printed in UTC throughout, so a day never drifts into its neighbour. barLayout returns the bars, the band centres, the maximum and the resolved colours in viewBox units, on scaleBand + scaleLinear with d3-shape's stack for the stacked form; lineLayout returns the same for lines — one LinePath per series carrying the stroked line, the area under it and a LineDot per point, all projected through linePoints against the maximum every series shares, joined by d3-shape's curveLinear or, under curve: 'monotone', its curveMonotoneX; chartBuckets is the bucket order the picture and the table share. summary builds the hidden table's model from the same series the picture drew. The default export condition is headless.ts: all of the above, no Svelte.
Accessibility ships by default, not behind a prop (§ 7.1). Every chart's <svg> is role="img" with the required title as its aria-label; a visually hidden <table> after it carries every number, built by summary() from the same series, so the two cannot drift (table={false} opts out where the number is already printed beside the picture); each mark carries a <title> for hover; and with onPointClick every mark becomes a real <button> that Tab and Enter reach.
Sparkline is one polyline, no axis, no labels, as wide as its container: series, title, height, table, format, locale. BarList is the breakdown — label, a proportional fill, the value, one row per point — and it is HTML rather than SVG, which is a behaviour contract and not a style choice: a phone frame that hides chart panels still counts a list as a list (mai2's d-f84719b8). Props: series, title, format, locale, limit, selected, onPointClick, empty, table. BarChart draws over an axis, one series or several: series (an array), title, stacked, height, format, locale, ticks (how many axis labels to aim for; 0 leaves the axis off), legend (defaults to on past one series), table, empty, onPointClick, and the mark snippet, which receives { bar, point, series } and draws the mark itself where the built-in <rect> will not do. Without stacked several series stand side by side inside each band (m's d-fb73d014); with it they stack and the band is the total. The picture is a fixed viewBox stretched with preserveAspectRatio="none", so nothing is measured and the chart renders on the server; the axis row and the legend are HTML beside it, and the click targets are an HTML overlay of <button>s mapped onto the same viewBox by percentage, because a <button> cannot live inside an <svg> and role="button" is not one. demo/pages/chart.svelte (?page=chart), tests/chart.spec.ts, src/chart/*.test.ts.
LineChart is the trend over the same axis, one series or several: series, title, area, height, format, locale, ticks, legend, table, empty, onPointClick, and the mark snippet, which receives { dot, point, series } and replaces the dot while the line stays. curve is how the points are joined: linear (the default) draws straight segments, monotone draws d3-shape's curveMonotoneX, a cubic that passes through every point and never overshoots one, so a flat run stays on its level until the value moves — the curve flexsiebels' health charts draw. The fill takes the same curve as the stroke, and the dots and the click targets sit where they sit under either. area fills under each line: one series reads as a shape, several fill more faintly so a line crossing another stays visible. A series that holds no point at a bucket draws straight through it, and the hidden table shows the hole as an empty cell. A value of zero is a point on the baseline rather than a missing one, so a line chart of an all-zero series is a flat line where a bar chart of one is empty. A dot is a zero-length subpath with a round cap rather than a <circle>, because preserveAspectRatio="none" scales x and y differently and would draw a circle as an ellipse; the click targets are one fixed-size <button> centred on each dot, since a full-height column cannot name which series was clicked.
TimelineChart is the sixth kind and the one with a second axis of rows (§ 5, #98): lanes and marks in place of series, the § 3.4 contract — a TimelineLane is { id, label, declared? } and a TimelineMark is { id, lane, at?, until?, label, color?, shape?, fill?, description? }, with at and until ISO days or instants read in UTC and no domain field, so paliad's kind × status → shape runs in paliad. A lane renders while a mark names it and always under declared (DataView's options rule); a mark naming a lane that is not listed is drawn in an appended row labelled by the id, flagged data-unknown, and counted in the hidden table — reported, never dropped, never thrown. until makes a span, and a day named there is included whole; at absent or null is the undated zone, a column between the labels and the plot that exists only while a mark is undated. The four shapes are dot, diamond, square, triangle and the four fills solid, hatched, dashed, faded; fill is the channel that survives greyscale. Props: lanes, marks, title, range, onRangeChange, today (the rule's day; the current UTC day by default), density (compact 24 px rows, standard 40, spacious 64 — LANE_HEIGHT and MARK_SIZE, paliad's table), ticks (8), labelWidth (9rem), locale, limit (500), table, onMarkClick(mark, event), and the snippets mark ({ mark, x, y, width, height }, rendered inside the mark's own box in place of the built-in shape) and empty ({ count, limit }). The picture is HTML positioned by percentage, not an SVG, and that is a measured choice: the other kinds stretch a fixed viewBox with preserveAspectRatio="none", and a circle survives that only as the zero-length round-capped stroke LineChart draws; a diamond, a square and a triangle have no such trick and would read as a rhombus, an oblong and a leaning wedge at 1280 px. A share of the axis as left: <x>% on a row of fixed pixel height is the mapping the other kinds' button overlay already relies on, and it makes a mark a real <button> under onMarkClick with no overlay; the four fills are one mechanism on currentColor, so a hatched span and a hatched dot are the same CSS. Nothing is measured for layout and the server renders the whole picture; BarList is the precedent for a kind that is HTML by design. The axis is a ladder: the finest of day, week (Mondays), month, quarter and year whose count stays within ticks, then whole years by a round step — paliad's 90 d and 730 d thresholds fall out of it at its presets (one and two years are quarters, longer is years), and under 90 days it steps to weeks and days, which a zoomed range needs; labels are Intl.DateTimeFormat in UTC on the consumer's locale, quarters as Q<n> <year>, and a tick on 1 January is drawn heavier.
The range is controlled the way the graph's viewport is (§ 3.1–3.3 of graph.md, #84): range: { from, to } in ISO days, to included whole, comes in and every gesture goes out through onRangeChange(next, reason) with reason one of wheel, drag, keys or fit; the app hands the value back, or to a URL through encodeRange/decodeRange (from,to; anything that is not two ISO days in order decodes to undefined, which means the fit), the way demo/pages/chart.svelte keeps it in ?r=. Absent, the kit keeps its own from the fit and follows the data until a gesture takes over, still reporting; nothing is emitted unprompted. The fit (fitRange) runs from the first at to the last until or at, each day whole, padded on both sides by 4 % of that span with a floor of one day; no dated mark gives a year either side of today, paliad's own fallback. The wheel zooms about the day under the pointer with the graph's delta (zoomRange), a drag on the plot's background pans by the travel converted through the plot's rendered width (panRange, the one place a width is read, and a gesture is the browser's), a press on a mark is the mark's own click and a press that never travels the 3 px slop emits nothing; +/= and -/_ zoom by 1.25 about the centre and the arrows pan by a tenth of the span from any focused mark or control button; three IconButtons (in, out, fit; strings.timelineZoomIn/timelineZoomOut/timelineFit) sit under the plot rather than over it, because a timeline of two lanes is lower than three stacked buttons. Days stay whole: a gesture that changes no whole day, and the bounds of RANGE_EXTENT (one day, a hundred years), return the same range and emit nothing. Above limit the kit draws empty or strings.timelineOverLimit and the hidden table still lists every mark: paliad's real timeline is one to five lanes with roughly sixty marks by default and about 250 at its lookahead ceiling (measured on its corpus in m/mkit#98), so 500 is a ceiling and not a budget, and there is no virtualisation. The hidden table is one row per mark the chart was given, whether or not the range shows it — the reader reaches the whole timeline and not the viewport — with the mark's description, its lane, at and until, in lane order then by date, the undated last, plus a line naming the unknown lanes (strings.timelineUnknownLanes). src/chart/timeline.test.ts is paliad's 254-line table test ported onto the two kit interfaces; tests/chart.spec.ts drives the rest on Chromium.
Graph
@mai/mkit/graph is the design docs/plans/graph.md; slices 1 (#81, the headless module), 2 (#82, NetworkGraph in the given and settled modes), 3 (#84, the viewport), 4 (#85, the node drag and the live mode) and 5 (#88, the edge labels and the label modes) have shipped; the four apps' adoption (6) has not. The contract is the chart rule again, pre-projected with no domain field (§ 2.1): a GraphNode is { id, label?, description?, size?, color?, ring?, fill?, pinned?, x?, y?, near? } and a GraphEdge is { id?, from, to, label?, description?, weight?, color?, directed?, dash?, faded? } — no kind, type, rel, role or href. size and weight are weights the kit scales onto a radius (log) and a stroke width (linear) against the largest it sees, never pixel values. color and ring are 1-based indices into the component's palette (m's d-e9a60ad4): CSS colour strings, cycling past the end, defaulting to GRAPH_PALETTE, the six --mk-chart-<n> tokens; mBrian and mWiki pass their own lists, and an entry a consumer supplies is its own contrast responsibility. That prop is the one place the kit takes a colour string. An edge naming an id that is not a node is dropped from the picture and counted in the hidden list, never thrown.
Headless. graphLayout(nodes, edges, { width, height, physics, radius }) runs d3-force to convergence on a stopped simulation over a deterministic seed — a node's own x/y, else a golden-angle step beside its near node, else its place on ringSeed — so the same input gives the same picture on the server, in a test and in the browser; pinned is honoured, no edges keeps the seed, and every circle is clamped whole into the box. The centring force is forceX + forceY, not forceCenter, which translates the picture onto a pinned node and holds two components not at all. nodeRadius, edgeWidth and edgePath (the bowed pair, with the arrowhead's point and angle and the curve's midpoint mid, where a label sits) are mai2's graphLayout.js; paletteColor resolves an index; fitViewport, zoomAt and panBy are the viewport arithmetic the four apps take from d3-zoom after a timer and a getBBox, computed from the positions instead, and encodeViewport/decodeViewport are its one-parameter URL form (k,x,y, the scale to three decimals and the translation to one; anything that is not three finite numbers with a positive scale decodes to undefined, which means the fit); degrees, neighbours and summary are the hidden list's model. The default export condition is headless.ts: all of the above, no Svelte, so a +page.server.ts or a job can lay a graph out once and store the positions beside the data.
NetworkGraph draws the picture: nodes, edges, title (required, the SVG's accessible name), width/height (the viewBox, 800 × 560; the SVG fills its container at that aspect under xMidYMid meet, so a circle stays a circle), layout (settled, the default, m's d-f21efbad: the converged layout before the first paint; given: each node's own x/y as shipped, a node without one on the ring; live: the seed first, then the simulation moving the picture in the browser — § Live below), physics, radius, palette, selected and selectedEdge (ids; the selection draws a --mk-primary ring or stroke and keeps its neighbourhood lit while nothing is hovered), onNodeClick(node, event), onEdgeClick(edge, event), nodeHref(node), onNodeMove(node, { x, y }), labels (always, hover, never; LABEL_MODES), labelLimit (200), edgeLabels, limit (1000), table, viewport, onViewportChange, zoomExtent, and the snippets node and empty. The fit is the initial transform, never enlarging past 1.5, the cap every estate fit carries. Positions carry over across a data change, keyed by id (§ 3.1, #85): under settled the re-layout after a change seeds every surviving node where it was, so a pin, a filter tweak or an expansion moves a neighbourhood and not the whole picture, and a new node seeds beside near or on the ring; under given the app's x/y are the positions and nothing carries. The first render of a page is still the input's alone — carry-over needs a previous picture in the same component — so the server and a fresh load draw the same markup. A directed edge ends in an arrowhead; a pair present in both directions bows apart, a lone edge is straight; dash and faded are the stroke's; a node's ring is a second palette index on its outline, and fill: 'hollow' draws the outline only, 'faded' at low opacity. Labels sit under the node with a --mk-surface halo (paint-order: stroke, mWiki's), drawn as given — truncation is the app's. labels is mBrian's three modes as one prop (#88): always (the default) draws every node label; hover draws every label into the markup and shows only the lit neighbourhood's — the node under the pointer or the focus, or the selection, with its neighbours; never renders no label at all, and the <title> and the hidden list still carry the name. Every mode is markup, so the server draws what the browser hydrates. Above labelLimit always behaves as hover; the other two are unaffected by the limit. edgeLabels draws each edge's label at the midpoint of its own path (edgePath's mid, the quadratic at t = ½): on the arc of a bowed pair, where the midpoint of the straight line between the two nodes would sit off the stroke, and on the line of a straight edge — 10 px to the node labels' 12, --mk-text-3, the same halo, dimming and lighting with its edge. It is independent of labels and of labelLimit, mBrian's showEdgeLabels beside its labelMode; an edge without a label draws none. Collision between labels is nobody's — the halo and labelLimit are the two answers. Above limit the kit lays nothing out and renders empty (it receives { count, limit }) or a line from strings.graphOverLimit, and the hidden list still carries every node.
The viewport is controlled the way DataView's state is (§ 3.1–3.3, #84): viewport: { k, x, y } comes in and every gesture goes out through onViewportChange(next, reason) with reason one of zoom, pan or fit; the app hands the value back, or to a URL through encodeViewport, the way demo/pages/graph.svelte keeps it in ?v=. Absent, the kit keeps its own, starting at the fit and keeping it until a gesture takes over — Tabs with an unbound value — and onViewportChange still fires, so an app that starts with an empty URL sees the first move and takes over by passing the value back. The fit is taken from a fresh picture — one no node of the previous picture is in — and kept across a change that carries nodes over, so a drop, a pin or an expansion never moves the picture under the pointer; a data change that replaces every node re-fits (#85). Nothing is emitted unprompted: undefined means the fit, so the server never emits and a URL stays empty until the reader moves the picture; the fit control emits the fit as a value. The gestures are listeners over zoomAt and panBy with no d3-zoom and no d3-drag: the wheel zooms about the pointer with d3-zoom's own delta (lines and pages weigh more than pixels, ctrl+wheel is Chromium's trackpad pinch), so a picture moved off d3.zoom feels the same; a drag on the background pans, with pointer capture, preventDefault() on the press (the native-drag rule the shell's handles follow) and touch-action: none on the SVG, and a press that never travels emits nothing; two pointers pinch about their midpoint and pan with it; a press on a node or an edge is theirs, not the viewport's. +/= and -/_ zoom by 1.25 about the box's centre and the arrows pan by 40 viewBox units in the arrow's direction, from any focused descendant — a node, an edge or one of the three control buttons (zoom in, zoom out, fit; strings.graphZoomIn/graphZoomOut/graphFit) in the frame's bottom-right corner, which are also the keyboard's way in on a graph whose nodes are not controls; the SVG itself is never a tab stop. zoomExtent (ZOOM_EXTENT, [0.25, 4]) clamps every gesture, and at a bound nothing is emitted. A client point becomes a box point through the SVG's getScreenCTM(), so the maths never measures the frame or assumes its aspect.
A node drags with pointer capture and no d3-drag (§ 3.1, § 3.3, #85): every node is draggable under live, and under settled and given while onNodeMove is set — there a drop means nothing the app does not write back, so without the callback a press on a node is its click alone. A press is tracked from the SVG and the pointer is captured only once it has travelled the slop (3 box units), which is what keeps a press that never moves the node's own click and keeps the click after a drag off an <a href>: a captured pointer's click lands on the SVG. The native link drag is stopped by cancelling dragstart on the SVG and user-select: none, not by preventDefault() on the press, which would also take the focus a role="button" node gets on mousedown. While the drag lasts the kit shows the node under the pointer (svg.dragging, .mk-graph-node.dragged); the drop goes out as onNodeMove(node, { x, y }) in picture units — what a node's own x/y mean — and the picture returns to what the props say, so the app that wants the drop to stick writes x/y back (given) or pinned with x/y (settled, which then re-lays out the neighbourhood from where it was). A pinned node carries data-pinned on its control. The demo's settled graph pins on the drop; its given graph writes the place back.
live is the one mode that does not render on the server beyond the seed (§ 5.2, #85): the server and the first client frame draw the seed — the ring, near, or the app's own x/y — with the seed's fit, so the page has its shape and its hidden list before hydration; then an effect, which render() never runs, builds the simulation (graphSimulation, the same stopped d3-force object graphLayout settles) and restarts it, every tick writing the positions into a $state map that the keyed {#each} follows. When a fresh picture's simulation ends the fit is taken once, while the viewport is uncontrolled and untouched, and never emitted. A drag reheats it (alphaTarget(0.3).restart(), the estate's value), holds the node through fx/fy and releases it on the drop unless it is pinned, in which case it stays where it was dropped; the reheat's end does not re-fit. A data change rebuilds the simulation with every surviving node where it is and reheats to 0.3 instead of 1, so a pin written back on a drop or an expansion settles gently. Under prefers-reduced-motion: reduce (read from matchMedia, followed on change) nothing moves on its own: the simulation settles in one step when it is built, so the picture appears converged, a drag moves the node with the pointer and the rest settles on the drop in one step. NetworkGraph.ssr.test.ts pins the seed markup with no window and that no timer was scheduled; tests/graph.spec.ts drives the rest on Chromium.
Focus lives on the nodes inside the SVG, not on an HTML overlay (§ 3.4) — the opposite of the charts, whose overlay is exact only under preserveAspectRatio="none". With nodeHref every node is an SVG <a href>, so middle-click and open-in-new-tab work; with onNodeClick and no href it is a <g role="button" tabindex="0"> that Enter and Space activate; with neither it is a plain group. An edge becomes the same kind of control under onEdgeClick, with a 12-unit transparent hit path under its stroke, and precedes the nodes in the Tab order because SVG paints in document order. Hover or focus lights the node, its neighbours and their edges and dims the rest — the :hover/:focus-within idiom three apps wrote, held as one piece of state here because a neighbour cannot be selected from CSS; Escape clears the hover. Nothing is lit on the server, so the server's markup and the first client frame agree. The SVG is role="img" while nothing inside it is a control and role="group" once something is, because img makes its descendants presentational and would hide the controls from a screen reader; either way it carries title as its name. Every node and edge has a <title> (the description, else the label, else the id; an edge's <from> → <to> with its label appended), and a visually hidden <ul> after the SVG lists every node with its link count and its neighbours' labels, plus a line for the dropped edges, under SummaryTable's wrapper rules (table={false} opts out). The node snippet receives { node, x, y, r, color } and replaces the <circle> inside the control; it is rendered inside a <g> already translated to the node's place, so it draws about the origin — mBrian's d3.symbol path and its "+N" badge drop in as they are. No colour literal anywhere in src/graph/; the demo page's consumer palette is the one hex in the group and it is the demo's. demo/pages/graph.svelte (?page=graph), tests/graph.spec.ts, src/graph/NetworkGraph.ssr.test.ts (the marks counted from render() under svelte/server, no window), src/graph/*.test.ts.
DataView
@mai/mkit/dataview implements docs/standards/dataview.md — filter and sort are mandatory on every list, the view set is a per-app choice, and the default set is Table and List (m's decisions). The design is docs/plans/dataview.md; all six slices have shipped.
Headless (#55). A FieldDef declares a field once — key, kind (enum | id-set | range | date-range | boolean | text), label, the short url key, the combination modes, and the affordances filterable / sortable / searchable / displayable / groupable / title — plus get, compare and format, which is what keeps the kit ignorant of the row type: a value behind a join, a metadata bag or a lookup map declares its own accessor and no adapter layer appears. encodeState writes the standard's keys (q, f.<url>, f.<url>.all, f.<url>.none, f.<url>.from, f.<url>.to, sort, dir, page, size, view) plus the two the kit adds for grouping and the column set (group=<url>, cols=<url,url,…> — the registry's url keys, as sort writes them) and omits every value equal to its default, so a clean list has a clean URL; decodeState is the validator — an unknown key is dropped, an unknown value for a known key is rejected rather than coerced. stateUrl keeps the parameters a base already carries, overCeiling measures the complete URL against 2000 characters. fieldComparator carries one null-last rule for every consumer, formatValue and truncate come with it, and filterRows / searchRows / groupRows reduce the rows the kit is handed. pageCount, clampPage, pageWindow and pageRange are the numbered pager's arithmetic. The default export condition is headless.ts, all of the above without the components.
DataView (#56, #77) is the surface: the search box, a sort select with its direction button, a group-by select when the registry marks a field groupable, the filter chips, the column picker in the table view, the page-size picker, the count line, the view switch and the clear control, over DataTable (the registry's displayable fields as columns on Table and TableHeaderCell, cut to state.columns, with its three-state sort cycle and aria-sort), DataList (on ListRow) or DataCards (a grid of auto-fill columns over a 15rem minimum, so the count follows the container and not the viewport; each card is the <a> or <button> itself, and the default body is the title, the first two other displayable fields and the first date-range field — cardFields). Grouped (state.group), every view heads each run of rows with DataGroupHead — the lane's label and its count; a multi field lands a row in every lane it matches, the ungrouped lane comes last, and the grouping runs over the page, after the slice. Props: rows, rowId, fields, state, onStateChange, viewOptions, remote, total, rowHref, onActivate, selectable, selectedIds (bindable) with onSelectionChange, onMove, labels, dense, and the snippets cell, rowMain, rowMeta, card, boardCard, groupHead, laneHead, toolbar, selection, empty. With no snippet the registry alone renders.
Board (#80, m's d-02963386): view=board lays the visible page out as DataBoard — one lane per value of a groupable field, the lane set being the field's declared options in declaration order unioned with the values the rows carry, the ungrouped lane (value: null) last, so a declared-but-empty lane still renders and still takes a drop; a multi field puts one card in every lane it matches; a label is display only and never round-trips as a value. The field is state.group, or the first groupable field when none is set, so view=board alone renders lanes, and in this view the group control has no "no grouping" option. The kit orders nothing within a lane — the order is the active sort — and pages the board the way it pages every view. Each card is DataCard (shared with the card grid): the link itself, its body boardCard({ row, lane, dragging }), then card, then the default; the lane header is laneHead, then groupHead, then the label and its count. A card dragged onto another lane, or stepped there with Ctrl+ArrowLeft / Ctrl+ArrowRight while focused, emits one event and nothing else: onMove({ id, field, from, to, copy }), with null for the ungrouped lane and copy true only when Shift was held on a multi field. The kit persists nothing, mutates no row, invents no lane and swallows a same-lane drop; the card moves when the consumer's rows change (mBrian's optimistic update and rollback stay in the app), and after a keyboard step the kit puts the focus back on the card in its new lane. The drop highlight survives the pointer crossing the lane's own cards, and the click a browser fires after a drag on a link is cancelled. While the board is up the root carries data-wide; a component cannot widen its host, so the page reads it — main:has(.mk-dataview[data-wide]) { max-width: none } — and changes its own padding. --mk-dataview-lane-w (280px) is the lane width; below a 600px container the lanes stack. The pure half is dataview/board.ts (stepLane, laneIndex, dragEffect, isCopy, moveOf, the BoardMove type), on the default condition too. docs/standards/dataview.md § 1 and § 2 carry groupable and view=board as kit additions; an app without a board never emits either.
Keyboard and selection (#79): every view has one tab stop over its rows — the row's own <a> or <button> (in the table, the title cell's), or the row container when there is neither — and ArrowUp/ArrowDown/Home/End move it, stopping at the ends the way the keyboard mode's region walk does; the stop is tracked by row id, so a sort keeps it on the same row and a page change lands it on the first, and grouped rows walk in DOM order. Enter is the element's own activation, so Ctrl+Enter on a link still opens a tab; Space toggles the row's selection when selectable and activates nothing. selectable renders a checkbox per row (a pointer control, tabindex="-1", outside the row's link) and one tri-state checkbox for the visible page in the status line — checked when every visible row is selected, mixed when some are; a click selects the visible rows, or deselects them when all were. selectedIds is a bindable string[] beside state, not in it: a selection is not on the wire (codec.ts never writes it) and not what a saved view stores, and the kit never trims it — an id off the visible page stays until the consumer drops it. The selection snippet { ids, clear } renders the consumer's bulk actions in a bar above the rows while the set is non-empty; clear empties the whole set. The step arithmetic is components/roving.ts, shared with Tabs, Menu and the keyboard mode; the row keyboard is dataview/focus.ts (pure) and rowfocus.svelte.ts (one delegated keydown/focusin per view over the data-dv-row elements); the selection arithmetic is dataview/selection.ts.
Saved views and the calendar (#83): a saved view is the consumer's row — a table row, a node, a file — and the kit sees SavedView { id, name, state, visibility? }, where state is the query string encodeState wrote (queryString(encodeState(state, fields))), so a view is the URL's query round-tripped through the codec that writes the address bar and decodeState(view.state, fields) opens it; that is what makes it the standard's escape hatch past the 2000-character ceiling (dataview.md:98). The seam is three props and three events: views (given, even empty, the bar carries a Views button whose list applies a view through onApplyView(id) and, when onDeleteView is wired, deletes one through it), onSaveView(name, state) (renders Save as a view, a dialog asking for a name; a name an existing view already carries turns the button into Replace, so the overwrite a consumer keys on the name is visible before the event leaves), and href, the complete URL of the current state as the consumer wrote it: with it the bar offers Copy link, and past the ceiling Save as a view stands in its place when onSaveView is wired, while an app without saved views keeps the long link. The kit stores nothing, fetches nothing, applies nothing and confirms nothing — the consumer decodes, writes its URL (pushState; a view is what Back should undo), persists, and confirms a delete if it wants to. visibility is carried through untouched and marked with an icon when it is organization or public (d-423c23b0); there is no control for it. The calendar (m's d-5293a219) is the calendar snippet, { rows, dateOf }: the consumer renders the kit's CalendarMonth or TimeGrid over rows — every row that matches after search, filter and sort, not the page, because a month is not a page; in this view the pager, the size control and the page checkbox stay off and the count line reads the whole set — reading each row's date through dateOf, the registry's first date-range field as the wire string the calendar module reads (YYYY-MM-DD or the ISO datetime the row carries, a Date through toISOString, undefined for none). The anchor month or day stays wherever the app keeps it, DataView learns no date field, and the snippet renders for an empty set too — an empty month is still a month. view=calendar without the snippet renders the table. The pure half is dataview/views.ts (SavedView, findView, isShared, ceilingChrome, dateField, dateAccessor), on the default condition too; the chrome is DataSavedViews. Twelve strings keys (dataviewViews, dataviewSaveView, dataviewCopyLink, dataviewLinkCopied, the visibility labels, …).
The filter surface follows the registry (filterChrome): chips in a row up to eight dimensions, and above that (d-da16bb8f) DataFilterPanel — one CollapsibleSection per dimension holding its chips, with a SegmentedControl of the dimension's any / all / none modes in the section's actions when it declares more than one; the panel's exclusion control, in place of mBrian's alt-click, which has no touch form. Desktop places it as a column beside the rows (--mk-dataview-panel-w, 16rem); the phone (viewport.phone) puts the same panel in a Sheet behind a Filters button carrying the active count (DataFilterSheet). DataColumnPicker is a Popover (a Sheet on the phone) of one Checkbox per displayable field and refuses to uncheck the last one, so a table never renders zero columns and the wire needs no sentinel for an empty set.
It is controlled and it navigates nothing: state comes in, onStateChange(next, reason) goes out with reason one of filter | sort | search | page | view | group | size | columns, and the app writes the URL — the standard asks for pushState on a page or a view change and replaceState otherwise, and a component with no router cannot make that call (mai2 runs a hash router and no SvelteKit, so no $app/* import appears anywhere in src/). No request ever leaves the kit: rows are the app's, reduced in place, or left exactly as given under remote for a server-reduced list. Every change but a page or a view change returns to page 1. rowHref makes a row an <a>, so middle-click and open-in-new-tab keep working; without it onActivate makes it a <button>; neither leaves a plain row. Chrome text reads the strings store (dataviewSearch … dataviewNextPage, dataviewViewCards, dataviewViewBoard, dataviewBoardNoGroup, dataviewGroup, dataviewUngrouped, dataviewColumns, the mode words, dataviewSelectPage, dataviewSelectRow, dataviewSelected), with a per-instance labels record winning. demo/pages/dataview.svelte, tests/dataview.spec.ts, src/dataview/*.test.ts.
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. Two facts matter while working here. A change ships a fragment — CHANGELOG.d/<issue>-<slug>.md, the shape is that directory's own README — and never a version: package.json, CHANGELOG.md and the root bun.lock are written on main after the merge, so two branches finishing in parallel never collide on a number (m/mkit#62). And @mai/mkit is versioned independently of @mai/meditor — a release that bumps only this package 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.tsexist (#1, slice 1b). - Slice 1 is complete (#1, slice 1d):
Sheetreconciled onto the shell'sBottomSheet; 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.tsscreenshots 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.0is on the registry under thelatestdist-tag. - The chat components —
Bubble,Composer,Chips,DecisionForm— and theswipeaction exist (#5, slice 3 ofdocs/plans/web-unify.mdin m/mAi2). The chat demo page renders them overmockups/data/pwa_chat_last_25.json;tests/chat.spec.tscovers the timestamp placement, the pin marker, swipe-to-reply, chips fading and the form's required-field guard. - The
stringsstore andLanguageSwitchexist (#25): every hardcoded English chrome literal insrc/shellandsrc/componentsreadsstringsinstead,SidebarHeadcarries the switch,demo/pages/language.svelteexercises DE/EN over a demo-fixture translation;tests/language.spec.tscoverssetStrings, 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
stringsstore too.demo/pages/auth.svelte,tests/auth.spec.ts. - The
pullToRefreshaction andShell'sonrefreshexist (#20, web-unify.md slice 4): the lists demo page pulls at 390;tests/pull-to-refresh.spec.ts. - The
inViewaction exists (#73): see § Actions.demo/pages/inview.svelte,tests/inview.spec.ts,src/actions/inView.test.ts. Sliderexists (#74): see § Slider.demo/pages/slider.svelte,tests/slider.spec.ts.DecisionFormrenders ten field types (#86): see § DecisionForm.decisionform.tsis the headless map.demo/pages/decisionform.svelte,tests/decisionform.spec.ts,src/components/decisionform.test.ts.@mai/mkit/authexists (#27):configureAuth,resolveUser,createAuthHandlers(gotrue password + mAuth-minted magic link, oidc, proxy, local), moved up from m/mWiki'sauth-provider.ts/oidc.tsand made config-driven; theSignIncard (@mai/mkit/auth/svelte) composes #26's presentational components.CollapsibleSectionand thesectionsstore exist (#19): the header is a<button aria-expanded>inside anh3, the chevron is the kit'sIcon,openis bindable,countrenders aBadge,summaryshows while folded,actionssits outside the button; with anidthe user's fold persists undermk:sections. Replaces mai2's and flexsiebels' ownCollapsibleSectionand mBrian'sFilterSidebarsection headers.demo/pages/components.svelte,tests/collapsible.spec.ts.CalendarMonthand the headlesscalendar.tsexist (#37): see § Calendar.stringsgainslocale,calendarPrevMonth,calendarNextMonth.demo/pages/calendar.svelte,tests/calendar.spec.ts.@mai/mkit/dataviewexists (#55 headless, #56DataView/DataTable/DataList, #77DataCards, group headings, the filter panel and the column picker, #79 the row keyboard and the selection, #80DataBoardandonMove): see § DataView.stringsgains twentydataview*keys, then nine more, then three, then two;ListRowgainshref;Checkboxgainsindeterminate.demo/pages/dataview.svelte,tests/dataview.spec.ts.DatePickerexists (#40): see § Calendar.CalendarMonthgainsmin/max;stringsgainsdatePicker,datePickerOpen.demo/pages/datepicker.svelte,tests/datepicker.spec.ts,src/components/DatePicker.ssr.test.ts.@mai/mkit/graphexists (#81 headless, #82NetworkGraphin thegivenandsettledmodes, #84 the controlled viewport with its gestures, keys and controls, #85 the node drag,onNodeMove,pinned, thelivemode with reduced motion, and position carry-over, #88labels,edgeLabels,LABEL_MODESandedgePath'smid): see § Graph.stringsgainsgraphLinks,graphDropped,graphOverLimit,graphZoomIn,graphZoomOut,graphFit.demo/pages/graph.svelte,tests/graph.spec.ts,src/graph/NetworkGraph.ssr.test.ts,src/graph/*.test.ts.
Dependencies
Dependencies
| ID | Version |
|---|---|
| @fortawesome/fontawesome-free | ^6.7.2 |
| d3-array | ^3.2.4 |
| d3-force | ^3.0.0 |
| d3-scale | ^4.0.2 |
| d3-shape | ^3.2.0 |
| d3-time | ^3.1.0 |
| 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/d3-array | ^3.2.1 |
| @types/d3-force | ^3.0.10 |
| @types/d3-scale | ^4.0.9 |
| @types/d3-shape | ^3.1.7 |
| @types/d3-time | ^3.0.4 |
| @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 |