• Joined on 2026-01-29

@mai/projax-ui (0.1.4)

Published 2026-09-27 15:20:13 +00:00 by mAi

Installation

@mai:registry=https://mgit.flexsiebels.de/api/packages/mAi/npm/
npm install @mai/projax-ui@0.1.4
"@mai/projax-ui": "0.1.4"

About this package

Svelte 5 screens for projax, on @mai/mkit's dataview and shell.

projax-ui

Svelte 5 screens for projax — the tree, board and timeline views that mount on @mai/mkit's DataView, DataBoard and Tree (m/mkit#242, item 6 of m/projax#40's ordered work: docs/plans/projax-as-a-module.md). The tree screen (m/mkit#243, item 7) is the first one to land.

A standalone package rather than a @mai/mkit subpath, so a projax-only change never bumps mBrian, paliad, mWiki or the HLC hub, none of which mount projax (m/mkit#242, d-8f030a3a, d-a91a1412). The -ui in the name keeps it clear of the Go data package m/projax ships under d-8f030a3a.

Install

Published as @mai/projax-ui on this Gitea instance's own npm registry, under the mAi account's namespace, the account that cuts every release.

bun add @mai/projax-ui @mai/mkit

Point the @mai scope at the registry in a .npmrc (project root or user level):

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

The package is readable without a credential. @mai/mkit is a peer dependency, not a regular one: install it alongside, at the version this package's peerDependencies names. A consumer that instead lets a nested copy resolve — mismatched kit versions inside one app — ends up with two kit stores, seen in mBrian at @mai/mkit 0.2.7 beside @mai/meditor 0.9.0.

Versioning

d-880df9d8, m's rule for this workspace, also in CHANGELOG.md's header: 0.x for now, a patch for every fix and every addition, a minor only for a change a consumer must react to, and 1.0 when m calls the package stable. A branch never names the number; it writes a CHANGELOG.d/ fragment with bump: patch or bump: minor (root README § Publishing).

Use

import { ProjaxTree, ProjaxBoard, ProjaxFields, buildForest, projaxQuery, loadAllRows } from '@mai/projax-ui';

ProjaxTree is the tree screen (m/mkit#243, item 7 of m/projax#40's ordered work), over @mai/mkit/shell's Tree/TreeGroup/TreeRow. It never holds or sends PROJAX_MCP_TOKEN: it takes rows, already fetched — the host's own server load calls projax's GET /api/items with its Bearer token (projaxQuery/loadAllRows build the request and page past the 100-row ceiling) and passes the result in as a prop.

// +page.server.ts, or the equivalent in a host that is not SvelteKit
import { loadAllRows, projaxQuery, ProjaxFields } from '@mai/projax-ui';
import { decodeState } from '@mai/mkit/dataview';

export async function load({ url, fetch }) {
	const state = decodeState(url.search, ProjaxFields);
	const rows = await loadAllRows(
		(query) => fetch(`https://projax.msbls.de/api/items?${query}`, { headers: { Authorization: `Bearer ${PROJAX_MCP_TOKEN}` } }).then((r) => r.json()),
		projaxQuery(state, ProjaxFields)
	);
	return { rows };
}

ProjaxItemCard is the per-item render slot — status chip, tags, management chips, pinned star, archived flag, the multi-parent badge — exported on its own, shared by the tree and the board so neither rebuilds it.

pathPlacement decides where the item's path sits: inline beside the title, the default, or below on its own line under it. ProjaxBoard passes below, because a lane's column is narrow enough that title and path on one row truncate the title (m/mkit#262). ProjaxTreeRow and the timeline's creation row keep inline, so neither list doubles its row height — a tree already draws the lineage as indentation, and a timeline day reads as one line per entry. Either way the path truncates with an ellipsis and carries its full value as a title.

The task and issue rollup

GET /api/items?rollup=true adds each row's own aggregate over CalDAV and Gitea — open tasks, overdue, open issues, next signal, last activity (m/projax#49) — at a measured cold cost of 7.8–8.2 s on the live item set (m/projax#52), against about 0.11 s for a plain page. ProjaxTree and ProjaxBoard therefore take the rollup as a second, separate load, never folded into the fast first one that paints the screen:

// +page.server.ts, or the equivalent in a host that is not SvelteKit
import { loadAllRows, loadRollups, projaxQuery, ProjaxFields } from '@mai/projax-ui';
import { decodeState } from '@mai/mkit/dataview';

export async function load({ url, fetch }) {
	const state = decodeState(url.search, ProjaxFields);
	const loader = (query) => fetch(`https://projax.msbls.de/api/items?${query}`, { headers: { Authorization: `Bearer ${PROJAX_MCP_TOKEN}` } }).then((r) => r.json());
	const rows = await loadAllRows(loader, projaxQuery(state, ProjaxFields));
	// Not awaited: the page renders `rows` at once, and `rollupSet` streams in once the slow call resolves.
	const rollupSet = loadRollups(loader, projaxQuery(state, ProjaxFields, undefined, true));
	return { rows, rollupSet };
}
<!-- +page.svelte -->
<ProjaxTree rows={data.rows} rollupSet={data.rollupSet} />

rollupSet takes either a resolved RollupSet ({rollups, builtAt}, keyed by item id) or a Promise<RollupSet> — a host that already awaited it hands in the plain value, a host that wants the tree to paint before the rollup arrives hands in the promise unawaited, as above. ProjaxTree/ProjaxBoard look each row's rollup up by id and pass it to that row's ProjaxItemCard, which renders nothing extra until its own rollup is present — a plain GET /api/items and the tree's first render are never blocked by it.

ProjaxBoard is the board screen (m/mkit#244, item 8), over @mai/mkit/dataview's DataBoard. It groups the items feed by status, area, tag or management; a drop moves the card at once and hands a {feed, group_by, card_id, from, to} intent to a host-supplied mover — the package holds no PROJAX_MCP_TOKEN and no move semantics, the mover's the loader's own counterpart. A refusal reverts the card and shows the mover's error text as is.

// +page.svelte, or the equivalent in a host that is not SvelteKit
import { ProjaxBoard } from '@mai/projax-ui';

async function mover(intent) {
	const res = await fetch('https://projax.msbls.de/api/board/move', {
		method: 'POST',
		headers: { Authorization: `Bearer ${PROJAX_MCP_TOKEN}`, 'Content-Type': 'application/json' },
		body: JSON.stringify(intent)
	});
	if (res.status === 204) return { ok: true };
	return { ok: false, error: (await res.json()).error };
}
<ProjaxBoard rows={data.rows} groupBy="status" {mover} />

The tasks feed (group_by=due) is not built: ProjaxFields has no due field and this package has no tasks rows source, so grouping items by a due date has nothing to group on. A tasks board waits on both landing first.

The timeline

ProjaxTimeline is the day-grouped spine (m/mkit#252, step 2 of m/projax#40's ordered work), over GET /api/feeds/timeline (m/projax#45 step 1). Same division as ProjaxTree/ProjaxBoard: it takes rows, already fetched, and holds no PROJAX_MCP_TOKEN and does no fetch of its own.

// +page.server.ts, or the equivalent in a host that is not SvelteKit
import { loadAllRows, timelineQuery, TimelineFields } from '@mai/projax-ui';
import { decodeState } from '@mai/mkit/dataview';

export async function load({ url, fetch }) {
	const state = decodeState(url.search, TimelineFields);
	const rows = await loadAllRows(
		(query) => fetch(`https://projax.msbls.de/api/feeds/timeline?${query}`, { headers: { Authorization: `Bearer ${PROJAX_MCP_TOKEN}` } }).then((r) => r.json()),
		timelineQuery(state, TimelineFields)
	);
	return { rows };
}
<!-- +page.svelte -->
<ProjaxTimeline rows={data.rows} getHref={(row) => `/i/${row.item_path}`} />

The feed answers with a flat rows array, each row carrying its own day, day_label and sticky rather than a nested days structure — groupTimelineDays folds that back into the day-grouped shape the screen renders, trusting the feed's own guarantee that a page boundary never splits a day from its header. getHref is optional, same convention as ProjaxTree's: absent, the project name renders as plain text; given, it is a link.

A todo, event or doc row renders through its own kind-specific markup; a creation row reuses ProjaxItemCard, since it names nothing but an item. On a creation row the whole card is the link getHref returns, the shape ProjaxTreeRow already has inside TreeRow — the card itself takes no href prop, so what the tree and the board pass it is unchanged. The package rebuilds no mutation: timeline.tmpl's own complete/edit/delete forms are the host's concern, same division as ProjaxBoard's mover.

timelineQuery adds no f.status/f.archived default the way projaxQuery does for the tree — the feed already defaults to active, non-archived items itself. Its order argument ('asc' | 'desc', default 'desc') writes the wire's dir directly, since the feed reads dir off the raw query regardless of any sort field and defaults to desc itself, the opposite of encodeState's own asc baseline.

The detail screen

ProjaxDetail is one item's page (m/mkit#245, item 10 of m/projax#40's ordered work), over GET /api/items/{ref} (m/projax#53). ref is a uuid, a dot-path or a PER, and every section of the screen travels in that one response (d-b356cab7) — so unlike the tree and the timeline this screen takes one object, not a row array, and the host makes one call.

// +page.server.ts, or the equivalent in a host that is not SvelteKit
export async function load({ params, fetch }) {
	const detail = await fetch(`https://projax.msbls.de/api/items/${params.ref}`, {
		headers: { Authorization: `Bearer ${PROJAX_MCP_TOKEN}` }
	}).then((r) => r.json());
	return { detail };
}
<!-- +page.svelte -->
<ProjaxDetail
	detail={data.detail}
	path={data.detail.item.paths[0]}
	editHref="/i/{data.detail.item.paths[0]}?edit=1"
	historyHref="/i/{data.detail.item.paths[0]}/history"
	getItemHref={(p) => `/i/${p}`}
/>

The screen is the header and the four cards detail.tmpl renders below it: tasks, issues, documents, history. The tree, the timeline and the calendar are not detail sections — they are their own pages on their own feeds, and ProjaxTree and ProjaxTimeline already read them; a host that wants one beside this screen mounts it beside this screen.

A card renders on the page's own predicate rather than on emptiness: tasks when tasks.show (the project has somewhere a task can live), issues when Gitea is configured and a repo is linked, documents always, history only when history.available. A card the page shows with nothing in it renders its own empty state, and a task list refuses the empty claim when a shown source was unreachable — down says so and stale dates the list, both from tasks.health.

Every link is a host-supplied href, because this package holds no route table: editHref, historyHref, codesHref, getItemHref(path) for the item's other paths, and getPillHref(pill) for the ?sources= URL a task pill toggles to. An absent href renders the text without the link rather than a dead one.

Every write is the host's too, for the reason ProjaxBoard's mover already states: a mutation needs a PROJAX_MCP_TOKEN and this package holds none. detail.tmpl's add-task, complete, edit, delete, new-issue, close, comment and add/remove-document forms are therefore not rebuilt here; the host puts its own in the taskActions, issueActions and documentActions snippets, each rendered where the page puts its form.

content_html is rendered as it arrives. It is already safe: projax renders content_md with goldmark's default, which omits raw HTML rather than passing it through (web/markdown.go), so the string is the endpoint's own output and nothing else. The container carries mk-prose, so an app that mounts @mai/mkit's Markdown anywhere — which is what loads prose.css — gets the kit's prose styling here for free; an app that does not styles .projax-detail-content itself.

Each section is exported on its own — ProjaxDetailHeader, ProjaxDetailTasks, ProjaxDetailIssues, ProjaxDetailDocuments, ProjaxDetailHistory — for a host that wants one card without the rest, plus the five pure helpers the screen derives with: showSourceChip, showIssues, isHighlighted, isoDay and perExample. showSourceChip is the one rule the response does not carry: the page shows a row's source only with two or more sources on display (web/task.go).

Develop

bun install
bun run test:unit # src/*.test.ts
bun run build     # svelte-package into dist/
bun run check     # svelte-check

Dependencies

Development Dependencies

ID Version
@mai/mkit ^0.2.25
@sveltejs/package ^2.3.0
@sveltejs/vite-plugin-svelte ^6.2.4
@types/bun ^1.3.9
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
@mai/mkit ^0.2.12
svelte ^5.0.0
Details
npm
2026-09-27 15:20:13 +00:00
0
MIT
31 KiB
Assets (1)
Versions (27) View all
0.2.2 2026-09-30
0.2.1 2026-09-30
0.2.0 2026-09-30
0.1.23 2026-09-28
0.1.22 2026-09-28