From a4fe5c9e9f3bb398bb8dae5b110a2e4442339041 Mon Sep 17 00:00:00 2001 From: Sy Bohy Date: Mon, 26 Oct 2026 15:36:32 -0700 Subject: [PATCH 1/3] feat: add provider-scoped Ecosystem device embeds for docs --- README.md | 36 ++++++++ examples/supported-devices/index.html | 15 ++++ examples/supported-devices/src/App.tsx | 67 +++++++++++++++ examples/supported-devices/src/config.test.ts | 27 ++++++ examples/supported-devices/src/config.ts | 34 ++++++++ examples/supported-devices/src/main.tsx | 10 +++ examples/supported-devices/src/style.css | 21 +++++ .../SupportedDevices/DeviceModelRow.tsx | 36 +++++++- .../SupportedDevices.element.ts | 1 + .../SupportedDevices.stories.tsx | 4 + .../SupportedDevices.test.tsx | 59 ++++++++++++- .../SupportedDevices/SupportedDevices.tsx | 14 +++- .../SupportedDevices/device-models.test.ts | 83 +++++++++++++++++++ .../SupportedDevices/device-models.ts | 69 ++++++++++++++- .../use-ecosystem-device-model-summaries.ts | 20 +++-- src/styles/_supported-devices.scss | 16 ++++ test/fixtures/ecosystem.tsx | 4 + tsconfig.json | 2 +- vercel.json | 26 +++++- 19 files changed, 527 insertions(+), 17 deletions(-) create mode 100644 examples/supported-devices/index.html create mode 100644 examples/supported-devices/src/App.tsx create mode 100644 examples/supported-devices/src/config.test.ts create mode 100644 examples/supported-devices/src/config.ts create mode 100644 examples/supported-devices/src/main.tsx create mode 100644 examples/supported-devices/src/style.css diff --git a/README.md b/README.md index c4761e844..f679cce69 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,7 @@ Load the lightweight `ecosystem.js` bundle, which only contains these two elemen | Attribute | Description | | -------------- | ------------------------------------------------------------------------------------- | | `manufacturer` | DeviceDB manufacturer slug. Defaults to the manufacturer of a manufacturer embed key. | +| `provider` | Ecosystem provider slug, e.g., `oracode`. Scopes both models and capability badges. | | `device-type` | DeviceDB device type slug, e.g., `smartlock` or `thermostat`. | | `capability` | Only devices supporting this capability, e.g., `can_program_online_access_codes`. | | `hide-search` | Hide the search field. | @@ -232,6 +233,41 @@ The data is also available with the `useCompatiblePartners`, `useEcosystemDevice [Seam Ecosystem]: https://ecosystem.seam.co/ +### Hosted supported-device embed + +The native React page at +`https://react.seam.co/examples/supported-devices/` is designed for documentation +hosts such as Mintlify that cannot import an npm component. It composes +`EcosystemProvider` and `SupportedDevices`, reads the public production Ecosystem +catalog without an embed key, and never uses Storybook fixtures or Strapi. + +Query parameters are `manufacturer`, `provider` (Ecosystem slugs, not Seam Connect +provider keys), `device_type`, `capability`, and `theme` (`light`, `dark`, `auto`). +For example, use `?manufacturer=dormakaba&provider=oracode` to avoid listing other +Dormakaba systems. Provider scoping applies to both models and capability badges. +Unavailable, planned, and stale capabilities are not advertised as supported; +beta capabilities are labelled, and conditional requirements can be expanded. + +Embed the page in an iframe with `referrerpolicy="origin"`. Approved frame +ancestors are Seam's website/docs, Mintlify previews, and localhost development. +Other routes retain their existing frame restrictions. The host should always +provide a device catalog link outside the iframe, so it remains available if +scripts or the iframe fail to load. + +The iframe posts `{ type: 'seam-supported-devices:resize', height }` to the exact +origin from `document.referrer` whenever its content height changes. The host +must check both `event.origin === 'https://react.seam.co'` and +`event.source === iframe.contentWindow`, then validate a finite positive height. +From that same parent window and origin, the page accepts +`{ type: 'seam-supported-devices:theme', theme: 'light' | 'dark' }` and +`{ type: 'seam-supported-devices:measure' }`. This lets docs follow their theme +toggle and request a fresh measurement after loading. Listeners and observers +are removed on unmount. + +Deploy this React change before switching the docs snippet to the new URL. +The page uses the component built in the same deployment and hashed assets; +it does not load an unpinned npm package or CDN script at runtime. + ### React Hooks All components interact with the Seam API via a set of React Hooks. diff --git a/examples/supported-devices/index.html b/examples/supported-devices/index.html new file mode 100644 index 000000000..c3e62f7ec --- /dev/null +++ b/examples/supported-devices/index.html @@ -0,0 +1,15 @@ + + + + + + + Supported devices — Seam + + +
+ Loading supported devices… +
+ + + diff --git a/examples/supported-devices/src/App.tsx b/examples/supported-devices/src/App.tsx new file mode 100644 index 000000000..a6f8f4c0c --- /dev/null +++ b/examples/supported-devices/src/App.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react' + +import { SupportedDevices } from 'lib/seam/components/SupportedDevices/SupportedDevices.js' +import { EcosystemProvider } from 'lib/seam/ecosystem/EcosystemProvider.js' + +import { getEmbedConfig, getParentOrigin } from './config.js' + +const config = getEmbedConfig(globalThis.location.search) +const parentOrigin = getParentOrigin(globalThis.document.referrer) + +export function App(): JSX.Element { + const [theme, setTheme] = useState(config.theme) + + useEffect(() => { + if (parentOrigin == null || globalThis.parent === globalThis.window) return + let previousHeight = 0 + const measure = (): void => { + const height = Math.ceil( + globalThis.document.body.getBoundingClientRect().height + ) + if (height <= 0 || height === previousHeight) return + previousHeight = height + globalThis.parent.postMessage( + { type: 'seam-supported-devices:resize', height }, + parentOrigin + ) + } + const onMessage = (event: MessageEvent): void => { + if (event.source !== globalThis.parent || event.origin !== parentOrigin) + return + const data = event.data as { type?: string; theme?: string } | null + if ( + data?.type === 'seam-supported-devices:theme' && + (data.theme === 'dark' || data.theme === 'light') + ) + setTheme(data.theme) + if (data?.type === 'seam-supported-devices:measure') { + previousHeight = 0 + measure() + } + } + const observer = new ResizeObserver(measure) + observer.observe(globalThis.document.body) + globalThis.addEventListener('message', onMessage) + measure() + return () => { + observer.disconnect() + globalThis.removeEventListener('message', onMessage) + } + }, []) + + const catalogUrl = + config.manufacturer == null + ? 'https://www.seam.co/supported-devices-and-systems' + : `https://www.seam.co/manufacturers/${encodeURIComponent(config.manufacturer)}` + + return ( + + +

