@mai/projax-ui (0.1.7)
Installation
@mai:registry=https://mgit.msbls.de/api/packages/mAi/npm/npm install @mai/projax-ui@0.1.7"@mai/projax-ui": "0.1.7"About this package
projax-ui
Svelte 5 screens for projax — the tree, board, timeline, detail and calendar 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) was 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, ProjaxTaskBoard, ProjaxCalendarMonth, 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={data.groupBy} {mover} />
The board's grouping travels in state.group — the group= key the URL already carries — and the load reads the prop off the same state, never a literal beside it:
// +page.server.ts
const state = decodeState(url.search, ProjaxFields);
state.group ??= 'status'; // the board's own default lane set, the tree's is ungrouped
const rows = await loadAllRows(loader, projaxQuery(state, ProjaxFields));
return { rows, groupBy: state.group as ProjaxBoardGroupBy };
That is what keeps the lanes loadable: projaxQuery's f.status=active default drops as soon as state.group is status, since a board restricted to one status renders its other four lanes empty and loses a card the moment it is moved into one of them (m/mkit#263). A board grouped by area, tag or management keeps the default, and an explicit state.filters.status wins either way. Archived stays hidden unless the state says otherwise ({mode: 'any', values: ['true', 'false']}). A host that leaves state.group unset and passes groupBy="status" to the component alone gets the restricted load back — the query builder sees the state, not the prop.
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).
The calendar
ProjaxCalendarMonth is the month grid (m/mkit#264, item 13 of m/projax#40's ordered work), over GET /api/feeds/calendar?month=YYYY-MM (m/projax#58), parity target calendar.tmpl plus calendar_section.tmpl. Same division as the other screens: the host passes the response already fetched, and this package 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 { calendarQuery, calendarQueryString, CalendarFields } from '@mai/projax-ui';
import { decodeState } from '@mai/mkit/dataview';
export async function load({ url, fetch }) {
const state = decodeState(url.search, CalendarFields);
const query = calendarQueryString(calendarQuery(state, url.searchParams.get('month') ?? undefined));
const month = await fetch(`https://projax.msbls.de/api/feeds/calendar?${query}`, {
headers: { Authorization: `Bearer ${PROJAX_MCP_TOKEN}` }
}).then((r) => r.json());
return { month };
}
<!-- +page.svelte -->
<ProjaxCalendarMonth
month={data.month}
getHref={(row) => `/i/${row.item_path}`}
getDayHref={(day) => `/timeline?from=${day}&to=${day}`}
getMonthHref={(m) => `/calendar?month=${m}`}
/>
The response is the grid, so the component renders it as it arrives and lays nothing out again: the weeks, the seven cells in each, the adjacent-month and today flags, each cell's rows already trimmed to max_rows_per_cell, and the overflow count. Read the overflow from extra_count and total_rows, never from rows.length — a day's full list comes from the timeline feed, which is what getDayHref(day) points the +N more link at.
The kit's CalendarMonth is not the grid underneath, and cannot be: it computes its own 42 cells from month and weekStart and always draws six rows, while this feed's grid is four, five or six weeks wide — 2026-09 is five — so mounting the server's cells in it would either drop a week or invent one the feed never sent. Its is_today would also come from the browser's clock rather than the server's zone, which the response answers for in today. The kit's date arithmetic is reused (weekdayLabels draws the Monday-first header); only its component is not. The week start is not a prop for the same reason: the feed's grid always starts on a Monday (mondayWeekday in web/calendar.go), so a host that could set it could only mislabel the server's own columns.
A cell is one seventh of the grid wide, so a marker stays compact for every kind — the time chip, the kind as a colour and data-kind, and summary as the link, which is what calendar_section.tmpl renders for a todo, an event and a dated document alike. A cell row is nevertheless a timeline row on the wire, the same keys and id scheme as GET /api/feeds/timeline, minus the day_label and sticky the spine needs for its day headers: timelineRowOf fills those two in empty so a host that wants the spine's own per-kind markup in a cell can pass it through the row snippet.
<ProjaxCalendarMonth month={data.month}>
{#snippet row(cellRow)}
<ProjaxTimelineRow row={timelineRowOf(cellRow)} getHref={(r) => `/i/${r.item_path}`} />
{/snippet}
</ProjaxCalendarMonth>
Month navigation is the host's. getMonthHref(month) renders prev, today and next as links, the way calendar.tmpl does; onMonth(month) renders them as buttons for a host that navigates in place; neither renders them as plain text rather than a dead link, and nav={false} drops the header for a host with its own. ProjaxCalendarRow is exported on its own for a host that lays the grid out itself.
CalendarFields is the only filter-only registry of the three: the response is a laid-out grid and not a row list, so no field is sortable, groupable or displayable, and calendarQuery drops any sort and dir the codec emits. Its kind declares the calendar's three kinds, not the timeline's four — a month grid holds no creation marker. The month is not a field: it travels as month=YYYY-MM, and omitted, the server answers with the month it is in. The cached/fresh pill calendar_section.tmpl shows is the page's own and not on the wire, so this screen renders the row count alone.
No mutation is rebuilt, for the reason ProjaxBoard's mover states: calendar_section.tmpl carries none, and a write needs a token this package does not hold.
The everyday and today boards
ProjaxTaskBoard is both of them (m/mkit#265, item 13 of m/projax#40's ordered work), over GET /api/feeds/board?feed=everyday|tasks (m/projax#55). One component, not two: the endpoint answers the same struct from the same builder for each feed, and projax's own everyday page renders its task half with the unmodified task-board partial. The feed is read off response.feed, so the screen cannot be told it is one board while showing the other.
// +page.server.ts, or the equivalent in a host that is not SvelteKit
export async function load({ fetch }) {
const response = await fetch('https://projax.msbls.de/api/feeds/board?feed=everyday', {
headers: { Authorization: `Bearer ${PROJAX_MCP_TOKEN}` }
}).then((r) => r.json());
return { response };
}
<!-- +page.svelte -->
<ProjaxTaskBoard response={data.response} refreshHref="/everyday?refresh=1" />
The board arrives already grouped, already ordered and already capped, and nothing here regroups it: the columns, their order and their pre-cap totals are the server's, so this screen and the page cannot disagree about what the board holds. A column whose total is above the cards it carries was capped, and its header says 12 of 15 rather than pretending it is everything.
projects is the wire's own discriminator between the two boards. The everyday feed carries the current-project strip and the tasks feed carries null, so an absent strip never reads as "no current projects"; the updated/cached line and the ↻ refresh link belong to the everyday payload cache and render with the strip.
An empty board and an unreadable one are different claims about m's day, and the screen keeps them apart: health.down says every source in scope was unreachable, health.stale dates the board, health.errors joins into one banner, and the genuinely empty board names where projax looked. The project strip has its own narrower health, because the repo read going down says nothing about the calendars.
<ProjaxTaskBoard
response={data.response}
mover={(intent) => fetch('/api/board/move', { method: 'POST', body: JSON.stringify(intent) }).then((r) => ({ ok: r.ok }))}
groupByHref={(groupBy) => `/today?group_by=${groupBy}`}
/>
mover is the host-supplied move, same division as ProjaxBoard's: an optimistic lane change, with a rollback to the board it started from on a refusal and the server's own {"error"} text shown as is. Only the today feed is draggable. POST /api/board/move refuses feed: "everyday" outright ("board move is not supported on the everyday feed"), so the everyday cards offer no gesture at all — one that always fails is worse than none. Without a mover neither board drags. groupByHref is optional on the same convention as every other href here: absent, the chip row is not rendered at all.
Note the divergence from projax's own everyday page, which is draggable: it posts its drops under feed: "tasks" (web/everyday.go sets data["Feed"] = BoardFeedTasks) while the API names the feed honestly, and the move engine dispatches on that name. Whether the API should accept an everyday move is projax's call, not this package's.
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.28 |
| @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.28 |
| svelte | ^5.0.0 |