• Joined on 2026-01-29

@mai/mkit (0.1.3)

Published 2026-09-15 10:35:54 +00:00 by mAi

Installation

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

About this package

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

mkit

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

What the kit is

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

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

What enters the kit, what stays in an app

A thing enters the kit when all three hold:

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

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

A runtime dependency enters the kit only when it replaces the same functionality in at least two apps (m, m/mAi2#101 d-e62b8fb5: "use synergies, not X times the same functionality"). Today that is four: marked + marked-footnote (the markdown renderer, moved here from @mai/meditor's render.ts; mEditor consumes it back), dompurify (sanitising by default, a trusted flag for the app's own content), @fortawesome/fontawesome-free (the icon set, d-b96fb2b4; icons CC BY 4.0, webfonts OFL 1.1, code MIT — bundled, never the CDN link). Nothing else.

Package layout

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

packages/mkit/
├── README.md
├── package.json            name @mai/mkit, peerDependency svelte ^5, exports below; svelte-package builds src/ into dist/
├── src/
│   ├── tokens.css          every --mk-* token, both themes, the touch scale, the base reset
│   ├── fonts.css           Inter + JetBrains Mono (@font-face), fonts/ holds the woff2 and OFL.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)
│   ├── index.ts            re-exports the five group indices below; a group adds its exports to its own index, never here
│   ├── shell/              Shell, Sidebar, SidebarHead, NavSection, NavItem, Tree, TreeGroup, TreeRow, PageHead, ContextPanel, StatusBar,
│   │                       PhoneTop, BottomNav, BottomSheet, MenuSheet; types.ts (NavSlot, NavCentre, MenuGroup), context.ts (what Shell tells its children)
│   ├── components/         Badge, Button, Card, Checkbox, Chip (removable, `removeLabel` prop on the close control, default 'Remove'; `wrap` prop, default false, wraps long text instead of overflowing), CommandPalette (Ctrl/Cmd K or commands.palette.open(), mBrian's QuickSwitcher shape;
│   │                       + search.ts, pure ranking/recents), Dialog, EmptyState, Field, Icon, IconButton, Kbd, ListRow,
│   │                       SearchResultRow, SegmentedControl, Select (native `<select>`, m/mAi2#74), Sheet (a thin wrapper on the shell's BottomSheet, desktop="modal"),
│   │                       Spinner, StatusDot, Table, TableHeaderCell, TextInput, Textarea, Tile, Toast (+ toast.svelte.ts, one queue for the kit), Toggle;
│   │                       TextInput, Textarea, Select and Composer all take a bindable `element` prop bound to their native input/textarea/select;
│   │                       chat: Bubble (me/them, the timestamp under the text, an optional pin marker, swipe-to-reply armed on every bubble), Composer
│   │                       (leading action slot, auto-growing input, send), Chips (a decision's options, faded once one is picked; wraps each option's Chip by default, since option labels are free text), DecisionForm (a
│   │                       decision row's `options.fields` on Field and the primitives)
│   ├── stores/             theme.svelte.ts (system/light/dark, data-theme on <html>), shell.svelte.ts (sidebar open/width/rail, context open per page/width, phone sheet),
│   │                       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()),
│   │                       viewport.svelte.ts (--mk-app-h from visualViewport, keyboard-up), storage.ts (mk:-prefixed localStorage)
│   └── actions/            swipe.ts (swipe-to-reply), slideUp.ts (drag-up on a nav slot opens a sheet), focusTrap.ts (a custom overlay's own trap; a native `<dialog>` traps focus itself), longPress.ts, interactive.ts (shared guard)
├── 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; chat, lists, today, saved, board, agent, analytics, settings are the page groups of web-unify.md § 5, each a thin `<ShellDemo group="…" />`
│   ├── ShellDemo.svelte, shell-data.ts   the shell chrome and the fixture-derived data shared by the eight page groups; Sidebar/StatusBar and PhoneTop/BottomNav each render only on their own breakpoint, so one component serves both shells
│   ├── fixtures.ts         imports mockups/data/*.json in place; fixture('projects_mai2_agents')
│   └── tokens.ts, tokens-parse.ts   src/tokens.css parsed into rows for the tokens page and the spec
├── tests/                  playwright.config.ts at the root; components.spec.ts, demo.spec.ts and shell.spec.ts run chromium against the demo; shots/ holds the screenshots (gitignored)
└── mockups/                the static HTML sketch of the shell from #101, kept as the visual reference until the components replace it
    ├── index.html, shell.css, shell.js, data/   render with any static server; ?page=…&theme=…&overlay=…
    └── shoot.mjs           screenshots every page at both breakpoints

Exports (package.json exports):

  • @mai/mkit/tokens.css, @mai/mkit/fonts.css, @mai/mkit/icons.css
  • @mai/mkit — every component and store
  • @mai/mkit/shell, @mai/mkit/stores, @mai/mkit/actions, @mai/mkit/markdown — the same, grouped

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

Tokens

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

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

How an app consumes the kit

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

bun add @mai/mkit

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

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

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

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

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

Entry point in a consumer:

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

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

Shell

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

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

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

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

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

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, resolveLink, options) renders into a div.mk-prose; prose.css styles that class on --mk-* tokens only.

Develop

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

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

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

MKIT_PORT=5181 bun run test

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

Every script runs on bun (root bunfig.toml [run] bun = true): the publish container has no node, and node 18 on the desktops cannot load vite-plugin-svelte.

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

Status

  • Tokens, fonts and the shell mockup exist (from #101).
  • Package, build, publish workflow, icons.css + Icon, markdown/, the demo harness and the first Playwright spec exist (#1, slice 1a).
  • The primitives, Button through Field, and their demo page exist (#1, slice 1c).
  • The shell components, the four stores, the four actions, the shell demo pages and tests/shell.spec.ts exist (#1, slice 1b).
  • Slice 1 is complete (#1, slice 1d): Sheet reconciled onto the shell's BottomSheet; the demo renders every page group of § 5 (chat, lists, today, saved, board, agent, analytics, settings) in both shells over the mockup fixtures; tests/shell.spec.ts screenshots every group at 1440 / 1100 / 390 px in both themes and asserts the scroll container, the status bar, the phone nav, the sheets and the theme tokens. 0.1.0 is on the registry under the latest dist-tag.
  • The chat components — Bubble, Composer, Chips, DecisionForm — and the swipe action exist (#5, slice 3 of docs/plans/web-unify.md in m/mAi2). The chat demo page renders them over mockups/data/pwa_chat_last_25.json; tests/chat.spec.ts covers the timestamp placement, the pin marker, swipe-to-reply, chips fading and the form's required-field guard.

Dependencies

Dependencies

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

Development Dependencies

ID Version
@playwright/test ^1.63.0
@sveltejs/package ^2.3.0
@sveltejs/vite-plugin-svelte ^6.2.4
@types/bun ^1.3.9
@types/jsdom ^30.0.0
jsdom ^30.0.1
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
svelte ^5.0.0
Details
npm
2026-09-15 10:35:54 +00:00
3
MIT
472 KiB
Assets (1)
mkit-0.1.3.tgz 472 KiB
Versions (230) View all
0.2.72 2026-09-30
0.2.71 2026-09-30
0.2.70 2026-09-30
0.2.69 2026-09-30
0.2.68 2026-09-30