diff --git a/README.md b/README.md
index 90f80fee8..2dda7a461 100644
--- a/README.md
+++ b/README.md
@@ -252,8 +252,11 @@ Query parameters are `manufacturer`, `provider` (Ecosystem slugs, not Seam Conne
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.
+Unavailable, planned, and stale capabilities are not advertised as supported.
+Feature icons distinguish support from features with requirements. Specific
+requirements are grouped with the features they affect, without repeating
+generic compatibility prose. Model numbers and integration providers are in
+expandable model details and remain searchable.
Embed the page in an iframe with `referrerpolicy="origin"`. Approved frame
ancestors are Seam's website/docs, Mintlify previews, and localhost development.
@@ -261,6 +264,11 @@ 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 document canvas follows the host theme as well as the component. Manufacturer
+logos use a white tile in both themes: monochrome catalog assets may be black,
+so they are not assumed to be dark-mode variants. The catalog footer appears
+only on standalone visits; embedded docs provide their own fallback link.
+
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
diff --git a/examples/supported-devices/src/App.tsx b/examples/supported-devices/src/App.tsx
index fda0c1b44..be034277d 100644
--- a/examples/supported-devices/src/App.tsx
+++ b/examples/supported-devices/src/App.tsx
@@ -9,7 +9,13 @@ const config = getEmbedConfig(globalThis.location.search)
const parentOrigin = getParentOrigin(globalThis.document.referrer)
export function App(): JSX.Element {
- const [theme, setTheme] = useState(config.theme)
+ const [theme, setTheme] = useState(config.theme ?? 'auto')
+
+ useEffect(() => {
+ const root = globalThis.document.documentElement
+ root.dataset['theme'] = theme
+ root.style.colorScheme = theme === 'auto' ? 'light dark' : theme
+ }, [theme])
useEffect(() => {
if (parentOrigin == null || globalThis.parent === globalThis.window) return
@@ -61,11 +67,13 @@ export function App(): JSX.Element {
disableFontInjection
>
-
-
- View the device catalog on seam.co
-
-
+ {globalThis.parent === globalThis.window && (
+
+
+ View the device catalog on seam.co
+
+
+ )}
)
}
diff --git a/examples/supported-devices/src/style.css b/examples/supported-devices/src/style.css
index 8ea9b6d02..c24018503 100644
--- a/examples/supported-devices/src/style.css
+++ b/examples/supported-devices/src/style.css
@@ -5,6 +5,21 @@ body {
background: transparent;
}
+html {
+ color-scheme: light dark;
+ --seam-device-embed-link: #505bb9;
+}
+
+html[data-theme='dark'] {
+ --seam-device-embed-link: #b0aaff;
+}
+
+@media (prefers-color-scheme: dark) {
+ html[data-theme='auto'] {
+ --seam-device-embed-link: #b0aaff;
+ }
+}
+
body {
display: flow-root;
font-family: system-ui, sans-serif;
@@ -17,5 +32,5 @@ body {
}
.seam-device-embed-footer a {
- color: #6675d9;
+ color: var(--seam-device-embed-link);
}
diff --git a/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx b/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx
index 0f2d1bae7..ccd7bb7d7 100644
--- a/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx
+++ b/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx
@@ -1,11 +1,13 @@
-import classNames from 'classnames'
-
+import { CheckIcon } from 'lib/icons/Check.js'
+import { TriangleWarningOutlineIcon } from 'lib/icons/TriangleWarningOutline.js'
import {
getCapabilityLabel,
+ getCapabilityRequirementMessages,
getDeviceModelImageUrl,
getDeviceModelName,
getSupportedCapabilities,
} from 'lib/seam/components/SupportedDevices/device-models.js'
+import { RequirementIcon } from 'lib/seam/components/SupportedDevices/RequirementIcon.js'
import type {
DeviceModelSummary,
Manufacturer,
@@ -29,28 +31,19 @@ export function DeviceModelRow({
.filter((id): id is string => id != null && id.length > 0)
),
]
- const providers = deviceModel.providers
- .map((p) => p.display_name ?? p.slug)
- .join(', ')
+ const providers = deviceModel.providers.map((p) => p.display_name ?? p.slug)
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,
- providers.length > 0 ? t.via(providers) : null,
- ].filter((value): value is string => value != null)
+ const requirements = new Map>()
+ for (const capability of capabilities) {
+ const messages = getCapabilityRequirementMessages(capability)
+ for (const message of messages) {
+ const features = requirements.get(message) ?? new Set()
+ features.add(getCapabilityLabel(capability.name))
+ requirements.set(message, features)
+ }
+ }
return (
@@ -74,58 +67,78 @@ export function DeviceModelRow({
{name}
)}
- {meta.length > 0 && (
+ {deviceModel.device_type != null && (
- {meta.join(' · ')}
+ {deviceModel.device_type.display_name}
)}
-
- {deviceModel.device_type != null && (
-
- {deviceModel.device_type.display_name}
-
- )}
- {capabilities.map((capability) => (
-
- {getCapabilityLabel(capability.name)}
- {capability.integrationSupportLevel === 'beta' && ' (beta)'}
- {capability.supported === 'conditionally' && (
-
- {` (${t.conditional})`}
+ {capabilities.length > 0 && (
+
+ {capabilities.map((capability) => (
+
+ {capability.supported === 'conditionally' ? (
+
+ ) : (
+
+ )}
+
+ {getCapabilityLabel(capability.name)}
+ {capability.integrationSupportLevel === 'beta' && ' (beta)'}
+ {capability.supported === 'conditionally' && (
+
+ {` (${t.requirementsBelow})`}
+
+ )}
- )}
-
- ))}
-
+
+ ))}
+
+ )}
{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}
- ))}
-
+ {requirements.size > 0 && (
+
+ )}
+ {(modelIds.length > 0 || providers.length > 0) && (
+
+ {t.modelDetails}
+
+ {modelIds.length > 0 && (
+
+
{t.modelNumbers}
+ {modelIds.join(', ')}
+
+ )}
+ {providers.length > 0 && (
+
+
{t.integration}
+ {providers.join(', ')}
+
+ )}
+
)}
@@ -133,10 +146,13 @@ export function DeviceModelRow({
}
const t = {
- via: (providers: string) => `via ${providers}`,
- badges: 'Device type and capabilities',
- conditional: 'conditionally supported',
- requirements: 'Requirements and compatibility',
+ features: 'Supported features',
+ requirements: 'Requirements',
+ requirementsBelow: 'see requirements below',
+ requiredFor: (features: string) => `For ${features}`,
+ modelDetails: 'Model details',
+ modelNumbers: 'Model numbers',
+ integration: 'Integration',
refreshing: 'Compatibility is being refreshed.',
unconfirmed: 'Compatibility not confirmed for this integration.',
}
diff --git a/src/lib/seam/components/SupportedDevices/ManufacturerSection.tsx b/src/lib/seam/components/SupportedDevices/ManufacturerSection.tsx
index 172f7b98b..72f233801 100644
--- a/src/lib/seam/components/SupportedDevices/ManufacturerSection.tsx
+++ b/src/lib/seam/components/SupportedDevices/ManufacturerSection.tsx
@@ -1,4 +1,3 @@
-import classNames from 'classnames'
import { useState } from 'react'
import {
@@ -40,8 +39,6 @@ export function ManufacturerSection({
: t.other
const logoUrl = getManufacturerLogoUrl(manufacturer)
- const darkLogoUrl = getManufacturerLogoUrl(manufacturer, { mono: true })
- const hasDarkLogo = darkLogoUrl != null && darkLogoUrl !== logoUrl
return (
@@ -50,28 +47,7 @@ export function ManufacturerSection({
{logoUrl == null ? (
{name.slice(0, 1)}
) : (
- <>
-
- {hasDarkLogo && (
-
- )}
- >
+
)}
{name}
diff --git a/src/lib/seam/components/SupportedDevices/RequirementIcon.tsx b/src/lib/seam/components/SupportedDevices/RequirementIcon.tsx
new file mode 100644
index 000000000..3d8075151
--- /dev/null
+++ b/src/lib/seam/components/SupportedDevices/RequirementIcon.tsx
@@ -0,0 +1,34 @@
+/** The adjacent text carries the meaning of these contextual illustrations. */
+export function RequirementIcon({ message }: { message: string }): JSX.Element {
+ return (
+
+ {/keypad/i.test(message) ? (
+ <>
+
+
+ >
+ ) : /bridge|hub/i.test(message) ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ )
+}
diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx b/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx
index ee2449785..e57e5e3ca 100644
--- a/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx
+++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx
@@ -111,24 +111,28 @@ test('conditional requirements are readable without hoveri
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).getByRole('list', { name: 'Requirements' })
within(row).getByText(/Requires an August keypad/)
})
-test('SupportedDevices shows device type and capability badges', async (ctx) => {
+test('SupportedDevices shows features and keeps identifiers in model details', async (ctx) => {
renderWithEcosystem( , ctx)
const name = await screen.findByText('Encode Plus Smart WiFi Deadbolt')
const row = name.closest('li')
if (row == null) throw new Error('Missing row')
const badges = within(row).getByRole('list', {
- name: 'Device type and capabilities',
+ name: 'Supported features',
})
expect(
within(badges)
.getAllByRole('listitem')
.map((badge) => badge.textContent)
- ).toEqual(['Smartlock', 'Remote unlock', 'Remote lock', 'Access codes'])
- within(row).getByText('BE499WB · via Schlage Home')
+ ).toEqual(['Remote unlock', 'Remote lock', 'Access codes'])
+ const modelNumber = within(row).getByText('BE499WB')
+ const details = modelNumber.closest('details')
+ expect(details?.hasAttribute('open')).toBe(false)
+ expect(within(row).getByText('Schlage Home').closest('details')).toBe(details)
+ expect(within(row).queryByText(/via Schlage Home/)).toBeNull()
})
test('SupportedDevices defaults to the manufacturer of a manufacturer key', async (ctx) => {
diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx b/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx
index ff0771f9e..008af7e57 100644
--- a/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx
+++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx
@@ -1,6 +1,8 @@
import classNames from 'classnames'
import { useDeferredValue, useMemo, useState } from 'react'
+import { CheckIcon } from 'lib/icons/Check.js'
+import { TriangleWarningOutlineIcon } from 'lib/icons/TriangleWarningOutline.js'
import {
applyEmbedExclusions,
capabilityLabels,
@@ -330,6 +332,21 @@ export function SupportedDevices({
)}
+ {!isLoading && !isError && availableDeviceModels.length > 0 && (
+
+
+
+ {t.supported}
+
+
+
+ {t.requiresSetup}
+
+
+ )}
{content}
)
@@ -382,8 +399,11 @@ const t = {
allManufacturers: 'All manufacturers',
deviceType: 'Device type',
allDeviceTypes: 'All device types',
- capability: 'Capability',
- allCapabilities: 'All capabilities',
+ capability: 'Feature',
+ allCapabilities: 'All features',
+ featureStatus: 'Feature status',
+ supported: 'Supported',
+ requiresSetup: 'Requires setup · see requirements below',
error: 'An error occurred while loading supported devices.',
retry: 'Retry',
noDevices: 'No supported devices found.',
diff --git a/src/lib/seam/components/SupportedDevices/device-models.test.ts b/src/lib/seam/components/SupportedDevices/device-models.test.ts
index 2cb6b0349..b9103eabf 100644
--- a/src/lib/seam/components/SupportedDevices/device-models.test.ts
+++ b/src/lib/seam/components/SupportedDevices/device-models.test.ts
@@ -11,6 +11,7 @@ import {
applyEmbedExclusions,
filterDeviceModels,
getCapabilityOptions,
+ getCapabilityRequirementMessages,
getSupportedCapabilities,
groupDeviceModelsByManufacturer,
scopeDeviceModelsToProvider,
@@ -241,11 +242,61 @@ test('conditional requirements include dependency reports without following cycl
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.',
+ ).toEqual(['An external keypad is required.', 'A Wi-Fi bridge is required.'])
+})
+
+test('structured dependency requirements replace generic compatibility prose', () => {
+ const model = deviceModel({
+ slug: 'lock',
+ capabilities: {
+ cloud: {
+ can_remotely_unlock: 'conditionally',
+ device_connectivity: 'conditionally',
+ },
+ },
+ })
+ const caps = model.capabilities_by_provider[0]?.capabilities
+ const unlock = caps?.find((c) => c.name === 'can_remotely_unlock')
+ const connectivity = caps?.find((c) => c.name === 'device_connectivity')
+ if (unlock == null || connectivity == null)
+ throw new Error('Missing capabilities')
+ unlock.report = {
+ details: 'Check additional requirements.',
+ dependencies: ['device_connectivity'],
+ }
+ connectivity.report = {
+ details: 'Device requires a compatible server bridge',
+ requirements: { bridge: { message: 'A compatible bridge is required.' } },
+ }
+ expect(
+ getSupportedCapabilities(model).find((c) => c.name === unlock.name)
+ ?.requirements
+ ).toEqual(['A compatible bridge is required.'])
+})
+
+test('requirement messages preserve specific constraints and flag unknown setup', () => {
+ const capability = {
+ name: 'can_remotely_unlock' as const,
+ supported: 'conditionally' as const,
+ integrationSupportLevel: 'stable' as const,
+ details: 'Only available on firmware 2 or later.',
+ requirements: ['A compatible bridge is required.'],
+ }
+ expect(getCapabilityRequirementMessages(capability)).toEqual([
+ 'A compatible bridge is required.',
+ 'Only available on firmware 2 or later.',
])
+ const generic = {
+ ...capability,
+ details:
+ 'This feature is supported by this system and can be used through Seam, but please check additional requirements are met.',
+ }
+ expect(getCapabilityRequirementMessages(generic)).toEqual([
+ 'A compatible bridge is required.',
+ ])
+ expect(
+ getCapabilityRequirementMessages({ ...generic, requirements: [] })
+ ).toEqual(['Additional setup is required.'])
})
test('unavailable or stale capabilities never become supported badges or filters', () => {
diff --git a/src/lib/seam/components/SupportedDevices/device-models.ts b/src/lib/seam/components/SupportedDevices/device-models.ts
index 575040c0d..7bd0bd51e 100644
--- a/src/lib/seam/components/SupportedDevices/device-models.ts
+++ b/src/lib/seam/components/SupportedDevices/device-models.ts
@@ -184,6 +184,30 @@ const supportRank = (
(level === 'stable' ? 4 : level === 'beta' ? 2 : 0) +
(supported === 'yes' ? 1 : 0)
+const genericSupportDetails = new Set([
+ 'This feature is supported by this system and can be used through Seam, but please check additional requirements are met.',
+ 'Device requires a compatible server bridge',
+])
+
+const isGenericSupportDetail = (details: string): boolean =>
+ genericSupportDetails.has(details.trim())
+
+/** Keep specific constraints; replace boilerplate with an actionable setup note. */
+export const getCapabilityRequirementMessages = (
+ capability: SupportedCapability
+): string[] => {
+ const messages = new Set(capability.requirements)
+ if (capability.supported === 'conditionally') {
+ if (
+ capability.details != null &&
+ !isGenericSupportDetail(capability.details)
+ )
+ messages.add(capability.details)
+ if (messages.size === 0) messages.add('Additional setup is required.')
+ }
+ return [...messages]
+}
+
const getCapabilityRequirements = (
capability: CapabilitySummary,
capabilities: CapabilitySummary[],
@@ -195,14 +219,19 @@ const getCapabilityRequirements = (
(requirement) => requirement.message
)
for (const name of capability.report?.dependencies ?? []) {
+ if (visited.has(name)) continue
const dependency = capabilities.find((c) => c.name === name)
if (dependency == null) continue
- if (dependency.supported !== 'yes' && dependency.report?.details != null) {
+ const nested = getCapabilityRequirements(dependency, capabilities, visited)
+ if (
+ dependency.supported !== 'yes' &&
+ dependency.report?.details != null &&
+ (nested.length === 0 ||
+ !isGenericSupportDetail(dependency.report.details))
+ ) {
requirements.push(dependency.report.details)
}
- requirements.push(
- ...getCapabilityRequirements(dependency, capabilities, visited)
- )
+ requirements.push(...nested)
}
return [...new Set(requirements)]
}
diff --git a/src/styles/_supported-devices.scss b/src/styles/_supported-devices.scss
index 6f83df194..4685a4691 100644
--- a/src/styles/_supported-devices.scss
+++ b/src/styles/_supported-devices.scss
@@ -2,6 +2,36 @@
.seam-supported-devices {
container-type: inline-size;
+ .seam-ecosystem-controls {
+ width: 100%;
+ margin-left: 0;
+ }
+
+ .seam-ecosystem-search {
+ flex: 1;
+ min-width: min(200px, 100%);
+ }
+
+ .seam-supported-devices-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px 16px;
+ margin-bottom: 12px;
+ color: var(--seam-ecosystem-fg-2);
+ font-size: 12px;
+
+ span {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ svg {
+ width: 16px;
+ height: 16px;
+ }
+ }
+
.seam-supported-devices-sections {
display: flex;
flex-direction: column;
@@ -32,8 +62,10 @@
height: 40px;
overflow: hidden;
border-radius: 8px;
- background: var(--seam-ecosystem-surface-2);
- color: var(--seam-ecosystem-fg-2);
+
+ // Catalog monochrome logos are often black, not dark-mode variants.
+ background: #fff;
+ color: #434a54;
font-weight: 600;
img {
@@ -70,9 +102,9 @@
.seam-supported-devices-row {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
- align-items: center;
+ align-items: start;
gap: 8px 12px;
- padding: 12px 16px;
+ padding: 20px 16px;
&:not(:last-child) {
border-bottom: 1px solid var(--seam-ecosystem-line-1);
@@ -81,6 +113,7 @@
.seam-supported-devices-image {
display: flex;
+ grid-row: 1 / span 2;
align-items: center;
justify-content: center;
width: 48px;
@@ -115,21 +148,35 @@
}
.seam-supported-devices-meta {
- overflow: hidden;
color: var(--seam-ecosystem-fg-2);
font-size: 13px;
- white-space: nowrap;
- text-overflow: ellipsis;
}
- .seam-supported-devices-badges {
+ .seam-supported-devices-features {
display: flex;
flex-wrap: wrap;
grid-column: 2;
- gap: 4px;
+ gap: 6px 16px;
margin: 0;
padding: 0;
list-style: none;
+ font-size: 13px;
+
+ li {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ svg {
+ flex-shrink: 0;
+ width: 16px;
+ height: 16px;
+ }
+
+ .seam-supported-devices-conditional-icon path {
+ fill: #d48c23;
+ }
}
.seam-supported-devices-section-footer {
@@ -140,29 +187,67 @@
}
.seam-supported-devices-status,
- .seam-supported-devices-requirements {
+ .seam-supported-devices-requirements,
+ .seam-supported-devices-details {
grid-column: 2 / -1;
color: var(--seam-ecosystem-fg-2);
font-size: 13px;
}
- .seam-supported-devices-requirements summary {
- cursor: pointer;
+ .seam-supported-devices-requirements {
+ display: grid;
+ gap: 12px;
+ margin: 4px 0;
+ padding: 12px;
+ border-radius: 8px;
+ background: var(--seam-ecosystem-surface-2);
+ list-style: none;
+
+ li {
+ display: flex;
+ align-items: start;
+ gap: 10px;
+ }
+
+ .seam-supported-devices-requirement-icon {
+ flex-shrink: 0;
+ margin-top: 2px;
+ color: var(--seam-ecosystem-fg-1);
+ }
}
- .seam-supported-devices-requirements ul {
- margin: 8px 0 0;
- padding-left: 20px;
+ .seam-supported-devices-requirement-features {
+ display: block;
+ margin-top: 2px;
+ color: var(--seam-ecosystem-fg-2);
+ font-size: 12px;
}
- @container (width >= 720px) {
- .seam-supported-devices-row {
- grid-template-columns: 48px minmax(0, 1fr) minmax(0, 45%);
+ .seam-supported-devices-details {
+ overflow-wrap: anywhere;
+
+ summary {
+ width: fit-content;
+ cursor: pointer;
+
+ &:focus-visible {
+ outline: 2px solid var(--seam-ecosystem-accent);
+ outline-offset: 4px;
+ }
+ }
+
+ dl {
+ display: grid;
+ gap: 8px;
+ margin-top: 10px;
+ }
+
+ dt {
+ font-weight: 600;
}
- .seam-supported-devices-badges {
- grid-column: 3;
- justify-content: flex-end;
+ dd {
+ margin: 2px 0 0;
}
}
}