@mai/projax-ui (0.2.2)
Installation
@mai:registry=https://mgit.msbls.de/api/packages/mAi/npm/npm install @mai/projax-ui@0.2.2"@mai/projax-ui": "0.2.2"About this package
projax-ui
Svelte 5 screens for projax — the tree, board, timeline, detail, calendar and graph 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 within-lane order
orderable gives the board m's own order inside a lane (m/mkit#294, m/projax#69 item 2). A same-lane drop and a Ctrl+ArrowUp/ArrowDown step both then carry where the card landed, and the intent names the two cards it sits between:
<ProjaxBoard rows={data.rows} groupBy={data.groupBy} {mover} orderable={data.sortMode === 'manual'} view={data.view} />
Whether a board holds a manual order is its saved view's, which the host reads; view is that view's slug, and projax refuses a position on a board in the computed order with a reason the screen shows on rollback. A page with no saved view behind it passes none and is always in the manual order.
The intent then carries four more fields, in projax's own names (web/board_move.go):
| field | what it names |
|---|---|
after |
the card above the drop — BoardMove.before |
before |
the card below it — BoardMove.after |
after_rank / before_rank |
the rank the board rendered that neighbour with, echoed back for a card projax has stored no order for |
The two sides are swapped against BoardMove, and projaxMovePosition is the one place that swap happens. A rank travels only for a neighbour the board carried one for; projax reads it only where it has no stored order of its own, and refuses naming the card when neither exists. Both after and before absent means the drop named no position at all, and projax writes the lane change alone.
Off — the default, and every board that stores no order — a same-lane drop is swallowed, no move names a slot, and the intent is unchanged.
The same two props are on ProjaxTaskBoard. The everyday feed is never ordered whatever the host passes (boardAcceptsOrder, m's d-d2d9fc12): it reads every project and every calendar, and both of projax's move doors refuse it.
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.
It draws two pictures over the same rows (m/mkit#279): the list, unchanged and the default, or @mai/mkit/chart's zoomable TimelineChart, hours to years, under view="chart". view is a controlled prop, not a toggle this component renders — the host keeps the choice and switches it, the same way a host owns taskListState's view for ProjaxTaskList. zone (an IANA name) threads to the chart's own ticks; pass the same zone the feed bins day/day_label on, or an hour tick and the list's own day header disagree near midnight. In the chart view a mark's tap resolves back to its row through the row's own id and reaches onMarkClick(row), so a host opens it the same way getHref already does for the list. stack passes through too: a day with several events in one lane draws them on sub-rows rather than over each other (m/mkit#326), and stack={false} is the shape before it.
// +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 chart view, hours to years, a host's own toggle owning `view` -->
<ProjaxTimeline rows={data.rows} view={chartMode ? 'chart' : 'list'} zone="Europe/Berlin" onMarkClick={(row) => goto(`/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 five cards detail.tmpl renders below it: tasks, issues, documents, links, 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 and links 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.
A task row takes two more, both handed the row: taskRowCheck for the complete/reopen control and taskRowActions for the rest (m/mkit#284). taskRowCheck replaces that row's read-only ☐/☑ glyph rather than sitting beside it, because that is where tasks_section.tmpl puts its own complete form and because two checkboxes on one row, one of them inert, is a render a reader clicks the wrong half of. taskRowActions renders after the chips. Both are absent by default, and a card the host hands neither renders exactly as before.
The row's write half is the package's, and taskWrite.ts carries it for this screen as well as for ProjaxTaskList:
{#snippet taskRowCheck(task)}
{#if detailTaskWriteOffered(task)}
<IconButton size="sm" label="{task.done ? 'Reopen' : 'Complete'} {task.title}" onclick={() => write(task, task.done ? 'reopen' : 'complete')}>
<Icon name={task.done ? 'square-check' : 'square'} />
</IconButton>
{:else}
<span aria-hidden="true">{task.done ? '☑' : '☐'}</span>
{/if}
{/snippet}
Every one of these slots renders on every row, so what goes in one is an IconButton with the kit's tooltip and never a text Button — the kit README's § Row actions is the rule, and its label names the row (Delete {task.title}) because a screen reader lists a page's controls without the rows beside them.
// The host's own POST /api/tasks call, and the optimistic section it paints in the meantime.
const outcome = await performDetailTaskWrite(tasks, task, 'complete', writer);
tasks = outcome.tasks; // reverted, with outcome.error set, when the door refused
detailTaskWriteOffered, detailTaskWriteIntent, detailTaskDraftOf, detailTaskEditFields, applyDetailTaskWrite and performDetailTaskWrite are the feed-row helpers' twins over a ProjaxDetailTask, and the reason they exist here rather than in each host is detailTaskWriteHandle: GET /api/items/{ref} publishes no write handle, and the field that looks like the door's source is not it. The detail feed fills every row's project_source with the SECTION's primary source (web/detail_feed.go hands NewTaskView the primary, where the tasks feed hands it the row's own source), so a native task in a CalDAV-primary project reports project_source: "caldav", and a body built from it is written through the wrong door. The handle is derived from the row's own source instead, which is what tasks_section.tmpl branches on.
The one difference from the feed row is the optimistic change: the section holds open and done as two lists, so a complete and a reopen move the row, and it lands at the END of the list it moves to — the order within a list is the server's, and a row it has not sorted yet belongs nowhere else until the host refetches.
The other three lists carry the same per-item slot (m/mkit#301), each handed the item it belongs to:
| snippet | handed | where it renders | what the page puts there |
|---|---|---|---|
issueRowActions |
one ProjaxIssue and its repo |
the end of that issue's row, open rows and the closed disclosure's rows alike | close, comment, reopen |
documentRowActions |
one ProjaxDocument |
the end of that document's row | remove |
taskPillActions |
one ProjaxSourcePill |
after that pill in the strip | the make-primary star, the CalDAV link-or-create control, the Paliad link form |
{#snippet issueRowActions(issue, repo)}
<IconButton
size="sm"
label="{issue.state === 'closed' ? 'Reopen' : 'Close'} {repo}#{issue.number}"
onclick={() => write(repo, issue.number, issue.state === 'closed' ? 'reopen' : 'close')}
>
<Icon name={issue.state === 'closed' ? 'rotate-left' : 'check'} />
</IconButton>
{/snippet}
issueRowActions reaches both issue lists and hands over the repo, because that is what every one of projax's three issue endpoints takes beside the number, and because the row itself is the <li> — a host cannot wrap it, so ProjaxIssueRow carries the slot as its own actions. Which of close, reopen and comment a row offers follows issue.state, the host's read, not this card's.
taskPillActions renders with no wrapper of its own, as the pill's sibling in the strip, so a pill the host offers nothing for costs no element and no gap. The pill itself always renders: a host's CalDAV or Paliad setup control therefore sits beside the greyed pill rather than becoming it, the one place this differs from tasks_section.tmpl, where the greyed pill is the disclosure's own <summary>. Letting the host replace a pill would hand it the pill's filter link, its ★ and its three greyed states to rebuild.
A list handed none of these renders exactly as before.
Every one of the five cards takes its own text from the host (m/mkit#318), so a screen can be rendered in another language without mounting the sections one by one: taskEmptyLabel, issueEmptyLabel, documentEmptyLabel and linkEmptyLabel reach that section's emptyLabel, and historyLabel is the history card's whole line, that card holding a link and no list. An absent prop keeps the section's English default. The two task health banners are not labels — down and stale are claims about the read, not about emptiness, and they replace the empty state rather than being replaced by it.
The documents card's empty state is emptyLabel and nothing else. The page's own second sentence names the PER a new artifact would take, which only a host that renders an add form can promise, so a host that wants to say it builds the label itself with the exported perExample(path, highlightDate). The stale task banner is short for the same reason: tasks_section.tmpl also promises that actions on a stale source are disabled, and every action on this screen is a host snippet.
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.
The links card is the whole of links (m/projax#68): every link the item carries, whatever its ref_type, in the shape MCP's list_links publishes. The cards above it each surface one kind — gitea-repo as issues, caldav-list as a task source, a dated link as a document — so a mai-project, note or url link reaches a reader only here. It is read-only, as the page's card is, and takes no actions snippet: adding and removing a dated link stays the documents card's job. A link of the url kind renders as an anchor into a new tab and every other kind as code, the same split ProjaxDetailDocuments makes. links is optional on the type, so a response captured before #68 renders the card empty rather than failing.
Each section is exported on its own — ProjaxDetailHeader, ProjaxDetailTasks, ProjaxDetailIssues, ProjaxDetailDocuments, ProjaxDetailLinks, 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 — the last of which no card renders any more and every host that wants the PER shape in its own empty label calls. 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. 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.
What says a card cannot move is the wire, not the feed name. A card carries move: {source, calendar_url} on the today feed and move: null on the everyday feed (m/projax#65, m's d-d2d9fc12: a drag never changes a due date on the everyday board, on any surface — the today board and the project page are where a due date moves). A card with no handle is never draggable, whatever feed it arrived on, boardCardMovable reads that one card and boardAcceptsMove the whole board. DataBoard's drag is board-wide, so one handle-less card takes the gesture off the board rather than offering a drag the wire cannot honour. The feed name stays as the second guard — POST /api/board/move refuses feed: "everyday" outright ("board move is not supported on the everyday feed"), which is what an everyday board served before m/projax#65 still needs.
boardCardKey is a card's identity inside the client: task.source, task.calendar_url and the card id, the fields every card carries on both feeds. It reads no move — an everyday card has none — and it never travels: POST /api/board/move takes card_id plus the handle's own fields. web/board_feed.go#boardFeedCardKey builds the same key off the handle, and the two agree except in one word: move.source is the project's resolved task source (native) where task.source is the row's own (mbrian). Every other source is the same word on both sides, and a CalDAV row's task.calendar_url is the value move.calendar_url carries, so the rename tells apart every pair of cards the Go key tells apart — including one VTODO UID sitting in two calendars.
The dashboard
ProjaxDashboard is the dashboard (m/mkit#267, item 13 of m/projax#40's ordered work), over GET /api/feeds/dashboard (m/projax#57), parity target dashboard.tmpl plus dashboard_section.tmpl and dashboard_tiles.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
export async function load({ url, fetch }) {
const scope = url.searchParams.get('scope') ?? 'current';
const dashboard = await fetch(`https://projax.msbls.de/api/feeds/dashboard?scope=${scope}`, {
headers: { Authorization: `Bearer ${PROJAX_MCP_TOKEN}` }
}).then((r) => r.json());
return { dashboard };
}
<!-- +page.svelte -->
<ProjaxDashboard
dashboard={data.dashboard}
getItemHref={(path) => `/i/${path}`}
getScopeHref={(scope) => `?scope=${scope}`}
refreshHref="?refresh=true"
pinner={pin}
/>
The read is one response and it is not paginated — every card is already capped by the build it comes from (d-b356cab7) — so the screen renders the whole of it at once: the tile grid, then open tasks, events, open issues and recent documents. The page splits the same payload across three tabs; a host that wants that mounts the sections it wants, each exported on its own: ProjaxDashboardTiles, ProjaxDashboardTasks, ProjaxDashboardEvents, ProjaxDashboardIssues, ProjaxDashboardDocuments and ProjaxDashboardStale.
A tile is ProjaxItemCard with the tile's own rollup — the six keys GET /api/items?rollup=true publishes (d-307ca38d), so the tree, the board and the dashboard show one item's numbers the same way — wrapped in the link getItemHref gives it. tileItem makes the card's item out of the tile's compact reference and deliberately leaves pinned off: on a tile the star is a control, not a state chip. The quiet projects sit behind their fold with the window and the stale count the server sent, and the split is the server's scope=current|all; nothing here decides which project is current.
The star is the one write on this screen and it is the host's, the division ProjaxBoard's mover draws:
const pin = async ({ ids, pinned }) => {
const r = await fetch('https://projax.msbls.de/api/items/pin', {
method: 'POST',
headers: { Authorization: `Bearer ${PROJAX_MCP_TOKEN}`, 'content-type': 'application/json' },
body: JSON.stringify({ ids, pinned })
});
if (!r.ok) throw new Error((await r.json()).error);
return (await r.json()).ids; // the ids the server says it wrote
};
The star paints at once and settles on that answer: an id the response leaves out was not written, so its star rolls back (performPin). A rejection is a refusal — its message shows above the grid and no star keeps its new state. Pinning moves a tile to the pinned-first slot, and into the current list, only on the next read: the order and the split are the server's, and the write empties its cache rather than answering with a rebuilt payload. Without a pinner each tile renders its state as a plain star rather than a button that cannot write.
Every label and every number is the server's — the bucket counts, due_rel and bucket, the day labels, updated_rel, the 14-day window, stale_rel. Nothing is recomputed against the browser's clock. The events card is rendered through ProjaxTimelineRow, the spine's own event markup, and so is the documents card through its doc markup: eventRowOf and documentRowOf adapt a card row to a spine row, so the package keeps one renderer per shape. The issues card shares ProjaxIssueRow with the detail screen's, adding the project each row came from.
An empty card follows the page's rule: it collapses to one line on an unfiltered read, where an empty card says nothing, and renders its own empty state under a filter, where "this filter matched nothing" is the answer. collapseEmpty overrides that for a host that always wants the cards. The stale set — the archiving candidates, mai-managed with a quiet repo and no open work — has no card on the page any more, its count having moved into the quiet fold's label; the feed still publishes the rows, so they get a fold of their own here rather than being dropped.
The page's own filter form is not rebuilt: it writes the page's query keys, while this read takes the DataView keys ProjaxFields validates, so a host builds the filter with projaxQuery as the tree and the board do. The chrome renders filter as the server echoes it, with clearFiltersHref and refreshHref as host-supplied links. The complete, edit and delete forms on a task row are the host's too, in the taskActions snippet.
The graph
ProjaxGraph is the item DAG (m/mkit#266, item 13 of m/projax#40's ordered work), over GET /api/feeds/graph (m/projax#59, narrowed by m/projax#64), parity target graph.tmpl plus graph_svg.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 { graphQuery, graphQueryString, GraphFields, loadAllRows, projaxQuery, ProjaxFields } from '@mai/projax-ui';
import { decodeState } from '@mai/mkit/dataview';
export async function load({ url, fetch }) {
const state = decodeState(url.search, GraphFields);
const headers = { Authorization: `Bearer ${PROJAX_MCP_TOKEN}` };
const query = graphQueryString(graphQuery(state, { isolate: url.searchParams.get('isolate') === '1' }));
const [graph, items] = await Promise.all([
fetch(`https://projax.msbls.de/api/feeds/graph?${query}`, { headers }).then((r) => r.json()),
loadAllRows((q) => fetch(`https://projax.msbls.de/api/items?${q}`, { headers }).then((r) => r.json()), projaxQuery(state, ProjaxFields))
]);
return { graph, items };
}
<!-- +page.svelte -->
<ProjaxGraph graph={data.graph} items={data.items} getHref={(node) => `/i/${node.path}`} />
The response carries no geometry — no position, no canvas, no node box, no edge endpoints (m's d-d511aec2) — so the DAG is laid out on the client, and the picture is the kit's own NetworkGraph from @mai/mkit/graph rather than markup of this package: the layout runs to convergence before the first paint, so the server, a test and the browser draw the same marks, and the arrowheads, the neighbourhood lighting, the viewport and the hidden list come with it. layout passes straight through, so a host that computes its own positions draws them with layout="given".
The layout is force-directed, not the layered, parent-above-child one the feed used to publish. That layout does not read at this scale: the live DAG's 68 nodes over 3 layers came back as a 6530 × 376 canvas, 17:1, which no box shows without scrolling both axes — the reason graph.tmpl carries a "fit to screen" button. An edge is directed, so the parent→child direction is an arrowhead rather than a row above a row.
Colour is the item's management class, which the feed does not publish either: pass the GET /api/items rows as items — the read the tree and the board already make — and mgmtClass derives the graph page's five classes from them (mai, self, external, mixed, unmanaged, in the legend's order). Without them every node draws unmanaged and the legend says so, as it counts the dimmed nodes. Each class is a custom property with the page's own colour as its fallback, so a host themes the five by setting --projax-graph-mai and its four siblings.
A node the filter does not match draws faded, and isolate=1 drops those nodes instead, which is why nothing is dimmed under it. Read the dim from matched and never from filter_active: the tree filter defaults the match to status=active while reporting no active filter, so an unfiltered read still carries non-matching nodes — 8 of the live 68 on 2026-09-27 — and the graph page dims every one of them. isDimmed is that rule, exported.
Three things the page draws that this screen does not, all of them consequences of a circle where the page has a box. The page's four-step status opacity is collapsed onto the kit's three node fills: a cancelled or archived item draws hollow, a non-match faded, and every other status solid, so on-hold, sleeping and completed are not shaded apart — filter on f.status to separate them. The tag pills and the ×N multi-parent badge have nowhere to sit, so the tags travel in the node's hover and screen-reader text instead, and the multi-parent case is already visible as a node with two arrowheads into it. And an edge between two dimmed nodes draws at full strength, as it does on the page.
graphNodes(graph, items) and graphEdges(graph) are the pre-projection, exported on their own: a host that wants the kit's component with its own viewport, physics or node mark mounts NetworkGraph directly on their output and skips this screen. That is the same rule as the kit's chart contract (d-269e403c) — the management→colour mapping runs once per data change, not per node per render inside a kit callback.
GraphFields is filter-only like CalendarFields, and for the same reason: the response is one whole DAG, so no field is sortable, groupable or displayable. graphQuery drops every key that pages, orders or shapes a row list — sort and cols are a 400 at this endpoint — and writes the two keys that are not dimensions itself: isolate=1, and show_archived in the true/false spelling the endpoint demands. GraphFields declares no kind: a graph has one row kind, an item.
ProjaxGraph downloads the picture as a standalone SVG file, graph.tmpl's own ?download=svg rebuilt where the picture now is (m/mkit#295). The control sits at the end of the head bar and carries it alone under head={false}, so a host with its own header keeps the download; download={false} drops it. The file is built on the click and never on a render, from the marks the screen already drew — the filter and the isolate state are in them, a non-matching node arriving faded and an isolated read arriving without it. The name is the label, slugged, with the feed's built_at date (graph-2026-09-27.svg), and filename overrides it.
A file the page's stylesheets never reach can resolve no var(), so the download draws in literal colours: the five management colours are GRAPH_MGMT_HEX, the same values GRAPH_MGMT_PALETTE falls back to, and the heading, the labels, the edges and the background come from GRAPH_SVG_THEME — tokens.css's light theme. A host exporting a dark picture passes its own values as downloadTheme. Styling is presentation attributes throughout, with no <style> block and no class, which keeps the file legible to an editor that parses no CSS.
graphSvg(nodes, edges, options) is that serialiser on its own, a string builder with no DOM and no fetch: a host that wants the file on the server, or under its own control, calls it on graphNodes/graphEdges output and writes the bytes where it likes, and downloadGraphSvg(svg, filename) is the browser's half. It redraws the picture from the same headless primitives NetworkGraph draws from — graphLayout, nodeRadius, edgeWidth, edgePath, paletteColor — which under settled converges to the marks already on the screen, the layout being arithmetic on a stopped simulation over a deterministic seed. ProjaxGraph.ssr.test.ts holds the two together by comparing the file's node centres and arrowheads against the rendered markup's. The one mode that cannot agree is live: a picture the browser is still moving exports at its converged positions.
No mutation is rebuilt, for the reason ProjaxBoard's mover states: graph.tmpl carries none, and a write needs a token this package does not hold.
The gantt
ProjaxGantt is the project plan (m/mkit#288), over GET /api/feeds/gantt (m/projax#79). 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 { ganttQuery, ganttQueryString, GanttFields } from '@mai/projax-ui';
import { decodeState } from '@mai/mkit/dataview';
export async function load({ url, fetch }) {
const state = decodeState(url.search, GanttFields);
const query = ganttQueryString(ganttQuery(state));
const gantt = await fetch(`https://projax.msbls.de/api/feeds/gantt?${query}`, {
headers: { Authorization: `Bearer ${PROJAX_MCP_TOKEN}` }
}).then((r) => r.json());
return { gantt };
}
<!-- +page.svelte -->
<ProjaxGantt gantt={data.gantt} onMarkClick={(row) => goto(row.href)} />
The picture is the kit's own TimelineChart from @mai/mkit/chart rather than markup of this package: the feed carries no geometry (m's d-d511aec2), so the axis, the lane rows, the undated zone, the today rule, the dependency arrows, the wheel, the drag, the pinch and the hidden table are all the kit's, and range/onRangeChange, zone, density, stack and labelWidth pass straight through. stack is forwarded and changes nothing here: ganttLanes gives every row a lane of its own (lane: row.id), so a Gantt lane holds one bar and the kit's sub-row packing (m/mkit#326) never has two to pack. Two bars that overlap in time sit in different lanes and already hide nothing. The prop is passed on so a host that reaches for the kit's own name gets the kit's behaviour rather than a prop this component drops. A chart of sixty lanes is a tall picture, not a paged one — the feed answers with one whole chart, because a page boundary cuts an arrow off its far end.
One lane per row, label = title, in the feed's own order: project by project in primary-path order, then that project's tasks in m's board order. Every lane is declared, so an undated row keeps its place in that order instead of being appended. A row is never dropped: both ends draw a span, one end a point at that end, and neither puts the mark in the chart's undated zone, beside the axis.
Three channels carry what the bar's position cannot. One colour is one project — TimelineLane has no group of its own, so ganttColors numbers the projects in the feed's row order and the kit's palette cycles past the sixth. An item row draws a diamond and a task a dot, so a project reads apart from its own work where the two share a colour. And the fill is one meaning at a time under a stated precedence: a planned span is hatched, a finished row faded, the rest solid, with planned winning — planned is projax's own date for a row whose source cannot hold one (m/projax#80), which is the one fact a reader cannot recover from where the bar sits. The row's own date still travels in task, so a planned bar's hover and screen-reader text reads Hang the doors · Garden · planned · source says 2026-03-15.
The arrows come from the feed's own edges, already paired in TimelineEdge's direction — from finishes first, to waits. They are the same relation dependencyEdges(rows) from @mai/mkit/chart builds off depends_on, and that function works on these rows unchanged, because a row's id is a TimelineMark.id. The difference is what the feed has already removed: the dependencies whose other end this build left out, counted in edges_unresolved. The screen prints that count in the head line — 1 dependency outside this view — rather than dropping an arrow in silence.
A bar is a control only under onMarkClick, which is handed the feed's own row; the row's href is the host's to open, and this package builds no route. Every string is a key of labels with an English default, merged by ganttLabels, so a German host translates the count line, the legend, the two banners and the planned/done words in a bar's text without defining lanes or colours. {n}, {as} and {date} are filled by the exported fillLabel.
ganttLanes, ganttMarks, ganttEdges, ganttFill, ganttMarkText, ganttColors and ganttPlanned are the whole mapping, exported on their own: a host that wants TimelineChart with its own chrome mounts it on their output and skips this screen. Health is the board feed's own: a stale read carries a banner naming the moment it was built, and a chart with no row refuses the empty claim when health.down says every source in scope was unreachable — "no rows" would otherwise be a factual claim about m's plan.
GanttFields is filter-only like GraphFields, and for the same reason: the response is one whole chart, so no field is sortable, groupable or displayable. The six item dimensions carry GraphFields' own URL keys, so one client speaks one language across the feeds; kind and state are the gantt's own and narrow the rows rather than the projects the chart is built from. ganttQuery drops every key that pages, orders or shapes a row list, and writes project_descendants and show_archived only when given, in the true/false spelling the endpoint demands.
No mutation is offered: a write needs a token this package does not hold, and a Gantt never writes.
The task list
ProjaxTaskList (m/mkit#269, item 13 of m/projax#40's ordered work) is every task across the four sources as one DataView list over GET /api/feeds/tasks, with the write door of POST /api/tasks behind each row's own controls (m/projax#56). TaskFields is the fourth field registry here, beside ProjaxFields, TimelineFields and CalendarFields.
// +page.server.ts
import { taskQuery, taskQueryString, TaskFields } from '@mai/projax-ui';
import { decodeState } from '@mai/mkit/dataview';
export async function load({ url, fetch }) {
const viewState = decodeState(url.search, TaskFields);
const query = taskQueryString(taskQuery(viewState, { scope: 'projects' }));
const response = await fetch(`https://projax.msbls.de/api/feeds/tasks?${query}`, {
headers: { Authorization: `Bearer ${PROJAX_MCP_TOKEN}` }
}).then((r) => r.json());
return { response, viewState };
}
<!-- +page.svelte -->
<ProjaxTaskList
response={data.response}
viewState={data.viewState}
onStateChange={(next) => goto(`?${taskQueryString(taskQuery(next))}`)}
getHref={(row) => (row.project_path ? `/i/${row.project_path}` : undefined)}
/>
The read arrives already searched, filtered, sorted and paged, so the kit runs remote: its reducers are off and the rows render as served. Grouping is the one step it still runs, over the page, the way groupRows works in every view. The DataView state arrives as viewState and not as state, because a prop called state makes the $state rune illegal in the same component (store_rune_conflict) and the optimistic rows need it.
taskQuery adds no filter of its own. dataviewTreeFilter already defaults the project scope to active items server-side, and a client-side default could restrict the field the state groups by — the defect m/mkit#263 fixed in projaxQuery, where f.status=active left four of five status lanes unfillable. It does add count=exact, which the kit codec writes for no state and a paged list needs for its pager. Nothing is filtered on the row dimensions either: a done row travels unless the caller asks otherwise, because a list does not get to decide for its caller what a board decides for itself.
scope=calendars is the only reader that reaches a list no project links — m's Plan and Privat (d-0c766f69). It starts from the calendars, so it cannot honour an item filter and refuses one by name rather than answering a narrowed request with a wider list. taskScopeConflict(query) names the offending key before the fetch:
const query = taskQuery(viewState, { scope: 'calendars' });
const refused = taskScopeConflict(query); // 'f.path', 'q', 'show_archived', … or undefined
Writing
<ProjaxTaskList
response={data.response}
viewState={data.viewState}
writer={(intent) =>
fetch('/api/tasks', { method: 'POST', body: JSON.stringify(intent) }).then(async (r) => ({
ok: r.ok,
...(await r.json())
}))}
onWritten={() => invalidateAll()}
addTarget={{ item: 'home.mhome', source: 'caldav', calendar_url: LIST_URL }}
/>
writer is the host's own POST /api/tasks call, the same division as ProjaxTaskBoard's mover: this package holds no PROJAX_MCP_TOKEN and does no fetch, so it builds the body and the host posts it. Serialise an intent with plain JSON.stringify and never fill an absent key — {} and { "due": "" } are two different requests, since a key present with an empty value clears it and an absent key leaves it alone. taskEditFields(row, draft) is what the editor sends, and it sends only what the reader changed, which is what makes both halves of that rule reachable.
Each write is applied optimistically and rolled back to the rows it started from on a refusal, with the server's own {"error"} text shown as is. A create is not optimistic: the id is the server's to mint. onWritten is where the host refetches — the optimistic row is a bridge, and status_key, due_bucket and health are only right again after the read. addTarget names where a new task goes, because a list spanning sixty projects cannot pick one.
A row offers a control only where the door would accept one (taskWriteOffered): no Gitea row and no paliad row, since projax reads both and never writes them back; nothing while a source is down or stale, since a done-flip computed against state projax could not read is how a lost update happens; and nothing on a task from a calendar no projax item links, which scope=calendars reaches and the door refuses 403. There is no priority control, because the door has no priority field.
Note three divergences from projax's own surfaces, all raised in m/mkit#269 for projax to decide. There is no task list page to compare against — the closest are the dashboard's Open tasks card, which is CalDAV-only, and tasks_section.tmpl, which is one project's — so this screen is the union of what those two do, over all four sources. The read reports a row from an unlinked calendar as writable: true while every write on it is refused, so the flag is not the whole test. And q is refused under scope=calendars although on this read q searches the rows rather than the projects.
An empty list and an unreadable one are different claims about m's day, and the screen keeps them apart the way the boards do: health.down says every source in scope was unreachable, health.stale dates the rows and turns every write off, health.errors joins into one banner, and only a clean read that matched nothing says so.
The admin index, classify and codes screens
ProjaxAdminIndex, ProjaxAdminClassify and ProjaxAdminCodes draw three of projax's five admin screens over one read each — GET /api/admin, GET /api/admin/classify and GET /api/admin/codes (m/projax#61). Same division as every other screen: the host fetches, the package renders, and every write is a function the host supplies.
<script>
import { ProjaxAdminIndex, ProjaxAdminClassify, ProjaxAdminCodes } from '@mai/projax-ui';
</script>
<ProjaxAdminIndex {index} getCardHref={(card) => `#${card.href}`} />
<ProjaxAdminClassify {classify} {reparenter} getItemHref={(path) => `#/${path}`} />
<ProjaxAdminCodes {codes} {codeSetter} getItemHref={(path) => `#/${path}`} />
The index is read-only: the screen cards and the system panel, every count and probe the server's. A card whose count could not be read carries -1, and cardCount prints ? for it — the count label already says the probe failed, and a printed -1 reads as a count. A disabled card renders its disabled_msg instead of a count and carries no link. getCardHref maps projax's own page route (/admin/classify) onto the host's; the default follows the served route.
Classify lists the mai-managed items that landed at root, each with the parent picker that re-homes it. The row is ProjaxItemCard, so the status, tags and management the page renders as its own columns come from the shared card; source_ref_id is the one field the card does not draw, and the row prints it as the mai id. The picker renders parent_options in the order served, which is sorted by path.
Move is an IconButton and not a word, because it repeats on every row (the kit README's § Row actions), and its label names the orphan it moves. It paints nothing before the answer:
const reparenter = async ({ item, parent_ids }) => {
const r = await fetch('https://projax.msbls.de/api/items/reparent', {
method: 'POST',
headers: { Authorization: `Bearer ${PROJAX_MCP_TOKEN}`, 'content-type': 'application/json' },
body: JSON.stringify({ item, parent_ids })
});
if (!r.ok) throw new Error((await r.json()).error);
return (await r.json()).item; // the moved item, under its new primary path
};
POST /api/items/reparent answers the item it wrote, and the primary path changes with the parent, so the new state cannot be drawn from the intent. The row waits, then leaves the list — it now has a parent, which is what an orphan is not — and the answered item's own path is what the moved line names (performReparent). A refusal keeps every row and shows the server's reason on the one that asked. Without a reparenter the orphans render without a picker.
Codes is the register from projax issue #17: one reference code per project, assigned / total, and the code each row carries. A code cannot be cleared — projaxdata.ValidateCodeFormat requires 2 to 20 characters — so there is no clear control, and codeRefusal answers a badly formed code in the server's own copy before any round trip. It is the server's rule and not the page's: the page's <input pattern> accepts a leading digit the server refuses. Save is an IconButton beside the field, the same reason Move is (the kit README's § Row actions), and its label names the row's own item.
const codeSetter = async ({ item, code }) => {
const r = await fetch('https://projax.msbls.de/api/items/code', {
method: 'POST',
headers: { Authorization: `Bearer ${PROJAX_MCP_TOKEN}`, 'content-type': 'application/json' },
body: JSON.stringify({ item, code })
});
if (!r.ok) throw new Error((await r.json()).error);
return (await r.json()).item; // the row, carrying the code the server stored
};
The save paints at once and settles on the answered item's code, which is the stored one: the server lower-cases and trims, then re-reads the row. A refusal rolls the stored code back and leaves the typed text in the input, so a rejected code can be corrected. writable: false is a backend with no code writer at all: the screen then says so once and renders no input and no Save, where the page renders inputs that cannot save.
The admin screens: bulk edit, the artefact register, the CalDAV links
ProjaxBulk, ProjaxArtefacts and ProjaxCalDAVLinks are three of the five admin screens (m/mkit#271, item 13 of m/projax#40's ordered work), over GET /api/admin/bulk, GET /api/admin/artefacts and GET /api/admin/caldav (m/projax#61). Parity targets bulk_section.tmpl, artefacts.tmpl and caldav_admin.tmpl. Same division as every other screen: the host passes the response it already fetched, and this package holds no PROJAX_MCP_TOKEN and does no fetch of its own.
Every admin read publishes an item as wire.ItemView, where the parents sit under paths — not as the registry-keyed row GET /api/items answers with, where they sit under path. itemViewCard projects the one onto the other, so ProjaxItemCard stays the single card renderer and learns no second key set; ProjaxItemView is that wire shape's name here.
Bulk edit
// +page.server.ts
import { bulkQuery, bulkQueryString, BulkFields } from '@mai/projax-ui';
import { decodeState } from '@mai/mkit/dataview';
export async function load({ url, fetch }) {
const state = decodeState(url.search, BulkFields);
const showArchived = url.searchParams.get('show_archived') === 'true';
const query = bulkQueryString(bulkQuery(state, BulkFields, undefined, showArchived));
const bulk = await fetch(`https://projax.msbls.de/api/admin/bulk?${query}`, {
headers: { Authorization: `Bearer ${PROJAX_MCP_TOKEN}` }
}).then((r) => r.json());
return { bulk, state };
}
<ProjaxBulk bulk={data.bulk} applier={applyBulk} getItemHref={(path) => `/i/${path}`} onReload={() => invalidate()} bind:selectedIds />
bulkQuery writes no status default, unlike projaxQuery. The page clears its own active default because bulk edit is for editing, and m wants the completed and cancelled rows; the read copies that, so a client-side default would ask for a narrower list than the page it stands in for (m/mkit#263). show_archived travels as its own boolean, and only when it is on. page, size and view are dropped — the read answers the whole matched list and pages nothing — and so are sort and dir, which this read refuses with a 400.
BulkFields is the filter registry, the fifth beside ProjaxFields, TimelineFields, CalendarFields and the board's, and it is filter-only: the read answers the screen's own order, so nothing here is sortable, groupable or displayable. Each dimension declares exactly the modes bulkMatches honours — status any, tags all, management any, links all, path any. That is what keeps an undeclared spelling off the wire: the server ignores f.tags.any rather than refusing it, so a query that reads as a filter would come back as the unfiltered list. Decode the URL against BulkFields, and render the filter chips against bulkFields(bulk.all_tags), which fills the tag vocabulary from the response — a registry that declares options rejects a value outside them, and a host decodes before it has a response to read.
The filter bar itself is the host's, as it is for the tree and the board. The screen renders the counts line, the action bar, the select-all and the rows, over DataList with selectable and bind:selectedIds — the same selection shape a host's own DataView binds.
const applyBulk = async (intent) => {
const r = await fetch('https://projax.msbls.de/api/items/bulk', {
method: 'POST',
headers: { Authorization: `Bearer ${PROJAX_MCP_TOKEN}`, 'content-type': 'application/json' },
body: JSON.stringify(intent)
});
if (!r.ok) throw new Error((await r.json()).error);
};
One call carries the whole selection (d-27a5c657) and the door answers 204. It is not one transaction: applyBulk is a per-id read-modify-write loop, so a refusal may have written the rows before the one that failed. So nothing is painted before the answer, and a refusal shows the server's reason with the ask to re-read — onReload, which the host wires to its own invalidation. On success the rows are painted from applyBulkIntent, which runs the dispatch applyBulk runs: the public switch first, then the two tag fields, management (clear empties it, a value replaces the whole set), status, and the timeline toggle. bulkActionLabel names the one action that will run, because the action bar offers six and only one ever does; bulkRefusal holds Apply back on the three grounds the door refuses on, in the door's own copy.
The artefact register
<ProjaxArtefacts
artefacts={data.artefacts}
onLookup={(ref) => goto(`?ref=${encodeURIComponent(ref)}`)}
getRefHref={(ref) => `?ref=${encodeURIComponent(ref)}`}
/>
A read with no write door: the rows, and the one full row an exact ?ref= resolves to. The served redaction is rendered as it is. A row's redacted flag is the only thing that decides whether its content shows — never its confidentiality, because an exact lookup serves that one reference unredacted while its confidentiality still reads client-confidential, and a screen keying on the confidentiality would hide the row the reader asked for. A redacted row carries its reference, its date, its confidentiality and its project and nothing else, so nothing below those is rendered for it at all. artefactPointer is the kind a register row prints; artefactPointerDetail is the detail table's line, where an unresolved pointer names why it points at nothing.
The CalDAV links
<ProjaxCalDAVLinks caldav={data.caldav} linker={writeLink} getItemHref={(path) => `/i/${path}`} />
Every discoverable calendar paired with an item: link is what tells a fact from a suggestion — with a link, item is what the calendar is linked to; without one, it is the title-or-slug heuristic's best match, or nothing. The picker starts empty. The read publishes the suggested item per row, so pre-selecting it would be possible, and it would let one click link a calendar to an item nobody picked; the suggestion renders as a suggestion and the reader picks. The suggestions and the picker are both rendered in the order served — this picker is in the reader's own order while the classify screen's is sorted by path, and each read publishes the order its own page shows. Link and unlink are IconButtons in the row's action cell, each naming its own calendar (the kit README's § Row actions).
const writeLink = async (intent) => {
const r = await fetch('https://projax.msbls.de/api/caldav/links', {
method: 'POST',
headers: { Authorization: `Bearer ${PROJAX_MCP_TOKEN}`, 'content-type': 'application/json' },
body: JSON.stringify(intent)
});
if (!r.ok) throw new Error((await r.json()).error);
return intent.op === 'link' ? (await r.json()).link : null; // unlink answers 204
};
One door for both operations, dispatching on op — the token grant is keyed on the whole path, so a link id in the path could not be granted at all. A link's answer is the ProjaxLink the door wrote, carrying the id every later unlink sends, so the row settles on the answer rather than on a guess. An unlink drops the row's item with its link: a linked row's item is the link's target and not a suggestion, and what the heuristic would say about a calendar that is suddenly free is the next read's to answer. A refusal leaves every row as it was served and shows the reason as it came. Without a linker every row renders read-only. configured: false renders what to set instead of an empty table, since an empty list alone cannot say why it is empty.
The saved views
<ProjaxViewSwitcher views={data.views} current={page.params.slug} getHref={(slug) => `/views/${slug}`} />
GET /api/views's whole body: the saved views in sort_order, the five code-resident system views in their own group, and the landing choice marked — most_recent, the slug GET /views redirects to, or the first saved view when nothing has been opened yet. viewGroups is that model on its own, for a host building its own chrome. A saved view with no getHref renders as a button and hands its slug to onOpen; a system view falls back to the url the read serves for it.
The kit's own saved-views row is not this switcher (m/mkit#259, the pick recorded on m/mkit#272). That row lists one flat set of {id, name, state} inside one DataView's toolbar, and a projax view names the screen it renders on, so applying one can mean leaving the DataView the row sits in; it also carries no icon per view, no second group, and no entry that is a link rather than a state to apply. For the case it does fit — a host mounting one DataView over items — kitSavedViews(views, opts) is the bridge: the slug as the id, and the view's state as the query string that row compares.
A view's spec onto a screen
const render = viewRender(view);
const { screen, state, groupBy } = render;
const rows = await loadAllRows(load, viewQuery(render));
viewRender is the mapping, and its parity target is /views/{slug} — not the read's keys taken literally. The read publishes what is stored; the page narrows that further before it renders, and this does the same, in the same order:
| The view | Screen | Component |
|---|---|---|
feed: everyday |
everyday |
ProjaxTaskBoard |
feed: tasks |
tasks |
ProjaxTaskBoard |
view_type: kanban |
kanban |
ProjaxBoard |
view_type: card |
card |
DataView in its cards view |
| anything else | tree |
ProjaxTree |
The feed is read first, as the page reads it: a tasks feed renders as one board whatever its stored view_type says. An items feed resolves its view_type against the tree's own catalog, which holds list, card and kanban and nothing else — so a view stored as calendar or timeline renders as the list, here as on the page, and so does an empty or unknown one.
groupBy is the lane dimension: an items board resolves an empty or unknown group_by to status, and a task board leaves it unset so the feed resolves its own per project scope. state.group names the grouped registry field only on the kanban screen, where lanes are actually drawn.
The view's own filter decides. Every key it carries is written into state.filters in the mode projax matches with — tags AND, management ANY (with the synthetic unmanaged), has_links AND, status ANY, project_path as a path filter that admits the subtree, or the one path alone where the view says so — so projaxQuery's tree defaults are never layered on top of it (m/mkit#263). show_archived is written both ways, as archived=false or as both values, because an absent key cannot mean "explicitly cleared".
One filter is dropped, and only there: a kanban grouped by status whose status is the wire default ['active'] leaves the status filter unset, so every lane holds its cards. That is widenStatusForBoard (m/projax#62) — with one difference the wire forces. The page distinguishes that default from a reader who picked exactly active and keeps the narrow filter for the second; the read publishes no such flag, so the two are one case here and both widen.
Load a view's rows with viewQuery(render), not with projaxQuery(render.state, ProjaxFields). One dimension cannot travel in the state: a view that scopes to a project without its children (include_descendants: false) narrows its path filter through project_descendants=false (m/projax#67), a modifier of the path dimension rather than a dimension of its own, and DataViewState has no slot for one. viewQuery writes it; render.descendants is the boolean for a host building its own query, and projaxQuery's fifth argument takes it. A view with descendants on, or with no project scope at all, writes no key, so a clean URL stays clean. The same key travels on the board feed, whose query the host builds — every projax feed folds its request through the one dataviewTreeFilter.
unmapped names what could not travel. It is empty for every view: include_descendants was the whole list, and the key above carries it, so a screen now loads what the view says. The door stays for the next dimension a view can store and GET /api/items cannot read.
state.sort carries sort_field when the registry declares it sortable, by its own key or by its URL key, and dir follows sort_dir. projax's own render reads neither column — the editor stores them and renderViewPage never looks — so a view carrying a sort renders here in an order the page does not apply.
Writing a view
<ProjaxViewSwitcher views={data.views} viewWriter={writeView} />
Without a viewWriter there is no create, rename, delete or reorder control at all. That is the case over PROJAX_MCP_TOKEN: GET /api/views and the /api/views/ prefix are GET-only token grants and POST /api/views is session-only, so a host reading projax over the token (mai, mBrian) reads saved views and changes none.
const writeView = async (intent) => {
const r = await fetch('/api/views', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(intent), credentials: 'include' });
if (!r.ok) throw new Error((await r.json()).error);
return r.json();
};
One door, dispatching on action. An update merges per key — a key sent is written, a key omitted keeps its stored value — so viewUpdateIntent(view, { name }) sends a rename and nothing else. A sent filter replaces the filter whole, because JSON null and an absent key decode the same on the wire and per-key merging would leave public no way back to "either"; viewUpdateIntent sends it whole as soon as any of its keys differ, and not at all when none does. A reorder sends every slug first to last, and the position in that list is the sort_order stored.
performViewWrite settles the list on what the door answers and paints nothing before it: a create's sort_order is the server's to assign and an update re-encodes the filter through projax's own encoder, so an optimistic row would invent both and then replace them. A refusal leaves the list as it was and carries the reason verbatim — a reserved slug, a slug format, a taken slug, an unknown view_type, feed, icon or sort_dir.
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.59 |
| @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.59 |
| svelte | ^5.0.0 |