Skip to content

Add @tessera/respond-core + embeddable <tessera-respond> widget - #5

Merged
mpizenberg merged 18 commits into
mainfrom
component
Jul 15, 2026
Merged

Add @tessera/respond-core + embeddable <tessera-respond> widget#5
mpizenberg merged 18 commits into
mainfrom
component

Conversation

@mpizenberg

Copy link
Copy Markdown
Owner

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 and
    the 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: a
    stepper/list answering form with --tessera-* theming, built to a
    self-contained ES module. Framework-agnostic; emits tessera:response /
    tessera:change / tessera:invalid. Includes a dev harness and README.
  • Localized cip-179 validation — validation problems now carry structured
    codes with localized (en/fr) text, consumed by both the app and the widget.
  • App integration — a dev-only <tessera-respond> reference-host page, and
    the wallet→role credential derivation now lives app-side (domain/roles.ts,
    walletResponder).

The Responder contract is a plain Partial<Record<Role, Credential>>: the
core 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, and pnpm format:check all pass.

mpizenberg and others added 18 commits July 13, 2026 09:49
`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
mpizenberg merged commit a467076 into main Jul 15, 2026
1 check passed
@mpizenberg
mpizenberg deleted the component branch July 15, 2026 16:13
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant