Add @tessera/respond-core + embeddable <tessera-respond> widget - #5
Merged
Conversation
`validateResponse` / `validateDefinition` now return structured
`ValidationProblem[]` (`{ code, params }`) instead of hard-coded English
prose, so the UI (and the future embeddable widget) can render them in the
active locale.
- cip-179: add `ValidationProblem`, the frozen `VALIDATION_PROBLEM_CODES`
set + `ValidationProblemCode`, and `describeProblem`/`describeProblems`
(default English rendering / self-describing fallback). Bump to 0.3.0
(breaking return-type change). Verdict semantics are unchanged — the
list is still empty iff valid — so the ruleset hash is untouched (no
`rulesetVersion` bump); golden artifact test stays green.
- app: new `validation` i18n namespace (en + fr) keyed by problem code,
and `problemText()` mapping a problem to a localized string. Respond and
Create render codec problems through it; Create's `buildDefinition` now
returns `(string | ValidationProblem)[]` (its own parse strings pass
through, codec problems localize).
- tests: cip-179 covers structured output + English renderer; the app
asserts the catalog is exhaustive over every declared code.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New pure, framework-free workspace package holding the answering logic that the app and the future embeddable <tessera-respond> widget both need — no dependency on the app store, router, or wallet seam. - draft.ts: verbatim move of frontend/app/src/domain/respond.ts (drafts → validated answers → SurveyResponse, the decided() progress gate, prefill). - identity.ts: WalletCredential (moved from the app's wallet seam) + the new slim ResponderIdentity (payment/stake/DRep credentials only). - roles.ts: claimableRoles / roleCredential / respondableRoles / walletCredToCip179, retyped over ResponderIdentity (claimableRoles now keys off `drep` presence rather than the raw DRep key — same truth value, the wallet sets both together). - eligibility.ts: new respondableRolesFor / credentialForRole covering host-trusted SPO/CC credentials the wallet can't derive. App repoint (no behavior change): - wallet/types.ts re-exports WalletCredential from respond-core and adds the WalletIdentity → ResponderIdentity adapter; the app's ownership helpers (walletControls/walletOwns/toWalletCredential) stay in domain/roles.ts. - state.tsx / Respond.tsx / Create.tsx import the moved functions from @tessera/respond-core, adapting the identity at each call site. - workspace + app aliases (vite, vitest, tsconfig paths) point @tessera/respond-core at its src for live cross-package edits. pnpm -r type-check + test green; app production build succeeds. Adds 24 respond-core unit tests (roles, eligibility, draft round-trip). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 0.2.0 → 0.3.0 bump (structured ValidationProblem[] return) was only recorded in package.json and git history. Start a Keep a Changelog file with an [Unreleased] section (targeting 0.3.0, not yet published) so the breaking change and its migration note are documented until release. Add CHANGELOG.md to the published `files` (npm doesn't auto-include it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the app's reactive i18n global with an instance-scoped factory the
widget can drive entirely from props — no localStorage, navigator, or document.
- messages/{en,fr}.ts: bundled catalogs, a widget-scoped subset of the app's
respond + roles namespaces (host-owned concerns — wallet, network, IPFS,
chain fetch, submit progress, navigation — intentionally excluded).
- messages/types.ts: RespondMessages = typeof en (the parity contract), plus
the derived MsgKey / Params / DeepPartial types.
- i18n.ts: createI18n({ locale, messages }) → { t, n, d }. t deep-merges the
host's messages over the bundled catalog and interpolates {token}; n/d are
Intl.NumberFormat / DateTimeFormat keyed on locale (d takes unix seconds,
replacing the app's browser-locale formatRevealDate). Numbers/dates always
follow locale; a one-time dev warning fires when a locale has neither a
bundled catalog nor messages (plan §2.4, option 1 + 3).
16 new tests (lookup/fallback/interpolation, deep-merge override, number +
date formatting, the missing-catalog warning). Whole respond-core suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
seal.ts exposes sealResponse(answers, round, paddingSize), which timelock- encrypts a sealed response's answers to a drand round. Both heavy dependencies — the evolution-sdk CBOR codec (cip-179/evolution) and the tlock stack (cip-179/tlock, @mattpiz/tlock-js) — are dynamically imported, so the public answering path never loads them and the widget build can split them into chunks fetched only when a sealed survey is answered (plan §2.3, §5). Declare both as optional peerDependencies + devDependencies, mirroring cip-179 (needed only for the sealed path, and for this package's own type-check/test). seal.test.ts asserts the ciphertext length matches the analytic oracle sealedCiphertextSize(plaintextLen, round) — the same size math the app's on-chain preview uses, already pinned against the real encryptor by cip-179's size.test.ts — for the unpadded, padded, and under-padded cases, plus that seals are size-deterministic yet byte-randomized. Fully offline (timelock encryption is pure local crypto). Whole workspace type-check + test green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…one 4)
Add the new @tessera/respond-widget package: the app's Respond.tsx screen
carved into a framework-light SolidJS component with every host coupling
stripped, plus a Vite dev harness that drives it.
- RespondRoot: the app's answering flow minus useApp/router/global-i18n/IPFS/
Pro-preview/network gating. Submit no longer signs — it validates, lazily
timelock-encrypts sealed answers (respond-core's seal.ts), encodes the
label-17 payload, and emits it via a composed CustomEvent (tessera:response),
alongside tessera:change (progress) and tessera:invalid (localized problems).
- bodies/: the 7 per-question bodies + the QuestionBody switch, moved and
rewired from the module-global t/n to an injected i18n (Solid context) and
from CSS-module classes to plain shadow-scoped class names. No logic changes.
- Shadow-root CSS (plan §4): theme tokens on :host + the moved styles as plain
CSS, delivered via ?inline imports into one shared constructed stylesheet
(adoptWidgetStyles), with a <style> fallback.
- Gates: closed (surveyStatus + cancelled), ineligible, sealed banner, and the
non-quicknet sealed block. list layout only; one-per-screen + theming are m5.
- types.ts: the public prop/event contract. Adds a required surveyRef prop the
plan's §3.1 list omitted — a SurveyDefinition carries no ref, yet the emitted
RespondResult and buildResponse both need it.
- dev harness: mounts RespondRoot in a shadow root with fabricated public /
sealed / closed / ineligible samples (all 7 question types) and logs every
emitted event; the sealed sample seals fully offline.
Supporting changes:
- cip-179: export surveyStatus(endEpoch, tipEpoch) from ./domain (factored out
of the internal statusOf) so a host can gate open/closed without the full
records snapshot. No behavior change (ruleset golden tests stay green).
- respond-core: add the validation.{response,answer} catalog subtrees (en+fr)
and renderProblem(i18n, problem), so tessera:invalid carries widget-localized
text for every code validateResponse can emit.
The public entry bundle stays ~79 kB; evolution-sdk (337 kB) and tlock (120 kB)
split into chunks fetched only when a sealed survey is answered. type-check and
tests are green across all packages.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Rename every design token to the public --tessera-* namespace and sweep
the component CSS so no literal color remains — everything a host can
re-skin routes through a :host token default in theme.css.
- Expose role chip colors as --tessera-role-{drep,spo,cc,stakeholder,
keyholder}[-bg] tokens.
- Reflect the `theme` prop as inline --tessera-<key> properties on the
shadow host (stale keys cleared), equivalent to host-CSS overrides.
- Add the "one-per-screen" layout (now the default, per plan §3.3): a
stepper over the same question cards, drafts, and submit bar; "list"
stays as-is. New stepPrev/stepNext/stepCount strings in en + fr.
- Enforce the plan-§4 token gate as a vitest test that CI runs with
`pnpm -r test`: no literal colors outside theme.css, every var()
reference defined, every defined token consumed.
- Dev harness: layout + theme (`theme`-prop demo) control groups.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…milestone 6) - src/element.tsx registers the custom element via solid-element: every public prop declared reactive (with attribute aliases for the string ones), styles adopted into the open shadow root, and nothing rendered until the required props arrive (HTML-first upgrades don't crash). The module doubles as the lib entry, re-exporting the public API. - vite.config gains the build half (plan §5): ES-only lib build (Rollup can't code-split UMD) with Solid, respond-core, and the cip-179 codec bundled in; the tlock/evolution chunks stay behind seal.ts's dynamic imports and load only when a sealed survey is answered. dev/ harness serving is unchanged. - test/artifact.test.ts drives the BUILT bundle offline in happy-dom (`test` builds first): self-contained import, shadow-root styling, stepper/list layouts, HTML upgrade + locale attribute, composed events crossing the boundary, a decodePayload/validateResponse round-trip of the emitted public payload, real tlock ciphertext from the lazy sealed chunks, and a static-import-graph walk proving the sealed machinery is reachable only via dynamic import(). - demo/index.html: the plain-HTML reference host for the artifact (mock props, fully offline), with a sealed toggle to watch the chunks lazy-load. - package.json: dual exports (source "."/"./element" + "./artifact" → dist) mirroring cip-179; solid-js floor raised to ^1.9.14 workspace-wide (solid-element 1.9.2 peer). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
QuestionBody picks its widget with a switch that runs once, at creation. The stepper's unkeyed Show kept one card alive across steps, so question 1's single-choice body rendered every later question: multi-select and ranking behaved as single choice, and stepping onto the option-less numericRange question crashed in optionCount. Keying the card remounts it per question (which also refreshes bodies that capture constraints at setup, e.g. NumericBody). Adds an artifact regression test stepping through all seven question types. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cancellation is a host-observed on-chain fact passed via the cancelled prop (surveyStatus stays pure epoch math) — the dev harness now has a Cancelled sample exercising it. The ineligible view's SPO/CC note claimed the roles were unreachable; reword it to say the site must supply the credential, and add an 'SPO (host cred)' sample showing hostCredentials unlock the answering flow (proof stays host-side). Artifact tests cover both: the cancelled notice, and a wallet-free responder answering as SPO with keyKind 'pool' in proveCredentials. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every color flows through the --tessera-* tokens (the gate test forbids literals outside theme.css), so a dark skin is a pure theme-prop override — this Dark sample proves it end to end. The harness darkens its frame alongside, which is also where color-scheme: dark lives: native controls need it and a custom property can't express it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Font families may only be named by the --tessera-{sans,mono,serif}
tokens in theme.css; component rules must use a token or inherit, and
@font-face is banned (it doesn't register inside a shadow root —
loading webfonts is the host page's job).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A Font group re-fonts the widget through the --tessera-{sans,mono,
serif} tokens, using system families since the harness (like any host
that doesn't load the default webfonts) sees the fallbacks. Merged with
the color theme into one theme prop to show the overrides compose.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a hidden `/dev/widget/:key` route that embeds the embeddable
`<tessera-respond>` widget and wires it the way a third-party integrator
would — the living copy-paste example and the sole owner of the real
end-to-end (emit → required_signers → sign → submit) test.
- Consume `@tessera/respond-widget` from workspace source (Vite alias +
`resolve.dedupe` for a single Solid instance); the `/element` subpath
registers the custom element. Adds `solid-element` as an app
devDependency so it resolves from the app root (it lives in the
widget's node_modules otherwise).
- DevWidgetHost.tsx sets the survey props as DOM properties (definition,
surveyRef, responder, tipEpoch, cancelled, priorResponse, locale,
layout), listens for `tessera:{response,change,invalid}` via
addEventListener, maps the emitted `proveCredentials` to
`required_signers`, and signs + submits through `app.submitMetadata`.
Network conformance is enforced host-side (the widget never sees it).
An integration-log side panel shows the emitted payloads.
- Survey page gains a dev-only "Respond via widget" CTA beside the
built-in Respond link, so both answering paths are one click apart.
- Route + screen are behind `import.meta.env.DEV` and lazy-loaded, so the
second copy of the answering UI is dead-code-eliminated from
production (verified: no widget markers in the prod bundle).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A responder eligible in several roles chooses the role *inside* the widget, so the host can't know up front which prior response applies. The singular `priorResponse` prop could only ever prefill one role's answers; switching to another role showed a blank form even when a prior response existed. Make the prop plural — `priorResponses?: readonly SurveyResponse[]`, one per role the responder has answered as — and let the widget's existing `findExistingResponse` select the one matching the current role + credential (exactly how the app's built-in Respond screen already works, just for its single selected role). Switching roles now re-prefills, or clears when that role is fresh. - Respond.tsx: `existing()` selects from the array by chosen role. - element.tsx: default key renamed. - DevWidgetHost.tsx (the reference host): resolve one prior response per respondable role from the deduped bundle, filtered by the wallet's own credential, and pass them all. - artifact test: a multi-role responder prefills DRep's prior answer by default and re-prefills Stakeholder's after picking that role. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Integrator-facing READMEs for the two new packages, plus their rows in the repo layout table: - respond-widget: division of labor, source-vs-artifact consumption, the full prop/event tables, RespondResult/proveCredentials contract, the responder & eligibility model (incl. host-trusted SPO/CC), the priorResponses one-per-role prefill contract, host responsibilities, sealed=quicknet caveat, i18n, theming (dark-mode color-scheme caveat + the "names fonts, never loads them" policy), and copy-paste host examples (plain-HTML + the DevWidgetHost reference host). - respond-core: the pure shared surface — identity/roles/eligibility, drafting, the createI18n factory, and the lazy sealResponse wrapper with the optional-peer workspace note.
The docs described proving a response credential only via required_signers (CIP-179 mechanism A). Mechanism B — a governance vote in the same tx by the same credential, on one of a governance-linked survey's linked actions — is equally valid for DRep/SPO/CC. Add a "Proving credentials" section covering both, and reword every required_signers mention to note the host chooses the mechanism (the widget only declares proveCredentials).
respond-core stops knowing about wallets — Responder is now a plain Partial<Record<Role, Credential>> it takes verbatim (it never validates a credential; the carrying tx binds authenticity, host-side). credentialForRole / respondableRolesFor just index/filter the map; the identity/hostCredentials precedence rule and the respondableRoles-vs-respondableRolesFor twins are gone. The wallet→role derivation moves back to the app, next to the wallet seam: claimableRoles / roleCredential / respondableRoles / walletCredToCip179 return to frontend/app/src/domain/roles.ts, plus a new walletResponder(identity) that builds the map for <tessera-respond>. toResponderIdentity and the slim ResponderIdentity are deleted (the app helpers take WalletIdentity directly); WalletCredential returns to the app's wallet/types.ts. The widget component is unchanged — it only ever passed responder through. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mpizenberg
added a commit
that referenced
this pull request
Aug 11, 2026
The plan folder held nine documents: mostly narrative of work already landed and already described where it belongs. What was not captured anywhere else is folded in here, then the folder is emptied. - ARCHITECTURE gains a §10 "Weighed and rejected": nine cheaper-looking alternatives that were measured or reasoned through and turned down (preview cron cadence, digest ETags, edge-caching the list, the snapshot blob, the credential index, a Durable Object lease, Analytics Engine, retention as a read lever, read replicas). They were the densest surviving content in the D1 study and every one of them is the obvious next idea. Appended as §10 rather than inserted, so no existing section renumbers. - §9 gains the two open items that had no home: full-text search — the one per-request cost that still grows with the archive, and so the one place §0's invariant does not hold — and the reusable results element, without which a host embedding the widget has to fork seven question methods to show an outcome. §8 gains what the design does not claim (sealed is not anonymous, a link is not an endorsement, a session is not a proof, the artifact is a Tessera profile), and §5 the epoch-boundary consequence of keying pparams on the stored snapshot: a transaction built in that window is rejected at submit. - OPERATIONS gains the workload study the collector was built for — the scenarios worth separating, the rule that a cost proportional to retained history is a defect rather than a number, and the instruction not to settle the question against a threshold invented in-house. - interop/preprod.md gains the honesty half of the host contract and names the operating question as its remaining deliverable. - CLAUDE.md gains the cross-reference convention this session paid for twice: design-doc section numbers are a public interface, published packages name the rule instead of a coordinate, READMEs never cite a section. Deliberately not kept: the measured D1 and Cloudflare baselines (they describe the pre-windowed-refresh code and would now mislead), the superseded optimization ideas #1/#5/#8, and the DRepTalk-side product ranking, which the report itself assigns to DRepTalk's own repository. Note: seven of the nine documents were git-ignored, so this deletion is their only record — the two tracked ones remain recoverable from history. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Extracts the survey-answering logic into a reusable, framework-free core and
ships it as an embeddable custom element, so a survey can be answered anywhere —
not just inside the Tessera app. Wallets and chain access stay host-side; the
widget only produces a ready-to-attach CIP-179 label-17 payload.
What's in it
@tessera/respond-core— the pure answering core shared by the app andthe widget: response drafting, responder eligibility, a side-effect-free i18n
factory, and a lazy sealed-submission wrapper. No wallet, chain, or DOM
knowledge.
@tessera/respond-widget— the embeddable<tessera-respond>element: astepper/list answering form with
--tessera-*theming, built to aself-contained ES module. Framework-agnostic; emits
tessera:response/tessera:change/tessera:invalid. Includes a dev harness and README.codes with localized (en/fr) text, consumed by both the app and the widget.
<tessera-respond>reference-host page, andthe wallet→role credential derivation now lives app-side (
domain/roles.ts,walletResponder).The
Respondercontract is a plainPartial<Record<Role, Credential>>: thecore takes each role's credential verbatim and never validates authenticity —
that binding is the host's job, done through the carrying transaction.
Testing
pnpm -r type-check,pnpm -r test, andpnpm format:checkall pass.