From 81bc10d3b1b0ff1ab3ba28171e55840add244734 Mon Sep 17 00:00:00 2001 From: Santiago Date: Thu, 30 Jul 2026 13:59:02 -0300 Subject: [PATCH] =?UTF-8?q?fix:=20registry=20UX=20truthfulness=20=E2=80=94?= =?UTF-8?q?=20try-out=20gating,=20snippet=20correctness,=20stable=20orderi?= =?UTF-8?q?ng,=20homepage,=20ref=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Try-out tab: gate channels on actual profile presence in the .tii; empty channel stubs (environment/parties both empty) are hidden. - Quick-start snippets: import from the generated protocol module (no gen/ segment) in all four languages; setup notes state the real default codegen output dir (.tx3/codegen/{plugin}/). - Quick-start snippets: the signer goes on the first non-script party (user/participant), never on a script party; script parties take their address from the profile env when published, and an explicit '' placeholder otherwise. - Backend: transactions and parties resolvers sort by name so the UI order is stable across reloads (TII maps have no iteration order). - Backend/frontend: homepageUrl read from the org.opencontainers.image.url manifest annotation and rendered in the protocol info panel. - Activity: reference inputs of each match are parsed and listed in the detail view so callers can discover ref-UTxO parameter values (e.g. bodega's project_info_ref). Plan: Brain/tx3 plans/feedback-cba-07-services-docs-registry-ux.md Co-Authored-By: Claude Fable 5 --- backend/src/schema/protocol/mod.rs | 26 +++-- backend/src/schema/protocol/query.rs | 14 +++ frontend/@types/graphql.d.ts | 12 +++ frontend/app/components/icons/world.tsx | 23 ++++ frontend/app/gql/protocols.query.ts | 1 + frontend/app/lib/tracker/lifted.ts | 41 ++++++- frontend/app/pages/protocol/details/info.tsx | 11 ++ .../pages/protocol/details/tab/activity.tsx | 31 +++++- .../details/tab/sdks/quick-start/go.ts | 12 ++- .../details/tab/sdks/quick-start/index.ts | 6 +- .../details/tab/sdks/quick-start/python.ts | 12 ++- .../details/tab/sdks/quick-start/rust.ts | 8 +- .../details/tab/sdks/quick-start/shared.ts | 102 +++++++++++++----- .../tab/sdks/quick-start/typescript.ts | 10 +- .../app/pages/protocol/details/tab/tryOut.tsx | 9 +- frontend/schema.graphql | 14 +++ 16 files changed, 277 insertions(+), 55 deletions(-) create mode 100644 frontend/app/components/icons/world.tsx diff --git a/backend/src/schema/protocol/mod.rs b/backend/src/schema/protocol/mod.rs index ca13233..ba20e19 100644 --- a/backend/src/schema/protocol/mod.rs +++ b/backend/src/schema/protocol/mod.rs @@ -56,6 +56,10 @@ pub struct Protocol { name: String, scope: String, repository_url: Option, + /// Project homepage, read from the `org.opencontainers.image.url` + /// annotation of the published OCI manifest. Populated on the detail + /// query (which pulls the manifest); `None` on list queries. + homepage_url: Option, published_date: i64, version: String, readme: Option, @@ -131,21 +135,31 @@ pub struct Tx { #[ComplexObject] impl Protocol { + /// Transactions in a stable order (sorted by name). The TII stores them in + /// a map, whose iteration order must never leak into the API: it would + /// reshuffle the UI on every load. async fn transactions(&self) -> Vec { - if let Some(tii) = &self.tii { - return self.transactions_from_tii(tii); - } + let mut txs = if let Some(tii) = &self.tii { + self.transactions_from_tii(tii) + } else { + self.transactions_from_source() + }; - self.transactions_from_source() + txs.sort_by(|a, b| a.name.cmp(&b.name)); + txs } + /// Parties in a stable order (sorted by name); see [`Self::transactions`]. async fn parties(&self) -> Vec { let Some(tii) = &self.tii else { return vec![] }; - tii.parties.iter().map(|(name, party)| Party { + let mut parties: Vec = tii.parties.iter().map(|(name, party)| Party { name: name.clone(), description: party.description.clone(), - }).collect() + }).collect(); + + parties.sort_by(|a, b| a.name.cmp(&b.name)); + parties } async fn profiles(&self) -> Vec { diff --git a/backend/src/schema/protocol/query.rs b/backend/src/schema/protocol/query.rs index 13e6817..8c262b2 100644 --- a/backend/src/schema/protocol/query.rs +++ b/backend/src/schema/protocol/query.rs @@ -85,6 +85,16 @@ pub async fn build_protocol(resolved: ResolvedProtocol) -> Result<(Protocol, Ima let tii = oci::get_tii(&oci_image) .and_then(|json| serde_json::from_str::(&json).ok()); + // The project homepage travels as the standard OCI `url` annotation on the + // published manifest (`trix publish` maps `[protocol].homepage` to it). + // The zot search summary does not surface it, but the full pull does. + let homepage_url = oci_image + .manifest + .as_ref() + .and_then(|m| m.annotations.as_ref()) + .and_then(|a| a.get("org.opencontainers.image.url")) + .cloned(); + let published_date = if let Some(published_date) = image.last_updated { chrono::DateTime::parse_from_rfc3339(&published_date) .unwrap() @@ -97,6 +107,7 @@ pub async fn build_protocol(resolved: ResolvedProtocol) -> Result<(Protocol, Ima name: image.title.unwrap_or_default(), scope: image.vendor.unwrap_or_default(), repository_url: image.source, + homepage_url, description: image.description, published_date, source, @@ -196,6 +207,9 @@ impl ProtocolQuery { scope: image.vendor.clone().unwrap_or_default(), version: image.tag.clone().unwrap_or_default(), repository_url: image.source.clone(), + // Not in the zot search summary; only the detail + // query (full manifest pull) can populate it. + homepage_url: None, description: image.description.clone(), published_date, source, diff --git a/frontend/@types/graphql.d.ts b/frontend/@types/graphql.d.ts index e01e336..e845975 100644 --- a/frontend/@types/graphql.d.ts +++ b/frontend/@types/graphql.d.ts @@ -95,8 +95,15 @@ interface ProfileParty { interface Protocol { description: Maybe; environment: Array; + /** + * Project homepage, read from the `org.opencontainers.image.url` + * annotation of the published OCI manifest. Populated on the detail + * query (which pulls the manifest); `None` on list queries. + */ + homepageUrl: Maybe; id: Scalars['ID']['output']; name: Scalars['String']['output']; + /** Parties in a stable order (sorted by name); see [`Self::transactions`]. */ parties: Array; profiles: Array; publishedDate: Scalars['Int']['output']; @@ -104,6 +111,11 @@ interface Protocol { repositoryUrl: Maybe; scope: Scalars['String']['output']; source: Maybe; + /** + * Transactions in a stable order (sorted by name). The TII stores them in + * a map, whose iteration order must never leak into the API: it would + * reshuffle the UI on every load. + */ transactions: Array; version: Scalars['String']['output']; } diff --git a/frontend/app/components/icons/world.tsx b/frontend/app/components/icons/world.tsx new file mode 100644 index 0000000..2cc9783 --- /dev/null +++ b/frontend/app/components/icons/world.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from 'react'; + +// Tabler Icons world +export function WorldIcon({ strokeWidth = 1.5, ...props }: SVGProps) { + return ( + + + + + + ); +} diff --git a/frontend/app/gql/protocols.query.ts b/frontend/app/gql/protocols.query.ts index c498261..5693fe6 100644 --- a/frontend/app/gql/protocols.query.ts +++ b/frontend/app/gql/protocols.query.ts @@ -84,6 +84,7 @@ export const DETAIL_QUERY = gql` version publishedDate repositoryUrl + homepageUrl readme description source diff --git a/frontend/app/lib/tracker/lifted.ts b/frontend/app/lib/tracker/lifted.ts index 2336172..bd12f3c 100644 --- a/frontend/app/lib/tracker/lifted.ts +++ b/frontend/app/lib/tracker/lifted.ts @@ -11,9 +11,21 @@ export interface LiftedParty { readonly role: string; } +export interface LiftedReference { + readonly name: string; + /** UTxO reference as `txhash#index`, lowercase hex. */ + readonly ref: string; +} + export interface Lifted { readonly txName: string; readonly parties: Record; + /** + * Reference inputs of the matched transaction. This is how callers discover + * the concrete UTxO refs a protocol expects as parameters (e.g. bodega's + * `project_info_ref`): the values real on-chain transactions used. + */ + readonly references: LiftedReference[]; readonly raw: string; } @@ -46,9 +58,15 @@ interface RawLiftedParty { role?: unknown; } +interface RawLiftedReference { + tir_input_name?: unknown; + utxo_ref?: unknown; +} + interface RawLifted { tx_name?: unknown; parties?: Record; + references?: unknown; } /** @@ -73,5 +91,26 @@ export function parseLifted(json: string): Lifted { } } - return { txName, parties, raw: json }; + // The tracker writes a reference input as + // `{ tir_input_name, utxo_ref: [[...tx hash bytes], index], ... }`. + const references: LiftedReference[] = []; + if (Array.isArray(data.references)) { + for (const value of data.references) { + if (!value || typeof value !== 'object') continue; + const entry = value as RawLiftedReference; + if (!Array.isArray(entry.utxo_ref) || entry.utxo_ref.length !== 2) continue; + const [hash, index] = entry.utxo_ref as [unknown, unknown]; + if (!Array.isArray(hash) || typeof index !== 'number') continue; + try { + references.push({ + name: typeof entry.tir_input_name === 'string' ? entry.tir_input_name : '', + ref: `${bytesToHex(hash as number[])}#${index}`, + }); + } catch { + // malformed byte array — skip this entry, keep the rest + } + } + } + + return { txName, parties, references, raw: json }; } diff --git a/frontend/app/pages/protocol/details/info.tsx b/frontend/app/pages/protocol/details/info.tsx index fc430f6..cacd538 100644 --- a/frontend/app/pages/protocol/details/info.tsx +++ b/frontend/app/pages/protocol/details/info.tsx @@ -2,6 +2,7 @@ import clsx from 'clsx'; import dayjs from 'dayjs'; import { GitIcon } from '~/components/icons/git'; +import { WorldIcon } from '~/components/icons/world'; interface Props { className?: string; @@ -27,6 +28,16 @@ export function Info({ protocol, className }: Props) {

@{protocol.scope}

+ {protocol.homepageUrl && ( + + )} + {protocol.repositoryUrl && (

Repository

diff --git a/frontend/app/pages/protocol/details/tab/activity.tsx b/frontend/app/pages/protocol/details/tab/activity.tsx index 58865eb..44392c6 100644 --- a/frontend/app/pages/protocol/details/tab/activity.tsx +++ b/frontend/app/pages/protocol/details/tab/activity.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useFetcher, useSearchParams } from 'react-router'; import { type darkStyles, JsonView } from 'react-json-view-lite'; -import { parseLifted, truncateHex } from '~/lib/tracker/lifted'; +import { type LiftedReference, parseLifted, truncateHex } from '~/lib/tracker/lifted'; import { useFetcherPolling } from '~/hooks/useFetcherPolling'; import { PartyChip } from './activity/PartyChip'; import { TxNamePill } from './activity/TxNamePill'; @@ -326,7 +326,7 @@ function DetailView({ match, hash, loading }: { match: Match | null; hash: strin ); } - const { parties } = parseLifted(match.lifted); + const { parties, references } = parseLifted(match.lifted); return (
@@ -334,6 +334,7 @@ function DetailView({ match, hash, loading }: { match: Match | null; hash: strin
+
@@ -389,6 +390,32 @@ function PartiesSection({ parties }: { parties: Record +

+ Reference inputs ({references.length}) +

+
+ {references.map((reference, i) => ( +
+ {reference.name || '—'} + {reference.ref} +
+ ))} +
+ + ); +} + function RawLiftedDetails({ rawLifted }: { rawLifted: string; }) { let parsed: unknown = null; let parseFailed = false; diff --git a/frontend/app/pages/protocol/details/tab/sdks/quick-start/go.ts b/frontend/app/pages/protocol/details/tab/sdks/quick-start/go.ts index 5aa81ce..f4bc401 100644 --- a/frontend/app/pages/protocol/details/tab/sdks/quick-start/go.ts +++ b/frontend/app/pages/protocol/details/tab/sdks/quick-start/go.ts @@ -4,7 +4,7 @@ import { type SdkRenderer, toPascalCase, type TrpConfig, - unboundParties, + unboundPartyBindings, userProvidedParams, } from './shared'; @@ -33,14 +33,14 @@ function clientOptionsBlock(trp: TrpConfig): string[] { function quickStart(protocol: Protocol, profile: Profile | null, trp: TrpConfig): string { const hasProfiles = (protocol.profiles ?? []).length > 0; const supplied = profileSuppliedNames(profile); - const unbound = unboundParties(protocol, supplied); + const unbound = unboundPartyBindings(protocol, profile, supplied); const profileArg = hasProfiles && profile ? `, protocol.Profile${toPascalCase(profile.name)}` : ''; - const partyLines = unbound.map((p, i) => { + const partyLines = unbound.map(p => { const setter = `With${toPascalCase(p.name)}`; - if (i === 0) { + if (p.kind === 'signer') { return ` .${setter}(facade.SignerParty(signer))`; } return ` .${setter}(facade.AddressParty(${JSON.stringify(p.address)}))`; @@ -54,7 +54,9 @@ function quickStart(protocol: Protocol, profile: Profile | null, trp: TrpConfig) ' "github.com/tx3-lang/go-sdk/sdk/facade"', ' "github.com/tx3-lang/go-sdk/sdk/signer"', ' "github.com/tx3-lang/go-sdk/sdk/trp"', - ' "./gen/go/protocol"', + '', + ' // The generated module; its package name is `protocol`.', + ` ${JSON.stringify(`yourapp/${protocol.name}`)}`, ')', '', 'ctx := context.Background()', diff --git a/frontend/app/pages/protocol/details/tab/sdks/quick-start/index.ts b/frontend/app/pages/protocol/details/tab/sdks/quick-start/index.ts index cc94605..563b8f5 100644 --- a/frontend/app/pages/protocol/details/tab/sdks/quick-start/index.ts +++ b/frontend/app/pages/protocol/details/tab/sdks/quick-start/index.ts @@ -24,7 +24,11 @@ const RENDERERS: Record = { go: goRenderer, }; -function profileHasData(profile: Profile): boolean { +// A profile "exists" only when the published `.tii` gives it actual content — +// party addresses or environment values. Publishers emit empty stubs for every +// known channel (`profiles.{channel}: { environment: {}, parties: {} }`), so +// key presence alone would list channels the protocol does not really support. +export function profileHasData(profile: Profile): boolean { if (profile.parties.length > 0) return true; if (!profile.environment) return false; try { diff --git a/frontend/app/pages/protocol/details/tab/sdks/quick-start/python.ts b/frontend/app/pages/protocol/details/tab/sdks/quick-start/python.ts index 14f805a..0b9127d 100644 --- a/frontend/app/pages/protocol/details/tab/sdks/quick-start/python.ts +++ b/frontend/app/pages/protocol/details/tab/sdks/quick-start/python.ts @@ -5,12 +5,14 @@ import { toPascalCase, toSnakeCase, type TrpConfig, - unboundParties, + unboundPartyBindings, userProvidedParams, } from './shared'; +// The generated package is the protocol's own folder (snake_cased so the +// module name stays a valid Python identifier); there is no `gen.` prefix. function pythonModule(protocol: Protocol): string { - return `gen.python.${toSnakeCase(protocol.name)}`; + return toSnakeCase(protocol.name); } function clientOptionsLiteral(trp: TrpConfig): string { @@ -27,14 +29,14 @@ function quickStart(protocol: Protocol, profile: Profile | null, trp: TrpConfig) const module = pythonModule(protocol); const hasProfiles = (protocol.profiles ?? []).length > 0; const supplied = profileSuppliedNames(profile); - const unbound = unboundParties(protocol, supplied); + const unbound = unboundPartyBindings(protocol, profile, supplied); const profileArg = hasProfiles && profile ? `, Profile.${toSnakeCase(profile.name).toUpperCase()}` : ''; - const partyLines = unbound.map((p, i) => { + const partyLines = unbound.map(p => { const setter = `with_${toSnakeCase(p.name)}`; - if (i === 0) { + if (p.kind === 'signer') { return `client = client.${setter}(Party.signer(signer))`; } return `client = client.${setter}(Party.address(${JSON.stringify(p.address)}))`; diff --git a/frontend/app/pages/protocol/details/tab/sdks/quick-start/rust.ts b/frontend/app/pages/protocol/details/tab/sdks/quick-start/rust.ts index db5d518..b0c97a5 100644 --- a/frontend/app/pages/protocol/details/tab/sdks/quick-start/rust.ts +++ b/frontend/app/pages/protocol/details/tab/sdks/quick-start/rust.ts @@ -5,7 +5,7 @@ import { toPascalCase, toSnakeCase, type TrpConfig, - unboundParties, + unboundPartyBindings, userProvidedParams, } from './shared'; @@ -39,14 +39,14 @@ function quickStart(protocol: Protocol, profile: Profile | null, trp: TrpConfig) const crate = cratePath(protocol); const hasProfiles = (protocol.profiles ?? []).length > 0; const supplied = profileSuppliedNames(profile); - const unbound = unboundParties(protocol, supplied); + const unbound = unboundPartyBindings(protocol, profile, supplied); const profileArg = hasProfiles && profile ? `, ${crate}::Profile::${toPascalCase(profile.name)}` : ''; - const partyLines = unbound.map((p, i) => { + const partyLines = unbound.map(p => { const setter = `with_${toSnakeCase(p.name)}`; - if (i === 0) { + if (p.kind === 'signer') { return ` .${setter}(Party::signer(signer))`; } return ` .${setter}(Party::address(${JSON.stringify(p.address)}))`; diff --git a/frontend/app/pages/protocol/details/tab/sdks/quick-start/shared.ts b/frontend/app/pages/protocol/details/tab/sdks/quick-start/shared.ts index d16d229..b6b9cf4 100644 --- a/frontend/app/pages/protocol/details/tab/sdks/quick-start/shared.ts +++ b/frontend/app/pages/protocol/details/tab/sdks/quick-start/shared.ts @@ -53,18 +53,83 @@ export const byName = (a: T, b: T) => a.name.locale export const PARTY_ADDRESS_PLACEHOLDER = 'addr_test1...'; +export type PartyBindingKind = 'signer' | 'address'; + export interface PartyBinding { name: string; + kind: PartyBindingKind; + // Meaningful when `kind` is 'address': a concrete address from the profile + // env, or a placeholder naming what the caller must provide. address: string; } +// Script parties (`positionscript`, `commitscript`, ...) are protocol-owned +// addresses. They must never receive the caller's signer or wallet address. +export function isScriptParty(name: string): boolean { + return /script$/i.test(name); +} + +function normalizeKey(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, ''); +} + +const BECH32_ADDRESS = /^(addr|addr_test|stake|stake_test)1[02-9ac-hj-np-z]+$/i; + +// Script-party address sourced from the selected profile's environment: an env +// key matching the party name (optionally suffixed `address`/`addr`) whose +// value is a bech32 address. +export function envScriptAddress(profile: Profile | null, partyName: string): string | null { + if (!profile?.environment) return null; + let env: Record; + try { + env = JSON.parse(profile.environment) as Record; + } catch { + return null; + } + const target = normalizeKey(partyName); + for (const [key, value] of Object.entries(env)) { + const norm = normalizeKey(key); + const matches = norm === target || norm === `${target}address` || norm === `${target}addr`; + if (matches && typeof value === 'string' && BECH32_ADDRESS.test(value)) { + return value; + } + } + return null; +} + +export function scriptAddressPlaceholder(partyName: string): string { + return `<${partyName} script address>`; +} + // Parties declared by the protocol that the profile does NOT supply — these -// need a `.with(...)` call on the generated Client. -export function unboundParties(protocol: Protocol, supplied: Set): PartyBinding[] { - return [...(protocol.parties ?? [])] +// need a `.with(...)` call on the generated Client. The signer belongs +// to the first non-script party (the caller's own wallet, e.g. `user` or +// `participant`); a script party takes its address from the profile env when +// one is published, and an explicit script-address placeholder otherwise. +export function unboundPartyBindings( + protocol: Protocol, + profile: Profile | null, + supplied: Set, +): PartyBinding[] { + const unbound = [...(protocol.parties ?? [])] .filter(p => !supplied.has(p.name)) - .sort(byName) - .map(p => ({ name: p.name, address: PARTY_ADDRESS_PLACEHOLDER })); + .sort(byName); + + const signerName = unbound.find(p => !isScriptParty(p.name))?.name ?? null; + + return unbound.map(p => { + if (p.name === signerName) { + return { name: p.name, kind: 'signer' as const, address: '' }; + } + if (isScriptParty(p.name)) { + return { + name: p.name, + kind: 'address' as const, + address: envScriptAddress(profile, p.name) ?? scriptAddressPlaceholder(p.name), + }; + } + return { name: p.name, kind: 'address' as const, address: PARTY_ADDRESS_PLACEHOLDER }; + }); } export function toCamelCase(name: string): string { @@ -125,11 +190,14 @@ const CODEGEN_PLUGIN: Record = { python: 'python-client', }; +// Default output dir used by `trix codegen` when the `[[codegen]]` entry sets +// no explicit `output_dir`: `.tx3/codegen/{plugin}/` (see trix +// `CodegenConfig::output_dir`). const OUTPUT_DIR: Record = { - typescript: './gen/typescript', - rust: './gen/rust', - go: './gen/go', - python: './gen/python', + typescript: '.tx3/codegen/ts-client', + rust: '.tx3/codegen/rust-client', + go: '.tx3/codegen/go-client', + python: '.tx3/codegen/python-client', }; // Human-readable SDK names, used in prose. @@ -147,22 +215,6 @@ function generatedOutputDir(lang: SDKKey, protocol: Protocol): string { return `${OUTPUT_DIR[lang]}/${protocol.name}`; } -export function bindingPlugin(lang: SDKKey): string { - return CODEGEN_PLUGIN[lang]; -} - -export function bindingsTomlBlock(lang: SDKKey): string { - return [ - '[[codegen]]', - `plugin = ${JSON.stringify(CODEGEN_PLUGIN[lang])}`, - `output_dir = ${JSON.stringify(OUTPUT_DIR[lang])}`, - ].join('\n'); -} - -export function outputDir(lang: SDKKey): string { - return OUTPUT_DIR[lang]; -} - // Shared install-flow steps, covering every SDK end to end. export function commonSetupSteps(lang: SDKKey, protocol: Protocol): SetupStep[] { const ref = `${protocol.scope}/${protocol.name}:${protocol.version}`; diff --git a/frontend/app/pages/protocol/details/tab/sdks/quick-start/typescript.ts b/frontend/app/pages/protocol/details/tab/sdks/quick-start/typescript.ts index ee36436..25d2d47 100644 --- a/frontend/app/pages/protocol/details/tab/sdks/quick-start/typescript.ts +++ b/frontend/app/pages/protocol/details/tab/sdks/quick-start/typescript.ts @@ -5,7 +5,7 @@ import { toCamelCase, toPascalCase, type TrpConfig, - unboundParties, + unboundPartyBindings, userProvidedParams, } from './shared'; @@ -27,19 +27,19 @@ function clientOptionsLiteral(trp: TrpConfig): string { function quickStart(protocol: Protocol, profile: Profile | null, trp: TrpConfig): string { const hasProfiles = (protocol.profiles ?? []).length > 0; const supplied = profileSuppliedNames(profile); - const unbound = unboundParties(protocol, supplied); + const unbound = unboundPartyBindings(protocol, profile, supplied); const profileArg = hasProfiles && profile ? `, ${JSON.stringify(profile.name)}` : ''; - const partyLines = unbound.map((p, i) => { + const partyLines = unbound.map(p => { const setter = `with${toPascalCase(p.name)}`; - if (i === 0) { + if (p.kind === 'signer') { return ` .${setter}(Party.signer(signer))`; } return ` .${setter}(Party.address(${JSON.stringify(p.address)}))`; }); const lines: string[] = [ - `import { Client } from "./gen/typescript/${protocol.name}";`, + `import { Client } from "./${protocol.name}/protocol";`, 'import { CardanoSigner, Party } from "tx3-sdk";', '', 'const signer = await CardanoSigner.fromHex("addr_test1...", "deadbeef...");', diff --git a/frontend/app/pages/protocol/details/tab/tryOut.tsx b/frontend/app/pages/protocol/details/tab/tryOut.tsx index a497b46..4cf2607 100644 --- a/frontend/app/pages/protocol/details/tab/tryOut.tsx +++ b/frontend/app/pages/protocol/details/tab/tryOut.tsx @@ -9,6 +9,9 @@ import { Dropdown } from '~/components/ui/Dropdown'; // Config import { getTrpForProfile } from '~/trp-config'; +// Internal +import { profileHasData } from './sdks/quick-start'; + interface Props { protocol: Protocol; } @@ -227,7 +230,11 @@ const Transaction: React.FunctionComponent = props => { }; export function TabTryOut({ protocol }: Props) { - const profiles = protocol.profiles ?? []; + // Gate channels on actual profile presence in the protocol's `.tii`: a + // channel whose profile is an empty stub is not executable and must not be + // offered. When a missing profile is later published, it appears here + // without further UI work. + const profiles = (protocol.profiles ?? []).filter(profileHasData); return (
diff --git a/frontend/schema.graphql b/frontend/schema.graphql index bbd7d78..0068c1e 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -107,12 +107,26 @@ type Protocol { name: String! scope: String! repositoryUrl: String + """ + Project homepage, read from the `org.opencontainers.image.url` + annotation of the published OCI manifest. Populated on the detail + query (which pulls the manifest); `None` on list queries. + """ + homepageUrl: String publishedDate: Int! version: String! readme: String source: String description: String + """ + Transactions in a stable order (sorted by name). The TII stores them in + a map, whose iteration order must never leak into the API: it would + reshuffle the UI on every load. + """ transactions: [Tx!]! + """ + Parties in a stable order (sorted by name); see [`Self::transactions`]. + """ parties: [Party!]! profiles: [Profile!]! environment: [EnvironmentParam!]!