Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2f7a540
Localize cip-179 validation problems (milestone 0, #4)
mpizenberg Jul 13, 2026
d606584
Extract @tessera/respond-core (milestone 1)
mpizenberg Jul 13, 2026
83cbd61
Add cip-179 CHANGELOG; document the 0.3.0 validator change
mpizenberg Jul 13, 2026
7c8f39e
respond-core: add the side-effect-free createI18n factory (milestone 2)
mpizenberg Jul 13, 2026
ee70a4a
respond-core: add the lazy sealed-submission wrapper (milestone 3)
mpizenberg Jul 13, 2026
57f0ff2
respond-widget: the trimmed answering component + dev harness (milest…
mpizenberg Jul 13, 2026
1ba97a9
respond-widget: stepper layout + --tessera-* theming (milestone 5)
mpizenberg Jul 13, 2026
98e3872
respond-widget: <tessera-respond> element + self-contained ES build (…
mpizenberg Jul 13, 2026
6cefde8
respond-widget: key the stepper card by its question
mpizenberg Jul 15, 2026
f33f13c
respond-widget: demo cancelled + host-credential samples, fix SPO note
mpizenberg Jul 15, 2026
40c92fa
respond-widget: dark theme sample in the dev harness
mpizenberg Jul 15, 2026
92460a9
respond-widget: gate font handling in the token test
mpizenberg Jul 15, 2026
4aaae44
respond-widget: font-override demo in the dev harness
mpizenberg Jul 15, 2026
a2217bf
app: dev-only <tessera-respond> reference-host page (milestone 7)
mpizenberg Jul 15, 2026
13fa90a
respond-widget: prefill by role — priorResponse → priorResponses[]
mpizenberg Jul 15, 2026
05e140e
respond-widget: document the widget + respond-core (milestone 8)
mpizenberg Jul 15, 2026
1b0d21f
respond-widget README: document both credential-proof mechanisms
mpizenberg Jul 15, 2026
a467076
respond-widget: collapse Responder to a role→credential map
mpizenberg Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,17 @@ served document matches the on-chain hash.

## Repository layout

| Path | What it is |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `frontend/app` | The browser app — [SolidJS][solid] + [Vite][vite] + TypeScript. |
| `packages/cip179` | The reusable `cip-179` package: label-17 codec plus the cross-implementation domain / tally / txproof / tlock surface. |
| `packages/core` | Tessera app core (`@tessera/core`): the `DataSource` seam, Explore list/health payloads, keyset paging, the survey-list aggregation adapter, and config. The reusable domain/tally surface lives in `cip-179`. |
| `packages/koios` | The Koios read path (`KoiosDataSource`, tally inputs), shared by direct mode, backend, and verifier. |
| `packages/verifier` | Standalone CLI that re-derives a survey's result artifact from chain data and checks its content hash. |
| `backend/server` | Tier-1 serving backend: cached chain reads, response validation, weight snapshots, artifact finalization. Node or CF+D1. |
| `backend/deps` | Indexer submodules (Adder / Yaci Store / Oura) for a future Tier-2; design notes in `backend/*.md`. |
| Path | What it is |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `frontend/app` | The browser app — [SolidJS][solid] + [Vite][vite] + TypeScript. |
| `packages/cip179` | The reusable `cip-179` package: label-17 codec plus the cross-implementation domain / tally / txproof / tlock surface. |
| `packages/core` | Tessera app core (`@tessera/core`): the `DataSource` seam, Explore list/health payloads, keyset paging, the survey-list aggregation adapter, and config. The reusable domain/tally surface lives in `cip-179`. |
| `packages/koios` | The Koios read path (`KoiosDataSource`, tally inputs), shared by direct mode, backend, and verifier. |
| `packages/respond-core` | `@tessera/respond-core`: the pure, framework-free answering core (drafting, responder eligibility, i18n factory, lazy sealed wrapper) shared by the app and the widget. |
| `packages/respond-widget` | `@tessera/respond-widget`: the embeddable `<tessera-respond>` custom element — answer a survey anywhere, emitting a ready-to-attach label-17 payload. Framework-agnostic; wallets/chain stay host-side. |
| `packages/verifier` | Standalone CLI that re-derives a survey's result artifact from chain data and checks its content hash. |
| `backend/server` | Tier-1 serving backend: cached chain reads, response validation, weight snapshots, artifact finalization. Node or CF+D1. |
| `backend/deps` | Indexer submodules (Adder / Yaci Store / Oura) for a future Tier-2; design notes in `backend/*.md`. |

## Quick start

Expand Down Expand Up @@ -98,7 +100,8 @@ entered in Settings, stored only in the browser.
## Development

The repo is a pnpm workspace (`frontend/app`, `packages/cip179`,
`packages/core`, `packages/koios`, `packages/verifier`, `backend/server`).
`packages/core`, `packages/koios`, `packages/respond-core`,
`packages/respond-widget`, `packages/verifier`, `backend/server`).
Packages are consumed from TypeScript source (Vite aliases / `exports` pointing
at `src`), so cross-package edits are live with no build step.

Expand Down
5 changes: 4 additions & 1 deletion frontend/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,14 @@
"@solidjs/router": "^0.16.1",
"@tessera/core": "workspace:*",
"@tessera/koios": "workspace:*",
"@tessera/respond-core": "workspace:*",
"@tessera/respond-widget": "workspace:*",
"cip-179": "workspace:*",
"solid-js": "^1.9.0"
"solid-js": "^1.9.14"
},
"devDependencies": {
"@types/node": "^25.9.3",
"solid-element": "^1.9.2",
"typescript": "^5.6.0",
"vite": "^6.0.0",
"vite-plugin-solid": "^2.11.0",
Expand Down
13 changes: 12 additions & 1 deletion frontend/app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ErrorBoundary, type ParentComponent } from "solid-js";
import { ErrorBoundary, lazy, type ParentComponent } from "solid-js";
import { Route, Router } from "@solidjs/router";

import { AppProvider } from "~/state";
Expand All @@ -12,6 +12,14 @@ import { Settings } from "~/ui/screens/Settings";
import { ProposeInfoAction } from "~/ui/screens/ProposeInfoAction";
import { BottomNav } from "~/ui/components/BottomNav";

// Dev-only reference host for the embeddable `<tessera-respond>` widget
// (milestone 7). Lazily loaded behind the `import.meta.env.DEV` guard below so
// its second copy of the answering UI (and the whole widget bundle) is
// dead-code-eliminated from production builds.
const DevWidgetHost = import.meta.env.DEV
? lazy(() => import("~/ui/screens/DevWidgetHost"))
: undefined;

const Layout: ParentComponent = (props) => (
<AppProvider>
<Header />
Expand All @@ -37,6 +45,9 @@ export default function App() {
<Route path="/create" component={Create} />
<Route path="/propose-info-action" component={ProposeInfoAction} />
<Route path="/settings" component={Settings} />
{import.meta.env.DEV && DevWidgetHost && (
<Route path="/dev/widget/:key" component={DevWidgetHost} />
)}
</Router>
);
}
17 changes: 14 additions & 3 deletions frontend/app/src/domain/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
type RatingScale,
type Role,
type SurveyDefinition,
type ValidationProblem,
} from "cip-179";

