@mai/mkit (0.2.53)
Installation
@mai:registry=https://mgit.msbls.de/api/packages/mAi/npm/npm install @mai/mkit@0.2.53"@mai/mkit": "0.2.53"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, jose 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, or mounts the OIDC issuer (jose), installs the matching peer; oauth4webapi/@supabase/ssr/jose 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/
│ ├── app.css the host page's frame: html, body and the mount node at full height, margin 0, and the print block that puts them back to their content height (§ Shell)
│ ├── 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), callouts.css (the `> [!kind]` block, § Callouts)
│ ├── 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, TimelineMark, DaySpan — 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), timeline.ts (the lanes, the range and the hour-to-year tick ladder of a date axis, on the wall clock of a zone), dayspan.ts (the day windows, the bars and the hour ticks of a clock axis, on the wall clock of a zone), zone.ts (the wall-clock instant of a zone and its inverse, timeline.ts's own), summary.ts, headless.ts, Sparkline.svelte + BarList.svelte + CellStrip.svelte + BarChart.svelte + LineChart.svelte + Heatmap.svelte + TimelineChart.svelte + DaySpanChart.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, the card's picture), focus.ts + rowfocus.svelte.ts (the row keyboard), selection.ts, board.ts (the lane step, the slot a drop lands in, the drag effect, the move), timeline.ts (the timeline view's field choices, lanes and marks), labels.ts, headless.ts, DataView.svelte + DataTable.svelte + DataList.svelte + DataCards.svelte + DataCard.svelte + DataCardBody.svelte + DataBoard.svelte + DataGroupHead.svelte + DataFilterPanel.svelte + DataFilterSheet.svelte + DataFilterDimension.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 (`count` Badge on the right, `hot` paints it red; `hint` a Kbd after it for the row's shortcut, hidden in the rail, m/mkit#150; `shortcut` the same chord as `aria-keyshortcuts` in the attribute's own grammar, the pair SidebarHead's `searchHint`/`searchShortcut` is, m/mkit#155; the active row's accent edge is `--mk-nav-accent-w` on an ancestor, 0 by default and `3px` for a stripe on the leading edge, painted in `--mk-nav-accent`, the row's own `--mk-primary-text` unless set — an inset shadow, so nothing moves and the rail's centred icon keeps its edge, m/mkit#167; `external` opens `href` as `target="_blank" rel="noopener noreferrer"`, drops `aria-current`, and adds a mark after the label, hidden in the rail with it, m/mkit#236), 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 (`rank` sets the title's size and its heading level together — `section`, the default, is --mk-text-lg as an h2, `label` is --mk-text-sm as an h3; `level` moves the heading level alone for a card nested under another heading; `padding` is the inset, sm 8 / md 12 / lg 24 px, and `--mk-card-pad` replaces it), 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; the search itself is PaletteBody, which SidebarHead renders in place too, m/mkit#153;
│ │ + 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; a grip at the bottom-right corner drags the width and height between the size's default and 92vw × 85vh, by pointer or by arrow keys on the focused handle, for the open lifetime only — `resizable={false}` removes it, and `full` and the phone never show one, m/mkit#121; 24 px around the body and 16 / 24 px on the head and the footer, `--mk-dialog-pad` on an ancestor sets the inset and `--mk-dialog-pad-body` / `-head` / `-foot` replace one part's padding, m/mkit#123), ConfirmHost (+ confirm.svelte.ts: `confirm()` as a promise over Dialog, § Confirm), EmptyState, Field, Icon, IconButton (`href` renders the control as an `<a>` in the same shape, for a control that navigates, m/mkit#310), Kbd, ListRow (a row never shrinks below its own content in a flex column: the coarse-pointer 44 px is a floor and not a height, so a consumer's bounded list needs no `flex-shrink` of its own, m/mkit#275), LegalPage + LegalLinks (the public legal pages and the links to them, § Legal pages; + legal.ts, the config file's format, its parser and the link list), 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, Tooltip (the bubble on hover and keyboard focus, § Tooltip; + tooltip.ts, the pure trigger model, and `tooltip`, the same bubble as an attachment);
│ │ 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: ChatFrame (the page's frame: the log is the one scroller and follows the newest line, the foot under it stands still, § ChatFrame; + chatframe.ts, the pure follow geometry), 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, trigger completion on `triggers`, § Composer; + TriggerMenu for a field of the app's own, triggers.ts the pure text arithmetic, caret.ts the caret measurement), 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, SetPasswordForm, 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; `configureStorage` renames, #109)
│ ├── 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, issuer.ts (the OIDC issuer, m/mkit#222), 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
├── tests/ ssr-harness.ts, the Vite dev server the *.ssr.test.ts files under src/ render through; runes-loader.ts + runes-harness.ts, the bun loader and the JSDOM window the *.runes.test.ts files run the real runes through; fixtures/, the components and rune modules those files render
└── 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=…
Exports (package.json exports):
@mai/mkit/app.css,@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/auth/email—isValidEmail, plain condition only, no imports
@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.
The brand is four tokens (m/mkit#165). --mk-primary is the fill — a primary Button, Badge, Checkbox, Toggle, the brand mark, a selected calendar day — and --mk-primary-ink the text on it. --mk-primary-text is the same brand drawn on the page's own ground: an active nav row and its icon, a tab, a link, a selected chip, a today ring, the focus ring, --mk-chart-1, every border and stroke in the primary; --mk-primary-text-hover is a link's hover. Both text tones default to the fill and its hover, so a brand whose fill reads as text sets --mk-primary alone, and a brand whose fill is too light for text on the page — a lime with midnight ink — sets --mk-primary-text (and -text-hover) beside it, per theme the way it sets the fill. --mk-primary-soft is the tint under an active row and follows neither.
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 '@mai/mkit/app.css'
import { Shell, Sidebar, ContextPanel, StatusBar, BottomNav } from '@mai/mkit/shell'
app.css is the host page's own frame and the line an app that mounts Shell needs (§ Shell); an app that takes tokens alone leaves it out. 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). It never scrolls sideways (m/mkit#256): the cross axis is pinned, because an unset overflow-x computes to auto beside a scrollable y axis and one child wider than the body would otherwise make the whole page a horizontal scroller. A block that is wider than the page by design — a code block, a markdown table, a board of lanes — carries its own overflow-x: auto and keeps it, since a clip on an ancestor does not reach into a descendant's scroller; anything else wider than the page is cut off at the edge, which is the layout defect to fix. ?page=sidescroll in the demo is the page to check it on. The body's inset is --mk-page-pad on any ancestor, the shape Card has with --mk-card-pad — 16 px, 12 px on the phone, one declaration for both, and a flush body keeps none; --mk-page-measure holds its children to a content width and centres them while the scroller keeps the column's width, so the scrollbar stays at the edge — a child that must fill the width takes mk-bleed, and a route that wants none of the measure sets none (m/mkit#157); the rule reaches the screen's own root through the slot item around it (m/mkit#191). --mk-body-gap sets the rhythm between those children, 16 px (m/mkit#156). 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 %), which is what @mai/mkit/app.css ships — import it in the app entry beside tokens.css and the host page is done; a SvelteKit app needs nothing else, since its app.html wraps the app in a display: contents div that passes the height through, and a plain div#app, #root or #svelte is one of the file's own selectors (.mk-app is the class for any other mount node). Without those heights the shell is 0 px tall and every screen paints the background and nothing else, a complete DOM with no error anywhere, so a dev build says it once on the console when the shell mounts that way (src/shell/hostFrame.ts, m/mkit#307); a production build carries no check. 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.
PageHead is the content column's head: title, an optional crumb before it, and the page's actions as children on the right. description (m/mkit#158) is the page's purpose in one line under the title, in the muted --mk-text-3 at --mk-text-sm, so a route does not put that line in the body of its own page. The head is then two lines — the crumb and the title keep their row, the description sits under them at the left edge of that block, the actions stay on the right and are centred against the pair — and it is as tall as it needs to be, never shorter than --mk-topbar-h. A head with no description is the fixed --mk-topbar-h row it always was, so the shell's geometry is unchanged wherever the prop is not passed. Desktop only: PhoneTop takes the head below 768 px and carries no description.
variant (m/mkit#160) picks which of two shapes it renders. bar, the default, is everything above. page is the page's opening block: no card, no border, no --mk-topbar-h and not sticky — a column that goes in the page body and scrolls with it, the title at --mk-text-xl semibold with letter-spacing: -0.01em, optional badges beside it, the actions (children) right-aligned on the same line, the description under it at --mk-text-md in --mk-text-2 and held to a 68ch measure, and below for the row a page adds under the header — a tab row, a filter strip. The title line wraps instead of taking a breakpoint, so the actions drop under it and stay right-aligned: this block sits in the content column, whose width the sidebar's rail and the context column change while the viewport does not move. crumb does not render under page — it is bar chrome. Unlike the bar, the block renders on the phone too, where it is the page's only heading. badges and below render under page alone.
Exactly one <h1> per page. The variant does not move where PageHead renders — the consumer does. Adopting page means putting it in the page body as its first child, passing Shell no head snippet (or one that carries no <h1>, the way an app bar does), and writing no second heading of that rank in the page. A page block left under a bar head gives the route's name twice at two ranks, which is the two-<h1> clash that makes a strict-mode locator('main h1') resolve to two elements.
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. The sidebar is one stacked slot, sidebar (m/mkit#128, docs/plans/areas.md § 2.1): the nav rows first, the Tree next when a tree snippet is given, and the panels a human puts there after them, a separator between every adjacent pair (Resize Navigation and Tree, Resize Tree and <panel>, stackSplit over regionNav and sidebarTree), the shares under mk:shell as stacks.sidebar for the ids ['nav', 'tree', ...panels]. The nav rows and the Tree are panels the kit registers itself (m/mkit#186, docs/plans/everything-is-a-panel.md § 2, d-471f94ea): Sidebar registers nav (home: 'sidebar', forms: ['column'], required, so a human may move it into any column — never the bottom row, never the page — and may not put it away) and, while tree is given, tree (the same home and forms, present while the snippet renders something, put away or moved like any panel); KIT_PANELS keeps both ahead of a consumer's panels whatever registered first, so the ids and the stored shares are the ones the stack had while the two were its fixed lead, and a record from before reads as it is. The registration is the component's, not the <aside>'s, and is made in its script so the server render carries it: a nav moved into another column stays on screen while the sidebar is closed, and the phone, which mounts no Sidebar, registers nothing. <Sidebar navPanel={false}> keeps the nav as the stack's fixed lead — no edit bar, never moved — for an app whose navigation must stay where it ships; the prop names the nav alone, and a consumer that wants no tree panel passes no tree. A tree snippet that renders nothing on a route takes no share and no separator. Without tree the nav rows are the one item and the Tree scrolls with them. A sidebarSplit still under mk:shell from before seeds the nav and tree shares once and the key is dropped. SlotPanels takes lead for a container's own fixed content — StackLead { id, title, body, present? }, rendered ahead of the panels without an edit bar, never moved or put away: the nav under navPanel={false}, the ContextPanel's route detail. The old nav and tree slot ids read as sidebar (SLOT_ALIASES, aliasSlot in layout.ts): a stored placement or a consumer's home naming either lands in the sidebar, a record from both halves keeps the nav half's panels above the tree half's, nothing is rewritten on load and the next write carries sidebar. The slot reads Left sidebar (slotSidebar).
The route's screen is a panel the shell registers itself (m/mkit#188, docs/plans/everything-is-a-panel.md § 3, d-6c57eaba): Shell registers screen (home: 'page', forms: ['page', 'column', 'row'], required, title the page's own name, icon window-maximize for a folded column's stub) with children as its body, in its script so the server render carries it, and the page body renders the page slot alone — the screen's markup in one slot item and the slot's other panels each in theirs after it; KIT_PANELS keeps the screen ahead of a consumer's page panels. In the mode the screen carries an edit bar and a row in the move menu like any panel, so a human may put the route in any column or in the bottom row; it is never put away, and it carries no floor of its own (docs/plans/free-placement.md § 4 — LEAF_WIDTH_MIN.main stays the leaf's, so a route in a 200 px column is the human's arrangement and Reset layout the way back). A flush body is no slot (m/mkit#116) and renders the screen alone and bare, no edit bar: while the route is flush the screen is fixed — a PanelDef flag slotOf reads, the panel resolving to its home whatever the record says and a placement naming it kept, not applied — so a screen a human moved into a column is back in the main column for a flush route and back in the column when the route scrolls again. The registration never comes and goes with flush, and the slot item around a panel's body is the same element whether the mode is on or off (m/mkit#191) — off the mode it is display: contents, no box and nothing but its class, and the mode adds the edit bar beside the body — so the screen keeps its keyed block across both flips: a route that toggles flush on a mounted shell (mBrian's focus mode) does not remount, and neither does a route or a panel when a human enters or leaves the mode. The item is one element between a slot and a panel's root: a selector of your own on .mk-page-body > .x reaches the item, and the kit's own child rules on the page body and on a stack item reach through it. The kit's own panels never summon an area: the bottom strip renders for a consumer's row panel or a panel placed there, not for the screen's row.
TreeRow and TreeGroup carry two numbers at the right, told apart by tone (m/mkit#147): count is the muted total or meta number (--mk-text-3, as before), unread a filled pill in the primary tone before it, the conventional unread badge, hidden at 0 the way count is. The pill is Badge in its primary variant with strings.treeUnread ({n} unread) as its title; --mk-badge-primary and --mk-badge-primary-ink on an ancestor (.mk-tree, one group, one row) retone it without touching the primary colour elsewhere. A consumer that put an unread count into count moves it to unread and keeps count for the total. TreeGroup's chevron (default true, m/mAi#586) is the collapsible head's own fold icon; a consumer sets it false to fold a top-level nav category without the icon, so it reads the same as a non-collapsible one beside it, click still toggling open.
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. The mark square paints --mk-primary on --mk-primary-ink in the body face; a brand whose mark is its own image — a favicon drawn as type — sets --mk-mark-bg, --mk-mark-ink and --mk-mark-font (a family) on any ancestor, the way --mk-card-pad and --mk-page-pad are set, and nothing else in the kit moves (m/mkit#166). Collapsing the sidebar has one result, its 56 px rail (m/mkit#201): SidebarHead's own buttons, the fold control on the separator beside it (m/mkit#198), the mk:area-sidebar command, the tray's checklist row and focus mode all write the layout record's collapsed, and a sidebar in collapsed renders as the rail. shell.sidebarRail and shell.sidebarOpen are two names for that one state (sidebarOpen === !sidebarRail), so setSidebarRail(true), setSidebarOpen(false) and toggleSidebar() from an open sidebar all give the rail; there is no stub and no full hide beyond it. A layer the page positions fixed beside the sidebar offsets itself by shell.sidebarOffset (m/mkit#215): 0 on the phone, where the sidebar is not rendered, SIDEBAR_RAIL_WIDTH (56, the --mk-rail-w token) in the rail, and shell.sidebarWidth open — one reactive number in place of a branch in every consumer. SIDEBAR_RAIL_WIDTH is exported from @mai/mkit/stores for a consumer that does its own arithmetic; one that overrides --mk-rail-w or --mk-sidebar-rail-w is out of step with both. The rail carries its own way back (the angles-right button and the separator's control), so PageHead renders no show-sidebar button. Each of these setters, and layout.collapse, expand, toggleArea and setCollapsed, takes { undoable: false } (m/mkit#203) for a write that answers the viewport rather than a human, such as a breakpoint forcing the rail: the record is written and persisted, no undo entry is pushed, and the next undo takes back the human's last change with this one still in force. 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 or groups is set (m/mkit#16) — there is no separate boolean, the prop's presence is the signal. Given groups (m/mkit#153, d-497a33e7; with loading, a bindable query and onSelectRecent, the same props as CommandPalette), a click on the row opens the search in place: the row becomes the search input, focused, and the results render under it where the nav was — the commands registry's Actions and Go to, Recent, the app's groups, the same ranking and the same mk:palette recents as the palette, so a pick behaves the same in both. The head sets data-search while it searches and Sidebar hides its slot on it (nothing unmounts, so the stack's shares and the panels' state survive). Escape and a pick put the row back and focus it; focus leaving the search with nothing typed puts it back too; collapsing to the rail closes it. Ctrl K keeps the modal palette, and on the phone nothing changes: Sidebar is not rendered there and MenuSheet's row keeps its onSearch. onSearch alone keeps the plain button for a consumer that opens its own surface from it. The row's accessible name is its label alone: the searchHint Kbd is aria-hidden and searchShortcut (default Control+K Meta+K) is the row's aria-keyshortcuts, so an app that rebinds the palette sets both. actions (m/mkit#310) is the host's own list of HeadAction — { id, label, icon, href } — rendered in the brand row between the rail toggle and the theme cycle, one IconButton link each: the kit's tooltip and no title, label as the accessible name, full so the rail hides them with the theme cycle and sign-out. An app whose Settings is a gear beside the theme rather than a nav row passes one record; an empty list, the default, renders the row as it was. The phone is the host's the way the theme cycle and sign-out already are — Sidebar is not rendered there — and a HeadAction is a MenuItem as it stands, so the same records go into MenuSheet's groups or footer, or into PhoneTop's actions.
State lives in the shell store (@mai/mkit/stores): the px each node of the layout tree keeps under mk:shell.sizes by node id (m/mkit#141, docs/plans/layout-tree.md § 2.3 — each between its floor — sidebar 200, sidebar2 200, context 200, detail 220, bottom 120 — and a ceiling that is the window less every other element on the axis — its own px plus the whole page beside it, which Shell measures on every layout, so a column pulls until it meets the next element and a wider window gives a wider column with no number in the kit (m/mkit#161); before the page is laid out, and where a stored size is read back, that ceiling is the shell's whole extent along the axis; layout.configure({ bounds: { <node id>: { min, max } } }) pins either end for one node over that rule, and the SIDEBAR_WIDTH_MAX, SIDEBAR2_WIDTH_MAX, CONTEXT_COLUMN_WIDTH_MAX, CONTEXT_WIDTH_MAX and BOTTOM_HEIGHT_MAX exports are unchanged numbers to pass there, no longer a clamp of the kit's own. Read as shell.sidebarWidth … shell.bottomHeight and written through shell.setSize(node, axis, px) or the five setters; a record from before the tree seeds them once from its sidebar2Width, contextColumnWidth, contextWidth and bottomHeight keys and drops those, while sidebarWidth stays on the record as the pre-paint script's key below) / rail, context pinned per page, nav sections open per title (isNavOpen/setNavOpen, default open; isNavOpen(title, false) for a section the app wants folded until chosen, m/mkit#106 — the stored choice wins over the fallback either way), 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 at the outer edge of its head, the same spot collapsed and open (m/mkit#119), 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 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 Android and installed-PWA back gesture closes a shown sheet rather than navigating the page under it, once a host asks for it (m/mkit#240, redone in m/mkit#255). One line does that, in the root layout: enableSheetHistory(afterNavigate), the function from @mai/mkit/shell and afterNavigate from $app/navigation. What it buys: a shown sheet pushes one history entry and closes on the popstate that pops it, a close caused by a link inside the sheet marks that entry dead in place rather than calling history.back() — which is what cancelled the navigation the link had just started (m/mkit#246, #253) — and the dead entry is stepped off as soon as afterNavigate reports that traversal settled. The root layout is the place for the line on two counts: afterNavigate is onMount-based, so it registers during a component's own initialisation and nowhere else, which is why the host hands the function over instead of the kit calling it from a sheet's effect; and the registration lives only as long as the component that made it, so it has to be one that outlives every sheet, including a consumer's own sheet inside {#if open}. Without the line the sheets touch history on no path at all — no pushed entry, nothing left behind, and a back gesture that navigates the page under an open sheet — so a consumer that takes a new kit version and adds nothing keeps what it had. enableSheetHistory() with no argument is for a host whose router cannot report a settled traversal, plain Svelte or a hash router: the gesture works, and the whole cost is one extra back press for a user who backs twice in a row right after a pick, onto a repeat of the page they were already on. The kit imports no $app/* on this path, so the sheets build and run with no @sveltejs/kit installed. 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. The title, the body and the foot sit 16 px off the sides on the phone and 24 px as the desktop modal, Dialog's inset (m/mkit#136); --mk-sheet-pad on an ancestor sets it and --mk-sheet-pad-body the body's alone — 0 for a full-bleed list, which is what MenuSheet, Menu's phone sheet and the palette's set. theme writes data-theme on <html> and persists under mk:theme; every persisted key carries the mk: prefix, until a consumer calls configureStorage (m/mkit#109): { prefix: 'paliad-' } puts every key the kit persists (theme, shell, sections, palette, keyboard-mode) under that prefix, { keys: { theme: 'paliad-theme' } } names one key in full and wins over the prefix for it, and the stores read again from the new names — the call comes after the module has loaded, so it can only ever follow the first read. A call replaces the previous configuration whole, and storageKey('shell') answers the name in force, for a pre-paint script or a test. The reloads run under untrack, so a consumer whose key follows its instance may call it from $effect or $effect.pre — the caller's effect then depends on what the caller itself reads and not on the state the reloads write (m/mkit#233). An app embedded in a host under the host's origin shares the host's theme and rail this way. The rail has the theme's pre-paint contract (m/mkit#108): the shell store writes data-mk-rail on <html> while the sidebar is in the rail and --mk-sidebar-w there as an inline custom property, on load and on every change, and the sidebar's CSS reads both — so a server-rendered consumer's pre-paint script sets the same two from the record under storageKey('shell') and the sidebar paints in its stored state before hydration. sidebarRail there is the store's copy of the desktop layout record's collapse, written whenever that record changes, since the layout record's own storage is the consumer's and a pre-paint script cannot reach it; a sidebarRail: true written before the copy existed (no railFromLayout beside it) is handed to the layout record once:
var s = JSON.parse(localStorage.getItem('mk:shell') || '{}');
if (s.sidebarRail) document.documentElement.setAttribute('data-mk-rail', '');
if (s.sidebarWidth >= 200) document.documentElement.style.setProperty('--mk-sidebar-w', s.sidebarWidth + 'px');
Shell sets no --mk-sidebar-w inline; tokens.css's :root value is the fallback with no script and no store.
The desktop shell renders the layout tree (m/mkit#141, docs/plans/layout-tree.md): layout.tree — the six-area preset PRESET_AREAS until a record carries an arrangement of its own — through LayoutTree (@mai/mkit/shell), a split as a flex container along its axis and a leaf as what Shell's own leaf snippet shows for it: the sidebar snippet, the content column, the sidebar2 and context slots, the ContextPanel, the bottom row, and a generic slot for a leaf the preset does not name. Each child carries its size inline — flex: 0 0 <px>px for a node with a px under sizes, its share grown from nothing for the rest (shell.stackShares(split, ids, px), a px child at no share), and flex: 1 1 <px>px for the last px child of a split in which no standing child takes a share (m/mkit#192: a made half absent off the mode or folded to its strip leaves its room to the px sibling, never to the split's own unpainted box) — so the server render paints the stored sizes. Between two adjacent standing children stands one separator (role="separator", aria-valuenow in px or in percent of the share space, pointer with capture, the arrow keys along the axis, Home and End), 0 px in the flow with a 6 px grip straddling the boundary, so the columns stay edge to edge. Every area folds and opens on its separator (m/mkit#198): a control in the middle of the separator folds the neighbour away from the page — the page's neighbour on the boundary with the page, the lower child along y, the earlier child before the page and the later one after it along x — and a neighbour that is a split folds every area in it together, one undoable write (layout.setCollapsed(ids, collapsed)). Its chevron points where the boundary moves. A press on it that moves less than 3 px (TREE_CLICK_SLOP) is a click and folds; one that moves further is a drag like any press on the grip, and never folds. The control is aria-hidden: the separator announces the fold through aria-describedby (Hide {area}, areaHide) with aria-keyshortcuts="Enter", and Enter on it folds, the window splitter's key. Beside a folded area, a rail or the bottom's strip there is nothing to drag, and the separator is the control alone, a <button> with Show {area} or Hide {area} and aria-expanded. The page never folds, and the detail's fold stays the ContextPanel's pin, so the tree's boundary on the detail's edge carries no control; the ContextPanel's own handle carries the pin instead (#199). LayoutTree takes foldOf and onfold for it; without them it draws no control. A px child is resized from its inner edge, the edge that faces the page — the sidebar's right, the left column's right, the right column's left, the bottom's top, which is where the four handles stood — under the names Resize sidebar, Resize left column, Resize right column and Resize bottom (sidebarResize, sidebar2Resize, contextColumnResize, bottomResize); any other pair reads treeResize (Resize {a} and {b}) over leafName. The ContextPanel keeps its own handle and width (contextResize, --mk-context-w). Its handle is a window splitter too (m/mkit#199): focusable while the panel is open, aria-valuenow its width with the range shell.contextWidthBounds announces, the arrow keys 16 px a press and Home and End to the bounds; the control in its middle, and Enter, toggle the page's pin — Pin context open or Unpin context, described through aria-describedby. The head's pin stays beside it, the control under the pointer that hovered the rail (#119); the collapsed rail has no handle and no edge control. On the phone the tree is the one main leaf and the context snippet renders beside it for the sheet. Whether the sidebar is open at all is the layout record's, wherever the consumer's layout.connect keeps it, and a pre-paint for that is the consumer's own. Whether the sidebar and the right column are open is not the shell store's but the layout record's (m/mkit#94): an area is a leaf of the layout tree that collapses (canCollapse, collapsibleLeaves — every leaf but the page and the detail, whose fold is the ContextPanel's pin; the preset's four are sidebar, sidebar2, context and bottom, AREAS, derived from PRESET_AREAS), at most collapsed to a stub in its own place in the tree while it stands (m/mkit#142) — under free every area but the furniture can also be removed and restored from the tray (m/mkit#183) — 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. A column's stub is a rail-wide strip, opened from the fold control on the separator at its page side, at the window's edge for an edge column and between its neighbours for a column in the middle; a leaf a human made keeps its slot element while collapsed, the bottom's rule, so a move is offered it and opens it in the same write and a drag dwelling on the strip peeks it open. The stub names what the folded area holds (m/mkit#163): each panel's rail — PanelEntry.rail, a list of PanelRailItem { icon, label, onclick? }, one per element the panel wants named — in the slot's order, a divider between two panels' groups, and a panel that declares none contributes its own icon under its title (railOf); a click expands the area and then runs the item's onclick. The bottom's strip shows the same while its row is folded, in a row with vertical dividers; the left sidebar's stub holds nothing, since the rail is what names its nav rows. Every name an area wears — in the checklist, the palette, on its fold control and on the tree's separators — is leafName(tree, id, leafNames(strings)): the preset's leaves read as they always have (areaSidebar, areaSidebar2, areaContext, areaBottom, the last three with {n}, dropped while a side holds one slot), and a second right column makes them Right column 1 and Right column 2; a row above the page and a slot made inside a leaf read treeTopRow, treeColumn, treeRow and treeChain. 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 collapsible leaf of the tree in force (Show Left sidebar / Hide Left sidebar, the same for the left column, the right column, the bottom and every leaf a human made) and its tray carries a Customize layout checklist of those leaves, in tree order, and every registered panel, read from the registry so a panel is listed whether or not the record names it; the phone's tree is the one page, so its tray lists the panels alone. The checklist is LayoutChecklist (@mai/mkit/shell, m/mkit#190): the tray renders it, and a consumer that wants the same list on a settings page of its own mounts it there — no props required, it reads the layout store and the registry as the tray does, so the two surfaces carry one set of rules (a required panel named without a box, the split and remove controls and the restore rows under free alone); sections names a subset of areas, restore and panels, default all three, rendered in that order. Above the list the mode's controls draw the tree in force as a small map, LayoutMap (@mai/mkit/shell, m/mkit#205): one box per leaf in its place, sized as the mode measured it and by the floors while nothing was measured (mapOf in shell/microlayout.ts, the pure part). A click on an area's box folds it, opens it, or restores it when it is removed, and it takes the row's name for that act (Hide {area}, Show {area}, Restore {area}); a removed area is a dashed outline in the place it comes back to, the page and the detail are drawn with no act, and the leaf New area and the split commands act on is outlined. A consumer mounts it on a settings page as it mounts the checklist; it renders nothing on the phone. A consumer that wants the human to change the tree itself says so once, layout.configure({ editing: 'free' }) before connect (m/mkit#143, docs/plans/layout-tree.md § 2.5): saying nothing keeps presets, where panels are arranged inside the tree the app ships, and the phone is never free. Under free the tray lists every leaf that collapses or that the tree sizes — the page among them, the ContextPanel's detail never — and each row carries Split {area} right, Split {area} down (editSplitRight, editSplitDown) while two of the leaf's floors fit in it, and Remove {area} (editRemoveArea) for every area but the furniture, the preset's own included (d-e1eb4f2c) — the panels in it are put away in the same write and their placements dropped, so the checklist shows each again at its home, and one undo takes the area and its panels back; an area holding a required panel is not offered it; the palette's mk:split-right, mk:split-down and mk:remove-leaf do the same from the keyboard, on the leaf the human is in. An area the preset ships that was removed stays in the tray, named by its place in the preset, with Restore {area} (editRestoreArea; mk:restore-<id> in the palette), which puts it back beside its preset neighbour at the preset's size and open (layout.removed, shell.restoreLeaf, restoreLeaf in @mai/mkit/shell); loading a preset restores everything at once. A split gives the new leaf half of what the leaf it came from had — half its px, half its share, or, when it nests, the leaf's px goes to the new split so the column keeps its width — and a remove gives the space back to the siblings; both are one undoable write on the record (layout.setTree(tree, { move?, open?, hide? })), while the sizes stay the browser's. What a leaf needs to stand is the consumer's own number where it asks for one (m/mkit#179, docs/plans/free-placement.md § 3.4): layout.configure({ floors: { width: { slot: 120, main: 240 }, height: 80 } }) sets the px a leaf of each content needs across and the px every leaf needs down, over the kit's LEAF_WIDTH_MIN and LEAF_HEIGHT_MIN, and a content it does not name keeps the kit's floor. It is what the split's arithmetic, the tray's controls, a drag's band and a separator's own stop are all read against, so a column floor of 120 offers Split → on the right column at the 320 px it ships at. A number under LEAF_FLOOR_MIN (48) is read as 48: a separator is 6 px of grip and 0 px of flow, so a leaf narrower than two grips could not be caught again. A leaf a human made stands at its size whether or not a panel is in it while the mode is on, and takes no room off the mode while nothing is in it (m/mkit#183: a column whose last panel was put away leaves the page, and comes back with a panel or with the mode); every leaf's own element carries data-mk-leaf (leafSizes in @mai/mkit/shell reads them). While the mode is on the page shows what it is made of (m/mkit#180, docs/plans/free-placement.md § 3.2): every area outlines itself ([data-mk-leaf], solid, against the dashes each slot draws), carries its leafName in its bottom-left corner (.mk-leaf-chrome, .mk-leaf-name) with the same Split {area} right, Split {area} down and Remove {area} controls the tray's row has, and every separator is tinted where it was tinted under the pointer alone. An area folded to a strip or a rail, and an empty column at no width, have no room for the corner and keep the tray as the surface that names them; the phone shows none of it. Every area paints its surface once (m/mkit#174): the sidebar, the second left column, a right column with a panel in it, a leaf a human made in either form, the bottom and every stub read --mk-bg-2, and nothing between an area and the panels in it paints again — not the slot's stack, not its items, not Panel, which carries no surface of its own. The page is the one area on --mk-bg, the tone the others stand against. So a column holding several panels reads as one region whatever a consumer puts in it, and a column split across the middle stays one column; a panel that wants a surface of its own says so in its own CSS. The move menu names a leaf by its place like everything else, so three right columns read Right column 1, 2, 3 and a slot made beside the page Main › column 1. A panel dragged onto the outer fifth of a leaf splits it there instead of taking a position in it (m/mkit#144, docs/plans/layout-tree.md § 3.5): the indicator fills the half the new slot would take, the drop writes the split and the move as one undoable act, and the centre of the leaf keeps its positions. The band is offered only under free, only where the panel's form fits the slot the split would make — a column along x, a row along y — and only while two floors fit; a folded leaf keeps the dwell that peeks it open, and the phone has no bands at all. A consumer that ships presets hands them to the same call (m/mkit#145, docs/plans/layout-tree.md § 2.5): layout.configure({ presets: { classic: PRESET_AREAS, review: { tree, sizes: { notes: 360 } } } }), each a bare LayoutNode or a LayoutPreset with the px its nodes start at, normalised like a stored tree (normalizePreset — the furniture completed, no depth refused). The entry named default, else the first, is what a record without a tree reads as, so paliad's "these presets only" is that call under editing: 'presets'; a consumer that ships none keeps the six areas. layout.presetNames lists them in the consumer's order, layout.presetName is the one in force — the one the human loaded, remembered as preset on the desktop record beside tree while the consumer still ships it, else the default — and layout.preset its tree, the one a removed area is restored from. layout.loadPreset(name) writes the tree and the name as one undoable act and keeps the placements: a placement naming a slot the loaded tree lacks renders nothing and returns with a tree that has the slot. A preset's sizes are read where the kit's own defaults are — shell.sizeOf answers the stored px, else the preset in force's, else the six-area preset's — so a size the human dragged wins. While the consumer ships more than one preset, EditMode's tray carries a Preset picker (a native <select> naming the one in force) and the palette one Load preset: name command per preset (mk:preset-<name>, editPreset, editPresetLoad), outside the mode and under presets as much as under free; the phone has no presets, its tree being the one page. 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). Two flags on the registration say what a human may not do to a panel and what it shows on this route (m/mkit#184, docs/plans/everything-is-a-panel.md § 5): required is a panel a human may move anywhere its forms allow and may not put away — no put-away control on its head, none in its edit bar, its row in the tray's checklist without a box, and layout.hide(panel) refuses it whoever calls (m/mkit#189: the registry tells the store which ids are required as they register and unregister, layout.require / layout.release / layout.isRequired, so a consumer's own checklist needs no registry in hand; the second argument #184 took is accepted and not read until the next release, and a record that put a required id away before then reads as not hiding it; since m/mkit#193 the registry declares home and forms beside the flag, layout.declare, so layout.show(panel) lands a panel where a human can see it — its slot while that leaf stands, else its home, restoring the home when it is an area the human removed, else the first leaf that takes its form — and the checklist's row for a put-away panel names that leaf, layout.landingOf) — for a panel whose absence leaves no way back to itself; present: false is a panel whose body renders nothing here, which stays registered, keeps its place in the slot's order and in the record, takes no share and no separator in a stacked slot, and comes back without a remount, so a panel that is empty on a route never unregisters and registers again. The bottom area is a row slot (m/mkit#95): Shell renders it between the three columns and the status bar as soon as a registered panel declares row in its forms, and a consumer with no such panel sees nothing of it. It ships collapsed to a strip, folded and opened from the separator above it (COLLAPSED_BY_DEFAULT in layout.ts, what emptyLayout and a record from before collapsed existed read), and the strip is the slot element, so the move menu offers the bottom while it is folded and the move opens it in the same write: layout.move(panel, slot, before, area) and resetPanel(panel, area) take the slot's area — the shell's slots declare it as data-mk-area (sidebar → sidebar, context → context, bottom → bottom), a consumer's own slot inside an area may too — and one undo takes the move and the expand back together. A row panel starts at its own width, grows to share the row and fills its height; the row is shell.bottomHeight tall (120–600, default 240, sizes.bottom under mk:shell), dragged on the layout tree's horizontal separator above it, which takes the arrow keys (16 px a press), Home and End like the sidebar's split. An open bottom with nothing placed in it shows the strip alone, and an open row stands without one; the phone renders no bottom. The second left column is the sidebar2 area (m/mkit#120): one column slot between the sidebar and the content, its panels stacked top to bottom inside it — a column with no content of its own. It is on the page only while a registered panel claims it, home: 'sidebar2' or a placement there (slotClaimed in layout.ts), and a panel the human put away claims nothing (m/mkit#122): a column whose every panel is hidden leaves the page rather than hold its width empty, and comes back when the checklist shows one again. A consumer with no such panel renders exactly what it rendered before, and one that wants the column reachable before anything is placed gives a panel that home. The column is shell.sidebar2Width wide (200–420, default 260, sizes.sidebar2 under mk:shell, written inline on the column by the layout tree), dragged on the tree's separator at its right edge, which takes the arrow keys (16 px a press), Home and End. Its slot is a stack (m/mkit#122, SlotPanels's stack): every panel fills the column's width, over the flex: none and the width a panel carries for the right column, and takes a share of its height; between each adjacent pair sits a horizontal separator (Resize <above> and <below>, stackSplit) that moves the boundary between those two and nothing else, by pointer and by the arrow keys (two hundredths of the column a press), Home and End to the floor on either side — no panel drops below a tenth of the column (STACK_SHARE_MIN; equal shares once there are more than ten). One panel has the column to itself and no separator; a panel's Panel body scrolls inside its share. The shares live under mk:shell as stacks.sidebar2, { ids, shares } for the ordered id list they were set for: shell.stackShares(slot, ids) answers them while the slot stacks exactly that list and equal shares for any other, so a panel added, removed or reordered starts the set over and the old arrangement comes back with the old order; shell.setStackSplit(slot, ids, index, position) moves the separator below the indexth panel to a fraction of the column (equalShares, moveSplit, splitBounds, splitPosition in shell/stack.ts are the pure model). A folded panel takes no share (m/mkit#182): it stands at its head and the space it held goes to the open panels beside it in their own proportions, so folding one moves the ones below it up and unfolding puts them back — the stored shares are the arrangement and a fold never writes them; foldedShares, stackBoundaries and moveStackBoundary derive what renders, and a folded panel takes no separator of its own, the one between its open neighbours moving them both over its head. A separator can be pinned on the thumbtack in its gutter (stackPin / stackUnpin, shown on hover, on focus and while pinned): a pinned boundary holds where it is while a neighbour folds or unfolds and the other boundaries absorb the change, it takes no drag and no arrow key until it is unpinned, and it sleeps while the panel above it is folded. The pins live with the shares, stacks.<slot>.pinned as the indices of the items above them, and drop with them when the id list changes. Collapsed, it gives way to a left-edge stub like the right column's and the slot leaves the document with it, so a move is never offered a folded column; layout.move into it opens it in the same write like any area, and the phone renders none of it. Its slot is Left column in the move menu, the checklist and the palette (slotSidebar2, areaSidebar2). Every slot has positions (m/mkit#127, docs/plans/areas.md § 3, § 4): the 1-based index in the order the slot renders, derived and never stored (positionOf). The move menu is one <optgroup> per area the panel fits and one position n option inside it (editPosition, the value slot#position) — as many as the slot holds where the panel is, one more everywhere else — and a slot a consumer declares inside a panel or a screen reads Right column › Under the pad, its host's area first (slotTitle over SlotEl.parent); the tray's panel rows say the same, a put-away panel's row where it comes back to. A move to a position is layout.moveTo(panels, panel, slot, form, index, area): it writes the panel's before and re-anchors every placed panel in the slot to its successor (moveToIndex), so the same record renders one order whichever panel mounted first, and a move that lands the panel where it ships writes no placement. With the focus anywhere in a panel's item while the mode is on, Ctrl+ArrowUp / Ctrl+ArrowDown step it one position in a column or a stack and Ctrl+ArrowLeft / Ctrl+ArrowRight in a side-by-side slot (SlotPanels's horizontal: the right column and the bottom row); at either end the key falls through, and a consumer that needs the chord claims the event first. The page body is the page slot only while it scrolls (m/mkit#116): a flush route's screen manages its own height, so the body declares no slot there and the move menu never offers the page on such a route; a flush screen that wants a slot declares one of its own inside it, where there is room. A panel folds on its head (m/mkit#96): Panel takes collapsible, folded (bindable), count, summary and onfold — the head's icon and title become a button with a chevron, CollapsibleSection's shape, the body is hidden while folded and stays mounted so a chat keeps its scroll, count shows folded or not and summary only folded, and the panel's own actions and the put-away control stay outside the button. Whose state the fold is, the consumer says: a registered panel binds it to the fold its slot hands the body snippet (PanelBodyArgs.fold, a PanelFold — folded, set, toggle), <Panel collapsible bind:folded={() => fold.folded, fold.set}>, and the fold lands in folded on the layout record beside hidden (layout.fold / unfold / toggleFold / isFolded, undone and reset with the rest, resetPanel unfolds the one panel); a panel with a head of its own reads fold.folded and calls fold.toggle(); a Panel outside a slot binds a local. mk:sections (CollapsibleSection, NavSection) stays what it is for sections inside a route's screen. The live record is the browser's; a workspace is a named copy of both surfaces' records (m/mkit#97): layout.snapshot() is that copy as plain values (LayoutSnapshot, normalizeSnapshot), layout.load(snapshot) replaces both records, one undoable write per surface. Where a workspace is kept, the consumer's storage says: LayoutStorage takes four optional hooks, offered together or not at all — listWorkspaces(), saveWorkspace(name, snapshot), loadWorkspace(name), deleteWorkspace(name), sync or async — and the store reads them as layout.workspaces (the names, null while none are offered), layout.workspace (the name last saved or loaded, null after connect), saveWorkspace(name), loadWorkspace(name), deleteWorkspace(name) and refreshWorkspaces(). While the hooks are there EditMode registers Save layout as… (a dialog for the name, also a tray button; the loaded name is offered so saving over it is one Enter) and one Load layout: name and Delete layout: name command per saved name, and the tray shows Workspace: name; a storage without the hooks shows none of it. The kit ships no client for wherever the copies go. 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;
Focus mode puts the shell down to the page in one act (m/mkit#170): Ctrl Alt Z on the window, or the mk:focus palette command (Focus the page / Leave focus, focusModeOn, focusModeOff, focusModeHint), and the same chord and the same command bring the shell back. Z is the key the layout labels Z, never the physical KeyZ, which QWERTZ labels Y: e.key decides while Alt leaves a letter, and where Alt rewrites it (macOS's Option reports Ω, an AltGr layout its own character) the layout map navigator.keyboard.getLayoutMap() places e.code; where no map knows the key, Ω is Z and nothing else is. Shell binds both, so every app that mounts it has them; neither is in KeyboardMode or EditMode, which are each opt-in. It folds every leaf of the tree that collapses — the preset's sidebar, sidebar2, context and bottom — and Shell takes the route's own ContextPanel off the page while focus is on, since its fold is the per-page pin and focus does not write it; the page never collapses, and the head and status snippets are the consumer's own and are untouched. The store is layout.focus(), layout.unfocus(), layout.toggleFocus() and layout.focused, one undoable write each. The fold set from the moment focus turned on is focus on the layout record beside collapsed and folded — its presence is the mode's own flag — so a reload in focus still knows what to restore. An area a human opens by hand while focus is on leaves that set and an area they fold joins it, whether through the tray, a stub's control, the mk:area-<id> command or a move that opens a collapsed area: leaving focus keeps what they changed and returns every area they did not touch. The desktop's alone, since the phone's tree is the one page and collapses nothing, and layout.configure({ focus: false }) drops the command and the chord together for an app that binds Ctrl Alt Z itself. A command carrying keyshortcuts has it on its palette row as aria-keyshortcuts; mk:focus carries Control+Alt+Z. Shell ignores a chord an embed already prevented, so a consumer whose embed swallows keys (a terminal calling preventDefault) lets the kit's chords through by their own predicates, isFocusKey(e, layout) with the map readKeyLayout() answers, isEditKey and isModeKey, all from the package root (m/mkit#209).
BottomNav takes items (four NavSlots, two each side) and centre (a NavCentre): the raised disc the page configures per route. A NavSlot with href is a link and one without is a button; the centre is the same since m/mkit#110 — href renders it as a link with the disc, the label and the slide-up a button has, onclick still firing for a router that intercepts, and disabled renders a <button disabled> that takes neither a tap nor a drag, the disc faded to --mk-opacity-disabled and without its lift. disabled wins over href. A slot's external (m/mkit#236) opens href as target="_blank" rel="noopener noreferrer", drops aria-current, and adds a mark after the label; meaningless without href. Demo: ?centre=href, ?centre=disabled.
On paper the frame is a document (m/mkit#107): under @media print the shell and its content column are static blocks at their content's height, the page body is no longer a scroller, and the sidebar, the context column, the bottom row, the status bar, the phone bar and the phone nav are gone, so a page prints whole instead of the one screen the fixed-height layout would give. PageHead stays, it carries the page's title; a consumer hides it in its own print rule if it wants no head on paper. The frame around the shell is the consumer's: app.css's own @media print block puts html, body and the mount node back to height: auto and overflow: visible, and a consumer that writes the frame itself owes those two lines the way the demo's demo.css gives them to its own #app, or the page clips there instead. Margins are @page's. Paper reads the light theme whatever the screen showed (m/mkit#115): tokens.css puts the light values back under @media print for the dark attribute and the dark system preference alike, since a browser drops backgrounds on paper and the dark text tokens would print light on white; src/tokens.test.ts holds that block and the two dark blocks to one list.
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. The active entry is the detail panel the ContextPanel registers itself (m/mkit#187, docs/plans/everything-is-a-panel.md § 4): home: 'detail', forms: ['column'], present while a route has an entry, so with the edit mode on it carries an edit bar and a human may move it into any column or put it away; KIT_PANELS keeps it ahead of a consumer's panels, so the right sidebar's ids and the shares under stacks.detail are ['detail', ...panels] as they were while the entry was the stack's fixed lead, and a route with no entry leaves the panel registered and absent — no share, no separator, no remount. registerContext is not registerPanel and stays a route's own call: padding and rail are the ContextPanel's — its body's inset and its collapsed rail's icons — and are not honoured once the entry stands in another column. The composed shape (title and children) registers nothing and keeps its detail as the fixed lead. demo/pages/editor.svelte (?page=editor) is the registry-driven example; the ShellDemo-based groups keep the snippet path. The demo 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. The lists page at 390 is where to try it by hand.
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; the geometry is checked by hand on the inview demo page.
Auth
LoginCard (frame: brand/error/footer slots, an optional lead snippet between the title and the methods, 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, an optional email seed, a forgot snippet called with the typed or prefilled address so a reset link never asks for it twice, and an onsignup/onforgot pair the kit lays out as one row of text actions — each called with that same address, each rendered only when it is wired, labels through signupLabel/forgotLabel or the strings store), SetPasswordForm (a new-password field and its confirm, onsubmit({password}) only when the two match; a mismatch shows an inline error and never calls onsubmit; the match check is headless in setPasswordForm.ts), OidcButton (one button per provider, onselect(id)), MagicLinkForm (idle/sent/expired phase, an optional email seed, onsubmit/onresend, the resend cooldown is the app's own resendIn, an optional hint under the sent description), 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 nine 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; demo/pages/auth.svelte opens with every panel state side by side on one card scale, with the alignment token on a switch, and then renders every method alone and all four behind MethodTabs.
The panels carry one type scale and one alignment, and they read it from the --mk-login-* tokens alone (m/mkit#315): six type roles — title (the card's heading and RedeemPanel's), label (a field's label and a panel's own lead line), body (prose, inputs, buttons, tabs), hint (a hint, an error, the divider, a counter), link (the forgot link, the footer's links and a ghost button's label), code (PinInput's digits) — plus the card's width and a two-step rhythm. LoginCard declares them over everything it contains, the shared controls a panel composes included, so a field label, an input, a button and a tab are one size on the card and not four; a ghost Button on the card is a text action at the link role and the fields' own edge, which is how a consumer writes a second action beside the forgot link. Text a consumer writes into the card inherits the card's size and alignment, so a hand-written paragraph no longer arrives in the app's own type.
| Token | Default | Sets |
|---|---|---|
--mk-login-title-size, --mk-login-title-weight |
--mk-text-xl, --mk-weight-semibold |
the card's title, RedeemPanel's, AuthNotice's icon |
--mk-login-label-size, --mk-login-label-weight |
--mk-text-sm, --mk-weight-medium |
a field's label, a notice's own title |
--mk-login-body-size |
--mk-text-sm |
prose, inputs, buttons, tabs, a notice's description |
--mk-login-hint-size |
--mk-text-xs |
a field hint or error, the card's error, the or divider, the PIN error |
--mk-login-link-size, --mk-login-link-weight |
--mk-text-xs, --mk-weight-medium |
the forgot link, the footer, a ghost button's label |
--mk-login-code-size |
--mk-text-xl |
PinInput's digits |
--mk-login-align |
start |
text-align on the card, and the main axis of the two rows narrower than it (the brand mark, the PIN boxes) |
--mk-login-width |
360px |
the card's max-width; a phone drops it |
--mk-login-gap, --mk-login-gap-tight |
--mk-space-6, --mk-space-3 |
the step between the card's blocks and a form's rows, and the step between the lines of one block |
SignIn forwards all three, because a prop only the panel takes is a prop no consumer of the composed card can pass (m/mkit#229): lead reaches LoginCard, onSignUp/onForgotPassword reach the password panel's action row, and magicLinkHint reaches the sent notice. Their labels stay in the strings store (authSignUp, authForgotPassword), the way every other label on that card does.
A consumer sets them on :root or on the card itself, and nothing else about the panels is a supported override — a consumer that writes its own font-family, font-size or text-align onto kit markup is the thing that put five sizes and three alignments on one login page. src/components/login.style.test.ts holds every panel to the scale: no panel declares a font family, every size, weight, alignment and gap in one is a --mk-login-* token, and no panel names a token tokens.css does not declare.
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), avatarStyle, avatarGrid, avatarCandidate, avatarNone, avatarCurrent, avatarPhoto, avatarMore, avatarUpload, avatarSave, avatarUploadFailed, avatarSaveFailed (AvatarPicker, m/mkit#311).
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: Language[] ({id, label, name?} — label is the visible text, a code like DE; name is the accessible name and tooltip when set, Deutsch where the button shows DE, m/mkit#132), 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.
Control labels
controlLabels (@mai/mkit/stores, m/mkit#248) is the kit-wide choice between an icon alone and an icon with its text, for every control that carries both. It defaults to 'icon' — m's preference — and an app that wants both shown, for people who do not read icons, sets it once:
import { setControlLabels } from '@mai/mkit/stores';
setControlLabels('icon-text'); // 'icon' | 'icon-text'
IconButton and a Button given both icon and label follow it: 'icon' shows the icon alone, keeping label as the accessible name and the kit Tooltip's text on hover and focus; 'icon-text' shows the icon and the label side by side, and the accessible name then comes from the visible text. A Button with an icon and no label has nowhere else to keep its name, so it always shows both, the same as a Button with no icon at all — the preference only ever hides text that has an accessible-name replacement standing in for it. The store holds no persistence of its own, the strings store's shape (m/mkit#25): an app that wants to remember the choice keeps it itself and calls setControlLabels again on load.
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.
Callouts
A blockquote whose first line is > [!kind] title renders as <div class="mk-callout" data-callout="<kind>"> with the title in a <p class="mk-callout-title"> and the rest of the quote as its body (m/mkit#300). The syntax is GitHub's and Obsidian's, so a file written for the kit still reads as a callout on the forge, in Obsidian, and as a blockquote with a visible marker line anywhere else.
The set of kinds is open (m's d-d645a418): the kit holds no list and emits whatever word the marker carried, lower-cased, so > [!fee] renders as a callout the day somebody writes it and callouts.css decides whether it has a colour of its own. The word is letters, digits and hyphens — > [!], > [!two words], > [!<script>] and the link reference definition > [!note]: … carry no kind and stay the plain blockquotes they were, as does any blockquote with no marker at all. data-lines and data-block are unaffected: the callout takes the blockquote's own container range and the body keeps its children's.
The marker is read off the blockquote's source, not off its rendered inline HTML, because breaks: true joins the marker line and the body's first line into one <p>. The body is lexed once, by marked's own blockquote tokenizer, so a footnote reference inside a callout is registered once.
callouts.css (@mai/mkit/callouts.css, imported by prose.css) draws the block from --mk-callout-color-<kind> and --mk-callout-glyph-<kind> in tokens.css, plus --mk-callout-font and --mk-callout-weight; the surface is mixed out of the colour, so a kind needs two tokens and no third. Seven are drawn — note, tip, important, warning, caution, law, practice — and every other kind takes --mk-callout-color-default. A callout with no title carries no title element, and so no glyph.
Embeds
![[target]] alone on a line is a block that names something instead of containing it. options.resolveEmbed(target, source) — synchronous, because renderMarkdown is — answers { html } and the kit drops that HTML in, or undefined and the block renders as the plain text ![[target]]. With no hook at all it renders as that same plain text, and in neither case as the <img> the wikilink rewrite used to make of it; an ![[…]] inside a paragraph stays text too. The HTML passes the same DOMPurify as everything else the renderer emits.
A callout whose title is the only thing in it reaches the same hook, so > [!law] UPCA.035 and ![[UPCA.035]] render the same block; a host that does not know the title answers undefined and the callout renders as written. source.kind says which shape asked — the callout's own lower-cased kind, undefined for ![[target]] — so a host that transcludes only > [!law] leaves the title of a body-less > [!warning] Be careful alone instead of looking it up. The hook stays kind-agnostic and the host decides: the kit never learns what a target means, and a host can answer with a transclusion, a chart or a table. A host that needs I/O pre-fetches and passes a lookup, the way a pre-fetched catalogue reaches resolveLink.
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.
Command palette
CommandPalette (Ctrl/Cmd K, or palette.open() from @mai/mkit/stores) reads the registry registerCommands fills. The search itself — the input row, the kind chips, the result groups, the keyboard, the recents — is PaletteBody, exported on its own: CommandPalette is its modal host (the desktop overlay, the phone Sheet) and SidebarHead renders it in place of the search row (m/mkit#153). Its open prop is the host's open state (the body resets and takes focus each time it turns true), onClose is Escape and a pick, and inline is the sidebar's look, with --mk-palette-inset on an ancestor as the side inset its input row and chips keep. 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 a side and an alignment: top, bottom, left and right, each bare (centred) or with -start / -end (start is the anchor's left edge above or below it, its top edge beside it). The flip stays on the side's own axis — right flips to left, never to bottom — which is what a bubble beside a rail at the viewport's left edge needs (§ Tooltip).
Props: open (bindable), anchor (the element the panel is placed against, usually the field wrapper, or a PopoverAnchor — anything with getBoundingClientRect and contains, such as the caret of Composer's trigger completion; the panel is placed again when the panel, an element anchor or the anchor's contextElement resizes), 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.
Tooltip
Tooltip (m/mkit#204) is the kit's bubble on hover and on keyboard focus, in place of the browser's title. It is Popover's layer and placement with a different surface and a different trigger model: the bubble is a popover="manual" element in the top layer, placed by placePopover, and it flips at the viewport's edge on its own axis.
It ships two ways, over one controller (tooltipController.ts) and one bubble (TooltipBubble.svelte):
<Tooltip text placement as when>wraps a trigger the caller renders:childrenreceivestriggerand spreads it onto any element, the wayPopoverhands outtriggerProps. The bubble renders right after the trigger and stays in the DOM while closed, so the server's markup already carriesaria-describedbyand the text it points at.contentis a snippet in place oftext.{@attach tooltip(text)}ortooltip({ text, placement, as, when })puts the same bubble on any element, or on any kit component that spreads its rest props (IconButton,Button). The bubble is mounted intodocument.bodywhen the element mounts, so the server's markup carries neither; use the component where the description must be there before hydration. A falsy argument attaches nothing.
The model (tooltip.ts, pure, pinned by src/components/tooltip.test.ts): a mouse or pen resting on the trigger opens the bubble after TOOLTIP_OPEN_DELAY (500 ms); keyboard focus (:focus-visible) opens it at once, and the focus a click leaves behind opens nothing. While another bubble is open, and for TOOLTIP_GROUP_WINDOW (300 ms) after one closed, a trigger opens with no delay, so a pointer sweeping down a rail reads each icon without waiting again. Only one bubble is open at a time. Leaving the trigger keeps the bubble for TOOLTIP_CLOSE_DELAY (100 ms), long enough to move onto it, and a pointer on the bubble keeps it open (WCAG 1.4.13). Blur hides it at once. Escape, a press on the trigger and another bubble opening dismiss it until a fresh hover or focus; an Escape that closes a bubble is preventDefaulted, so a Popover or Dialog underneath stays open for the next press.
Touch opens nothing: a touch has no hover, and the pointer-enter before a tap would open a bubble the finger then covers. The text stays reachable as the trigger's description or name, and a touch layout that needs it on screen renders it in view.
The name and the description. as="description" (default) wires aria-describedby: the trigger has a name of its own and the bubble adds to it. as="label" wires aria-labelledby: the bubble is the name — for a trigger with none of its own (a status dot, a bare icon) or one whose name is the same text, where a description would be read a second time. The controller adds its id to the ids the trigger already has and removes only its own.
Adopted in the kit: IconButton (its label, as: 'label'; aria-label stays), NavItem (label and count, named by the bubble, opening only while the label is hidden in the rail or cut off), the BottomNav centre button, SlotPanels' stack pin, Panel's fold toggle (a description), and the unread badges of TreeGroup and TreeRow. None of them sets title any more, so no control shows two bubbles. The charts (TimelineChart, Heatmap, BarList) keep title for their per-mark data readouts: a bubble per mark would mount hundreds of elements, and a data readout wants one bubble that follows the pointer.
The surface is --mk-text on --mk-text-inverse, both themes, at --mk-z-tooltip; --mk-tooltip-bg, --mk-tooltip-fg and --mk-tooltip-max-w (240 px) on an ancestor retone and resize it. demo/pages/tooltip.svelte.
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 and --mk-menu-max-h its ceiling (min(18rem, calc(100dvh - 2 * var(--mk-space-6))), m/mkit#230): the rows scroll inside the panel past it, and the arrows bring the row they reach into view. The panel is in the top layer, so nothing outside it could scroll a longer list; a call site with a list of its own length sets the property on any ancestor rather than writing a rule against the kit's class names. The phone sheet keeps its own scroll and reads neither.
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, active and external (m/mkit#236 — target="_blank" rel="noopener noreferrer", no aria-current, a mark after the label; pick still closes the sheet) 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.
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; the roles, the tab stop and the arrows are checked by hand 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, 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.
Picker
PickerSheet and PickerRow (m/mkit#249) are the sheet form of a search-and-pick list: mai's ProjectPicker, ThreadSelector and TargetPicker each grew this shape on their own, over the kit's Sheet. PickerSheet adds no second phone-versus-desktop split — Sheet already rises as a bottom sheet on the phone and opens as a centred dialog on desktop — and no second search or highlight arithmetic: both are Combobox's own, filterOptions and moveHighlight, reached through pickerRows (picker.ts) rather than reimplemented.
Rows are the host's own data, in the host's own order — the sheet fetches nothing. Props: open, onclose, onpick(option) (a pick calls it and closes the sheet itself; a host must not call onclose again from its own handler), options (PickerOption[]: Combobox's value/label/hint/icon/keywords plus an optional kind), value (the current pick; its row renders selected, with the check mark), title, query (bindable, the typed text), onQuery (hands the search to the host, Combobox's own contract: rows render as options gives them, narrowed by the chip alone), loading, placeholder, size (Sheet's own sm | md | lg).
filters renders one Chip per given { kind, label } above the list; picking one narrows options to the rows whose kind matches it. kind: '' matches every row, so a host that wants an "all" chip supplies one with that kind. A chip and the search field compose — the chip narrows first, the query then ranks what remains — both through the one call, pickerRows(options, filter, query).
filterGroups (m/mkit#260) is the same narrowing over several axes at once, for a host whose rows are cut by more than one: paliad's submission-type picker has four — the court system, the instance, the side of the patent, and whether the filing opens a step or answers one — and folding four axes into the one chip row gives a dozen chips where picking one clears the other three. Each group is a { key, label, options } drawn as its own SegmentedControl, label being the control's accessible name; groupSelection (bindable, Record<string, string>) holds one selected value per group key, and a row declares its own value per key in PickerOption.facets.
Three rules the kit owns rather than each host:
- A group with nothing selected filters nothing. Each control offers
strings.pickerAllas its first option for that state (§ Strings & language), unless the group's ownoptionsalready name an empty value — then that label wins. - The groups AND together, where a chip replaces whatever was selected before: a row has to answer every group that has a selection.
- A row whose
facetsname no value for a group is excluded from that group alone. An axis answers with what the host could derive, and a row it places on no value is not guessed onto one — so such a row shows while the group holds nothing and drops as soon as it holds something.
The chip and the groups compose with the search in that order: pickerGroupRows(pickerRows(options, filter, ''), groupSelection, query), and pickerGroupRows keeps pickerRows's identity — an empty query is the narrowed rows in their own order, for a host that owns the ranking through onQuery. A selection under a key no group offers narrows all the same, so a host whose groups come and go with its own data clears the selections its groups no longer carry.
PickerRow is the row itself, exported on its own for a picker with no sheet around it: leading (an icon or a status dot), label, sub (a meta line clamped to two lines rather than cut mid-word, a snippet or a plain string), trailing. active colours the row as the current pick, the way mai's rows already do; selected marks it with a check instead — the two compose. highlighted is the keyboard cursor. indent nests a row under a heading row, muted is a row that adds rather than picks, dimmed a pick with nothing live behind it.
mai's ThreadSelector carries more than this shape covers, and stays local to mai rather than entering the kit: sectioned rendering (Pinned, Topics, Agents, Archived, each folding or sorting its own way), pin/star toggling and swipe-to-archive on a row, an inline new-topic form, per-row secondary IconButtons beside the main row, and AgentDot status leads with unread counts — all of it built on the kit's PickerRow rather than replacing it. demo/pages/picker.svelte.
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.
Composer
Composer is the chat input row: a leading slot, an auto-growing textarea, a trailing slot and the send button. Enter sends and Shift Enter inserts a newline. busy keeps the field editable and focused while a send is in flight, and turns off the send button and Enter. value and element are bindable. Enter that ends an IME composition never sends.
Trigger completion
triggers (m/mkit#210) puts a completion list on the textarea. Each trigger is a character and an async provider: { char: '@', provider: (query, context) => Promise<TriggerGroup[]>, label? }. The provider answers with groups (label, items), and each item has an id, a label, a sub line, an icon and the insert text. An item with an action runs the action instead of inserting. The kit ships the mechanism and no items: what a trigger offers and how it ranks is the consumer's. filterTriggerGroups(groups, query) filters a local list with the palette's matcher, rankCommands, for a provider that holds its items itself.
- When a trigger opens. The word holding the caret must start with a trigger character, at the start of the text or after whitespace.
@annopens,mail@annandand/ordo not. The query is the rest of the word up to the caret, so a space closes the list. - Keys. ArrowDown and ArrowUp move the highlight and wrap. Enter or Tab take the highlighted row. Escape closes the list for that trigger until the caret leaves the word. The textarea keeps the focus, and the highlight travels as
aria-activedescendantinto arole="listbox"ofrole="option"rows (TriggerList, onSearchResultRow). - Enter. The list takes Enter only while it shows a highlighted row. While it shows only its searching line or its empty line, Enter sends. To send text that ends in an open trigger word, press Escape and then Enter. While a composition is in progress, the list takes no key.
- Taking a row. The trigger and its whole word are replaced with the item's
insert, or itslabelwhen it has none, and one space follows unless whitespace already does. An action removes the trigger's word, inserts nothing and then runs. - The provider. The provider gets
{ trigger, text, start, signal }beside the query. Every change of the query aborts the query before it throughsignal, and only the newest answer is shown. The rows of the previous query stay until the new answer arrives; a different trigger clears them at once. Before the first answer the list shows Searching; an answer with no items shows No matches. A provider that throws or rejects counts as an answer with no items. The provider owns any debounce: wait on a timer and give up whensignalaborts. - Where the list sits. On the desktop the panel is a
Popoveranchored at the trigger character,top-startand flipping below when there is no room.caretRectmeasures the character with a hidden mirror of the textarea, because a range rect cannot reach inside a form field. The measurement subtracts the textarea's scroll, pins a character scrolled out of view to the field's nearest edge, and runs again on every scroll, window resize and resize of the textarea or the panel. On the phone (viewport.phone) the panel sits above the textarea at its width. A row takes the pointer without taking the focus, so the soft keyboard stays up.
TriggerMenu is the same mechanism for a field of the consumer's own: mount it with field (a textarea or an input), triggers, the bound value, and spread its bindable inputProps onto the field. It listens on the field, and its keydown listener runs in the capture phase; the field's own keydown handler must return on e.defaultPrevented. The text arithmetic — findTrigger, replaceTrigger, acceptItem, flattenGroups, triggerInputProps — is pure in triggers.ts and pinned by src/components/triggers.test.ts. The chat demo registers @ with a slow provider and / with an action row.
ChatFrame
ChatFrame (m/mkit#238) is the chat page's frame: a log that is the one scroller, following the newest line, and a foot under it that stands still. It goes in a flush Shell body, or in any column that gives it a height; a page bar above it is the consumer's own and stands as its sibling.
The log follows the newest line only while the reader stands at the end of it: a line arriving while they read further back leaves them where they are. What wakes the follow is a ResizeObserver and not a dependency the consumer declares — it watches the rows (a line arrives, an image in one finishes loading) and the log itself (the phone's keyboard shrinks the column the shell sizes from the visual viewport, and an offset kept across that leaves the newest line under the composer).
Props: children (the rows), composer (the foot: the Composer, and whatever stands with it — a reply quote, an attachment row, a status line), key (a change puts the reader back at the newest line: the open thread's id), slack (how far short of the end the reader may stand and still follow, 64 px), following (bindable), element (bindable, the scroller), class. scrollToEnd() on the instance follows again and lands at the newest line wherever the reader stood: the line they sent themselves is always followed.
--mk-chat-log-pad is the log's padding (--mk-space-5), --mk-chat-log-gap the space between rows (--mk-space-4), and --mk-chat-log-behavior the log's scroll-behavior (auto) — a consumer scrolling one named row into view sets it to smooth, and the follow's own jump stays in one step whatever it holds.
What stays the consumer's: the rows, the scrollback and its sentinel, a scroll to one named message, the menus, the composer's own controls. A consumer that prepends a page sets following false first, restores the offset on element, and the assignment's own scroll event recomputes the flag from the slack. chatframe.ts holds the geometry pure — nearEnd, jumpToEnd, holdAtEnd, exported from the root — and src/components/chatframe.test.ts pins it. The chat demo (demo/pages/chat.svelte) renders it: Add a line sends one from the other end, and the composer's Send is the always-followed line.
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; the chat demo renders the same form in a bubble.
Avatar picker
AvatarPicker (m/mkit#311, HLC/hlcpat docs/avatars-design.md § 8) is one colleague choosing their own picture: the styles as a segmented row, a grid of drawn candidates in the chosen style, a more button that draws new ones, an optional photo upload, a no-avatar tile and a save button. It fits a Dialog body on the desktop and on the phone, which is where the hlcpat hub mounts it — the Settings dialog, above the name field.
The kit owns the interaction and nothing else. Every URL comes out of the host's own candidateUrl(style, seed), every request out of its onUpload and onSave: the kit names no service, builds no path, and fetches nothing. A host rendering its avatars itself and a host proxying somebody else's renderer place the same component.
Props: styles ({ value, label? }[], in the order the row shows them; a single style renders no row), candidateUrl(style, seed), value (the avatar the host holds, empty for none), onSave(url) (writes the pick; an empty string means no avatar), onUpload(file) (given, the upload button is drawn; the host stores the file and answers with the new URL, or throws the message to show), candidates (how many the grid draws, 12), columns (tiles per row, 4 — the arrows move by it, so it is a number here and not a CSS auto-fill), accept (the file input's, image/*), random (the draw's randomness; pass one to pin the candidates, since a server render and its hydration otherwise draw different ones).
The tiles are the no-avatar tile, then the photo this session uploaded, then the avatar the host holds, then the candidates. A URL appears once: a held avatar a candidate already draws is that candidate, checked, and not a second tile. Nothing is written until Save, which stays off while the pick is what the host already holds; a onSave or onUpload that throws shows its own message above nothing else changing.
The grid is one radio group with a roving tab stop. Left and right walk it and wrap, up and down move by a row and stop at the edge, Home and End take the ends, and the selection travels with the focus — so what a reader hears on arrival is the tile that is now picked. Changing the style keeps the draw and carries a picked candidate over by its seed: the same faces, drawn the other way. More keeps a picked candidate at the front of the new draw, so a redraw never takes the pick out of the grid.
avatar.ts holds it all headless — drawSeeds(count, random?, exclude?), redrawSeeds(count, keep, random?), avatarTiles({ seeds, style, candidateUrl, value, photo }) and moveInGrid(count, from, columns, key), exported from both root entries — so a host that draws candidates of its own elsewhere reads the seeds the way the picker does. Labels: the eleven strings keys below, each with a per-instance prop of its own (styleLabel, gridLabel, candidateLabel, noneLabel, currentLabel, photoLabel, moreLabel, uploadLabel, saveLabel, uploadFailedLabel, saveFailedLabel) that wins over the store. demo/pages/avatar.svelte draws its candidates as data URLs in the browser, so the page needs no service at all.
Legal pages
Every app of a suite serves the same four public pages — about, legal (the Impressum), privacy and, optionally, terms — and links to them from its footer, its sidebar and its phone menu. LegalPage and LegalLinks render them (m/mkit#303, m's answer on d-9bacc18d; the routes are d-347cfb72), and the text is never the kit's: each app keeps a legal config file in its own repo, mainly HLC/hlcpat, and hands the parsed file to both components. There is no shared text package and no page in the kit that carries a firm's words.
The file is JSON. It is data, not code: three repos keep one each, somebody who writes no code edits them, a build step or a check script in any language reads them, and text that renders on a public page never needs to execute. A .ts module export would need no parser at all, which is exactly why it could not be checked at the boundary where the text arrives — another repo's copy, a CMS row, a fetch. The one thing JSON is bad at, a multi-line body, markdown takes as an array of lines joined with newlines. The kit does no file I/O: the app reads or imports the file and calls parseLegalConfig on the value, so the kit stays framework-neutral.
One entry per page under pages. title is the page's heading and markdown its whole text (a string, or an array of lines); link is the short form a link to the page carries and defaults to title; route is where the app serves it and defaults to /about, /legal, /privacy, /terms; updated is the date the text last changed, as YYYY-MM-DD (d-908e4855, the one date form the kit shows). about, legal and privacy are required, terms is optional, and a further key — cookies, whatever a jurisdiction asks for — is a page too and names its own route, since only the four have a default.
{
"pages": {
"about": {
"title": "Über uns",
"markdown": ["Die **Beispiel GmbH** baut seit 2019 Software für kleine Kanzleien.", "", "Wir sind acht Leute in Freiburg."]
},
"legal": {
"title": "Impressum",
"updated": "2026-09-14",
"markdown": ["## Anbieter", "", "Beispiel GmbH ", "Musterstraße 1 ", "79098 Freiburg", "", "## Kontakt", "", "Website: [example.org](https://example.org)"]
},
"privacy": {
"title": "Datenschutzerklärung",
"link": "Datenschutz",
"updated": "2026-09-20",
"markdown": ["## Verantwortlich", "", "Beispiel GmbH, Musterstraße 1, 79098 Freiburg."]
},
"terms": {
"title": "Nutzungsbedingungen",
"link": "Nutzung",
"markdown": "Ein Platzhalter."
}
}
}
parseLegalConfig(value) turns that value into the LegalConfig both components read: one LegalText per page (key, title, link, route, markdown, updated?) with every default resolved, in the order the four are named above and any further key of the file after them. It throws LegalConfigError naming every fault of the file at once, each by its own path — legal config: pages.privacy is missing; pages.legal.updated must be a date as YYYY-MM-DD — so an app that calls it where the file is loaded fails loudly and says what to fix; checkLegalConfig(value) returns that same list without throwing, for a route that would rather show the faults. legalPage(config, key) is one page, legalEntries(config, keys?, href?) the link list; all of it is headless in legal.ts and exported from the root, so a consumer that builds a footer of its own reads the same list.
<script lang="ts">
import file from '$lib/legal.json';
import { LegalPage, parseLegalConfig } from '@mai/mkit';
const config = parseLegalConfig(file);
</script>
<LegalPage {config} page="legal" trusted links updatedLabel={'Stand: {date}'} linksLabel="Rechtliches" />
LegalPage takes config, page (which key), updatedLabel ({date} is the entry's updated; a label without the token is followed by the date), missingLabel (what stands in for a key the config has no page for), links (the sibling links under the text, off by default — a shell that already shows them in its foot would show them twice) with linksLabel, href (rewrites a route, for a deployment under a base path), trusted, resolveLink, a children snippet under the text and class. Every string is a prop with an English default; the German is the config's. The text renders through renderMarkdown with breaks={false}, the document's form, so a hard-wrapped body reads as paragraphs. trusted is off by default as everywhere in the kit: a config the app ships is the trusted case, one read from a database is not, and the default must not hand it the unsanitised path. Off a browser — a server render — the untrusted path needs setSanitizerWindow first (§ Markdown).
LegalLinks takes config, keys (which and in what order; the config's own by default), href, current (the shown page, marked aria-current="page"), label (the group's accessible name, default Legal), variant and a children snippet after the links for a copyright line. Three variants, one list: footer is the row under a page, with a dot between the links; column is the stack for the foot of the Sidebar; menu is the same stack wearing mk-menu-item, the row look a MenuSheet gives the markup in its footer snippet.
Both pages and links stand whole in the server's markup — the pages are public and must be reachable without JS and without a login — and the page prints: a 72ch measure on screen (--mk-legal-measure on an ancestor changes it) and the sheet's own width on paper, the title kept with the text under it, an external link's target written beside it, and the links themselves off the sheet, where they lead nowhere. demo/pages/legal.svelte (?page=legal) renders demo/legal.json — a demo firm, in German — through the whole path, and shows what a malformed file says. src/components/legal.test.ts pins the format and the parser, Legal.ssr.test.ts the server markup and the links in each of the three placements, legal.style.test.ts the print rules.
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 and the focus ring read --mk-primary-text, the selection --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.
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.
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;
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, plus CellStrip (#226) and DaySpanChart (#251) outside the slice list; 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, and the two ends of the box — baseline is the value the bottom stands for (0, a number of the app's own, or 'min' for the series minimum, so a level series shows its spread instead of a flat stroke pinned to the top) and max the value the top stands for. Without max the top is the series maximum, so the highest point always touches it; a tracker with a configured ceiling (a mood from 1 to 10) passes that ceiling and a week of 4s and 5s then sits in the lower half of the box. A value outside either end is drawn on it, and the hidden summary table keeps the real numbers. 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), src/chart/*.test.ts.
CellStrip is one cell per point, filled above zero and muted at or below it, as wide as its container (#226): series, title, height, format, locale, table. It is the picture a state or select tracker draws for its recent days, and the reason it is its own kind is that BarChart draws nothing for a zero — an unlogged day leaves the picture instead of showing as an empty cell, and Heatmap is a grid with a colour scale rather than a row with two states. The fill is --mk-chart-<n> through the series' color and the empty cell is --mk-text-3 at 30 %, so a consumer retones the filled state by setting the token on a wrapper. A zero and a negative value are the same state, empty, and a bucket the series holds no point for is no cell at all. HTML rather than SVG, BarList's and Heatmap's choice in this group: flex: 1 1 0 divides the container between the cells with nothing measured. There is no empty text and no onPointClick: the strip keeps its height with no cell in it, which is what holds a row of tracker strips steady, and no consumer has asked for a clickable cell. src/chart/CellStrip.ssr.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, extended to hours in #279): 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, edges (the dependencies, § below), title, range, onRangeChange, today (the rule's day; the current day in zone by default), density (compact 24 px rows, standard 40, spacious 64 — LANE_HEIGHT and MARK_SIZE, paliad's table), ticks (8), labelWidth (9rem), locale, zone (an IANA name; UTC by default), 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), markCard (the same payload, drawn as a card on the mark's own hover and keyboard focus in place of the title tooltip — #313) and empty ({ count, limit }). The hover card is CSS and no state: every mark's card is in the markup, .mk-timeline-mark:hover + .mk-timeline-card shows one, so the server draws them all, nothing is measured and the keyboard reaches the card the pointer does. It is a sibling of the mark rather than a child, because a diamond's rotate() and a triangle's clip-path would take a child with them; it takes no pointer, so a press that lands on it still pans; it is aria-hidden, the mark's own aria-label and the hidden table carrying the words, the rule the arrows follow; and with it the plot and the undated zone stop clipping (.carded), since a card stands taller than a lane — the layout already keeps every mark inside the range, so what then reaches past the plot is a point's own half side and the card. The card's width is the component's own --_cw (16rem), and it stays inside the plot's two edges by max(0px, min(<x>%, calc(100% - var(--_cw)))) — the arrows' routing trick, so the browser resolves the percentage at used-value time and the component measures nothing. 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 hour, 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, days and, below two days, round hour steps (1, 2, 3, 4, 6, 12); labels are Intl.DateTimeFormat in zone or UTC on the consumer's locale, an hour tick HH:mm with no locale hour word and its own day once the range crosses one, quarters as Q<n> <year>, and a tick on 1 January is drawn heavier. zone moves every boundary onto that zone's wall clock, DST included, the same Intl.formatToParts technique DaySpanChart proved (src/chart/zone.ts); with no zone every boundary is exactly the pre-#279 UTC arithmetic.
The range is controlled the way the graph's viewport is (§ 3.1–3.3 of graph.md, #84): range: { from, to } comes in and every gesture goes out through onRangeChange(next, reason) with reason one of wheel, drag, pinch, 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 or instants in order decodes to undefined, which means the fit), the way demo/pages/chart.svelte keeps it in ?r=. Each of from/to is an ISO day (to included whole) or an ISO instant, the same either-or a mark's at already took; a day-string pair stays on whole-day arithmetic, byte for byte with every range the kit drew before #279, and an instant pair reaches below a day, rounded to the minute. 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 point under the pointer with the graph's delta (zoomRange), a one-finger 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 two-finger pinch zooms about the fingers' midpoint from the distance between them, 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. A range's own span stays whole at its own grain — days at two or more, minutes below it — and a gesture that changes nothing at that grain, or the bounds of RANGE_EXTENT (one hour, 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, plus #279's hour, zone and instant-range coverage; src/chart/TimelineChart.ssr.test.ts checks the component itself server-side; the pinch and the pointer gestures are checked by hand on the chart demo page (no Playwright in this kit, d-ff9caa05).
Dependency arrows turn the timeline into a Gantt (#281, the mkit half of m/projax#69 § 3.7). edges is a list of { from, to, label?, color? } where each end names a TimelineMark.id; a feed that carries a depends_on list per row hands it over through dependencyEdges(rows) and maps nothing else, which is the shape m/projax#78 answers with on its items, its tasks and its board cards. An arrow is the elbow every Gantt draws: out of the source's finish, along the source's row to EDGE_STUB (8 px) left of the target's start, down or up the rows there, then the stub into the target's start where the head sits. Only the first leg follows the data — CSS min() and max() give it whichever direction the two ends resolve to, so a target that starts before its source finishes runs that leg backwards and needs no second route. Every coordinate is a share of the axis plus the mark's own pixels, handed to the browser as one calc() and never folded together, so the server draws the whole route and nothing is measured. A colour is ChartReference's rule: 1..6 picks --mk-chart-<n>, absent draws the neutral --mk-text-3 connector. An edge either of whose ends is not a dated mark inside the range draws nothing, and a from/to pair drawn twice draws one arrow. The arrows are aria-hidden: a dependency reaches a reader as the hidden table's Depends on column (strings.timelineDependsOn), which names every dependency whatever the range shows, beside a line counting the edges that name a mark marks does not carry (strings.timelineUnknownEdges) — reported, never dropped, never thrown, the rule an unknown lane already follows. The column and that line appear only while edges is given, so a chart without dependencies renders exactly what it rendered before #281.
DaySpanChart is the kind whose axis is a clock rather than a calendar (#251): spans in place of series, a DaySpan being { id, at, until, label, color?, fill?, description? } with at and until ISO instants and no domain field. One row is one day window, every row runs the same 24 hours from dayStart, and every span is a bar at its clock time — flexsiebels' /health sleep chart, where a noon-to-noon window keeps a night that crosses midnight as one bar. TimelineChart does not do this even with its own hour ticks (#279): its axis is a continuous calendar the reader zooms and pans across, not a fixed 24-hour window repeated per row, and a fixed window there would need fake dates the mark titles and the hidden table would print. Props: spans, title, dayStart (0..23; 0 runs midnight to midnight, 12 noon to noon), timeZone (an IANA name, UTC by default), tickHours (3; 0 leaves the axis off), order (newest, the default, or oldest), days (DAY_ROWS, 400), density (compact 16 px rows, standard 24, spacious 36 — DAY_ROW_HEIGHT and DAY_BAR_HEIGHT), labelWidth (min(9rem, 36%)), locale, table, onSpanClick(span, event), and the snippets note (a DaySpanRow, a second line in the day's label cell) and empty ({ count }). There is no zoom, no pan and no range.
The clock, not UTC, which is the one place the chart group departs from axis.ts. A noon-to-noon window keeps a night whole only in the reader's own zone, so every instant is read through Intl.DateTimeFormat in timeZone and daySpanLayout runs on wall-clock minutes. A bar therefore covers the hours of the clock face it spans and not the hours that elapsed: 23:00 to 07:00 across a spring-forward lasted seven hours and is drawn as eight, across a fall-back lasted nine and is drawn as eight, and both print the recorded clock times. A span crossing a window edge becomes one bar in each window, each flagged data-before/data-after and squared off on that side, and both halves carry the whole span's clock times, so a reader of either sees one night rather than two segments; the hidden table still gives it one row, at the window it starts in. A row is named by the calendar day holding most of its window — the day it starts on below noon, the day it ends on from noon — which is why a noon-to-noon sleep row is the morning it ended in, and DaySpanRow carries startDay and endDay beside it. A window between the first and the last with no span is an empty row and never a gap, the heatmap's pale-cell rule. A span whose at or until does not parse is reported in the hidden table (strings.daySpanDropped) rather than drawn or thrown. row.minutes is the clock minutes the row's bars cover, two overlapping spans counted twice, and the note snippet is what prints it, because a duration's wording is the app's. The axis labels are the plain two-digit 24-hour number rather than Intl's hour, which carries the locale's hour word (07 Uhr, 07時) and does not fit a tick; the closing tick reads back into the track so its label is not cut. demo/pages/chart.svelte (?page=chart), src/chart/dayspan.test.ts, src/chart/DaySpanChart.ssr.test.ts.
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-text 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; the rest is checked by hand on the graph demo page.
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), 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 three the kit adds for grouping, the column set and the column widths (group=<url>, cols=<url,url,…>, w=<url>.<px>,… — the registry's url keys, as sort writes them; a w pair is read from its last dot) 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. Both take CodecOptions, where an app declares the view set it ships: views, the accepted view= values (STANDARD_VIEWS unset), and defaultView, the one it rests in and therefore the one left out of the URL (table unset) — an app whose views replace the standard's set outright needs the second to reach a clean URL at all, since none of its views is table (#228). 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 (rendered only while a field is chosen — dir is always asc without one, so the control would name nothing, #148), 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; grouped, each group's heading row is a band that reads --mk-table-band on any ancestor or inline on the root, --mk-bg-2 without it, #168; a field declaring wrap: <n> gives up the single line every other cell keeps — that column's cell wraps and stops after n lines inside a box no wider than --mk-data-table-wrap-w (24rem), the whole value on the cell's title, #290), 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 (bindable) with selectToggle, selectedIds (bindable) with onSelectionChange, onMove, filterKey, resizable, timeline, labels, dense, and the snippets cell, rowMain, rowMeta, card, boardCard, groupHead, laneHead, toolbar, selection, empty. With no snippet the registry alone renders.
The toolbar is five rows, m's own order (#247, #296), each rendering only while it has something to show: (1) filters — the search box or, above eight dimensions on the phone, the Filters button that opens DataFilterSheet; (2) the chips chrome — one filter dimension per row, stacked; (3) saved views — DataSavedViews, present whenever views, onSaveView or href is given; (4) view type — the toolbar snippet (unchanged: still beside the view switch), the view switch, then the column picker, which always follows the view switch and never precedes it; (5) grouping, sorting, then the page size at the right end of the row, behind a spacer (#259). Every row's controls and the status line's share one font size through the --mk-dv-toolbar-text kit token, which a consumer overrides rather than sizing a control on its own; the filter chips keep their own smaller label token. Every button in those rows and in the status line carries the kit's bordered secondary style (#259): a borderless control does not read as a button. The numbered pager keeps its own boxing, where only the current page is framed. The status line below — the select-page checkbox, the count, the selection count, Clear, and the selection toggle at its right end — carries no page-size control any more.
The selection toggle (#259): selectToggle renders a compact bordered icon button at the right end of the status line which flips selectable, so a consumer drops its own Select button and binds the prop (bind:selectable). It sits there and not in a toolbar row because the rows above carry what the codec writes and a selection never reaches the URL or a saved view, the status line already owns every other selection affordance (the page checkbox, the count), and it renders in every configuration, so the toggle needs no row of its own. Leaving select mode empties the selection: a consumer's selection bar renders on selectedIds and would otherwise stand over rows with no checkbox on them. Off by default, so an app that wants no selection carries no control for it. The calendar view renders no toggle, as it renders no per-row checkbox. Label: dataviewSelectMode.
Resizable columns (#290): in the table view every header carries a grip on its right edge, and the width the reader leaves it at lands in state.widths — px, keyed by field key, written as the w URL key, so a saved view keeps the widths it was saved at and a copied link carries them. The grip is the shell separators' shape: pointer with capture, ArrowLeft / ArrowRight by 16 px, Home / End at the bounds (48 px and 960 px), and Enter or a double-click back to the default; the column picker carries Reset widths for the whole table, offered only while a column carries one. A width is the cell's own content width, its padding excluded, and the box inside the cell carries it — a <td> in an automatic table layout treats a width as a hint and takes what its longest line needs, while a box of a definite width with its overflow hidden asks for exactly that. The header takes that same width and truncates its label inside it, whole on the cell's title and in its accessible name (#299), so the label is no floor under the column; the last column's grip sits inside its cell rather than across its edge, because 3 px past that cell is 3 px past the table and its scroll container counts them as overflow at every width. The kit stores nothing: onStateChange(next, 'widths') fires once when the press ends, never per pointer move, and resizable={false} takes the gesture away while the widths the state carries still render. The arithmetic is dataview/widths.ts (clampWidth, widthOf, setWidth, clearWidth, widthAction, steppedWidth), on the default condition too.
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 holds no order of its own and pages the board the way it pages every view: without orderable the order inside a lane is the active sort. 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. orderable (default false, m/mkit#280) says the lane has an order of its own: a drop then reports where in the lane it landed — before and after on the move, the two cards it sits between, either null at that end of the lane — a drop inside one lane is a move too, Ctrl+ArrowUp / Ctrl+ArrowDown steps a focused card one place inside its lane, and a rule marks the slot under the pointer while a drag is over the lane. A same-lane move never copies, and a lane step carries no slot, so a card that changes lane keeps the order it had. The kit still stores no order and computes no rank: the consumer writes one between the two cards the move names (m/projax#69 § 3.2). movable (default true) turns the gesture off entirely — no card is draggable and no lane step fires — which is what a board whose backend refuses every move offers instead of a drag that always fails. 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, stepSlot, laneIndex, slotAt, positionOf, dragEffect, isCopy, moveOf, the BoardMove and BoardPosition types), 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.
Timeline (#313): view=timeline draws TimelineChart over the rows — one lane per value of a groupable field, a bar for a row carrying a start and an end, a point for one carrying a single date, a diamond point for a row carrying an end and no start, and the undated column for a row carrying neither. Which fields those are is the timeline prop: start, end and lane by field key, image for the hover card's picture, edges for the dependencies (dependencyEdges(rows) builds them from a feed's depends_on), and density, title, zone and ticks passed through to the chart. Every key is optional and so is the prop — the start falls back to the registry's first date-range field and the lane to its first groupable one, so view=timeline draws a picture with no configuration; a registry with no date-range field at all renders the table, the rule view=calendar without its snippet follows. state.timeline wins over what the prop names, and that is the half the codec writes (tl.start, tl.end, tl.lane, tl.range), so a saved view restores the picture and not only the rows; the reason on those changes is timeline. Like the calendar the view takes every match and not the page — a window is not a page — so the pager, the size control and the page checkbox stay off, the count line reads the whole set, and it renders for an empty set too, because an empty window is still an axis with its lanes on it; the chart's own 500-mark ceiling is what bounds a very long list, and above it the chart prints its over-limit line with every mark still in the hidden table. The group control is the lane picker here, the way it is the board's, and it drops its "no grouping" option; the root carries data-wide, as the board's does. On hover and on keyboard focus a mark shows the row as a card — the consumer's card snippet where it wired one, else DataCardBody, the same title, fields and date the card grid draws, with the image field's value as a picture above them (mBrian's metadata.cover_url); a row with no URL there draws no image and leaves no gap. A mark is a <button> under onActivate, which is how a row is opened in this view — rowHref renders no link, because a link inside the mark's button would be invalid markup, so a host wanting navigation passes onActivate={(row) => goto(rowHref(row))}. The pure half is dataview/timeline.ts (timelineFields, timelineLanes, timelineMarks, markInstant, TIMELINE_LANE, the DataTimeline and TimelineFields types), on the default condition too; the date reading follows mBrian's own reference implementation (src/lib/nodeTimeline.ts, m/mBrian#167), whose cases were measured on live rows — a stored YYYY-MM-DD HH:mm becomes an instant with a Z, because Date.parse reads a zone-less date-time as local while the chart reads at in UTC. Two strings keys join the set (dataviewViewTimeline, dataviewTimelineAll, the one lane a registry with no groupable field draws every row on). docs/standards/dataview.md § 2 carries view=timeline and the four tl. keys as kit additions.
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 views (given, even empty, the row carries the dropdown whose list applies a view through onApplyView(id) and, when onDeleteView is wired, deletes one through it), onSaveView(name, state), onUpdateView(id, state), currentViewId and href, the complete URL of the current state as the consumer wrote it: with it the row offers Copy link as a compact bordered icon button that flips to a check for two seconds, and past the ceiling Save stands in its place when onSaveView is wired, while an app without saved views keeps the long link.
The views row (#259): on the left the applied view's name as a dropdown over the saved views, which marks the applied one with aria-current and a check. The applied view is currentViewId when the consumer names it — a view restored from its own storage or its URL — and otherwise the last view the component itself applied; a deleted view leaves neither pointing anywhere. Save appears beside the name only once the current state stops matching that view's stored state, the two queries compared after both have gone through this instance's codec, so a stored spelling of one state does not read as a change; with no view applied Save always stands, and past the ceiling it stands either way, because there it is the only way to keep the state. Save opens one dialog with two steps: with a view applied and onUpdateView wired it first asks whether to overwrite that view — onUpdateView(id, state), the applied view's id and the current query — or to save a new one, which is the name form; without onUpdateView it goes straight to the name form, where a name an existing view already carries turns the submit into Replace, so the overwrite a consumer keys on the name stays available. Every prop here is optional, so a consumer that wires none of the new ones keeps the behaviour it had. Six strings keys join the set (dataviewSavedView, dataviewSavedViewNone, dataviewSaveChoice, dataviewOverwriteView, dataviewSaveAsNew, dataviewSelectMode). 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. It does not learn the id of a view it just saved either, so a consumer that wants a freshly saved view to read as the applied one passes currentViewId. 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 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. The chips chrome draws the same control (#309), beside the dimension's heading. 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). The chips chrome puts one dimension on one row (#296): the dimensions stand in a container of their own below the search field, laid out as a column, so no two of them share a line at any width and a fold changes one group's height and moves no other group. Only the chips inside a dimension wrap. Either chrome folds per dimension (#292): the chips chrome's heading is a button with a chevron that hides that dimension's chips and carries the count of its selected values while folded (DataFilterDimension), and the panel's sections fold as they always did. FieldDef.collapsed opens a dimension folded; filterKey on DataView is where a reader's own fold is stored — <filterKey>:<field.key> in the kit's mk:sections record, the one CollapsibleSection writes, so the fold survives a reload and two DataViews declaring one dimension keep two folds. Without a filterKey a fold holds for the session and nothing is written, which is what keeps two instances off one entry. The stored choice wins over collapsed and is read once, at mount. 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.
DataFilterDimension is public (#308): a host that lays its filters out itself — a tree beside the rows, a dimension per grid cell — takes the chips chrome's per-dimension group from @mai/mkit/dataview and places it, rather than stacking one DataFilterPanel per dimension. It renders one dimension and nothing around it: the heading with its chevron, then that dimension's pills. Props: field (the FieldDef, whose label names the heading and whose options — a boolean field's yes and no — are the pills), values (the values selected in this dimension), onToggle(field, value), id and labels. Every value it draws comes in as a prop; it reads no context and no DataView, so the host keeps the filter state and the kit mutates nothing. id is the fold's key in the kit's mk:sections record (#292) — foldId(filterKey, field.key) is what DataView passes, and any string the host picks works the same; without one the fold holds for the session and nothing is written. The stored choice wins over field.collapsed and is read once, at mount, and a folded dimension carries the count of its selected values so an active filter stays visible. src/dataview/DataFilterDimension.ssr.test.ts renders it from the package entry. The dimension carries the mode control too (#309): with onMode(field, mode) wired, and where the field declares more than one set mode, it draws the same SegmentedControl of any / all / none the panel draws in a section's actions — the same words, the same rule — and mode names the one it marks, the first mode the field declares when the host names none (modeOf). It stands between the heading and the pills, so the dimension is still one flat group, and a fold hides the pills alone: the panel's control sits in a section header, which a fold keeps, and a folded dimension still filters under a mode. Without onMode no control is drawn, since one the host does not hear changes nothing. DataView passes both from the chips chrome, so a consumer under eight dimensions has the exclusion control the panel has always offered. setModes(field) and modeOf(field, filters[field.key]) are exported for a host that places the dimension itself.
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 | widths | timeline, 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, 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, for every host servedHosts does not list |
servedHosts |
optional | further hosts one deployment answers on, e.g. ['wiki.example.org', 'kicker.example.org'] — see § Several hosts, one deployment |
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; purpose and lang on the delivery pick the template (below) |
gotrue.onLinkError |
optional | (error) => void, sync or async — receives the error when requestLink failed to mint or deliver a link (below) |
gotrue.admit |
optional | (email) => string | null, sync or async — judges the address password, requestLink and verifyCode received and the address a redeemed magic link signs in, after the kit trimmed, lowercased and shape-checked it (§ Admission) |
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 paths (so a migration keeps its registered OIDC redirect URI and GoTrue GOTRUE_URI_ALLOW_LIST entries); only the magic link's redemption page moves, through gotrueCallbackPath (below):
| 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>
createAuthHandlers({ gotrueCallbackPath }) names the route the app mounted that page on, relative to base and defaulting to /api/auth/callback; requestLink mints every link against it, so a host whose mailed links already point at /login sets gotrueCallbackPath: '/login' and mounts the pair there. The path must start with / and carry no query or fragment. GoTrue's generate_link checks the minted target against GOTRUE_URI_ALLOW_LIST, so the new path goes on that list before the switch. gotrueCallbackLoad and gotrueCallbackAction read only the query string, never the path, so both routes can be mounted at once: re-export the pair from the old route's +page.server.ts and the new one's while links minted against the old path are still in mailboxes (mAuth's token lifetime), then remove the old route. A link to a route that is no longer mounted is a 404.
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).
Every delivery carries a purpose, so an app can word a registration or reset mail differently from a sign-in mail. requestLink sends 'setpassword' when its JSON body names purpose: 'setpassword', and 'signin' for any other body, a missing purpose included. Both mint the same self-creating magiclink, and the link signs its recipient in either way; the set-password form behind it stays the app's own page on that session. The value is the caller's claim, so an app uses it to choose the wording and grants nothing on it.
A delivery also carries the lang the requestLink body named, so an app mails sign-in in the reader's language. requestLink accepts a short BCP 47 tag — a primary language subtag with an optional region, de, en, en-GB — and hands deliverLink undefined for a missing tag or any other value; the answer stays {ok:true} either way. SignIn posts nothing itself, so the app's own requestLink wiring adds the language it renders the page in, e.g. lang: document.documentElement.lang or the locale it gave setStrings.
requestLink answers {ok:true} when the mint fails or deliverLink throws or rejects, the same answer as every other outcome: only an address that passed every check gets that far, so a different answer would tell the caller the address is real (mAuth contract §7.1). The kit hands the original error to gotrue.onLinkError, once per failed request, for the host to report; its message may carry the address, so the host decides what it logs. Without the hook, and when the hook itself throws, the kit logs one console.error line naming the error class or mAuth code, never the address, the link or the code. A requestLink mounted without gotrue.deliverLink still answers 500: that is a deploy mistake every address hits alike.
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.
Several hosts, one deployment
servedHosts lets one deployment serve several hosts and send each person back to the host they signed in on (m/mkit#217). A request whose host is on the list builds the oidc redirect_uri, the post_logout_redirect_uri and the mAuth magic-link redirectTo on its own host; any other host builds them from siteUrl, which is also the behaviour without the option.
The list is the security boundary. The request only chooses an entry: the host in the redirect is the entry as configureAuth() normalised it, and the scheme is always siteUrl's, so a forged Host header can never put a foreign domain into a redirect or a mailed link. An entry is a bare host, optionally with a port; configureAuth() refuses a scheme, a path, userinfo, a query or a fragment.
- Case does not matter: the URL parser lowercases both sides, so
WIKI.example.orgis the listedwiki.example.org. - A port is part of the host and must match as listed.
wiki.example.org:8443does not matchwiki.example.org; list it with its port when the deployment is reached on one. - Forwarding headers are never read by the kit. It compares
event.url.host, the host the SvelteKit adapter resolved. Withadapter-node, leaveORIGINunset, because it pinsevent.urlto one host; setHOST_HEADER=x-forwarded-hostonly when the proxy in front overwrites that header on every request. AnX-Forwarded-Hostnaming a listed host on a request the adapter resolved to an unlisted one falls back tositeUrl.
Every host on the list needs its own entries outside the app: the oidc issuer's registered redirect URIs and post-logout URIs, and GoTrue's GOTRUE_URI_ALLOW_LIST, each per host.
Admission
gotrue.admit(email) is the one place a host canonicalises or refuses an address on the GoTrue paths, instead of wrapping the kit's handlers. It runs on password, requestLink and verifyCode before any GoTrue or mAuth call, and returns the canonical address (a hoganlovells.com address as its hlc.com form, say) or null to refuse. The kit trims, lowercases and shape-checks the returned address again; one that fails that check, and a hook that throws, count as a refusal.
The canonical address is the one the kit mints for, signs in, verifies the code against and keys the requestLink rate limit on. A hook that maps several typed addresses onto one also puts them in one rate-limit bucket.
A refusal answers as a failure without the hook does: requestLink answers {ok: true} and sends no mail, password and verifyCode answer 401 {ok: false}, the same as a wrong password or code for an address GoTrue does not know.
gotrueCallbackAction runs admit again on the address the redeemed magic link signs in, after mAuth's redeem and before the session is written, so a link minted before the address was refused, or minted outside the kit, signs nobody in. The session is written only when admit returns that address itself; a refusal, a throw and a different canonical address write no session and answer fail(400) as a spent link does. verifyCode needs no second check: GoTrue verifies the code against the canonical address and the session it issues belongs to that address. The kit makes no GoTrue call for a refused address, so the answer depends on the address and the hook alone. A hook whose own lookup takes longer for an address that has an account hands that fact to the caller; decide from the address and host policy.
Email shape
isValidEmail(email) from @mai/mkit/auth/email is the one check the handlers run on password, verifyCode and requestLink: local@domain.tld, no whitespace, one @, at most EMAIL_MAX_LENGTH (254) characters. The module imports nothing, so a login page, a server endpoint and a bun script load the same function; @mai/mkit/auth and @mai/mkit/auth/svelte re-export it. It neither trims nor lowercases — normalise first, as the handlers do.
Rate limits
Every path that an anonymous caller can repeat is limited per normalised address. Each path has its own bucket, so a mistyped password does not spend the code budget or the magic-link budget:
| Handler | Limit | Past the limit |
|---|---|---|
password |
10 per hour (CREDENTIAL_RATE_LIMIT, CREDENTIAL_RATE_WINDOW_MS in gotrue.ts) |
401 {ok:false}, the same answer a wrong password gets; GoTrue is not asked |
verifyCode |
10 per hour, own bucket | 401 {ok:false}, the same answer a wrong code gets; GoTrue is not asked |
requestLink |
5 per 24 hours (mAuth contract §8.2) | {ok:true} and no mail, the same answer as every other outcome |
Every well-formed attempt counts, a successful one too; a refused attempt does not. The window slides: an attempt leaves the count one window after it was made.
The counters are in memory, per process. Two instances of the app give an address two budgets, and a restart empties every counter. An app that runs several instances and needs a hard cap has to put a shared limit in front of these handlers, for example at its proxy.
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 with passwordEmail, onOidcSelect, onMagicLinkSubmit/onMagicLinkResend with magicLinkPhase/magicLinkEmail/magicLinkResendIn, onCodeComplete with codeError — plus error and busy. passwordEmail and magicLinkEmail each seed their own path's email field (m/mkit#229), so a page re-rendered from a form action's fail(400, { email }) shows the address the visitor already typed instead of an empty field. Both are one-time seeds, not live bindings: the field owns its value from mount on, and an app that wants to reset it remounts the card. 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.
OIDC issuer
createOidcIssuer(options) (m/mkit#222, m's d-f403a3d6) turns an app into an OpenID Connect issuer for other apps: the hub at https://hlcpat.de/oidc vouches for its own signed-in colleagues, and every other app signs in through the kit's oidc provider against it. Design: HLC/hlcpat docs/sso-design.md § 3.
It is server-only, like the rest of @mai/mkit/auth, and nothing about it is client-safe: the signing key and every client secret live in the issuer's closures, and no log line or error message it writes carries a key, a secret, a code or a token. Import it from $lib/server/ only. It needs jose, a third optional peer beside oauth4webapi and @supabase/ssr; an app that is only an OIDC client installs neither jose nor anything else for it.
Authorization code flow only: PKCE S256, state and nonce required, client_secret_post, ID tokens signed ES256. There is no refresh token and no userinfo endpoint. The token endpoint returns the access token the protocol requires, a random string nothing accepts.
// src/lib/server/issuer.ts — awaited once; a missing jose, a bad key or a bad registry stops the boot
import { createOidcIssuer, gotrueIssuerSession } from '@mai/mkit/auth';
export const issuer = await createOidcIssuer({
issuer: 'https://hlcpat.de/oidc',
signingKey: env.HUB_OIDC_SIGNING_KEY, // an EC P-256 private JWK, JSON text or object
clients: JSON.parse(env.HUB_OIDC_CLIENTS), // [{ clientId, clientSecret, redirectUris, postLogoutRedirectUris }]
sessionUser: gotrueIssuerSession, // or the app's own (event) => IssuerSession | null
isAllowed: (session) => hubAllows(session.email),
endSession: (event) => signOutLocally(event),
idTokenTtlSeconds: 360000
});
// src/routes/oidc/authorize/+server.ts
import { issuer } from '$lib/server/issuer';
export const GET = issuer.authorize;
| Handler | Method | Path under the issuer (ISSUER_PATHS) |
|---|---|---|
discovery |
GET | /.well-known/openid-configuration |
authorize |
GET | /authorize |
token |
POST | /token |
jwks |
GET | /jwks |
logout |
GET | /logout |
The issuer is an https URL with a path (http only on a loopback host), and every client configures exactly that string as its oidc.issuer. Each client registers a clientSecret of at least 32 characters (the kit's client also signs its session cookie with it), exact redirectUris and exact postLogoutRedirectUris; URIs compare as whole strings, never as prefixes.
authorize answers an unknown client or an unregistered redirect_uri with a 400 page of the app and never redirects it. Every other refusal goes back to the registered redirect_uri as error=… with state and iss. With no session it sends the browser to loginPath (default /login) with ?next= set to the authorize request itself, so the app's login page must pass next through sanitizeNext and return there. With a session it re-checks isAllowed (a throw refuses), then issues a code: 32 random bytes, single use, 60 seconds, bound to the issuer, the client, the redirect URI and the PKCE challenge. prompt=none is supported and answers login_required without a session; any other prompt value is refused.
token spends the code before it checks anything else, so a code presented once is gone whether that request succeeds or not. The ID token carries iss, aud, sub, iat, exp (idTokenTtlSeconds after issue), nonce, amr and auth_time, plus email and email_verified under the email scope and name under profile. amr and auth_time are left out when the session does not know them; an unknown method is never reported as a password.
logout accepts only a registered client_id + post_logout_redirect_uri pair, calls endSession(event) and redirects there with state. A forged link can only sign a person out.
gotrueIssuerSession(event) is sessionUser for an app whose own sign-in is the kit's gotrue provider. getUser() validates the session against GoTrue first; only then does it read amr and auth_time from the same access token (password → pwd, otp and magiclink → otp, any other method left out, the latest proof as auth_time).
Codes. codeStore takes any IssuerCodeStore (put(key, grant), take(key)); the default is MemoryCodeStore, one process's memory. Keys are the SHA-256 of the code, never the code. take must return and remove the grant in one atomic step (GETDEL, DELETE … RETURNING), or a replayed code succeeds. Every issuer process must share one store: a second instance with its own memory refuses every code the first one issued. Two issuers may share one store — the grant records its own issuer and token answers invalid_grant to a code the other one issued, so a host serving https://a.example/oidc and https://b.example/oidc from one store never mints an iss the person did not sign in at. A restart of a single process loses only codes still in flight.
Key rotation. One active key. generateIssuerKey() returns a new private JWK as JSON text; rotate by replacing the key and restarting. The JWKS kid is the key's RFC 7638 thumbprint, so a new key has a new kid. The kit's client (oauth4webapi 3.8) caches the JWKS for 5 minutes and refetches on an unknown kid once its copy is 60 seconds old, so after a rotation a client refuses the sign-ins it completes within 60 seconds of its last JWKS fetch, and from then on refetches and accepts the new key. A leaked key is rotated the same way, and the old key stops verifying anything the moment it leaves the JWKS.
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 *.test.ts files (pure modules), the *.ssr.test.ts files (components and stores rendered on the server through tests/ssr-harness.ts) and the *.runes.test.ts files (stores whose reactivity is the subject, through tests/runes-harness.ts)
bun run check # svelte-check
There is no browser suite (m/mkit#172, d-ff9caa05): what a component does in a browser is a unit test on the component where one can say it, and otherwise checked by hand on the demo. Do not add a browser runner.
A store whose reactivity is the subject needs tests/runes-harness.ts, not the SSR one: ssrLoadModule compiles a .svelte.ts module for the server, which drops every $effect and turns $state into a plain field, so an effect loop or a tracked read is invisible there. tests/runes-loader.ts, preloaded from bunfig.toml, compiles every *.svelte.ts with generate: 'client', and startRunesHarness() puts a JSDOM window on the globals, so the runtime is the one a consumer's bundle gets. Four rules come with it. The module under test is reached through await import() inside beforeAll, after await startRunesHarness() has put the window there, because a store reads browser once at load. The test file imports nothing from svelte and takes tick from the harness: bun resolves the bare specifier to the package's server entry, where untrack is a plain call and a case about tracking would pass whatever the code does — the harness redirects that entry and throws if a file loaded it first. An error Svelte throws out of a flush reaches no try, because the flush runs in a microtask; read it from harness.errors. And nothing in the import chain may be a .svelte component, since bun compiles the modules here and not Vite — a component belongs in an *.ssr.test.ts file. src/stores/storage.runes.test.ts is the pattern.
A test file puts the process-wide state it changes back in the same file, configureStorage first of all. bun shares one module registry across the files of a run, so a prefix left standing outlives the file that set it and decides what storageKey answers in every file after it, which makes the result of a case depend on the order bun reads the files in.
No component scrolls on an axis it does not declare (m/mkit#258): a rule that sets one overflow axis and leaves the other unset gets the other computed to auto (m/mkit#53), so a vertical scroll region also scrolls sideways and a horizontal one is a second vertical scroller inside the page's. Every such rule therefore names both axes — hidden on the one that must not scroll, or the overflow shorthand where neither does. src/crossAxis.test.ts reads the compiled CSS of every component in src/ and fails on the next rule that names one axis and not the other.
The demo binds 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 demo at the same time:
MKIT_PORT=5181 bun run dev
strictPort is on, so a port already in use fails the start instead of moving the demo.
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 dev 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 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 exist (#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.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. - 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. - The auth components exist (#26): see § Auth above. Their text reads through the
stringsstore too.demo/pages/auth.svelte. - The
pullToRefreshaction andShell'sonrefreshexist (#20, web-unify.md slice 4): the lists demo page pulls at 390. - The
inViewaction exists (#73): see § Actions.demo/pages/inview.svelte,src/actions/inView.test.ts. Sliderexists (#74): see § Slider.demo/pages/slider.svelte.DecisionFormrenders ten field types (#86): see § DecisionForm.decisionform.tsis the headless map.demo/pages/decisionform.svelte,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.CalendarMonthand the headlesscalendar.tsexist (#37): see § Calendar.stringsgainslocale,calendarPrevMonth,calendarNextMonth.demo/pages/calendar.svelte.@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, #280orderable, #292 the filter fold, #313 the timeline view withDataCardBodyandTimelineChart'smarkCard): see § DataView.stringsgains twentydataview*keys, then nine more, then three, then two, then two;ListRowgainshref;Checkboxgainsindeterminate.demo/pages/dataview.svelte.DatePickerexists (#40): see § Calendar.CalendarMonthgainsmin/max;stringsgainsdatePicker,datePickerOpen.demo/pages/datepicker.svelte,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,src/graph/NetworkGraph.ssr.test.ts,src/graph/*.test.ts.TreeRowandTreeGroupcarryunreadbesidecount(#147): see § Shell.Badgegains theprimaryvariant,stringsgainstreeUnread. The chat tree indemo/ShellDemo.svelteshows both on a row and on a group head.- The Playwright suite is gone (#172,
d-ff9caa05):tests/holdsssr-harness.tsonly, there is nobun run test, and what a component does in a browser is a unit test on the component or a check by hand on the demo. ChatFrameexists (#238): see § ChatFrame. It replaces the frame mai'sChatView, paliad'sChatand this demo each built by hand, and it is the first of the three to follow the log's own resize.demo/pages/chat.svelte,src/components/chatframe.test.ts,src/components/ChatFrame.ssr.test.ts.- The
controlLabelsstore exists (#248): see § Control labels.Buttongainslabel,IconButtonshowslabelbeside its icon under'icon-text'.src/components/Button.ssr.test.ts,src/components/IconButton.ssr.test.ts. LegalPage,LegalLinksand the legal config format exist (#303): see § Legal pages. The app keeps its ownlegal.json;legal.tsis the format,parseLegalConfigthe check at the boundary.demo/pages/legal.svelteoverdemo/legal.json,src/components/legal.test.ts,src/components/Legal.ssr.test.ts,src/components/legal.style.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 |
| esm-env | ^1.2.2 |
| marked | ^17.0.3 |
| marked-footnote | ^1.4.0 |
Development Dependencies
| ID | Version |
|---|---|
| @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 |
| jose | ^6.1.3 |
| 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 |
| jose | ^6.1.3 |
| oauth4webapi | ^3.8.8 |
| svelte | ^5.0.0 |