From e4965da819b95ba6e436cc18f2fac41a8f8036a8 Mon Sep 17 00:00:00 2001 From: Sy Bohy Date: Mon, 26 Oct 2026 22:10:46 -0700 Subject: [PATCH 1/2] fix: clarify device compatibility and match the docs theme --- README.md | 12 +- examples/supported-devices/src/App.tsx | 20 ++- examples/supported-devices/src/style.css | 17 +- .../SupportedDevices/DeviceModelRow.tsx | 148 ++++++++++-------- .../SupportedDevices/ManufacturerSection.tsx | 26 +-- .../SupportedDevices/RequirementIcon.tsx | 34 ++++ .../SupportedDevices.test.tsx | 14 +- .../SupportedDevices/SupportedDevices.tsx | 4 +- .../SupportedDevices/device-models.test.ts | 59 ++++++- .../SupportedDevices/device-models.ts | 37 ++++- src/styles/_supported-devices.scss | 107 ++++++++++--- 11 files changed, 342 insertions(+), 136 deletions(-) create mode 100644 src/lib/seam/components/SupportedDevices/RequirementIcon.tsx 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 && ( +
      + {[...requirements].map(([message, features]) => ( +
    • + +
      + {message} + + {t.requiredFor([...features].join(', '))} + +
      +
    • + ))} +
    + )} + {(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..dffe855a9 100644 --- a/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx +++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx @@ -382,8 +382,8 @@ const t = { allManufacturers: 'All manufacturers', deviceType: 'Device type', allDeviceTypes: 'All device types', - capability: 'Capability', - allCapabilities: 'All capabilities', + capability: 'Feature', + allCapabilities: 'All features', 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..a725cd342 100644 --- a/src/styles/_supported-devices.scss +++ b/src/styles/_supported-devices.scss @@ -2,6 +2,16 @@ .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-sections { display: flex; flex-direction: column; @@ -32,8 +42,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 +82,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 +93,7 @@ .seam-supported-devices-image { display: flex; + grid-row: 1 / span 2; align-items: center; justify-content: center; width: 48px; @@ -115,21 +128,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 +167,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; } } } From 9d8991174fd04db65dcf36145bbf45c04b65db89 Mon Sep 17 00:00:00 2001 From: Sy Bohy Date: Mon, 26 Oct 2026 22:14:06 -0700 Subject: [PATCH 2/2] fix: explain feature support and setup icons --- .../SupportedDevices/SupportedDevices.tsx | 20 +++++++++++++++++++ src/styles/_supported-devices.scss | 20 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx b/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx index dffe855a9..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} ) @@ -384,6 +401,9 @@ const t = { allDeviceTypes: 'All device types', 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/styles/_supported-devices.scss b/src/styles/_supported-devices.scss index a725cd342..4685a4691 100644 --- a/src/styles/_supported-devices.scss +++ b/src/styles/_supported-devices.scss @@ -12,6 +12,26 @@ 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;