import { hexToBytes } from "cip-179/domain";
Expand Down Expand Up @@ -351,12 +352,20 @@ const PLACEHOLDER_ANCHOR: ContentAnchor = {
hash: new Uint8Array(32),
};

/**
* A single publish-blocking problem. Parse failures the builder detects itself
* are already-localized (or literal) strings; the codec's semantic problems are
* structured cip-179 {@link ValidationProblem}s the caller renders in its own
* locale (see `~/i18n/problem`).
*/
export type CreateProblem = string | ValidationProblem;

export function buildDefinition(
owner: Credential,
meta: DefinitionMeta,
drafts: readonly QuestionDraft[],
contentAnchor?: ContentAnchor,
): { definition: SurveyDefinition; problems: string[] } {
): { definition: SurveyDefinition; problems: CreateProblem[] } {
const problems: string[] = [];
const external = meta.contentMode === "external";

Expand Down Expand Up @@ -389,8 +398,10 @@ export function buildDefinition(
...(external ? { contentAnchor: contentAnchor ?? PLACEHOLDER_ANCHOR } : {}),
};

problems.push(...validateDefinition(definition));
return { definition, problems };
return {
definition,
problems: [...problems, ...validateDefinition(definition)],
};
}

/** The off-chain presentation document (JSON) for an external-content survey. */
Expand Down
138 changes: 138 additions & 0 deletions frontend/app/src/domain/roles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";

import { Role, type SurveyDefinition } from "cip-179";
import { bytesToHex } from "cip-179/domain";

