Datler does not hand-roll a UI primitive. Its buttons, inputs, fields, dialogs, popovers, tables, cards, toasts, the error boundary and the theme runtime come from the shared library that every Jeliroco project uses, published from the jeliroco-components repository as four packages on GitHub Packages:
| package | what it gives Datler |
|---|---|
@jeliroco-multimedia/blunt-ui | the rendering half: parts (Button, Dialog, Popover, Sheet, Tooltip, Card, Checkbox, NativeSelect, SegmentedControl, ScrollArea, ItemTitle, Empty, KbdHints, TagsField, ColorPicker, Tree and TreeItem…), assemblies (Field, PageHeader, ConfirmProvider, ToastProvider, ShortcutProvider, CrashSurface, IssueList and IssueTrigger, useDragAndDrop, useListboxKeys…), data-table (DataTable, DataCards, DataPicker, DataToolbar), filters, and markdown (Markdown, MarkdownLink, which need react-markdown and remark-gfm installed) |
@jeliroco-multimedia/blunt-tokens | the token contract every component reads, its default values and theme axes, and the runtime that applies and persists a theme |
@jeliroco-multimedia/blunt-mechanisms | the logic half: no React, no DOM, one subpath per module |
@jeliroco-multimedia/blunt-content | markdown dialects as remark plugins, with no React: wikilinks, which turns a term article’s [[links]] into the drawer’s links between terms |
Where Datler uses the newer ones: the sidebar is a Tree (components/layout/SidebarTree.tsx), a row keyed by its path through the tags so a trackable under two tags is two rows; every issue list and count is IssueList and IssueTrigger (Issues); a term’s article is Markdown with wikilinks (UI Vocabulary and the Info Affordance).
The default is to centralise: a fix to a shared component reaches every project, so “Datler’s version already works” is not a reason to keep one. Whether a component fits a case is the library’s call. When one cannot do the job, the case goes upstream as a report and Datler adopts the fix when it ships, rather than working around it locally. Nothing is vendored. If a copy is ever needed until the library ships a component, it goes in apps/renderer/src/components/ui/ with its reason and its library issue at its top, and goes when theirs ships.
What stays Datler’s
- Charts. The charts are Datler’s own, drawn with visx (
components/charts/,components/common/charts.tsx), and read the library’s--chart-1…8series colours. The library’schartssubpath (ChartArea, ChartTooltip, ChartGrid, the axis styles, Sparkline, chartColor) was extracted from them and is not adopted, by the owner’s decision of 2026-10-15: while Datler would be its only user, every chart change would go through the library, so the charts are built out here first and offered to the library once they are right. - The quick-add palette.
QuickAddPalettehas two modes on one bar and expands the chosen row into its entry editor in place, with Enter bound on the panel, so it is not the library’s generic command palette. Its listbox keys come from the library’suseListboxKeys. - Compositions of Datler’s concepts: the
Termcomponent and its (i) (see UI Vocabulary and the Info Affordance), the trackable card, the entry panel. They are built from the library’s parts.
Tokens and the theme
Datler’s token package takes the library’s contract rather than keeping its own copy of the same design, because two copies drift without a sign: a custom property that disagrees raises no error, it renders differently. packages/design-tokens/datler-tokens.css imports @jeliroco-multimedia/blunt-tokens/defaults.css (one complete theme at :root) and @jeliroco-multimedia/blunt-tokens/axes.css (mode, palette, radius, density and font, each on a data-* attribute). Only the accent axis is written out locally: it keeps Datler’s attribute, data-accent, where the library’s is data-brand, with the library’s values. Components read custom properties, never axis selectors, so the attribute name makes no difference to them.
The theme runtime is @jeliroco-multimedia/blunt-tokens/runtime. apps/renderer/src/lib/theme.ts binds it to Datler’s axis catalog (datlerThemeAxes) and Datler’s storage keys (datler:theme:data-mode and so on), so saved themes keep working. A Storybook story, apps/storybook/src/foundations/JelirocoContract.stories.tsx, reads every contract token off the live document and lists any that resolve to nothing: a missing custom property raises no error and simply renders wrong. See Design Tokens and Theming System.
The mechanisms in use
| subpath | used for | where |
|---|---|---|
random | createRng, the seeded generator behind the demo profiles; a test pins it draw for draw to a literal mulberry32, so a seed always builds the same profile | packages/core/src/seed-generator.ts, seed-john.ts, seed-chad.ts |
reorder | moveItem and placement, the arithmetic of a drop on the Organize board | components/layout/group-tree.ts |
dnd | underOrNear, the board’s rule for which target a pointer release lands on | routes/organize.index.tsx |
tour | restoreTour, which reads the tour’s saved position and refuses one from a plan that no longer exists; the plan’s id is a fingerprint of its stop ids | onboarding/tutorial.ts |
color-ramp | ratioOf, a value’s place between the low and high of a calendar’s range; a range of one value counts as full | lib/colors.ts |
format | formatDurationDays, behind fmtDuration (“1d 6h 30m”), rounded to the minute first because the library’s drops seconds | lib/format.ts |
filters | the filter state behind the Calendar’s trackable facets, kept per profile | components/calendar/TrackablesLegend.tsx, routes/calendar.index.tsx |
@datler/core may take a mechanism, since the package uses neither React nor Tauri. Declaring it as a direct dependency matters even though @jeliroco-multimedia/blunt-ui depends on it: pnpm’s strict node_modules does not resolve a transitive dependency from Datler’s own source.
Rules a consumer follows here
- A popover inside a dialog passes
container. A popover otherwise portals todocument.body, which a dialog’s scroll lock does not reach, and the picker stops responding to the wheel. The container is resolved from the trigger withclosest('[role="dialog"]'). The library’sDialogContentdoes not setdata-slot="dialog-content", so a selector on that returnsnulland brings the bug back. Seecomponents/entries/ItemPicker.tsx,components/entries/AmountRow.tsxandcomponents/common/Term.tsx. - Errors are caught in two places. A route that throws is caught by the router’s
defaultErrorComponent,components/errors/RouteError.tsx, built from the library’sEmptyandButtonso it looks like the library’s own fallback; the shell around it keeps working. The router owns the match, so it is the only thing that can forget the one that threw: aCrashSurfacearound the outlet would reset and then render the same failed match again, which is why there is none.CrashSurfacewraps the whole app inmain.tsxas the last resort, with no retry. Anything built at module scope, such as the router and the query client, throws before React exists and is outside both. - The live region is mounted explicitly.
AnnounceProvidersits at the root inroutes/__root.tsx, aboveToastProvider.ToastProvidermounts one only when nothing above it has, which would make every announcement in the app depend on the toaster being there. - Labels sit in a
Field. The library’sFieldassociates its label with the control it wraps, so a control gets its id,aria-describedbyandaria-requiredwithout the caller naming them. An inline control beside its label is aFieldwithorientation="horizontal". - A failed save is a
SaveError.components/errors/SaveError.tsxshows a save the person asked for that did not go through as the library’s dangerAlert, withrole="alert"so it is announced at once (theAlert’s own role isstatus). It sits beside the form’s submit. A problem with one field stays on thatField’serror, and a page that did not load is anErrorState. - Prose in the code about the library states the invariant Datler depends on, not the mechanism that provides it today. The library releases often, and a description of its internals goes stale with its next release.
apps/renderer/src/lib/hand-rolled.test.ts fails on a library shape written by hand as a class string: a section label, card chrome, a progress bar, a brand-filled button, an icon affordance, a bordered input, a loading state, a label element, a failed save or a danger surface. Each exemption it allows is asserted to still match, so the list cannot go stale. apps/renderer/src/lib/vendor-coupling.test.ts fails when a file that imports a library surface reads another vendor’s custom properties off it, a coupling no compiler sees.
Channels to and from the library
- To the library: an item is filed as an issue on jeliroco-multimedia/blunt-ui, labelled
consumer:datler, and put on no board; the library puts each on its own.jeliroco-components-feedback.mdat the repo root was the channel until 2026-10-09; its last items moved to issues on 2026-10-14, and it holds nothing open. - From the library:
blunt-ui-notices.mdat the repo root is regenerated upstream against this checkout and is never edited here. It names the versions Datler declares against those released, and what in Datler’s source reads a token or utility the library no longer provides. A change to it is a message to read.
Installing
The repository’s .npmrc maps the @jeliroco-multimedia scope to GitHub Packages and holds nothing else; the token belongs in the user-level ~/.npmrc, since a project .npmrc outranks it and an unresolved placeholder there shadows a working token. pnpm-workspace.yaml excludes @jeliroco-multimedia/* from pnpm’s minimumReleaseAge, because the library is first-party and releases often, so a release-age hold would only keep Datler behind fixes it asked for.
Trying a build before it is released
The library can be installed from its local build, a “portal”, before it is published. From the repo root:
node ../../../blunt-workspace/blunt-ui/portal/switch.mjs oninstalls the packed build in place of the registry’s; nothing tracked here changes.- Set
pnpm_config_verify_deps_before_run=falsefor the shell. Otherwise pnpm reinstalls from the lockfile before each script, silently, and the result describes the registry’s build. switch.mjs statusnames the build installed (PORTAL, build of <hash>), orSTALEwhen a newer one has been packed; any result reported from the portal quotes it.switch.mjs offbefore committing, so the checks behind a commit ran against what the lockfile pins.
An adoption that needs an unreleased build does not compile against the registry’s, so it is kept as a patch in scratchpad/ (gitignored) until the release, then applied, checked and committed. The library’s guide is section 6 of ../../../blunt-workspace/blunt-ui/docs/adoption/README.md.
Related
Theming System · Design Tokens · App Shell · Data Table · Storybook · Driving the App (CDP)