+ + View the device catalog on seam.co + +

+
+ ) +} diff --git a/examples/supported-devices/src/config.test.ts b/examples/supported-devices/src/config.test.ts new file mode 100644 index 000000000..2e078d4ab --- /dev/null +++ b/examples/supported-devices/src/config.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from 'vitest' + +import { getEmbedConfig, getParentOrigin } from './config.js' + +test('embed config scopes a provider without inventing a manufacturer', () => { + expect( + getEmbedConfig('?provider=first-alert&device_type=thermostat&theme=dark') + ).toEqual({ + manufacturer: undefined, + provider: 'first-alert', + deviceType: 'thermostat', + capability: undefined, + theme: 'dark', + hideTitle: true, + }) + expect(getEmbedConfig('?manufacturer=august&theme=invalid').theme).toBe( + 'auto' + ) +}) + +test('parent messages use the exact HTTP origin of the referrer', () => { + expect(getParentOrigin('https://www.seam.co/docs/august')).toBe( + 'https://www.seam.co' + ) + expect(getParentOrigin('')).toBeNull() + expect(getParentOrigin('javascript:alert(1)')).toBeNull() +}) diff --git a/examples/supported-devices/src/config.ts b/examples/supported-devices/src/config.ts new file mode 100644 index 000000000..412fd0182 --- /dev/null +++ b/examples/supported-devices/src/config.ts @@ -0,0 +1,34 @@ +import type { SupportedDevicesProps } from 'lib/seam/components/SupportedDevices/SupportedDevices.js' + +export const getEmbedConfig = (search: string): SupportedDevicesProps => { + const params = new URLSearchParams(search) + const theme = params.get('theme') + return { + manufacturer: optionalParam(params, 'manufacturer'), + provider: optionalParam(params, 'provider'), + deviceType: optionalParam(params, 'device_type'), + capability: optionalParam(params, 'capability'), + theme: theme === 'dark' || theme === 'light' ? theme : 'auto', + hideTitle: true, + } +} + +/** An exact origin, never a wildcard; referrerPolicy="origin" on the host iframe. */ +export const getParentOrigin = (referrer: string): string | null => { + try { + const url = new URL(referrer) + return url.protocol === 'https:' || url.protocol === 'http:' + ? url.origin + : null + } catch { + return null + } +} + +const optionalParam = ( + params: URLSearchParams, + name: string +): string | undefined => { + const value = params.get(name)?.trim() + return value == null || value.length === 0 ? undefined : value +} diff --git a/examples/supported-devices/src/main.tsx b/examples/supported-devices/src/main.tsx new file mode 100644 index 000000000..ab1da13df --- /dev/null +++ b/examples/supported-devices/src/main.tsx @@ -0,0 +1,10 @@ +import '@seamapi/react/index.css' +import './style.css' + +import { createRoot } from 'react-dom/client' + +import { App } from './App.js' + +const root = globalThis.document.getElementById('root') +if (root == null) throw new Error('Root element not found') +createRoot(root).render() diff --git a/examples/supported-devices/src/style.css b/examples/supported-devices/src/style.css new file mode 100644 index 000000000..8ea9b6d02 --- /dev/null +++ b/examples/supported-devices/src/style.css @@ -0,0 +1,21 @@ +html, +body { + margin: 0; + padding: 0; + background: transparent; +} + +body { + display: flow-root; + font-family: system-ui, sans-serif; +} + +.seam-device-embed-footer { + margin: 0; + padding: 12px 0; + font-size: 13px; +} + +.seam-device-embed-footer a { + color: #6675d9; +} diff --git a/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx b/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx index fd4d55750..0f2d1bae7 100644 --- a/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx +++ b/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx @@ -35,6 +35,17 @@ export function DeviceModelRow({ const capabilities = getSupportedCapabilities(deviceModel).filter( (c) => c.name !== 'device_connectivity' ) + const requirements = [ + ...new Set( + capabilities.flatMap((capability) => [ + ...capability.requirements, + ...(capability.supported === 'conditionally' && + capability.details != null + ? [capability.details] + : []), + ]) + ), + ] const meta = [ modelIds.length > 0 ? modelIds.slice(0, 2).join(', ') : null, @@ -84,11 +95,15 @@ export function DeviceModelRow({ })} title={ capability.supported === 'conditionally' - ? (capability.details ?? t.conditional) + ? [ + ...capability.requirements, + capability.details ?? t.conditional, + ].join('\n') : undefined } > {getCapabilityLabel(capability.name)} + {capability.integrationSupportLevel === 'beta' && ' (beta)'} {capability.supported === 'conditionally' && ( {` (${t.conditional})`} @@ -97,6 +112,22 @@ export function DeviceModelRow({ ))} + {deviceModel.capabilities_stale ? ( + {t.refreshing} + ) : capabilities.length === 0 && + deviceModel.device_type?.is_accessory !== true ? ( + {t.unconfirmed} + ) : null} + {requirements.length > 0 && ( +
+ {t.requirements} +
    + {requirements.map((requirement) => ( +
  • {requirement}
  • + ))} +
+
+ )} ) } @@ -105,4 +136,7 @@ const t = { via: (providers: string) => `via ${providers}`, badges: 'Device type and capabilities', conditional: 'conditionally supported', + requirements: 'Requirements and compatibility', + refreshing: 'Compatibility is being refreshed.', + unconfirmed: 'Compatibility not confirmed for this integration.', } diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.element.ts b/src/lib/seam/components/SupportedDevices/SupportedDevices.element.ts index e62783d2b..b2b46f12c 100644 --- a/src/lib/seam/components/SupportedDevices/SupportedDevices.element.ts +++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.element.ts @@ -6,6 +6,7 @@ export const name = 'seam-supported-devices' export const props: EcosystemElementProps = { manufacturer: 'string', + provider: 'string', deviceType: 'string', capability: 'string', hideSearch: 'boolean', diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.stories.tsx b/src/lib/seam/components/SupportedDevices/SupportedDevices.stories.tsx index 37fc59bc6..f11f348bd 100644 --- a/src/lib/seam/components/SupportedDevices/SupportedDevices.stories.tsx +++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.stories.tsx @@ -68,6 +68,10 @@ export const SingleManufacturer: Story = { args: { manufacturer: 'schlage' }, } +export const SingleProvider: Story = { + args: { manufacturer: 'schlage', provider: 'schlage-engage' }, +} + export const ManufacturerEmbedKey: Story = { args: { embedKey: storybookEmbedKeys.manufacturer }, } diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx b/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx index 0f8e4ccd1..ee2449785 100644 --- a/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx +++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, screen, within } from '@testing-library/react' -import { expect, test } from 'vitest' +import { expect, test, vi } from 'vitest' import { type EcosystemTestContext, @@ -15,6 +15,39 @@ import { SupportedDevices } from './SupportedDevices.js' setupFakeEcosystem() +test('SupportedDevices follows every catalog page', async (ctx) => { + const fetch = globalThis.fetch + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async (input, init) => { + const response = await fetch(input, init) + const url = new URL(String(input)) + if ( + !url.pathname.endsWith('device_models/summaries/list') || + url.searchParams.has('page_cursor') + ) + return response + const data = await response.json() + data.device_models = data.device_models.slice(0, 2) + data.pagination = { + has_next_page: true, + next_page_cursor: data.device_models[1].slug, + } + return new Response(JSON.stringify(data), { status: 200 }) + }) + try { + renderWithEcosystem(, ctx) + await screen.findByText('7 devices') + expect( + ctx.ecosystemRequestUrls.filter((url) => + url.pathname.endsWith('device_models/summaries/list') + ) + ).toHaveLength(2) + } finally { + fetchSpy.mockRestore() + } +}) + const getSectionTitles = (): string[] => screen .getAllByRole('heading', { level: 3 }) @@ -58,6 +91,30 @@ test('SupportedDevices shows one manufacturer', async (ctx screen.getByText('Engage Gateway') }) +test('SupportedDevices scopes the query and badges to a provider', async (ctx) => { + renderWithEcosystem( + , + ctx + ) + await screen.findByText('Control Smart Lock') + expect(screen.queryByText('Encode Plus Smart WiFi Deadbolt')).toBeNull() + expect(screen.queryByText(/via Schlage Home/)).toBeNull() + screen.getByText('Engage Gateway') + const request = ctx.ecosystemRequestUrls.find((url) => + url.pathname.endsWith('device_models/summaries/list') + ) + expect(request?.searchParams.get('provider')).toBe('schlage-engage') +}) + +test('conditional requirements are readable without hovering', async (ctx) => { + renderWithEcosystem(, ctx) + const name = await screen.findByText('Smart Lock Pro') + const row = name.closest('li') + if (row == null) throw new Error('Missing row') + within(row).getByText('Requirements and compatibility') + within(row).getByText(/Requires an August keypad/) +}) + test('SupportedDevices shows device type and capability badges', async (ctx) => { renderWithEcosystem(, ctx) const name = await screen.findByText('Encode Plus Smart WiFi Deadbolt') diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx b/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx index a1253e5bd..ff0771f9e 100644 --- a/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx +++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx @@ -10,6 +10,7 @@ import { getManufacturerName, getManufacturerOptions, groupDeviceModelsByManufacturer, + scopeDeviceModelsToProvider, } from 'lib/seam/components/SupportedDevices/device-models.js' import { FilterSelect } from 'lib/seam/components/SupportedDevices/FilterSelect.js' import { ManufacturerSection } from 'lib/seam/components/SupportedDevices/ManufacturerSection.js' @@ -35,6 +36,8 @@ export interface SupportedDevicesProps { * Defaults to the manufacturer of a manufacturer embed key. */ manufacturer?: string | undefined + /** Ecosystem provider slug, e.g., `oracode`. Scopes models and capability badges. */ + provider?: string | undefined /** DeviceDB device type slug, e.g., `smartlock` or `thermostat`. */ deviceType?: string | undefined /** Only devices supporting this capability, e.g., `can_program_online_access_codes`. */ @@ -53,6 +56,7 @@ export interface SupportedDevicesProps { */ export function SupportedDevices({ manufacturer, + provider, deviceType, capability, hideSearch = false, @@ -63,6 +67,7 @@ export function SupportedDevices({ }: SupportedDevicesProps): JSX.Element { const { embedKey } = useEcosystemContext() const explicitManufacturer = normalizeSlug(manufacturer) + const fixedProvider = normalizeSlug(provider) const fixedDeviceType = normalizeSlug(deviceType) const fixedCapability = normalizeSlug(capability) @@ -84,6 +89,7 @@ export function SupportedDevices({ refetch: refetchDeviceModels, } = useEcosystemDeviceModelSummaries({ manufacturer: scopeManufacturer ?? '', + provider: fixedProvider ?? '', deviceType: fixedDeviceType ?? '', capability: fixedCapability != null && fixedCapability in capabilityLabels @@ -93,8 +99,12 @@ export function SupportedDevices({ }) const availableDeviceModels = useMemo( - () => applyEmbedExclusions(deviceModels ?? [], embedResolution?.exclusions), - [deviceModels, embedResolution] + () => + scopeDeviceModelsToProvider( + applyEmbedExclusions(deviceModels ?? [], embedResolution?.exclusions), + fixedProvider + ), + [deviceModels, embedResolution, fixedProvider] ) const manufacturerSlugs = useMemo( diff --git a/src/lib/seam/components/SupportedDevices/device-models.test.ts b/src/lib/seam/components/SupportedDevices/device-models.test.ts index bea8ed0d2..9291fa33e 100644 --- a/src/lib/seam/components/SupportedDevices/device-models.test.ts +++ b/src/lib/seam/components/SupportedDevices/device-models.test.ts @@ -13,6 +13,7 @@ import { getCapabilityOptions, getSupportedCapabilities, groupDeviceModelsByManufacturer, + scopeDeviceModelsToProvider, } from './device-models.js' const ref = (slug: string): { slug: string; display_name: string } => ({ @@ -173,11 +174,15 @@ test('getSupportedCapabilities: takes the best support across providers', () => name: 'can_remotely_unlock', supported: 'yes', details: 'can_remotely_unlock via cloud', + integrationSupportLevel: 'stable', + requirements: [], }, { name: 'can_provision_mobile_keys', supported: 'yes', details: 'can_provision_mobile_keys via cloud', + integrationSupportLevel: 'stable', + requirements: [], }, ]) expect(getSupportedCapabilities(at(models, 1))).toEqual([ @@ -185,6 +190,8 @@ test('getSupportedCapabilities: takes the best support across providers', () => name: 'can_remotely_unlock', supported: 'conditionally', details: 'can_remotely_unlock via hub', + integrationSupportLevel: 'stable', + requirements: [], }, ]) expect(getSupportedCapabilities(at(models, 2)).map((c) => c.name)).toEqual([ @@ -192,6 +199,82 @@ test('getSupportedCapabilities: takes the best support across providers', () => ]) }) +test('provider scope removes unrelated models, providers and capability badges', () => { + const result = scopeDeviceModelsToProvider(models, 'hub') + expect(result.map((m) => m.slug)).toEqual(['a', 'b']) + expect(result[0]?.providers.map((p) => p.slug)).toEqual(['hub']) + expect( + result[0]?.capabilities_by_provider.map((p) => p.provider.slug) + ).toEqual(['hub']) + expect(getSupportedCapabilities(at(result, 0)).map((c) => c.name)).toEqual([ + 'can_remotely_unlock', + ]) + expect(scopeDeviceModelsToProvider(models, 'unknown')).toEqual([]) + expect(scopeDeviceModelsToProvider(models, null)).toBe(models) +}) + +test('conditional requirements include dependency reports without following cycles', () => { + const model = deviceModel({ + slug: 'lock', + capabilities: { + cloud: { + can_program_online_access_codes: 'conditionally', + device_connectivity: 'conditionally', + }, + }, + }) + const caps = model.capabilities_by_provider[0]?.capabilities + if (caps == null) throw new Error('Missing capabilities') + const codes = caps.find((c) => c.name === 'can_program_online_access_codes') + const connectivity = caps.find((c) => c.name === 'device_connectivity') + if (codes == null || connectivity == null) + throw new Error('Missing capability') + codes.report = { + details: 'Codes need a keypad.', + requirements: { keypad: { message: 'An external keypad is required.' } }, + dependencies: ['device_connectivity'], + } + connectivity.report = { + details: 'A Wi-Fi bridge is required.', + dependencies: ['can_program_online_access_codes'], + } + expect( + getSupportedCapabilities(model).find((c) => c.name === codes.name) + ?.requirements + ).toEqual([ + 'An external keypad is required.', + 'A Wi-Fi bridge is required.', + 'Codes need a keypad.', + ]) +}) + +test('unavailable or stale capabilities never become supported badges or filters', () => { + const model = deviceModel({ + slug: 'lock', + capabilities: { cloud: { can_remotely_unlock: 'yes' } }, + }) + const capability = model.capabilities_by_provider[0]?.capabilities[0] + if (capability == null) throw new Error('Missing capability') + for (const level of [ + 'planned', + 'in-progress', + 'unavailable', + 'inquire', + ] as const) { + capability.integration_support_level = level + expect(getSupportedCapabilities(model)).toEqual([]) + expect( + filterDeviceModels([model], { capability: 'can_remotely_unlock' }) + ).toEqual([]) + } + capability.integration_support_level = 'beta' + expect(getSupportedCapabilities(model)[0]?.integrationSupportLevel).toBe( + 'beta' + ) + model.capabilities_stale = true + expect(getSupportedCapabilities(model)).toEqual([]) +}) + test('filterDeviceModels: filters by manufacturer, device type, capability and search', () => { const slugs = (filters: Parameters[1]): string[] => filterDeviceModels(models, filters).map((m) => m.slug) diff --git a/src/lib/seam/components/SupportedDevices/device-models.ts b/src/lib/seam/components/SupportedDevices/device-models.ts index d253ae6cb..8cbe1d835 100644 --- a/src/lib/seam/components/SupportedDevices/device-models.ts +++ b/src/lib/seam/components/SupportedDevices/device-models.ts @@ -3,6 +3,7 @@ import type { CapabilitySummary, DeviceModelSummary, EmbedExclusions, + IntegrationSupportLevel, Manufacturer, ManufacturerRef, } from 'lib/seam/ecosystem/types.js' @@ -18,6 +19,8 @@ export interface SupportedCapability { name: CapabilityName supported: 'yes' | 'conditionally' details: string | null + integrationSupportLevel: IntegrationSupportLevel | null + requirements: string[] } export interface ManufacturerGroup { @@ -121,21 +124,51 @@ export const applyEmbedExclusions = ( return result } -/** The best support for each capability across the model's providers. */ +/** The API filters models by provider, but still returns every provider's capabilities. */ +export const scopeDeviceModelsToProvider = ( + deviceModels: DeviceModelSummary[], + provider: string | null | undefined +): DeviceModelSummary[] => { + if (provider == null) return deviceModels + return deviceModels + .filter((model) => model.providers.some((p) => p.slug === provider)) + .map((model) => ({ + ...model, + providers: model.providers.filter((p) => p.slug === provider), + capabilities_by_provider: model.capabilities_by_provider.filter( + (entry) => entry.provider.slug === provider + ), + })) +} + +/** The best currently available support for each capability across the model's providers. */ export const getSupportedCapabilities = ( deviceModel: DeviceModelSummary ): SupportedCapability[] => { + if (deviceModel.capabilities_stale) return [] const best = new Map() for (const { capabilities } of deviceModel.capabilities_by_provider) { for (const capability of capabilities) { const supported = toSupported(capability) if (supported == null) continue const current = best.get(capability.name) - if (current?.supported === 'yes') continue + if (current != null) { + const currentRank = supportRank( + current.integrationSupportLevel, + current.supported + ) + const nextRank = supportRank( + capability.integration_support_level, + supported + ) + if (currentRank >= nextRank) continue + } best.set(capability.name, { name: capability.name, supported, details: capability.report?.details ?? null, + integrationSupportLevel: capability.integration_support_level, + requirements: getCapabilityRequirements(capability, capabilities), }) } } @@ -144,6 +177,36 @@ export const getSupportedCapabilities = ( ) } +const supportRank = ( + level: IntegrationSupportLevel | null, + supported: SupportedCapability['supported'] +): number => + (level === 'stable' ? 4 : level === 'beta' ? 2 : 0) + + (supported === 'yes' ? 1 : 0) + +const getCapabilityRequirements = ( + capability: CapabilitySummary, + capabilities: CapabilitySummary[], + visited = new Set() +): string[] => { + if (visited.has(capability.name)) return [] + visited.add(capability.name) + const requirements = Object.values(capability.report?.requirements ?? {}).map( + (requirement) => requirement.message + ) + for (const name of capability.report?.dependencies ?? []) { + const dependency = capabilities.find((c) => c.name === name) + if (dependency == null) continue + if (dependency.supported !== 'yes' && dependency.report?.details != null) { + requirements.push(dependency.report.details) + } + requirements.push( + ...getCapabilityRequirements(dependency, capabilities, visited) + ) + } + return [...new Set(requirements)] +} + export const supportsCapability = ( deviceModel: DeviceModelSummary, capability: string @@ -305,6 +368,8 @@ export const getManufacturerLogoUrl = ( const toSupported = ( capability: CapabilitySummary ): SupportedCapability['supported'] | null => { + const level = capability.integration_support_level + if (level != null && level !== 'stable' && level !== 'beta') return null if (capability.supported === 'yes') return 'yes' if (capability.supported === 'conditionally') return 'conditionally' return null diff --git a/src/lib/seam/ecosystem/use-ecosystem-device-model-summaries.ts b/src/lib/seam/ecosystem/use-ecosystem-device-model-summaries.ts index 577b6cfb5..7c89c6592 100644 --- a/src/lib/seam/ecosystem/use-ecosystem-device-model-summaries.ts +++ b/src/lib/seam/ecosystem/use-ecosystem-device-model-summaries.ts @@ -1,9 +1,6 @@ import { useQuery } from '@tanstack/react-query' -import { - type EcosystemApiError, - fetchEcosystem, -} from 'lib/seam/ecosystem/client.js' +import { EcosystemApiError, fetchEcosystem } from 'lib/seam/ecosystem/client.js' import { useEcosystemContext } from 'lib/seam/ecosystem/EcosystemProvider.js' import type { DeviceModelSummariesListResponse, @@ -77,10 +74,21 @@ export function useEcosystemDeviceModelSummaries({ deviceModels.push(...res.device_models) const { has_next_page: hasNextPage, next_page_cursor: nextPageCursor } = res.pagination - if (!hasNextPage || nextPageCursor == null) break + if (!hasNextPage) return deviceModels + if (nextPageCursor == null || nextPageCursor === pageCursor) { + throw new EcosystemApiError({ + status: 502, + type: 'pagination_incomplete', + message: 'The device catalog returned an invalid page cursor.', + }) + } pageCursor = nextPageCursor } - return deviceModels + throw new EcosystemApiError({ + status: 502, + type: 'pagination_incomplete', + message: 'The device catalog exceeded the supported number of pages.', + }) }, retry: retryEcosystemQuery, }) diff --git a/src/styles/_supported-devices.scss b/src/styles/_supported-devices.scss index aefa2b854..6f83df194 100644 --- a/src/styles/_supported-devices.scss +++ b/src/styles/_supported-devices.scss @@ -139,6 +139,22 @@ border-top: 1px solid var(--seam-ecosystem-line-1); } + .seam-supported-devices-status, + .seam-supported-devices-requirements { + grid-column: 2 / -1; + color: var(--seam-ecosystem-fg-2); + font-size: 13px; + } + + .seam-supported-devices-requirements summary { + cursor: pointer; + } + + .seam-supported-devices-requirements ul { + margin: 8px 0 0; + padding-left: 20px; + } + @container (width >= 720px) { .seam-supported-devices-row { grid-template-columns: 48px minmax(0, 1fr) minmax(0, 45%); diff --git a/test/fixtures/ecosystem.tsx b/test/fixtures/ecosystem.tsx index 651370552..644292478 100644 --- a/test/fixtures/ecosystem.tsx +++ b/test/fixtures/ecosystem.tsx @@ -21,6 +21,7 @@ export interface EcosystemTestContext { ecosystemEndpoint: string /** The method and path of every request to the fake, e.g., `GET v1/compatibility`. */ ecosystemRequests: string[] + ecosystemRequestUrls: URL[] } const fakeEcosystemOrigin = 'http://fake-ecosystem.test' @@ -35,6 +36,7 @@ export const setupFakeEcosystem = (): void => { const requests: string[] = [] ctx.ecosystemEndpoint = `${fakeEcosystemOrigin}/v1/` ctx.ecosystemRequests = requests + ctx.ecosystemRequestUrls = [] const { fetch } = globalThis // UPSTREAM: Under happy-dom, the global fetch is Node's, @@ -46,6 +48,7 @@ export const setupFakeEcosystem = (): void => { const method = init.method ?? 'GET' const path = url.pathname.replace(/^\/+/, '') requests.push(`${method} ${path}`) + ctx.ecosystemRequestUrls.push(url) const headers: Record = {} new Headers(init.headers).forEach((value, key) => { @@ -83,6 +86,7 @@ export const renderWithEcosystem = ( { ecosystemEndpoint, ecosystemRequests: _ecosystemRequests, + ecosystemRequestUrls: _ecosystemRequestUrls, embedKey, ...options }: Parameters[1] & EcosystemTestContext & { embedKey?: string } diff --git a/tsconfig.json b/tsconfig.json index 4ce63ae42..2aca95fb3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,7 +33,7 @@ "lib/*": ["./src/lib/*"] } }, - "files": ["src/index.ts", "src/ecosystem.d.ts"], + "files": ["src/index.ts", "src/elements.d.ts", "src/ecosystem.d.ts"], "include": [ "src/**/*", "test/**/*", diff --git a/vercel.json b/vercel.json index ec1b37458..e2342b520 100644 --- a/vercel.json +++ b/vercel.json @@ -44,10 +44,6 @@ "key": "X-Content-Type-Options", "value": "nosniff" }, - { - "key": "X-Frame-Options", - "value": "SAMEORIGIN" - }, { "key": "X-XSS-Protection", "value": "1; mode=block" @@ -154,6 +150,28 @@ "value": "noindex, follow" } ] + }, + { + "source": "/((?!examples/supported-devices(?:/|$)).*)", + "headers": [ + { + "key": "X-Frame-Options", + "value": "SAMEORIGIN" + } + ] + }, + { + "source": "/examples/supported-devices/:path*", + "headers": [ + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self'; connect-src 'self' https://ecosystem.seam.co; img-src 'self' data: https://seam-ecosystem-media.fly.storage.tigris.dev https://strapi-media-bucket.fly.storage.tigris.dev; style-src 'self'; frame-ancestors 'self' https://www.seam.co https://seam.co https://docs.seam.co https://*.mintlify.app http://localhost:* http://127.0.0.1:*" + }, + { + "key": "X-Robots-Tag", + "value": "noindex, follow" + } + ] } ] } From 4d48873fa927bacce01cf3c7720cab2f676c3971 Mon Sep 17 00:00:00 2001 From: Sy Bohy Date: Mon, 26 Oct 2026 15:41:29 -0700 Subject: [PATCH 2/3] fix: apply Ecosystem embed headers with or without trailing slash --- README.md | 2 +- vercel.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f679cce69..4dcc4acab 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ The data is also available with the `useCompatiblePartners`, `useEcosystemDevice ### Hosted supported-device embed The native React page at -`https://react.seam.co/examples/supported-devices/` is designed for documentation +`https://react.seam.co/examples/supported-devices` is designed for documentation hosts such as Mintlify that cannot import an npm component. It composes `EcosystemProvider` and `SupportedDevices`, reads the public production Ecosystem catalog without an embed key, and never uses Storybook fixtures or Strapi. diff --git a/vercel.json b/vercel.json index e2342b520..49575e38b 100644 --- a/vercel.json +++ b/vercel.json @@ -161,7 +161,7 @@ ] }, { - "source": "/examples/supported-devices/:path*", + "source": "/examples/supported-devices(/.*)?", "headers": [ { "key": "Content-Security-Policy", From 2891a05610bccea0dbe4fc548202a5e6236e9b25 Mon Sep 17 00:00:00 2001 From: Sy Bohy Date: Mon, 26 Oct 2026 15:46:07 -0700 Subject: [PATCH 3/3] fix: keep unknown integration support out of capability badges --- .../seam/components/SupportedDevices/device-models.test.ts | 1 + src/lib/seam/components/SupportedDevices/device-models.ts | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/lib/seam/components/SupportedDevices/device-models.test.ts b/src/lib/seam/components/SupportedDevices/device-models.test.ts index 9291fa33e..2cb6b0349 100644 --- a/src/lib/seam/components/SupportedDevices/device-models.test.ts +++ b/src/lib/seam/components/SupportedDevices/device-models.test.ts @@ -260,6 +260,7 @@ test('unavailable or stale capabilities never become supported badges or filters 'in-progress', 'unavailable', 'inquire', + null, ] as const) { capability.integration_support_level = level expect(getSupportedCapabilities(model)).toEqual([]) diff --git a/src/lib/seam/components/SupportedDevices/device-models.ts b/src/lib/seam/components/SupportedDevices/device-models.ts index 8cbe1d835..575040c0d 100644 --- a/src/lib/seam/components/SupportedDevices/device-models.ts +++ b/src/lib/seam/components/SupportedDevices/device-models.ts @@ -369,7 +369,12 @@ const toSupported = ( capability: CapabilitySummary ): SupportedCapability['supported'] | null => { const level = capability.integration_support_level - if (level != null && level !== 'stable' && level !== 'beta') return null + if ( + level !== 'stable' && + level !== 'beta' && + capability.name !== 'device_connectivity' + ) + return null if (capability.supported === 'yes') return 'yes' if (capability.supported === 'conditionally') return 'conditionally' return null