import {
claimableRoles,
respondableRoles,
roleCredential,
walletCredToCip179,
walletResponder,
} from "./roles";
import type { WalletCredential, WalletIdentity } from "~/wallet/types";

const hex = (n: number, fill: number): string =>
bytesToHex(new Uint8Array(n).fill(fill));

const keyCred = (fill: number): WalletCredential => ({
kind: "key",
hashHex: hex(28, fill),
});

const payment = keyCred(1);
const stake = keyCred(2);
const drep = keyCred(3);

/** A {@link WalletIdentity} carrying the given credentials (rest is filler). */
const identity = (creds: {
stake?: WalletCredential;
drep?: WalletCredential;
}): WalletIdentity => ({
walletKey: "demo",
walletName: "Demo",
networkId: 0,
changeAddressBech32: "addr_test1demo",
payment,
stake: creds.stake,
drepKeyHex: creds.drep ? hex(32, 3) : undefined,
drep: creds.drep,
});

const defWith = (...roles: Role[]): SurveyDefinition =>
({ eligibleRoles: roles }) as unknown as SurveyDefinition;

describe("claimableRoles", () => {
it("payment-only wallet claims Keyholder", () => {
expect(claimableRoles(identity({}))).toEqual([Role.Keyholder]);
});

it("adds Stakeholder / DRep when present, Keyholder last", () => {
expect(claimableRoles(identity({ stake, drep }))).toEqual([
Role.Stakeholder,
Role.DRep,
Role.Keyholder,
]);
});

it("keys off the DRep credential's presence", () => {
expect(claimableRoles(identity({ drep }))).toEqual([
Role.DRep,
Role.Keyholder,
]);
});
});

describe("walletCredToCip179", () => {
it("round-trips a key credential's hash", () => {
const cred = walletCredToCip179(payment);
expect(cred.type).toBe("key");
expect(bytesToHex((cred as { keyHash: Uint8Array }).keyHash)).toBe(
payment.hashHex,
);
});

it("maps a script credential to scriptHash", () => {
const cred = walletCredToCip179({ kind: "script", hashHex: hex(28, 9) });
expect(cred.type).toBe("script");
expect(bytesToHex((cred as { scriptHash: Uint8Array }).scriptHash)).toBe(
hex(28, 9),
);
});
});

describe("roleCredential", () => {
const full = identity({ stake, drep });

it("maps each wallet-derivable role to its credential", () => {
expect(roleCredential(full, Role.Keyholder)).toEqual(
walletCredToCip179(payment),
);
expect(roleCredential(full, Role.Stakeholder)).toEqual(
walletCredToCip179(stake),
);
expect(roleCredential(full, Role.DRep)).toEqual(walletCredToCip179(drep));
});

it("returns undefined for a role the wallet can't produce", () => {
expect(roleCredential(identity({}), Role.Stakeholder)).toBeUndefined();
expect(roleCredential(identity({}), Role.DRep)).toBeUndefined();
});

it("returns undefined for non-wallet-derivable roles (SPO/CC)", () => {
expect(roleCredential(full, Role.SPO)).toBeUndefined();
expect(roleCredential(full, Role.CC)).toBeUndefined();
});
});

describe("respondableRoles", () => {
it("intersects eligible roles with what the wallet can produce", () => {
const def = defWith(Role.SPO, Role.DRep, Role.Keyholder);
expect(respondableRoles(def, identity({ drep }))).toEqual([
Role.DRep,
Role.Keyholder,
]);
});

it("is empty when the wallet can't claim any eligible role", () => {
const def = defWith(Role.SPO, Role.CC);
expect(respondableRoles(def, identity({ stake, drep }))).toEqual([]);
});
});

describe("walletResponder", () => {
it("maps every claimable role to its credential", () => {
expect(walletResponder(identity({ stake, drep }))).toEqual({
[Role.Keyholder]: walletCredToCip179(payment),
[Role.Stakeholder]: walletCredToCip179(stake),
[Role.DRep]: walletCredToCip179(drep),
});
});

it("omits roles the wallet can't derive (no SPO/CC entry)", () => {
const map = walletResponder(identity({}));
expect(map[Role.Keyholder]).toEqual(walletCredToCip179(payment));
expect(map[Role.Stakeholder]).toBeUndefined();
expect(map[Role.SPO]).toBeUndefined();
});
});
Loading