diff --git a/.changeset/canonical-format-sample-render-url.md b/.changeset/canonical-format-sample-render-url.md new file mode 100644 index 0000000000..6078aa38b9 --- /dev/null +++ b/.changeset/canonical-format-sample-render-url.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Add `sample_render_url` to canonical format declarations and registry summaries so catalogs can link to human-facing sample renders without implying buyer-asset preview, validation, creative approval, or live delivery fidelity. diff --git a/server/public/publisher-home.html b/server/public/publisher-home.html index 45b7890167..157a54f5b6 100644 --- a/server/public/publisher-home.html +++ b/server/public/publisher-home.html @@ -447,6 +447,14 @@ border-radius: var(--radius-lg); background: var(--color-bg-card); overflow: hidden; + cursor: pointer; + transition: var(--transition-all); + } + .format-card:hover, + .format-card:focus { + border-color: var(--color-brand); + box-shadow: var(--shadow-sm); + outline: none; } .format-preview { display: grid; @@ -547,6 +555,130 @@ color: var(--color-text-secondary); overflow-wrap: anywhere; } + .format-card__actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-3); + } + .format-card__sample-state { + font-size: var(--text-xs); + font-weight: var(--font-semibold); + color: var(--color-brand); + } + .format-dialog { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + padding: var(--space-4); + background: var(--modal-backdrop); + } + .format-dialog__panel { + width: min(720px, 100%); + max-height: min(760px, calc(100vh - 32px)); + overflow: auto; + border-radius: var(--radius-lg); + border: var(--border-1) solid var(--color-border); + background: var(--color-bg-card); + box-shadow: var(--shadow-xl); + } + .format-dialog__header { + position: sticky; + top: 0; + z-index: 1; + display: flex; + justify-content: space-between; + gap: var(--space-4); + align-items: flex-start; + padding: var(--space-5); + border-bottom: var(--border-1) solid var(--color-border-light); + background: var(--color-bg-card); + } + .format-dialog__title { + margin: 0; + font-size: var(--text-xl); + font-weight: var(--font-bold); + color: var(--color-text-heading); + } + .format-dialog__subtitle { + margin-top: 2px; + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--color-text-secondary); + overflow-wrap: anywhere; + } + .format-dialog__close { + border: var(--border-1) solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-subtle); + color: var(--color-text); + cursor: pointer; + font-size: var(--text-lg); + line-height: 1; + padding: var(--space-2); + } + .format-dialog__body { + padding: var(--space-5); + } + .format-dialog__preview { + margin-bottom: var(--space-5); + } + .format-dialog__actions { + display: flex; + gap: var(--space-3); + flex-wrap: wrap; + margin-bottom: var(--space-5); + } + .format-dialog__primary { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 38px; + padding: 0 var(--space-4); + border-radius: var(--radius-md); + background: var(--color-brand); + color: var(--color-bg-card); + text-decoration: none; + font-weight: var(--font-semibold); + } + .format-dialog__empty { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-sm); + } + .format-dialog__section { + margin-top: var(--space-5); + } + .format-dialog__section h4 { + margin: 0 0 var(--space-3); + font-size: var(--text-sm); + font-weight: var(--font-semibold); + color: var(--color-text-heading); + } + .format-detail-list { + display: grid; + gap: var(--space-2); + margin: 0; + } + .format-detail-list div { + display: grid; + grid-template-columns: minmax(120px, 0.35fr) minmax(0, 1fr); + gap: var(--space-3); + padding: var(--space-2) 0; + border-bottom: var(--border-1) solid var(--color-border-light); + } + .format-detail-list dt { + color: var(--color-text-secondary); + font-size: var(--text-xs); + font-weight: var(--font-semibold); + } + .format-detail-list dd { + margin: 0; + font-family: var(--font-mono); + font-size: var(--text-xs); + overflow-wrap: anywhere; + } @media (max-width: 760px) { .publisher-profile__grid { @@ -568,6 +700,10 @@ .publisher-formats__intro { margin-top: var(--space-2); } + .format-detail-list div { + grid-template-columns: 1fr; + gap: var(--space-1); + } } /* Contextual relationship line — single line, dismissible, no @@ -758,6 +894,16 @@

Build adagents.json

.replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); + function safeHttpsUrl(raw) { + if (!raw) return ''; + try { + const url = new URL(String(raw)); + return url.protocol === 'https:' ? url.href : ''; + } catch (_) { + return ''; + } + } + function getDomain() { const path = window.location.pathname; const m = path.match(/^\/publisher\/(.+?)\/?$/); @@ -1297,6 +1443,43 @@

${escapeHtml(name)}

return chips.slice(0, 5); } + function formatParamValue(value) { + if (value === null || value === undefined) return ''; + if (Array.isArray(value) || typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); + } + + function renderFormatParams(params) { + const entries = Object.entries(params || {}) + .filter(([, value]) => value !== undefined && value !== null) + .slice(0, 32); + if (!entries.length) return '

No canonical parameters published.

'; + return `
${ + entries.map(([key, value]) => `
+
${escapeHtml(compactIdentifier(key))}
+
${escapeHtml(formatParamValue(value))}
+
`).join('') + }
`; + } + + function renderFormatDetailList(format, scope) { + const rows = [ + ['Format kind', compactKind(format.format_kind)], + ['Format option ID', format.format_option_id || 'not published'], + ['Scope', scope], + ['Seller preference', format.seller_preference || 'not set'], + ['Experimental', format.experimental ? 'yes' : 'no'], + ]; + return `
${ + rows.map(([label, value]) => `
+
${escapeHtml(label)}
+
${escapeHtml(value)}
+
`).join('') + }
`; + } + function previewShape(format) { const kind = format.format_kind || ''; const params = format.params || {}; @@ -1336,6 +1519,131 @@

${escapeHtml(name)}

`; } + function formatScopeLabel(format, props) { + const propertyNames = new Map( + props + .filter((p) => p.id) + .map((p) => [p.id, p.name || p.id]) + ); + const scopedIds = Array.isArray(format.applies_to_property_ids) ? format.applies_to_property_ids : []; + const scopedTags = Array.isArray(format.applies_to_property_tags) ? format.applies_to_property_tags : []; + if (scopedIds.length) { + return `Applies to ${scopedIds.map((id) => propertyNames.get(id) || compactIdentifier(id)).join(', ')}`; + } + if (scopedTags.length) { + return `Applies to ${scopedTags.map((tag) => compactIdentifier(tag)).join(', ')} properties`; + } + return 'Applies to all listed properties'; + } + + let formatDialogTrigger = null; + + function closeFormatDialog(options = {}) { + const { restoreFocus = true } = options; + const existing = document.getElementById('format-detail-dialog'); + if (existing) existing.remove(); + document.removeEventListener('keydown', handleFormatDialogKeydown); + if (restoreFocus && formatDialogTrigger instanceof HTMLElement) { + formatDialogTrigger.focus(); + } + formatDialogTrigger = null; + } + + function handleFormatDialogKeydown(event) { + if (event.key === 'Escape') { + closeFormatDialog(); + return; + } + if (event.key !== 'Tab') return; + const dialog = document.getElementById('format-detail-dialog'); + if (!dialog) return; + const focusable = Array.from(dialog.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])')) + .filter((el) => el instanceof HTMLElement); + if (!focusable.length) { + event.preventDefault(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + + function showFormatDetails(format, scope, trigger) { + closeFormatDialog({ restoreFocus: false }); + formatDialogTrigger = trigger instanceof HTMLElement ? trigger : null; + const sampleRenderUrl = safeHttpsUrl(format.sample_render_url); + const title = format.display_name || format.format_option_id || compactKind(format.format_kind); + const chips = formatChips(format); + const dialog = document.createElement('div'); + dialog.id = 'format-detail-dialog'; + dialog.className = 'format-dialog'; + dialog.innerHTML = ` + + `; + dialog.addEventListener('click', (event) => { + if (event.target === dialog) closeFormatDialog(); + }); + dialog.querySelector('.format-dialog__close').addEventListener('click', closeFormatDialog); + document.addEventListener('keydown', handleFormatDialogKeydown); + document.body.appendChild(dialog); + dialog.querySelector('.format-dialog__close').focus(); + } + + function bindFormatCards(formats, props) { + const grid = document.getElementById('publisher-formats-grid'); + if (!grid) return; + grid.querySelectorAll('.format-card[data-format-index]').forEach((card) => { + const open = () => { + const index = Number(card.getAttribute('data-format-index')); + const format = formats[index]; + if (!format) return; + showFormatDetails(format, formatScopeLabel(format, props), card); + }; + card.addEventListener('click', (event) => { + if (event.target instanceof Element && event.target.closest('a')) return; + open(); + }); + card.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + open(); + }); + }); + } + function renderFormats(formats, props, agents) { const intro = document.getElementById('publisher-formats-intro'); const grid = document.getElementById('publisher-formats-grid'); @@ -1351,32 +1659,27 @@

${escapeHtml(name)}

grid.innerHTML = `${note}
No creative format declarations recorded yet.
`; return; } - const propertyNames = new Map( - props - .filter((p) => p.id) - .map((p) => [p.id, p.name || p.id]) - ); - grid.innerHTML = note + formats.map((format) => { + grid.innerHTML = note + formats.map((format, index) => { const chips = formatChips(format); - const scopedIds = Array.isArray(format.applies_to_property_ids) ? format.applies_to_property_ids : []; - const scopedTags = Array.isArray(format.applies_to_property_tags) ? format.applies_to_property_tags : []; - const scope = scopedIds.length - ? `Applies to ${scopedIds.map((id) => escapeHtml(propertyNames.get(id) || compactIdentifier(id))).join(', ')}` - : scopedTags.length - ? `Applies to ${scopedTags.map((tag) => escapeHtml(compactIdentifier(tag))).join(', ')} properties` - : 'Applies to all listed properties'; - return `
+ const scope = formatScopeLabel(format, props); + const sampleRenderUrl = safeHttpsUrl(format.sample_render_url); + const title = format.display_name || format.format_option_id || compactKind(format.format_kind); + return `
${renderFormatPreview(format)}
-

${escapeHtml(format.display_name || format.format_option_id || compactKind(format.format_kind))}

+

${escapeHtml(title)}

${format.format_option_id ? `${escapeHtml(compactIdentifier(format.format_option_id))} · ` : ''}${escapeHtml(compactKind(format.format_kind))}
${chips.length ? `
${ chips.map((chip) => `${escapeHtml(chip)}`).join('') }
` : ''} -
${scope}
+
${escapeHtml(scope)}
+ ${sampleRenderUrl ? `
+ Sample render available +
` : ''}
`; }).join(''); + bindFormatCards(formats, props); } // ─── On-file: adagents.json + brand.json file cards ───────── diff --git a/server/src/routes/registry-api.ts b/server/src/routes/registry-api.ts index 187524e7d5..7a16f05b4c 100644 --- a/server/src/routes/registry-api.ts +++ b/server/src/routes/registry-api.ts @@ -151,6 +151,7 @@ type PublisherFormatSummary = { format_option_id?: string; display_name: string; format_kind: string; + sample_render_url?: string; params?: Record; applies_to_property_ids?: string[]; applies_to_property_tags?: string[]; @@ -168,6 +169,17 @@ function stringOrUndefined(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } +function httpsUrlOrUndefined(value: unknown): string | undefined { + const raw = stringOrUndefined(value); + if (!raw) return undefined; + try { + const url = new URL(raw); + return url.protocol === "https:" ? url.href : undefined; + } catch { + return undefined; + } +} + function stringArray(value: unknown, cap = 8): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0).slice(0, cap) @@ -301,6 +313,7 @@ function summarizeFormats( format_option_id: optionId, display_name: displayName, format_kind: formatKind, + sample_render_url: httpsUrlOrUndefined(format.sample_render_url), params, applies_to_property_ids: appliesToPropertyIds, applies_to_property_tags: appliesToPropertyTags, diff --git a/server/src/schemas/registry.ts b/server/src/schemas/registry.ts index 45c05a9958..aac08ad90e 100644 --- a/server/src/schemas/registry.ts +++ b/server/src/schemas/registry.ts @@ -910,6 +910,10 @@ const PublisherFormatSummarySchema = z.object({ format_option_id: z.string().optional().openapi({ description: "Stable format option identifier from adagents.json `formats[]`." }), display_name: z.string().openapi({ description: "Human-readable format label for catalog and publisher UI display." }), format_kind: z.string().openapi({ description: "Canonical format discriminator, such as `image`, `video_hosted`, `native_in_feed`, or `custom`." }), + sample_render_url: HttpsUrlSchema.optional().openapi({ + description: + "Optional HTTPS page where a human can inspect a sample render of this catalog format using publisher- or registry-provided example assets. Informational only; this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, or live delivery guarantee.", + }), params: z.record(z.string(), z.unknown()).optional().openapi({ description: "Canonical format params from the publisher's adagents.json declaration." }), applies_to_property_ids: z.array(z.string()).optional().openapi({ description: "Property IDs this format applies to; absent means all properties." }), applies_to_property_tags: z.array(z.string()).optional().openapi({ description: "Property tags this format applies to; absent means all properties." }), diff --git a/static/openapi/registry.yaml b/static/openapi/registry.yaml index 6eb1257cc3..be2f4fbc69 100644 --- a/static/openapi/registry.yaml +++ b/static/openapi/registry.yaml @@ -1058,6 +1058,10 @@ components: format_kind: type: string description: Canonical format discriminator, such as `image`, `video_hosted`, `native_in_feed`, or `custom`. + sample_render_url: + type: string + pattern: ^https:\/\/ + description: Optional HTTPS page where a human can inspect a sample render of this catalog format using publisher- or registry-provided example assets. Informational only; this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, or live delivery guarantee. params: type: object additionalProperties: {} diff --git a/static/schemas/source/core/product-format-declaration.json b/static/schemas/source/core/product-format-declaration.json index d143dfbff8..c1064ad43e 100644 --- a/static/schemas/source/core/product-format-declaration.json +++ b/static/schemas/source/core/product-format-declaration.json @@ -20,6 +20,12 @@ "type": "string", "description": "Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." }, + "sample_render_url": { + "type": "string", + "format": "uri", + "pattern": "^https://", + "description": "Optional human-facing rendered example for this format declaration. Used by registry/catalog UIs to let buyers inspect the unit shape (for example, an AgenticAdvertising.org-translated Snap unit) before constructing a creative manifest. Uses publisher- or registry-provided sample assets, not buyer-submitted creative assets. Informational only: this URL is not a renderer endpoint, validation oracle, creative approval, or guarantee of how submitted creative will render in live delivery. Sellers SHOULD keep the URL stable while the declaration is active." + }, "applies_to_channels": { "type": "array", "items": { "$ref": "/schemas/enums/channels.json" }, diff --git a/tests/placement-catalog-schema.test.cjs b/tests/placement-catalog-schema.test.cjs index 20d62d3b87..23cc41971a 100644 --- a/tests/placement-catalog-schema.test.cjs +++ b/tests/placement-catalog-schema.test.cjs @@ -413,6 +413,7 @@ test('format options can be referenced by publisher domain or product-local ID', validateDeclaration({ publisher_domain: 'daily-pulse.example', format_option_id: 'homepage_image', + sample_render_url: 'https://creative.adcontextprotocol.org/translated/snap/samples/story', format_kind: 'image', params: { width: 300, @@ -423,6 +424,19 @@ test('format options can be referenced by publisher domain or product-local ID', JSON.stringify(validateDeclaration.errors, null, 2) ); + assert.equal( + validateDeclaration({ + format_option_id: 'homepage_image', + sample_render_url: 'http://creative.adcontextprotocol.org/translated/snap/samples/story', + format_kind: 'image', + params: { + width: 300, + height: 250 + } + }), + false + ); + assert.equal( validateDeclaration({ format_option_id: 'seller_takeover_image',