Status: alpha. The design system and prototype playground for the Nondominium hApp. See CONTRIBUTING.md.
A design system for Nondominium — a Holochain application for resource governance without ownership, built on ValueFlows and the nondominium property regime, developed inside the Sensorica open value network.
This repo documents what the app actually looks like today, not an aspiration. The prototype at /app is a replica: its components are copies of ../nondominium/ui/src/lib/components, rendering the same markup and the same UnoCSS classes against mock data instead of a conductor. A check enforces that (bun run check:fidelity), and the playbook cites the file each pattern came from. When the app changes, this repo is wrong until someone re-copies it — deliberately, because a design system that drifts quietly is worse than none.
- Bun. Never npm.
- The app checked out as a sibling (
../nondominium) if you want the fidelity check to run.
git clone git@github.com:Sensorica/nondominium-design-system.git
cd nondominium-design-system
bun install
bun run build:registry # the custom-element bundle; the playbook loads it from disk
bun run devbun run dev sets DEV=true and serves at the domain root. The deployed build serves under /nondominium-design-system.
| URL | What |
|---|---|
/ |
Hub |
/tokens |
The palette and scale the app uses, rendered live |
/patterns |
Seven categories of pattern the app writes today, each citing its source file |
/playbook, /ui-kit |
@nondominium/ndo-ui: component sheets, and screens composed from them |
/scenarios |
Six composed pages, each arguing one design question |
/app |
The replica: 44 keyed states of the real app, on mock data |
src/lib/replica/ mirrors ../nondominium/ui/src/lib/components one for one:
replica/
├── shell/ AppShell, Sidebar
├── lobby/ LobbyView, LobbyProfileBar, NdoBrowser, NdoCard,
│ UserProfileForm, ProfileSetupModal
├── group/ GroupView, MemberList, SoftLinkList, WorkLogFeed,
│ NdoCreateModal, GroupProfileModal
├── ndo/ NdoView, NdoIdentityLayer, ResourcesTab, GovernanceTab,
│ ActivityTab, CompositionTab, TransitionHistoryPanel,
│ LifecycleTransitionModal, ForkNdoModal, AssociateNdoModal
├── types.ts copied from packages/shared-types
├── mock.ts seed data in the shapes the real stores emit
└── stores.svelte.ts mock lobbyStore / groupStore / appContext, same API
Seventeen of the twenty-four have byte-identical markup. The other seven differ only in wiring, and only for two reasons:
- hrefs go through
src/lib/paths.ts, because this site deploys under a GitHub Pages sub-path and the prototype is mounted at/app. - Modal, tab and panel state is read from the query string, so each of those states is a URL a reviewer can link to and comment on. The app keeps them in local component state, which is right for the app and useless for a review tool.
Neither changes a class. bun run check:fidelity fails the build if any of them ever does.
Roughly half the screens the app implements are ones nobody can open on demand. A spinner shows for as long as the conductor takes. An error banner needs a broken conductor. The onboarding panel needs an agent with no groups, which you are exactly once. A not-found NDO needs a link that outlived its record. They ship, they go unreviewed, and they are usually where the rough edges are.
?state= picks which one the mock layer serves:
| Param | Renders |
|---|---|
loading |
the in-flight spinner on the lobby, a group, or an NDO |
error |
the load-failure banner and its Retry, with the app's own copy |
empty |
nothing in it yet: no NDOs, no members, no soft links, no work log |
onboarding |
first run, no groups — the dashed Create-or-join panel |
filtered / filtered-empty |
filters applied, with and without matches |
no-profile |
no Level 1 profile: the first-launch profile modal opens over the lobby, as the app's root layout opens it |
anonymous |
no agent key: which write actions survive, and which do not |
Four more are properties of a record rather than a route, so they are keyed to a hash: hibernating, deprecated with a successor, end of life, and every tab empty. src/lib/records.ts names them and says why each earns a key.
The replica components never see the param. They read lobbyStore.isLoading and appContext.myAgentPubKey exactly as they always have; the mock store decides what those mean. That is what lets the components stay copies rather than forks.
No conductor, no DHT, no gossip. Everything eventually consistent in production is immediate here — which is exactly the gap the group-collaboration scenario exists to discuss.
lobby/GroupSidebar.svelteis orphaned. Nothing imports it;shell/Sidebar.sveltesuperseded it and the two have drifted (different widths, different palettes, different copy for the same two buttons). Replicating dead code would misrepresent the app, so it is recorded here instead. Deleting it is a call for the team.HolochainProvider.svelteis replicated asreplica/ConnectionState.svelte, which reproduces all three of its branches. It differs in one respect: the app's disconnected branch offers a Connect button whenautoConnectis false, and the app always passes true, so that button is unreachable in production and is not shown here.
Every prototype state and every scenario can receive threaded comments, hosted in a private GitHub Discussions repo. The site stays fully static: comments are read and written straight from the browser.
- Click the floating button (bottom right) or press
c. - A thread is keyed to the surface you are viewing, not its URL, so a route change never orphans a review thread. Threads are created lazily — browsing leaves no trace.
You need a fine-grained GitHub token from a member of the Sensorica organisation, scoped to nondominium-design-review with Discussions: Read and write. It is stored only in your browser. See src/lib/comments/README.md.
Inside /app, press m: every keyed state, grouped, one click away — including the three connection states nobody can reach in a running app.
/prototypes holds six working UI directions for the v0.1 release, from the prototype handoff (docs/prototypes/HANDOFF.md). Each covers the whole journey: profile, group (create, join by invite link, or the example network), declaring a shared resource, and managing it. The audience is people new to resource sharing, so the default UI uses everyday words; a Developer details switch adds zome calls, hashes and rule chains. A to E carry it in their shared menu (Ctrl/Cmd+K); F has no such menu and carries it, a scenario picker and a Reset button in its header.
- A to E are mid-fidelity explorations restyled onto this system's tokens, as the handoff asked. Tokens only means the handoff's own typefaces are gone (B's serif notebook, the distinct sans and mono of A, C, D and E): all six are set in the design-system sans and mono, which the index and each registry entry say. They run on one shared mock store (
src/lib/prototypes/store/), so what you do in one shows up in the others. - F Flow Graph is high fidelity, already built on this system, and runs on its own mock of the real zome calls.
- What the mocks enforce. Both enforce the hApp's rules at
nondominium@3cbebf0: the integrity zome's lifecycle table, initiator-only lifecycle, custodian-only custody transfer and operational state, a successor for Deprecated, rules only their author may change (the shared store; F has no rule update), and the Hard constraints ofcrates/shared/src/constraints.rs(no ownership-transfer rule on a Nondominium NDO; in F also no Layer 1 at Ideation and no Transfer, Consume or Lower on a Nondominium NDO). They also enforce two prototype rules the hApp does not enforce yet: no self-validation (create_validation_receipthas no validator check) and one claim per commitment (claim_commitmentleaves it as a Phase 2 TODO).bun run check:prototypesfails if any of these drift, and says which are which. - Words come from one module,
src/lib/prototypes/plain.ts. Shared modals, the menu, onboarding and toasts are insrc/lib/prototypes/ui/. The contract for whoever builds a direction issrc/lib/prototypes/README.md. - Every direction and each of its views is a keyed surface:
mfor the screen map,cto comment.?fresh=1starts a direction as a new person;?example=1reloads the example network.
Choosing and archiving. Leave a comment on the direction you want (press c on it). The choice is then recorded by editing one field, status, in src/lib/prototypes/directions.ts: 'target' for the chosen direction, 'archived' for the ones set aside, 'candidate' for the rest. The index, the rail and the banner on each direction follow that field; nothing else changes. Once a target exists, the index shows it beside the current app (the replica at /app) under "Current app versus target".
The handoff's standalone builds (about 10 MB of bundled React) are not committed; its notes, backend map and seven user stories are in docs/prototypes/.
bun install # Install dependencies
bun run dev # Dev server at the domain root
bun run build # Registry bundle, then the static site → build/
bun run build:registry # Just the ndo-* custom-element bundle
bun run preview # Preview the production build
bun run check # svelte-check + the fidelity check
bun run check:fidelity # Compare the replica against ../nondominium/ui
bun run check:prototypes # The prototype mocks' rules against the zome, plus the prototype-only rulesndo-* elements are an embeddable layer: Svelte compiled to custom elements, styled entirely through --ndo-* custom properties so they inherit tokens through the shadow DOM. The app does not use them. They exist so the visual language can be dropped into a slide, a wiki page, or another framework.
<link rel="stylesheet" href="https://…/tokens.css" />
<script type="module" src="https://…/registry/bundle.js"></script>
<ndo-badge variant="lifecycle-active" label="Active"></ndo-badge>
<ndo-button variant="primary">Create NDO</ndo-button>
<ndo-status-dot status="active" label="Active"></ndo-status-dot>Two non-obvious constraints, both real: Svelte's customElement option is incompatible with SvelteKit's SSR, so the registry builds through its own vite.registry.config.ts; and runes are unsupported in that mode, so registry components use export let.
Builds to build/, deploys to GitHub Pages on every push to master. The SPA fallback is 404.html, because Pages honours only a custom 404 page as its fallback, and that is what serves the client-rendered /app/ndo/[hash] routes on a deep-link reload.
SKILL.md carries a designsystem: manifest declaring which capability packs this repo has, where the tokens and brand brief live, and what the host stack is. An agent reading this repo learns its shape in one read.
Voice is plain, technical and slightly terse. Sentences state what a thing is. Nothing is sold.
- Pronouns: "you" and "your" for the reader's own things ("your groups", "How you appear in the Lobby"). The system is named, never personified.
- Casing: Sentence case throughout, including buttons ("Create NDO", "Copy invite link", "Set up your profile"). Domain terms keep their capitals: NDO, Lobby, Group, Person, NondominiumIdentity, Layer 0, SoftLink. Enum values render exactly as spelled:
EndOfLife,CommonPool,AccountableAgent. - Length: Short. Headings two to four words. Descriptions one sentence. Section subtitles one sentence ("All NDOs across your groups.").
- Explanations sit under the control they explain, as
text-xs text-gray-500. The create form's regime and nature hints change with the selection; that is the app's only teaching surface and it is worth keeping. - Unbuilt things are named, with a citation. "NDO member listing is not yet implemented on the DHT. See documentation/zomes/resource_zome.md § NDO membership (planned)." This is unusual and it is the best habit in the codebase.
- Empty states state the fact, then the remedy: "No NDOs yet. Create one inside a group to see it here."
- Errors are plain: "Group name is required.", "Invalid invite code.", "Could not refresh NDO details from the chain. Data shown may be cached."
- Domain words: NDO, Lobby, group, network seed, invite link, lifecycle stage, property regime, resource nature, initiator, custodian, soft link, specification, economic resource, governance rule, Layer 0.
There is one brand asset: the Nondominium logo — a linked-node monogram in teal, violet and blue over a near-black navy wordmark, at static/assets/.
| Asset | Use |
|---|---|
nondominium-logo.png |
The full lockup: mark plus wordmark. The hub's hero. |
nondominium-mark.png |
The mark alone, square. Rails, chips, anywhere the wordmark will not fit. |
favicon.png |
The mark at 256px. |
Both are white-keyed to transparency, so the mark sits correctly on the ink rail as well as on white.
Sampled from the file, and the only values in tokens.css that are a brand decision rather than a Tailwind default:
| Token | Value | Where it comes from |
|---|---|---|
--ndo-brand-teal-500 |
#14b8b8 |
The mark's largest area, so it leads |
--ndo-brand-violet-500 |
#7048d8 |
Upper-right node |
--ndo-brand-blue-500 |
#2f6bcc |
Lower-left node |
--ndo-brand-ink |
#0b1a38 |
The wordmark |
--ndo-brand-gradient |
teal → blue → violet, 135° | Repeats the mark's own left-to-right run |
These dress the design system's own chrome and nothing else. The rail is brand ink with a gradient hairline down its edge; the hub's cards carry the gradient on their top edge; the active nav item is marked in brand teal. Inside /app none of it appears, because the app has no brand layer and the replica must not invent one. The one exception is the chip at the bottom left, which is prototype chrome rather than app UI, and which exists because the design-system rail is hidden inside the prototype and the app has no link back out.
The app reads as a plain, dense administrative interface. Grey canvas, white cards, hairline borders, one blue for action, and colour used almost exclusively to carry domain meaning. There is no brand layer at all: no logo, no wordmark, no illustration, no icon set.
Entirely in UnoCSS utility classes written inline. app.css is one line (the Tailwind reset) and uno.config.ts is presetUno() plus presetIcons(). There is no theme file, no token file, and no component library in the app. Every value below is a Tailwind default, and this repo's static/tokens.css documents them so the custom elements have something to inherit.
- Action blue:
blue-600fill with white text,blue-700on hover.blue-300borders andblue-50hovers for the secondary variant.blue-500for focus borders. - Canvas:
gray-100page,gray-50sidebar and table headers, white cards,gray-200borders,gray-100hairlines. - Text:
gray-900headings,gray-800values,gray-700labels,gray-600body,gray-500hints,gray-400monospace and italics. - Domain colour is the only decorative colour, and it always uses a 100/700 pair: lifecycle stage (gray, blue, indigo, amber, green, teal, emerald, yellow, orange, red), resource nature (blue, purple, orange, teal, indigo), property regime (gray, cyan, emerald, rose).
- Semantic:
red-50/200/600/700for errors,amber-50/100/600/700/800for "not built yet",yellow-50/700andorange-50/700for the hibernating and deprecated strips,green-600for a confirmed selection. - No dark mode.
- No font is declared anywhere. The app inherits UnoCSS's Tailwind reset stack, and
font-monofor hashes, ids and network seeds. Any typeface choice would be a new decision, not a documented one. - Sizes:
text-xs(12px) carries most of the interface,text-sm(14px) is body,text-basefor tab-panel headings,text-lgfor card titles and modal titles,text-2xlfor page titles. Nothing larger exists. - Weights:
font-mediumandfont-semibolddo nearly all the work;font-boldis reserved for page titles and the+/→glyphs. - Uppercase
text-xs tracking-wide text-gray-400is the section-label treatment: "GROUPS", "Optional fields", "Stage:", "DESCRIPTION".
- Page padding is
p-6. Cards arep-4. Modal sections arepx-6 py-4. The sidebar isp-3. - Gaps are small:
gap-1andgap-1.5inside a row of badges,gap-2between buttons,gap-3in a card grid. - The interface is dense on purpose. A group row is 28px tall.
rounded(4px) for buttons, inputs, badges and rows.rounded-lg(8px) for cards and panels.rounded-xl(12px) for modals.rounded-tfor tabs.rounded-fullappears exactly once, on the member role pill.- Borders are 1px,
gray-200orgray-100. - A dashed border means declarative rather than observed: the property-regime badge, an empty list, an unbuilt feature, the onboarding card. It is the app's most distinctive convention and also its most overloaded, because "empty" and "unbuilt" look the same.
shadow-smon cards,shadow-mdon card hover,shadow-xlon modals. The active sidebar item usesshadow-smon white to read as raised.
- Two transitions:
transition-colorson anything interactive,transition-shadowon cards. Durations are Tailwind's default. transition-opacityreveals the copy-invite button on row hover.- One animation:
animate-spinon a bordered circle, andanimate-pulseon a single title-width bar while an NDO loads.
flex min-h-screen bg-gray-100, aw-52sidebar, and amin-w-0 flex-1 overflow-automain column. That is the whole frame.- Card grids are
grid gap-3 sm:grid-cols-2 lg:grid-cols-3. - Modals are centred over
bg-black/40 backdrop-blur-sm, with a header, amax-h-[70vh]scrolling body, and a right-aligned footer.
There is no icon system. presetIcons() is configured in uno.config.ts and never used. No SVG set, no icon font, and no emoji in the app proper.
What the app uses instead is a handful of text glyphs, styled as text:
+for create,→for join, bothfont-boldinside aflex items-center gap-1button.⎘for copy invite link, becoming✓for two seconds after a successful copy.⧉for copy event hash, in the lifecycle history.·as a separator between an agent and a timestamp.
The one exception is HolochainProvider.svelte, which uses three large emoji for its connection states: ⚡ connecting, ❌ failed, 🔌 not connected. They appear at text-6xl and exist nowhere else in the app.
Recommendation: if a future surface needs icons, pick one set and introduce it deliberately. presetIcons is already installed, so i-lucide-* classes would work today with no new dependency. Adding icons piecemeal would produce a third convention alongside the glyphs and the emoji.
Everything below is a statement about the app, not about this repo.
- The app has no brand layer. There is a logo, and this repo uses it, but nothing in
ui/references it: no logo, no wordmark, no typeface, no icon set. The app's palette is Tailwind's defaults. Bringing the brand into the product is an open decision, not something this repo should quietly assume. - The brand palette is sampled, not specified. The five brand tokens come from reading pixels out of the logo file. If there is a brand guide with authoritative values, they should replace these.
- The stage colour map is written out three times and the three do not agree:
NdoBrowserhas ten colours with borders,NdoIdentityLayerhas the same ten without borders, andNdoCardcollapses all ten into green-or-grey. See/playbook/badges. - Two sidebars exist.
shell/Sidebar.svelteis mounted;lobby/GroupSidebar.svelteis unreferenced and differs in width, background and hover colour. /agent/<key>does not exist.NdoIdentityLayerlinks an initiator's name to it whenever a Person entry resolves. The prototype keeps the link and renders an explanation at that route rather than hiding the defect.- Dashed borders are overloaded across "declarative", "empty" and "not built yet".
- Two modal mechanisms. The lobby profile modal is a native
<dialog>driven by a Melt builder and traps focus; every other modal is a fixed div that does not. They look nearly identical. presetIconsis configured and unused.- Transition event hashes are real in the app and mocked here. The prototype seeds plausible hashes so the history panel renders; the app shows whatever the zome returns.
- The token file is this repo's invention. The app has no design tokens.
static/tokens.cssexists to serve the custom elements and to give the values a name; it declares nobodystyles precisely so that including it cannot change how a page renders. - The integrity zome accepts more hibernation than the UI offers.
dnas/nondominium/zomes/integrity/zome_resource/src/lib.rs(validate_update_nondominium_identity, the "Suspend (any non-terminal → Hibernating)" rule) lets any stage other than Hibernating, Deprecated and EndOfLife enter Hibernating. The app'sui/src/lib/components/ndo/LifecycleTransitionModal.svelteoffers Hibernating from Active only, anddocumentation/specifications/specifications.md§7.5 says the modal and the zome enforce that same table. They do not. The replica copies the modal, so it inherits the stricter UI. This is an app-side finding for the team; nothing here changes it. - NdoView's descriptor load most likely re-triggers itself. In
ui/src/lib/components/ndo/NdoView.svelte, the effect that callsloadDescriptorreadsndoDescriptorsynchronously (if (!ndoDescriptor) isLoading = true;, before the firstawait), so the effect depends on it, and after the await it assigns the fresh object the service returns. Each successful load should therefore schedule the next. This is a reading of the code, not a runtime observation. The replica has the same shape and does not loop only because its mock returns the same object each time. - The NDO member list polls itself on an NDO with no members. The same file's effect
if (showJoinPanel && ndoMembers.length === 0 && !membersLoading && !membersError) void loadNdoMembers();is re-armed byloadNdoMembersitself, which clearsmembersLoadingand assigns a new empty array, so with the join panel open on an NDO nobody has joined it callsgetNdoMembersagain after every answer. The replica reproduces it (open?join=1on a bare NDO): its mock answers on a later task, so the loop costs a render per turn instead of freezing the tab. - A second NDO opened in the same view can show the first one. SvelteKit keeps
NdoViewmounted across/ndo/Ato/ndo/B(a successor link does this).loadDescriptor(B)only raises the skeleton, and only shows the error banner, whenndoDescriptoris empty, and it still holds A. If B's read fails, the header, detail card and tabs keep showing A under B's hash, with no banner. - A failed association is never shown.
AssociateNdoModalflips to "Saved!" whatevergroupStore.associateNdoWithGroupdid. The failure is written togroupStore.errorMessage, which onlyGroupViewrenders, andGroupViewclears it by callingloadGroupDataon mount.
These three are statements about registry/ and packages/ndo-ui rather than about the app.
- The registry card does not colour stages the way the app's card does.
registry/ndo-card.svelterenders each badge throughregistry/ndo-badge.svelte, whoselifecycle-*variants give every stage its own family (Active emerald, Prototype amber, Hibernating yellow). The app'sui/src/lib/components/lobby/NdoCard.sveltepaints Active, Stable, Distributed, Development and Prototypebg-green-100 text-green-700and every other stage gray. The regime chip agrees on both sides (gray-400 dashed), andpackages/ndo-ui/src/components/primitives/NdoCard.sveltefollows the app, so only the custom element diverges. - Create NDO is a single form in the app and a wizard in the kit. The app's
ui/src/lib/components/group/NdoCreateModal.svelteis one modal: name, regime, nature, stage, rivalry and description, with hints from its ownregimeTooltipsandnatureTooltips./ui-kit/ndo-createmountspackages/ndo-ui/src/components/patterns/group/NdoCreateModal.svelte, a three- or four-step wizard (intent, identity, optional governance, review) with a live preview panel and its own question copy inpackages/ndo-ui/src/domain/wizard-questions.ts("Tools, equipment, spaces, materials..." where the app says "A tangible, material resource."). - The kit's group view has no invite button. The app's
ui/src/lib/components/group/GroupView.svelteputs a "Copy invite link" button beside "Create NDO" in the header and wraps the member list inmt-6.packages/ndo-ui/src/components/patterns/group/GroupView.svelte, which/ui-kit/grouprenders, has the header, error banner,NdoBrowserandMemberListin the same order, but neither the invite button nor the wrapper.
This repo now holds both, and they are not the same thing. Keeping them apart is deliberate.
| Subject | Where | |
|---|---|---|
| Patterns | The classes the app writes inline today. Descriptive: change the app, then re-copy. | /patterns, src/lib/replica/ |
| ndo-ui library | The component library the app is meant to move to. Prescriptive. | /playbook, /ui-kit, packages/ndo-ui/ |
Both were called "playbook" before the two lines of work met, which is what collided in the merge. The rewrite's moved to /patterns; @nondominium/ndo-ui kept /playbook, because it was there first.
The open question is what happens when they disagree, and they already do: packages/ndo-ui/src/domain/variants.ts declares one canonical stage colour map, while the app still writes three that contradict each other. That is the library doing its job, and it is a decision for the team rather than something either side should settle alone.
Presentational Svelte 5 components ported from the nondominium hApp UI. No Holochain or Effect-TS dependencies — props and callbacks only.
import {
AppShell,
Sidebar,
LobbyView,
NdoBrowser,
NdoCard,
GroupView,
NdoDetailLayout,
applyNdoFilters,
MOCK_NDOS
} from '@nondominium/ndo-ui';
import '@nondominium/ndo-ui/styles/tokens.css';UI-kit demos (hApp-fidelity scenarios with mock data):
| Route | hApp equivalent |
|---|---|
/ui-kit/browse |
Lobby + Sidebar + NdoBrowser |
/ui-kit/group |
GroupView |
/ui-kit/ndo-detail |
NdoView + NdoIdentityLayer |
/ui-kit/ndo-create |
NdoCreateModal |
/ui-kit/agent-profile |
UserProfileForm |
Domain color maps live in packages/ndo-ui/src/domain/variants.ts (single source of truth matching hApp NdoBrowser.svelte filter chips and NdoCard.svelte card badges).
Future hApp adoption: add "@nondominium/ndo-ui": "workspace:*" (monorepo) or publish to npm and replace inline UnoCSS in nondominium/ui.
See docs/INTEGRATION.md for parallel development workflow, badge modes, and migration checklist.