• Joined on 2026-01-29

@mai/projax-ui (0.1.9)

Published 2026-09-27 16:31:49 +00:00 by mAi

Installation

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

About this package

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

projax-ui

Svelte 5 screens for projax — the tree, board, 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. 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.

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
Details
npm
2026-09-27 16:31:49 +00:00
1
MIT
59 KiB
Assets (1)
Versions (27) View all
0.2.2 2026-09-30
0.2.1 2026-09-30
0.2.0 2026-09-30
0.1.23 2026-09-28
0.1.22 2026-09-28