Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,15 +252,23 @@ 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.
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
Expand Down
20 changes: 14 additions & 6 deletions examples/supported-devices/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -61,11 +67,13 @@ export function App(): JSX.Element {
disableFontInjection
>
<SupportedDevices {...config} theme={theme} />
<p className='seam-device-embed-footer'>
<a href={catalogUrl} target='_blank' rel='noopener noreferrer'>
View the device catalog on seam.co
</a>
</p>
{globalThis.parent === globalThis.window && (
<p className='seam-device-embed-footer'>
<a href={catalogUrl} target='_blank' rel='noopener noreferrer'>
View the device catalog on seam.co
</a>
</p>
)}
</EcosystemProvider>
)
}
17 changes: 16 additions & 1 deletion examples/supported-devices/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,5 +32,5 @@ body {
}

.seam-device-embed-footer a {
color: #6675d9;
color: var(--seam-device-embed-link);
}
148 changes: 82 additions & 66 deletions src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<string, Set<string>>()
for (const capability of capabilities) {
const messages = getCapabilityRequirementMessages(capability)
for (const message of messages) {
const features = requirements.get(message) ?? new Set<string>()
features.add(getCapabilityLabel(capability.name))
requirements.set(message, features)
}
}

return (
<li className='seam-supported-devices-row'>
Expand All @@ -74,69 +67,92 @@ export function DeviceModelRow({
{name}
</a>
)}
{meta.length > 0 && (
{deviceModel.device_type != null && (
<span className='seam-supported-devices-meta'>
{meta.join(' · ')}
{deviceModel.device_type.display_name}
</span>
)}
</div>
<ul className='seam-supported-devices-badges' aria-label={t.badges}>
{deviceModel.device_type != null && (
<li className='seam-ecosystem-badge seam-ecosystem-badge-type'>
{deviceModel.device_type.display_name}
</li>
)}
{capabilities.map((capability) => (
<li
key={capability.name}
className={classNames('seam-ecosystem-badge', {
'seam-ecosystem-badge-conditional':
capability.supported === 'conditionally',
})}
title={
capability.supported === 'conditionally'
? [
...capability.requirements,
capability.details ?? t.conditional,
].join('\n')
: undefined
}
>
{getCapabilityLabel(capability.name)}
{capability.integrationSupportLevel === 'beta' && ' (beta)'}
{capability.supported === 'conditionally' && (
<span className='seam-ecosystem-visually-hidden'>
{` (${t.conditional})`}
{capabilities.length > 0 && (
<ul className='seam-supported-devices-features' aria-label={t.features}>
{capabilities.map((capability) => (
<li key={capability.name}>
{capability.supported === 'conditionally' ? (
<TriangleWarningOutlineIcon
aria-hidden
className='seam-supported-devices-conditional-icon'
/>
) : (
<CheckIcon aria-hidden />
)}
<span>
{getCapabilityLabel(capability.name)}
{capability.integrationSupportLevel === 'beta' && ' (beta)'}
{capability.supported === 'conditionally' && (
<span className='seam-ecosystem-visually-hidden'>
{` (${t.requirementsBelow})`}
</span>
)}
</span>
)}
</li>
))}
</ul>
</li>
))}
</ul>
)}
{deviceModel.capabilities_stale ? (
<span className='seam-supported-devices-status'>{t.refreshing}</span>
) : capabilities.length === 0 &&
deviceModel.device_type?.is_accessory !== true ? (
<span className='seam-supported-devices-status'>{t.unconfirmed}</span>
) : null}
{requirements.length > 0 && (
<details className='seam-supported-devices-requirements'>
<summary>{t.requirements}</summary>
<ul>
{requirements.map((requirement) => (
<li key={requirement}>{requirement}</li>
))}
</ul>
{requirements.size > 0 && (
<ul
className='seam-supported-devices-requirements'
aria-label={t.requirements}
>
{[...requirements].map(([message, features]) => (
<li key={message}>
<RequirementIcon message={message} />
<div>
<span>{message}</span>
<span className='seam-supported-devices-requirement-features'>
{t.requiredFor([...features].join(', '))}
</span>
</div>
</li>
))}
</ul>
)}
{(modelIds.length > 0 || providers.length > 0) && (
<details className='seam-supported-devices-details'>
<summary>{t.modelDetails}</summary>
<dl>
{modelIds.length > 0 && (
<div>
<dt>{t.modelNumbers}</dt>
<dd>{modelIds.join(', ')}</dd>
</div>
)}
{providers.length > 0 && (
<div>
<dt>{t.integration}</dt>
<dd>{providers.join(', ')}</dd>
</div>
)}
</dl>
</details>
)}
</li>
)
}

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.',
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import classNames from 'classnames'
import { useState } from 'react'

import {
Expand Down Expand Up @@ -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 (
<section className='seam-supported-devices-section'>
Expand All @@ -50,28 +47,7 @@ export function ManufacturerSection({
{logoUrl == null ? (
<span aria-hidden>{name.slice(0, 1)}</span>
) : (
<>
<img
loading='lazy'
src={logoUrl}
alt=''
width={40}
height={40}
className={classNames({
'seam-ecosystem-light-only': hasDarkLogo,
})}
/>
{hasDarkLogo && (
<img
loading='lazy'
src={darkLogoUrl}
alt=''
width={40}
height={40}
className='seam-ecosystem-dark-only'
/>
)}
</>
<img loading='lazy' src={logoUrl} alt='' width={40} height={40} />
)}
</div>
<h3 className='seam-supported-devices-section-title'>{name}</h3>
Expand Down
34 changes: 34 additions & 0 deletions src/lib/seam/components/SupportedDevices/RequirementIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/** The adjacent text carries the meaning of these contextual illustrations. */
export function RequirementIcon({ message }: { message: string }): JSX.Element {
return (
<svg
className='seam-supported-devices-requirement-icon'
aria-hidden
width='20'
height='20'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
>
{/keypad/i.test(message) ? (
<>
<rect x='5' y='2' width='14' height='20' rx='3' />
<path d='M9 6h6M9 10h.01M12 10h.01M15 10h.01M9 14h.01M12 14h.01M15 14h.01M12 18h.01' />
</>
) : /bridge|hub/i.test(message) ? (
<>
<rect x='3' y='12' width='18' height='8' rx='2' />
<path d='M7 12V8m10 4V8M7 16h.01M11 16h.01M9 5a5 5 0 0 1 6 0M6 2a10 10 0 0 1 12 0' />
</>
) : (
<>
<circle cx='12' cy='12' r='9' />
<path d='M12 11v6m0-10h.01' />
</>
)}
</svg>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -111,24 +111,28 @@ test<EcosystemTestContext>('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<EcosystemTestContext>('SupportedDevices shows device type and capability badges', async (ctx) => {
test<EcosystemTestContext>('SupportedDevices shows features and keeps identifiers in model details', async (ctx) => {
renderWithEcosystem(<SupportedDevices manufacturer='schlage' />, 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<EcosystemTestContext>('SupportedDevices defaults to the manufacturer of a manufacturer key', async (ctx) => {
Expand Down
Loading
Loading