diff --git a/examples/supported-devices/src/App.tsx b/examples/supported-devices/src/App.tsx index be034277d..e52d59f43 100644 --- a/examples/supported-devices/src/App.tsx +++ b/examples/supported-devices/src/App.tsx @@ -3,7 +3,11 @@ 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' +import { + getEmbedConfig, + getEmbeddedViewport, + getParentOrigin, +} from './config.js' const config = getEmbedConfig(globalThis.location.search) const parentOrigin = getParentOrigin(globalThis.document.referrer) @@ -34,6 +38,18 @@ export function App(): JSX.Element { const onMessage = (event: MessageEvent): void => { if (event.source !== globalThis.parent || event.origin !== parentOrigin) return + const viewport = getEmbeddedViewport(event.data) + if (viewport != null) { + const root = globalThis.document.documentElement + root.style.setProperty( + '--seam-supported-device-viewport-top', + `${viewport.top}px` + ) + root.style.setProperty( + '--seam-supported-device-viewport-height', + `${viewport.height}px` + ) + } const data = event.data as { type?: string; theme?: string } | null if ( data?.type === 'seam-supported-devices:theme' && diff --git a/examples/supported-devices/src/config.test.ts b/examples/supported-devices/src/config.test.ts index 2e078d4ab..453452976 100644 --- a/examples/supported-devices/src/config.test.ts +++ b/examples/supported-devices/src/config.test.ts @@ -1,6 +1,10 @@ import { expect, test } from 'vitest' -import { getEmbedConfig, getParentOrigin } from './config.js' +import { + getEmbedConfig, + getEmbeddedViewport, + getParentOrigin, +} from './config.js' test('embed config scopes a provider without inventing a manufacturer', () => { expect( @@ -25,3 +29,21 @@ test('parent messages use the exact HTTP origin of the referrer', () => { expect(getParentOrigin('')).toBeNull() expect(getParentOrigin('javascript:alert(1)')).toBeNull() }) + +test('embedded viewport dimensions accept finite bounds and reject malformed messages', () => { + const type = 'seam-supported-devices:viewport' + expect(getEmbeddedViewport({ type, top: 120, height: 640 })).toEqual({ + top: 120, + height: 640, + }) + for (const data of [ + null, + { type, top: -1, height: 640 }, + { type, top: 0, height: 0 }, + { type, top: 0, height: Infinity }, + { type, top: NaN, height: 640 }, + { type, top: '0', height: 640 }, + { type: 'other', top: 0, height: 640 }, + ]) + expect(getEmbeddedViewport(data)).toBeNull() +}) diff --git a/examples/supported-devices/src/config.ts b/examples/supported-devices/src/config.ts index 412fd0182..fbeeec500 100644 --- a/examples/supported-devices/src/config.ts +++ b/examples/supported-devices/src/config.ts @@ -32,3 +32,21 @@ const optionalParam = ( const value = params.get(name)?.trim() return value == null || value.length === 0 ? undefined : value } + +export const getEmbeddedViewport = ( + data: unknown +): { top: number; height: number } | null => { + if (data == null || typeof data !== 'object') return null + const message = data as { type?: unknown; top?: unknown; height?: unknown } + if ( + message.type !== 'seam-supported-devices:viewport' || + typeof message.top !== 'number' || + !Number.isFinite(message.top) || + message.top < 0 || + typeof message.height !== 'number' || + !Number.isFinite(message.height) || + message.height <= 0 + ) + return null + return { top: message.top, height: message.height } +} diff --git a/src/lib/seam/components/SupportedDevices/AccessoryCatalog.test.tsx b/src/lib/seam/components/SupportedDevices/AccessoryCatalog.test.tsx new file mode 100644 index 000000000..420625892 --- /dev/null +++ b/src/lib/seam/components/SupportedDevices/AccessoryCatalog.test.tsx @@ -0,0 +1,73 @@ +import { screen } from '@testing-library/react' +import { expect, test } from 'vitest' + +import { + type EcosystemTestContext, + renderWithEcosystem, + setupFakeEcosystem, +} from 'fixtures/ecosystem.js' + +import { AccessoryCatalog, AccessoryLinks } from './AccessoryCatalog.js' + +setupFakeEcosystem() + +test('accessory lookups are batched by slug across manufacturers and use canonical device routes', async (ctx) => { + renderWithEcosystem( + + + + , + ctx + ) + const gateway = await screen.findByRole('link', { name: /Engage Gateway/ }) + const bridge = await screen.findByRole('link', { + name: /Connect Wi-Fi Bridge/, + }) + expect(gateway.getAttribute('href')).toBe( + 'https://www.seam.co/gateways/schlage_engage-gateway' + ) + expect(bridge.getAttribute('href')).toBe( + 'https://www.seam.co/bridges/august_august-connect-wifi-bridge' + ) + expect(ctx.ecosystemRequestUrls).toHaveLength(1) + const request = ctx.ecosystemRequestUrls[0] + expect(request?.searchParams.get('slugs')).toBe( + 'august-connect-wifi-bridge,engage-gateway' + ) + expect(request?.searchParams.has('manufacturer')).toBe(false) + expect(request?.searchParams.has('provider')).toBe(false) +}) + +test('missing accessory records stay explicit while known accessories remain linked', async (ctx) => { + renderWithEcosystem( + + + , + ctx + ) + await screen.findByRole('link', { name: /Engage Gateway/ }) + await screen.findByText('Some accessory details couldn’t be loaded.') + expect(screen.queryByRole('link', { name: /missing/ })).toBeNull() +}) + +test('requirements without accessory relations do not fetch an unfiltered catalog', (ctx) => { + renderWithEcosystem( + + + , + ctx + ) + expect(ctx.ecosystemRequests).toEqual([]) + expect(screen.queryByText('Compatible keypads')).toBeNull() +}) diff --git a/src/lib/seam/components/SupportedDevices/AccessoryCatalog.tsx b/src/lib/seam/components/SupportedDevices/AccessoryCatalog.tsx new file mode 100644 index 000000000..5ff47de84 --- /dev/null +++ b/src/lib/seam/components/SupportedDevices/AccessoryCatalog.tsx @@ -0,0 +1,138 @@ +import { createContext, type PropsWithChildren, useContext } from 'react' + +import { + getDeviceModelImageUrl, + getDeviceModelName, +} from 'lib/seam/components/SupportedDevices/device-models.js' +import type { DeviceModelSummary } from 'lib/seam/ecosystem/types.js' +import { useEcosystemDeviceModelSummaries } from 'lib/seam/ecosystem/use-ecosystem-device-model-summaries.js' + +import { getRequirementKinds } from './presentation.js' + +const AccessoryContext = createContext<{ + models: DeviceModelSummary[] + isLoading: boolean + isError: boolean +} | null>(null) + +/** One batched lookup per catalog, shared by every expanded provider. */ +export function AccessoryCatalog({ + slugs, + children, +}: PropsWithChildren<{ slugs: string[] }>): JSX.Element { + const { deviceModels, isLoading, isError } = useEcosystemDeviceModelSummaries( + { + slugs, + enabled: slugs.length > 0, + } + ) + return ( + + {children} + + ) +} + +export function AccessoryLinks({ + message, + slugs = [], +}: { + message: string + slugs?: string[] | undefined +}): JSX.Element | null { + const catalog = useContext(AccessoryContext) + if (slugs.length === 0 || catalog == null) return null + const kinds = getRequirementKinds(message) + const label = kinds.includes('bridge') + ? 'Compatible bridges' + : kinds.includes('keypad') + ? 'Compatible keypads' + : 'Compatible accessories' + const models = [...new Set(slugs)].flatMap((slug) => { + const model = catalog.models.find((entry) => entry.slug === slug) + return model == null ? [] : [model] + }) + return ( +
+

{label}

+ {catalog.isLoading ? ( +

+ Loading accessories… +

+ ) : ( + <> + {models.length > 0 && ( +
    + {models.map((model) => { + const image = getDeviceModelImageUrl(model) + const name = getDeviceModelName(model) + const manufacturer = model.manufacturer + const href = + manufacturer == null || model.device_type == null + ? null + : `https://www.seam.co/${encodeURIComponent(model.device_type.slug)}s/${encodeURIComponent(manufacturer.slug)}_${encodeURIComponent(model.slug)}` + const content = ( + <> + {image != null && ( + + )} +
    + {name} + {manufacturer != null && ( + + {manufacturer.display_name ?? manufacturer.slug} + + )} +
    + {href != null && ( + + ↗ + + )} + + ) + return ( +
  • + {href == null ? ( +
    + {content} +
    + ) : ( + + {content} + + )} +
  • + ) + })} +
+ )} + {(catalog.isError || models.length < new Set(slugs).size) && ( +

+ Some accessory details couldn’t be loaded. +

+ )} + + )} +
+ ) +} diff --git a/src/lib/seam/components/SupportedDevices/BrandMark.tsx b/src/lib/seam/components/SupportedDevices/BrandMark.tsx new file mode 100644 index 000000000..61fcb73ec --- /dev/null +++ b/src/lib/seam/components/SupportedDevices/BrandMark.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react' + +import type { DeviceAsset } from 'lib/seam/ecosystem/types.js' + +const logoKinds = [ + 'square_logo', + 'horizontal_logo', + 'square_logo_mono', + 'horizontal_logo_mono', +] as const + +export function BrandMark({ + name, + logos, + className = '', +}: { + name: string + logos?: DeviceAsset[] + className?: string +}): JSX.Element { + const logo = logoKinds + .map((kind) => logos?.find((asset) => asset.kind === kind)) + .find((asset) => asset != null) + const src = logo?.formats.small?.url ?? logo?.url + const initial = name.slice(0, 1) + return ( + + {src == null ? ( + {initial} + ) : ( + + )} + + ) +} + +function LogoImage({ + src, + initial, +}: { + src: string + initial: string +}): JSX.Element { + const [failed, setFailed] = useState(false) + return failed ? ( + {initial} + ) : ( + { + setFailed(true) + }} + /> + ) +} diff --git a/src/lib/seam/components/SupportedDevices/CapabilityFilter.tsx b/src/lib/seam/components/SupportedDevices/CapabilityFilter.tsx new file mode 100644 index 000000000..2d978662e --- /dev/null +++ b/src/lib/seam/components/SupportedDevices/CapabilityFilter.tsx @@ -0,0 +1,142 @@ +import { useEffect, useId, useRef, useState } from 'react' + +import { getCapabilityLabel } from 'lib/seam/components/SupportedDevices/device-models.js' + +export function CapabilityFilter({ + capabilities, + selected, + onChange, + disabled = false, +}: { + capabilities: string[] + selected: string[] + onChange: (capabilities: string[]) => void + disabled?: boolean | undefined +}): JSX.Element { + const [open, setOpen] = useState(false) + const root = useRef(null) + const trigger = useRef(null) + const id = useId() + const summary = + selected.length === 0 + ? 'All capabilities' + : selected.length === 1 + ? getCapabilityLabel(selected[0] ?? '') + : `${selected.length} selected` + + useEffect(() => { + if (!open) return + root.current?.querySelector('input')?.focus() + const onPointerDown = (event: PointerEvent): void => { + if ( + event.target instanceof Node && + root.current?.contains(event.target) !== true + ) + setOpen(false) + } + document.addEventListener('pointerdown', onPointerDown) + return () => { + document.removeEventListener('pointerdown', onPointerDown) + } + }, [open]) + + return ( +
{ + if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false) + }} + onKeyDown={(event) => { + if (open) event.stopPropagation() + if (event.key === 'Escape' && open) { + event.preventDefault() + setOpen(false) + trigger.current?.focus() + } else if ( + event.key === 'ArrowDown' && + event.target === trigger.current + ) { + event.preventDefault() + event.stopPropagation() + setOpen(true) + root.current?.querySelector('input')?.focus() + } + }} + > + Capabilities + + {open && ( +
+

Show devices supporting every selected capability.

+
+ + Required capabilities + + {capabilities.map((name) => ( + + ))} +
+ +
+ )} +
+ ) +} diff --git a/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx b/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx deleted file mode 100644 index a3252fcd3..000000000 --- a/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { useId, useState } from 'react' - -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, -} from 'lib/seam/ecosystem/types.js' - -interface DeviceModelRowProps { - deviceModel: DeviceModelSummary - manufacturer: Manufacturer | null -} - -export function DeviceModelRow({ - deviceModel, - manufacturer, -}: DeviceModelRowProps): JSX.Element { - const detailsId = useId() - const [expanded, setExpanded] = useState(false) - const name = getDeviceModelName(deviceModel) - const imageUrl = getDeviceModelImageUrl(deviceModel, manufacturer) - const modelIds = [ - ...new Set( - deviceModel.variants - .map((v) => v.manufacturer_model_id) - .filter((id): id is string => id != null && id.length > 0) - ), - ] - const providers = deviceModel.providers.map((p) => p.display_name ?? p.slug) - const capabilities = getSupportedCapabilities(deviceModel).filter( - (c) => c.name !== 'device_connectivity' - ) - 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 ( -
  • -
    - {imageUrl == null ? ( - {name.slice(0, 1)} - ) : ( - - )} -
    -
    - {deviceModel.product_url == null ? ( - {name} - ) : ( - - {name} - - )} - {deviceModel.device_type != null && ( - - {deviceModel.device_type.display_name} - - )} -
    - {capabilities.length > 0 && ( -
    - - {t.features} - -
      - {capabilities.map((capability) => ( -
    • - {capability.supported === 'conditionally' ? ( - - ) : ( - - )} - - {getCapabilityLabel(capability.name)} - {capability.integrationSupportLevel === 'beta' && ' (beta)'} - {capability.supported === 'conditionally' && ( - - {` (${t.requirementsDetails})`} - - )} - -
    • - ))} -
    -
    - )} - {deviceModel.capabilities_stale ? ( - {t.refreshing} - ) : capabilities.length === 0 && - deviceModel.device_type?.is_accessory !== true ? ( - {t.unconfirmed} - ) : null} - {(requirements.size > 0 || - modelIds.length > 0 || - providers.length > 0) && ( -
    { - setExpanded(event.currentTarget.open) - }} - > - {t.modelDetails} - {requirements.size > 0 && ( -
      - {[...requirements].map(([message, features]) => ( -
    • - -
      - {message} - - {t.requiredFor([...features].join(', '))} - -
      -
    • - ))} -
    - )} - {(modelIds.length > 0 || providers.length > 0) && ( -
    - {modelIds.length > 0 && ( -
    -
    {t.modelNumbers}
    -
    {modelIds.join(', ')}
    -
    - )} - {providers.length > 0 && ( -
    -
    {t.integration}
    -
    {providers.join(', ')}
    -
    - )} -
    - )} -
    - )} -
  • - ) -} - -const t = { - features: 'Supported capabilities', - requirements: 'Requirements', - requirementsDetails: 'expand model and requirements details', - toggleRequirements: (capability: string, device: string) => - `${capability} requirements for ${device}`, - requiredFor: (features: string) => `For ${features}`, - modelDetails: 'Model and requirements 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/DeviceRow.tsx b/src/lib/seam/components/SupportedDevices/DeviceRow.tsx new file mode 100644 index 000000000..117ced24d --- /dev/null +++ b/src/lib/seam/components/SupportedDevices/DeviceRow.tsx @@ -0,0 +1,163 @@ +import { BrandMark } from './BrandMark.js' +import { getDeviceModelImageUrl } from './device-models.js' +import { HardwareInformation } from './HardwareInformation.js' +import { getRequirementPills, type PresentedDevice } from './presentation.js' +import { ProviderRequirements } from './ProviderRequirements.js' +import { CheckMark, InfoMark } from './StatusMarks.js' + +export function DeviceRow({ + device, +}: { + device: PresentedDevice +}): JSX.Element { + const image = getDeviceModelImageUrl(device.source) + const pills = getRequirementPills(device) + return ( +
    0 ? ' seam-supported-device-with-pills' : ''}`} + aria-label={device.name} + > + + + + {image == null ? ( + {device.name.slice(0, 1)} + ) : ( + + )} + + + {device.name} + {device.source.capabilities_stale ? ( + + Compatibility is being refreshed. + + ) : device.capabilities.length === 0 ? ( + + Compatibility not confirmed. + + ) : ( + + {device.capabilities.map((capability) => { + const conditional = capability.messages.length > 0 + const status = conditional ? 'Requires setup' : 'Supported' + return ( + + + {conditional ? : } + {capability.label} + + {' '} + · {status} + + + + ) + })} + + )} + + + + {pills.length > 0 ? ( + + {pills.map((pill) => ( + + {pill.label} + + ))} + + ) : ( + Details + )} + + + + + Open requirements and model details + + + +
    + +
    +

    Available providers

    +
      + {device.providers.map((provider) => ( +
    • +
      + +
      + {provider.display_name ?? provider.slug} + {provider.primary && This guide} + {provider.integrationStatus === 'unsupported' && ( + Not supported + )} +
      +
      + +
    • + ))} +
    +
    +
    +
    + ) +} + +export function CapabilityLegend(): JSX.Element { + return ( +
    + + Supported + + + Requires setup · open requirements + +
    + ) +} diff --git a/src/lib/seam/components/SupportedDevices/HardwareGallery.tsx b/src/lib/seam/components/SupportedDevices/HardwareGallery.tsx new file mode 100644 index 000000000..bc80e233a --- /dev/null +++ b/src/lib/seam/components/SupportedDevices/HardwareGallery.tsx @@ -0,0 +1,284 @@ +import { useEffect, useId, useRef, useState } from 'react' + +import type { + DeviceImage, + DeviceModelSummary, +} from 'lib/seam/ecosystem/types.js' + +interface GalleryImage { + image: DeviceImage + label: string +} + +export function HardwareGallery({ + source, + name, +}: { + source: DeviceModelSummary + name: string +}): JSX.Element { + const seen = new Set() + const photos: GalleryImage[] = [] + for (const variant of source.variants) + for (const image of variant.images) { + if (seen.has(image.url)) continue + seen.add(image.url) + photos.push({ + image, + label: [variant.color_name, variant.style_name] + .filter(Boolean) + .join(' · '), + }) + } + if (photos.length === 0 && source.featured_image != null) + photos.push({ image: source.featured_image, label: name }) + const swatches = source.variants + .filter( + (variant) => + variant.color_name != null || variant.primary_color_hex != null + ) + .map((variant) => ({ + label: variant.color_name ?? 'Color not specified', + color: variant.primary_color_hex, + index: photos.findIndex((photo) => + variant.images.some((image) => image.url === photo.image.url) + ), + })) + .filter( + (swatch, index, all) => + all.findIndex( + (entry) => + entry.label === swatch.label && entry.color === swatch.color + ) === index + ) + + const [selected, setSelected] = useState(null) + const [hovered, setHovered] = useState<{ + index: number + left: number + top: number + } | null>(null) + const dialog = useRef(null) + const titleId = useId() + const current = selected == null ? null : photos[selected] + const hoverPhoto = hovered == null ? null : photos[hovered.index] + + useEffect(() => { + if (selected != null && dialog.current != null && !dialog.current.open) + dialog.current.showModal() + }, [selected]) + + useEffect(() => { + if (hovered == null) return + const dismiss = (): void => { + setHovered(null) + } + window.addEventListener('scroll', dismiss, true) + window.addEventListener('resize', dismiss) + return () => { + window.removeEventListener('scroll', dismiss, true) + window.removeEventListener('resize', dismiss) + } + }, [hovered]) + + const move = (direction: number): void => { + setSelected((index) => + index == null ? null : (index + direction + photos.length) % photos.length + ) + } + + return ( +
    + {swatches.length > 0 ? ( +
    + {swatches.map(({ color, label, index }) => ( + + ))} + {photos.length > 0 && swatches.every(({ index }) => index < 0) && ( + + )} +
    + ) : photos.length > 0 ? ( + + ) : ( + Not listed + )} + {hovered != null && hoverPhoto != null && ( +
    + + {hoverPhoto.label === '' ? name : hoverPhoto.label} +
    + )} + { + setSelected(null) + setHovered(null) + }} + onClick={(event) => { + if (event.target === event.currentTarget) event.currentTarget.close() + }} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + event.preventDefault() + move(event.key === 'ArrowLeft' ? -1 : 1) + } + }} + > + {current != null && ( + <> +
    +

    {name}

    + +
    + + {`${name}${current.label +
    + + + {current.label === '' ? name : current.label} ·{' '} + {(selected ?? 0) + 1} / {photos.length} + + +
    + + )} +
    +
    + ) +} + +function largeImage(image: DeviceImage): string { + return image.formats.large?.url ?? image.formats.medium?.url ?? image.url +} diff --git a/src/lib/seam/components/SupportedDevices/HardwareInformation.tsx b/src/lib/seam/components/SupportedDevices/HardwareInformation.tsx new file mode 100644 index 000000000..588dfcdb4 --- /dev/null +++ b/src/lib/seam/components/SupportedDevices/HardwareInformation.tsx @@ -0,0 +1,103 @@ +import type { DeviceModelSummary } from 'lib/seam/ecosystem/types.js' + +import { HardwareGallery } from './HardwareGallery.js' +import type { PresentedDevice } from './presentation.js' + +export function HardwareInformation({ + device, +}: { + device: PresentedDevice +}): JSX.Element { + const { source, name } = device + const facts = hardwareFacts(source) + return ( +
    +

    Hardware information

    +
    + {facts.map(({ label, value }) => ( +
    +
    + {label} + {label === 'Model numbers' && source.manufacturer != null && ( + + ↗ + + )} +
    +
    {value}
    +
    + ))} +
    +
    Colors
    +
    + +
    +
    +
    +
    + ) +} + +function hardwareFacts( + source: DeviceModelSummary +): Array<{ label: string; value: string }> { + const facts: Array<{ label: string; value: string }> = [] + const numbers = [ + ...new Set( + source.variants + .map((variant) => variant.manufacturer_model_id) + .filter((id): id is string => id != null && id !== '') + ), + ] + facts.push({ + label: 'Model numbers', + value: numbers.length > 0 ? numbers.join(', ') : 'Not listed', + }) + const properties = + typeof source.device_type_properties === 'object' && + source.device_type_properties != null + ? (source.device_type_properties as Record) + : {} + const lockType = properties['lock_type'] + if (typeof lockType === 'string' && lockType !== '') + facts.push({ + label: 'Lock type', + value: + lockType.charAt(0).toUpperCase() + + lockType.slice(1).replaceAll('_', ' '), + }) + const batteryType = properties['battery_type'] + const batteryCount = properties['battery_count'] + if (typeof batteryType === 'string' && batteryType !== '') + facts.push({ + label: 'Batteries', + value: + typeof batteryCount === 'number' && batteryCount > 0 + ? `${batteryCount} × ${batteryType}` + : batteryType, + }) + else if (typeof properties['is_battery_powered'] === 'boolean') + facts.push({ + label: 'Power', + value: properties['is_battery_powered'] + ? 'Battery powered' + : 'Not battery powered', + }) + return facts +} diff --git a/src/lib/seam/components/SupportedDevices/ManufacturerSection.tsx b/src/lib/seam/components/SupportedDevices/ManufacturerSection.tsx index 72f233801..5b250acb6 100644 --- a/src/lib/seam/components/SupportedDevices/ManufacturerSection.tsx +++ b/src/lib/seam/components/SupportedDevices/ManufacturerSection.tsx @@ -1,69 +1,59 @@ import { useState } from 'react' -import { - getManufacturerLogoUrl, - getManufacturerName, - type ManufacturerGroup, -} from 'lib/seam/components/SupportedDevices/device-models.js' -import { DeviceModelRow } from 'lib/seam/components/SupportedDevices/DeviceModelRow.js' +import { getManufacturerName } from 'lib/seam/components/SupportedDevices/device-models.js' import type { Manufacturer } from 'lib/seam/ecosystem/types.js' +import { BrandMark } from './BrandMark.js' +import { DeviceRow } from './DeviceRow.js' +import type { PresentedDevice } from './presentation.js' + /** Device models shown per manufacturer before the show all button. */ const collapsedCount = 5 interface ManufacturerSectionProps { - group: ManufacturerGroup + devices: PresentedDevice[] manufacturer: Manufacturer | null collapsible: boolean } export function ManufacturerSection({ - group, + devices, manufacturer, collapsible, }: ManufacturerSectionProps): JSX.Element { const [expanded, setExpanded] = useState(false) - const { deviceModels } = group - const canExpand = collapsible && deviceModels.length > collapsedCount + const canExpand = collapsible && devices.length > collapsedCount const visibleDeviceModels = - canExpand && !expanded - ? deviceModels.slice(0, collapsedCount) - : deviceModels + canExpand && !expanded ? devices.slice(0, collapsedCount) : devices const name = manufacturer != null ? getManufacturerName(manufacturer) - : group.manufacturer != null - ? getManufacturerName(group.manufacturer) + : devices[0]?.source.manufacturer != null + ? getManufacturerName(devices[0].source.manufacturer) : t.other - const logoUrl = getManufacturerLogoUrl(manufacturer) - return (
    -
    - {logoUrl == null ? ( - {name.slice(0, 1)} - ) : ( - - )} -
    +

    {name}

    - {t.count(deviceModels.length)} + {t.count(devices.length)}
    -
      +
      +
      + Device & supported capabilities +
      {visibleDeviceModels.map((deviceModel) => ( - ))} -
    + {canExpand && (
    )} diff --git a/src/lib/seam/components/SupportedDevices/ProviderRequirements.tsx b/src/lib/seam/components/SupportedDevices/ProviderRequirements.tsx new file mode 100644 index 000000000..a6654c6ff --- /dev/null +++ b/src/lib/seam/components/SupportedDevices/ProviderRequirements.tsx @@ -0,0 +1,127 @@ +import { AccessoryLinks } from './AccessoryCatalog.js' +import { + getRequirementKinds, + type PresentedDevice, + type RequirementKind, +} from './presentation.js' +import { RequirementIcon } from './RequirementIcon.js' +import { CheckMark } from './StatusMarks.js' + +const columns: Array<{ kind: RequirementKind; label: string }> = [ + { kind: 'subscription', label: 'Subscription' }, + { kind: 'bridge', label: 'Bridge' }, + { kind: 'keypad', label: 'Keypad' }, + { kind: 'legal', label: 'Legal' }, + { kind: 'other', label: 'Other' }, +] + +export function ProviderRequirements({ + provider, +}: { + provider: PresentedDevice['providers'][number] +}): JSX.Element { + if (provider.integrationStatus === 'unsupported') + return ( + + Request Integration → + + ) + return ( + <> + {provider.capabilities.length === 0 ? ( +

    + Capabilities not confirmed. +

    + ) : ( + + + + + + + + + + + + + + + + {provider.capabilities.map((capability) => ( + + + + + + ))} + +
    + Supported capabilities and their additional requirements +
    Capability + Supported + Requirements
    {capability.label} + + + + +
    + {columns + .filter(({ kind }) => + capability.messages.some((message) => + getRequirementKinds(message).includes(kind) + ) + ) + .map(({ kind, label }) => ( + + + {label} + + ))} + {capability.messages.length === 0 && ( + + — + + )} +
    +
    + )} + {provider.requirements.length > 0 && ( +
      + {provider.requirements.map(({ message, deviceSlugs }) => ( +
    • + +
      +

      {message}

      + +
      +
    • + ))} +
    + )} + + ) +} diff --git a/src/lib/seam/components/SupportedDevices/RequirementIcon.tsx b/src/lib/seam/components/SupportedDevices/RequirementIcon.tsx index 66f210f2d..454d32ad5 100644 --- a/src/lib/seam/components/SupportedDevices/RequirementIcon.tsx +++ b/src/lib/seam/components/SupportedDevices/RequirementIcon.tsx @@ -13,7 +13,14 @@ export function RequirementIcon({ message }: { message: string }): JSX.Element { strokeLinecap='round' strokeLinejoin='round' > - {/subscription|licen[sc]e|paying|paid|fees?/i.test(message) ? ( + {/\b(?:legal|eula|waiver|end[- ]user licen[cs]e agreement)\b/i.test( + message + ) ? ( + <> + + + + ) : /subscription|licen[sc]e|paying|paid|fees?/i.test(message) ? ( <> diff --git a/src/lib/seam/components/SupportedDevices/StatusMarks.tsx b/src/lib/seam/components/SupportedDevices/StatusMarks.tsx new file mode 100644 index 000000000..3726d71b3 --- /dev/null +++ b/src/lib/seam/components/SupportedDevices/StatusMarks.tsx @@ -0,0 +1,32 @@ +export function InfoMark(): JSX.Element { + return ( + + + + + ) +} + +export function CheckMark(): JSX.Element { + return ( + + + + ) +} diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx b/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx index cf1846b03..db208740c 100644 --- a/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx +++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx @@ -106,40 +106,30 @@ test('SupportedDevices scopes the query and badges to a pr expect(request?.searchParams.get('provider')).toBe('schlage-engage') }) -test('warning buttons toggle requirements and model details together', async (ctx) => { +test('requirements and hardware share one native device disclosure', 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') - const requirement = within(row).getByText(/Requires an August keypad/) - const details = requirement.closest('details') - expect(details?.hasAttribute('open')).toBe(false) - expect(within(row).getByText('Model numbers').closest('details')).toBe( - details - ) - const warnings = within(row).getAllByRole('button', { - name: /requirements for/, - }) - const warning = warnings[0] - if (warning == null) throw new Error('Missing warning control') - expect(warning.getAttribute('aria-controls')).toBe(details?.id) - fireEvent.click(warning) - await waitFor(() => { - expect(details?.hasAttribute('open')).toBe(true) - }) - for (const button of warnings) - expect(button.getAttribute('aria-expanded')).toBe('true') - within(row).getByRole('list', { name: 'Requirements' }) - fireEvent.click(warning) - await waitFor(() => { - expect(details?.hasAttribute('open')).toBe(false) - }) + const row = name.closest('details') + if (row == null) throw new Error('Missing device row') + expect(row.hasAttribute('open')).toBe(false) + expect(within(row).getByText('Model numbers').closest('details')).toBe(row) + expect( + within(row) + .getByText(/Requires an August keypad/) + .closest('details') + ).toBe(row) + const summary = row.querySelector('summary') + if (summary == null) throw new Error('Missing summary') + expect(summary.contains(name)).toBe(true) + within(row).getByRole('list', { name: 'Requirement details', hidden: true }) }) 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') + const row = name.closest( + 'details.seam-supported-device-row' + ) if (row == null) throw new Error('Missing row') const badges = within(row).getByRole('list', { name: 'Supported capabilities', @@ -147,7 +137,7 @@ test('SupportedDevices shows features and keeps identifier expect( within(badges) .getAllByRole('listitem') - .map((badge) => badge.textContent) + .map((badge) => badge.textContent?.replace(' · Supported', '')) ).toEqual(['Remote unlock', 'Remote lock', 'Access codes']) const modelNumber = within(row).getByText('BE499WB') const details = modelNumber.closest('details') @@ -207,7 +197,9 @@ for (const missingMetadata of [false, true]) { ctx ) const name = await screen.findByText('Smart Lock Pro') - const row = name.closest('li') + const row = name.closest( + 'details.seam-supported-device-row' + ) if (row == null) throw new Error('Missing row') await waitFor(() => { expect(providerRequests).toHaveLength(1) @@ -221,14 +213,9 @@ for (const missingMetadata of [false, true]) { : 'A paid subscription is required.' ) expect(text.closest('details')?.hasAttribute('open')).toBe(false) - const warning = within(row).getAllByRole('button', { - name: /requirements for/, - })[0] - if (warning == null) throw new Error('Missing warning control') - fireEvent.click(warning) - await waitFor(() => { - expect(text.closest('details')?.hasAttribute('open')).toBe(true) - }) + const summary = row.querySelector('summary') + if (summary == null) throw new Error('Missing summary') + expect(summary.contains(name)).toBe(true) within(row).getByText('ASL-03') expect(screen.queryByText('Requirement message not found')).toBeNull() } finally { @@ -291,6 +278,28 @@ test('SupportedDevices filters by capability', async (ctx) expect(getSectionTitles()).toEqual(['Salto', 'Schlage']) }) +test('capability checkboxes require every selection and support keyboard dismissal', async (ctx) => { + renderWithEcosystem(, ctx) + await screen.findByText('Encode Plus Smart WiFi Deadbolt') + const trigger = screen.getByRole('button', { + name: /Capabilities All capabilities/, + }) + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('checkbox', { name: 'Remote unlock' })) + fireEvent.click(screen.getByRole('checkbox', { name: 'Mobile keys' })) + await screen.findByText('Control Smart Lock') + expect(screen.queryByText('Encode Plus Smart WiFi Deadbolt')).toBeNull() + screen.getByRole('button', { name: /Capabilities 2 selected/ }) + fireEvent.keyDown(screen.getByRole('checkbox', { name: 'Mobile keys' }), { + key: 'Escape', + }) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(document.activeElement).toBe(trigger) + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('button', { name: 'Clear selection' })) + await screen.findByText('Encode Plus Smart WiFi Deadbolt') +}) + test('SupportedDevices searches and filters', async (ctx) => { renderWithEcosystem(, ctx) 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 c9123cd3d..0db32eb46 100644 --- a/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx +++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx @@ -1,8 +1,6 @@ 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, @@ -35,13 +33,18 @@ import { getEcosystemThemeClassName, } from 'lib/ui/ecosystem/theme.js' +import { AccessoryCatalog } from './AccessoryCatalog.js' +import { CapabilityFilter } from './CapabilityFilter.js' +import { CapabilityLegend } from './DeviceRow.js' +import { presentDevice } from './presentation.js' + export interface SupportedDevicesProps { /** * DeviceDB manufacturer slug, e.g., `schlage`. * Defaults to the manufacturer of a manufacturer embed key. */ manufacturer?: string | undefined - /** Ecosystem provider slug, e.g., `oracode`. Scopes models and capability badges. */ + /** Ecosystem provider slug, e.g., `oracode`. Scopes models and capability summaries. */ provider?: string | undefined /** DeviceDB device type slug, e.g., `smartlock` or `thermostat`. */ deviceType?: string | undefined @@ -57,7 +60,7 @@ export interface SupportedDevicesProps { /** * Device models that work with Seam, grouped by manufacturer, - * with device type and capability badges. + * with capability checks and expandable hardware and provider requirements. */ export function SupportedDevices({ manufacturer, @@ -103,23 +106,71 @@ export function SupportedDevices({ enabled: (explicitManufacturer != null || isResolved) && !isResolutionError, }) - const scopedDeviceModels = useMemo( - () => - scopeDeviceModelsToProvider( - applyEmbedExclusions(deviceModels ?? [], embedResolution?.exclusions), - fixedProvider - ), - [deviceModels, embedResolution, fixedProvider] + const allowedDeviceModels = useMemo( + () => applyEmbedExclusions(deviceModels ?? [], embedResolution?.exclusions), + [deviceModels, embedResolution] ) const missingProviderSlugs = useMemo( - () => getProvidersWithMissingRequirements(scopedDeviceModels), - [scopedDeviceModels] + () => getProvidersWithMissingRequirements(allowedDeviceModels), + [allowedDeviceModels] + ) + const providerSlugs = useMemo( + () => + allowedDeviceModels.flatMap((model) => + model.providers.map((provider) => provider.slug) + ), + [allowedDeviceModels] + ) + const { + providers, + isError: isProvidersError, + error: providersError, + refetch: refetchProviders, + } = useEcosystemProviderRequirements(providerSlugs) + const resolvedDeviceModels = useMemo( + () => resolveProviderRequirements(allowedDeviceModels, providers ?? []), + [allowedDeviceModels, providers] ) - const { providers } = useEcosystemProviderRequirements(missingProviderSlugs) const availableDeviceModels = useMemo( - () => resolveProviderRequirements(scopedDeviceModels, providers ?? []), - [scopedDeviceModels, providers] + () => scopeDeviceModelsToProvider(resolvedDeviceModels, fixedProvider), + [resolvedDeviceModels, fixedProvider] + ) + const presentedDevices = useMemo(() => { + const allProvidersById = new Map( + resolvedDeviceModels.map((model) => [model.device_model_id, model]) + ) + return availableDeviceModels.map((source) => + presentDevice( + source, + providers ?? [], + allProvidersById.get(source.device_model_id), + fixedProvider ?? undefined + ) + ) + }, [availableDeviceModels, resolvedDeviceModels, providers, fixedProvider]) + const presentedDevicesById = useMemo( + () => + new Map( + presentedDevices.map((device) => [ + device.source.device_model_id, + device, + ]) + ), + [presentedDevices] + ) + const accessorySlugs = useMemo( + () => + presentedDevices.flatMap((device) => + device.providers + .filter((provider) => provider.integrationStatus !== 'unsupported') + .flatMap((provider) => + provider.requirements.flatMap( + (requirement) => requirement.deviceSlugs ?? [] + ) + ) + ), + [presentedDevices] ) const manufacturerSlugs = useMemo( @@ -150,19 +201,21 @@ export function SupportedDevices({ const [selectedDeviceType, setSelectedDeviceType] = useState( null ) - const [selectedCapability, setSelectedCapability] = useState( - null - ) + const [selectedCapabilities, setSelectedCapabilities] = useState([]) const search = useDeferredValue(searchValue) const filteredDeviceModels = useMemo( () => - filterDeviceModels(availableDeviceModels, { - manufacturer: scopeManufacturer ?? selectedManufacturer, - deviceType: fixedDeviceType ?? selectedDeviceType, - capability: fixedCapability ?? selectedCapability, - search, - }), + selectedCapabilities.reduce( + (models, selected) => + filterDeviceModels(models, { capability: selected }), + filterDeviceModels(availableDeviceModels, { + manufacturer: scopeManufacturer ?? selectedManufacturer, + deviceType: fixedDeviceType ?? selectedDeviceType, + capability: fixedCapability, + search, + }) + ), [ availableDeviceModels, scopeManufacturer, @@ -170,7 +223,7 @@ export function SupportedDevices({ fixedDeviceType, selectedDeviceType, fixedCapability, - selectedCapability, + selectedCapabilities, search, ] ) @@ -204,17 +257,25 @@ export function SupportedDevices({ searchValue.trim().length > 0 || selectedManufacturer != null || selectedDeviceType != null || - selectedCapability != null + selectedCapabilities.length > 0 const resetFilters = (): void => { setSearchValue('') setSelectedManufacturer(null) setSelectedDeviceType(null) - setSelectedCapability(null) + setSelectedCapabilities([]) } - const isError = isResolutionError || isDeviceModelsError - const isLoading = !isError && (!isResolved || deviceModels == null) + const areRequirementsMissing = missingProviderSlugs.length > 0 + const isError = + isResolutionError || + isDeviceModelsError || + (areRequirementsMissing && isProvidersError) + const isLoading = + !isError && + (!isResolved || + deviceModels == null || + (areRequirementsMissing && providers == null)) const scopedManufacturerName = scopeManufacturer == null @@ -233,7 +294,11 @@ export function SupportedDevices({ const content = (() => { if (isError) { const { message, canRetry } = getEcosystemErrorMessage( - isResolutionError ? resolutionError : deviceModelsError, + isResolutionError + ? resolutionError + : isDeviceModelsError + ? deviceModelsError + : providersError, t.error ) return ( @@ -245,6 +310,7 @@ export function SupportedDevices({ onAction: () => { if (isResolutionError) void refetchResolution() if (isDeviceModelsError) void refetchDeviceModels() + if (isProvidersError) void refetchProviders() }, } : {})} @@ -277,7 +343,10 @@ export function SupportedDevices({ {groups.map((group) => ( { + const device = presentedDevicesById.get(model.device_model_id) + return device == null ? [] : [device] + })} manufacturer={manufacturersBySlug.get(group.key) ?? null} collapsible={groups.length > 1} /> @@ -303,7 +372,15 @@ export function SupportedDevices({ {(!hideTitle || !hideSearch || showFilters) && (
    {!hideTitle &&

    {title}

    } -
    +
    + {!hideSearch && ( + + )} {showFilters && showManufacturerFilter && ( )} {showFilters && showCapabilityFilter && ( - - )} - {!hideSearch && ( - option.value)} + selected={selectedCapabilities} + onChange={setSelectedCapabilities} disabled={controlsDisabled} /> )} @@ -346,53 +413,20 @@ export function SupportedDevices({
    )} {!isLoading && !isError && availableDeviceModels.length > 0 && ( -
    - - - {t.supported} - - - - {t.requiresSetup} - -
    + )} - {content} + {content}
    ) } function LoadingRows(): JSX.Element { - return ( -
    -
    -
    -
    -
    -
    -
      - {Array.from({ length: 3 }, (_, index) => ( -
    • -
      -
      -
      -
      -
      -
    • - ))} -
    -
    -
    - ) + return Loading supported devices… } -const normalizeSlug = (value: string | undefined): string | null => { - const slug = value?.trim() - if (slug == null || slug.length === 0) return null - return slug +const normalizeSlug = (slug: string | undefined): string | null => { + const value = slug?.trim() + return value == null || value.length === 0 ? null : value } const getScopedManufacturerName = ( @@ -412,11 +446,6 @@ const t = { allManufacturers: 'All manufacturers', deviceType: 'Device type', allDeviceTypes: 'All device types', - capability: 'Feature', - allCapabilities: 'All features', - featureStatus: 'Feature status', - supported: 'Supported', - requiresSetup: 'Requires setup · expand details', error: 'An error occurred while loading supported devices.', retry: 'Retry', noDevices: 'No supported devices found.', diff --git a/src/lib/seam/components/SupportedDevices/presentation.test.ts b/src/lib/seam/components/SupportedDevices/presentation.test.ts new file mode 100644 index 000000000..6c72b70e9 --- /dev/null +++ b/src/lib/seam/components/SupportedDevices/presentation.test.ts @@ -0,0 +1,262 @@ +import { expect, test } from 'vitest' + +import type { + CapabilitySummary, + DeviceModelSummary, + IntegrationSupportLevel, + ProviderRef, + ProviderRequirements, +} from 'lib/seam/ecosystem/types.js' + +import { + getRequirementKinds, + getRequirementPills, + presentDevice, +} from './presentation.js' + +const legal = + 'To be able to use this system, you will need to first sign an EULA with Salto.' +const combined = + 'To be able to use this system, you will both need to first sign an EULA with Salto and install a Seam bridge to make it cloud-ready.' + +test('an EULA is a legal prerequisite, distinct from a subscription', () => { + expect(getRequirementKinds(legal)).toEqual(['legal']) + expect(getRequirementKinds('Sign an end-user license agreement.')).toEqual([ + 'legal', + ]) + expect(getRequirementKinds('Sign a waiver.')).toEqual(['legal']) +}) + +test('a combined provider requirement preserves legal and bridge categories and source copy', () => { + expect(getRequirementKinds(combined)).toEqual(['legal', 'bridge']) + expect( + getRequirementPills({ + requirements: [{ message: combined, capabilities: ['Access codes'] }], + }) + ).toEqual([ + { label: 'Accessories Required', messages: [combined] }, + { label: 'Legal Requirements', messages: [combined] }, + ]) +}) + +test('existing accessory and subscription categories remain distinct; unknown prose stays explicit', () => { + expect(getRequirementKinds('A compatible bridge is required.')).toEqual([ + 'bridge', + ]) + expect(getRequirementKinds('An external keypad is required.')).toEqual([ + 'keypad', + ]) + expect( + getRequirementKinds('A paying Nuki SmartHosting subscription is required.') + ).toEqual(['subscription']) + expect( + getRequirementKinds('Contact the provider to configure access.') + ).toEqual(['other']) +}) + +const providerRef = (slug: string): ProviderRef => ({ + provider_id: slug, + slug, + display_name: slug, +}) + +function deviceWithProviders( + entries: Array<{ slug: string; capabilities: CapabilitySummary[] }> +): DeviceModelSummary { + return { + device_model_id: 'lock', + slug: 'lock', + display_name: 'Lock', + description_md: null, + product_url: null, + no_longer_sold: false, + is_forbidden_to_3dparty: false, + manufacturer: null, + device_type: null, + device_type_properties: null, + featured_image: null, + variants: [], + providers: entries.map(({ slug }) => providerRef(slug)), + capabilities_by_provider: entries.map(({ slug, capabilities }) => ({ + provider: providerRef(slug), + capabilities, + })), + capabilities_stale: false, + updated_at: '2026-10-01T00:00:00.000Z', + } +} + +const capability = ( + level: IntegrationSupportLevel | null, + name: CapabilitySummary['name'] = 'can_remotely_unlock' +): CapabilitySummary => ({ + name, + supported: 'yes', + integration_support_level: level, + report: null, +}) + +const metadata = ( + slug: string, + levels: Array +): ProviderRequirements => ({ + ...providerRef(slug), + has_requirements: false, + requirements_md: null, + integrations: levels.map((level) => ({ integration_support_level: level })), +}) + +test('unsupported providers sort last, even when they are the guide provider', () => { + const model = deviceWithProviders([ + { slug: 'homelok', capabilities: [capability('inquire')] }, + { slug: 'space', capabilities: [capability('stable')] }, + { slug: 'ks', capabilities: [capability('stable')] }, + ]) + const result = presentDevice(model, [], model, 'homelok') + expect(result.providers.map((provider) => provider.slug)).toEqual([ + 'ks', + 'space', + 'homelok', + ]) + expect( + result.providers.map((provider) => provider.integrationStatus) + ).toEqual(['supported', 'supported', 'unsupported']) + expect(result.providers[2]?.capabilities).toEqual([]) +}) + +test('the supported guide provider precedes other supported providers', () => { + const model = deviceWithProviders([ + { slug: 'ks', capabilities: [capability('stable')] }, + { slug: 'homelok', capabilities: [capability('inquire')] }, + { slug: 'another-live-provider', capabilities: [capability('beta')] }, + ]) + expect( + presentDevice(model, [], model, 'ks').providers.map( + (provider) => provider.slug + ) + ).toEqual(['ks', 'another-live-provider', 'homelok']) +}) + +test.each([ + 'inquire', + 'unavailable', + 'planned', + 'in-progress', +])('a provider with only %s integrations is not supported', (level) => { + const model = deviceWithProviders([{ slug: 'cloud', capabilities: [] }]) + expect( + presentDevice(model, [metadata('cloud', [level])]).providers[0] + ?.integrationStatus + ).toBe('unsupported') +}) + +test.each(['stable', 'beta'])( + 'a %s provider stays supported despite missing or stale device capabilities', + (level) => { + const model = deviceWithProviders([{ slug: 'cloud', capabilities: [] }]) + model.capabilities_stale = true + const result = presentDevice(model, [metadata('cloud', ['inquire', level])]) + expect(result.providers[0]?.integrationStatus).toBe('supported') + expect(result.providers[0]?.capabilities).toEqual([]) + } +) + +test('missing support levels and connectivity alone do not imply an unsupported integration', () => { + const model = deviceWithProviders([ + { slug: 'empty', capabilities: [] }, + { + slug: 'connectivity-only', + capabilities: [capability(null, 'device_connectivity')], + }, + { slug: 'null-level', capabilities: [capability(null)] }, + ]) + expect( + presentDevice(model).providers.every( + (provider) => provider.integrationStatus === 'unknown' + ) + ).toBe(true) + expect( + presentDevice(model, [metadata('empty', ['inquire', null])]).providers.find( + (provider) => provider.slug === 'empty' + )?.integrationStatus + ).toBe('unknown') +}) + +test('stale device reports cannot classify a provider without metadata', () => { + const model = deviceWithProviders([ + { slug: 'cloud', capabilities: [capability('inquire')] }, + ]) + model.capabilities_stale = true + expect(presentDevice(model).providers[0]?.integrationStatus).toBe('unknown') + expect( + presentDevice(model, [metadata('cloud', ['inquire'])]).providers[0] + ?.integrationStatus + ).toBe('unsupported') +}) + +test('required accessories follow dependencies and stay specific to each provider', () => { + const bridge = 'A compatible bridge is required.' + const keypad = 'An external keypad is required.' + const code: CapabilitySummary = { + ...capability('stable', 'can_program_online_access_codes'), + supported: 'conditionally', + report: { + details: '', + requirements: { + keypad: { message: keypad, device_slugs: ['keypad-a', 'keypad-b'] }, + }, + dependencies: ['device_connectivity'], + }, + } + const connectivity = (slug: string): CapabilitySummary => ({ + ...capability(null, 'device_connectivity'), + supported: 'conditionally', + report: { + details: '', + requirements: { bridge: { message: bridge, device_slugs: [slug, slug] } }, + dependencies: ['can_program_online_access_codes'], + }, + }) + const model = deviceWithProviders([ + { slug: 'provider-a', capabilities: [code, connectivity('bridge-a')] }, + { slug: 'provider-b', capabilities: [code, connectivity('bridge-b')] }, + ]) + const result = presentDevice(model) + expect( + result.providers[0]?.requirements.find((r) => r.message === bridge) + ?.deviceSlugs + ).toEqual(['bridge-a']) + expect( + result.providers[1]?.requirements.find((r) => r.message === bridge) + ?.deviceSlugs + ).toEqual(['bridge-b']) + expect( + result.providers[0]?.requirements.find((r) => r.message === keypad) + ?.deviceSlugs + ).toEqual(['keypad-a', 'keypad-b']) +}) + +test('generic requirements without accessory relations do not invent accessory matches', () => { + const model = deviceWithProviders([ + { + slug: 'provider', + capabilities: [ + { + ...capability('stable'), + supported: 'conditionally', + report: { + details: '', + requirements: { + bridge: { message: 'A compatible bridge is required.' }, + }, + }, + }, + ], + }, + ]) + expect( + presentDevice(model).providers[0]?.requirements[0]?.deviceSlugs + ).toEqual([]) + model.capabilities_stale = true + expect(presentDevice(model).providers[0]?.requirements).toEqual([]) +}) diff --git a/src/lib/seam/components/SupportedDevices/presentation.ts b/src/lib/seam/components/SupportedDevices/presentation.ts new file mode 100644 index 000000000..a36793f87 --- /dev/null +++ b/src/lib/seam/components/SupportedDevices/presentation.ts @@ -0,0 +1,234 @@ +import { + getCapabilityLabel, + getCapabilityRequirementMessages, + getDeviceModelName, + getSupportedCapabilities, +} from 'lib/seam/components/SupportedDevices/device-models.js' +import type { + CapabilitySummary, + DeviceAsset, + DeviceModelSummary, + IntegrationSupportLevel, + ProviderRef, + ProviderRequirements, +} from 'lib/seam/ecosystem/types.js' + +export interface Capability { + name: string + label: string + messages: string[] +} + +export interface PresentedDevice { + source: DeviceModelSummary + name: string + capabilities: Capability[] + requirements: Array<{ + message: string + capabilities: string[] + deviceSlugs?: string[] + }> + providers: PresentedProvider[] +} + +interface PresentedProvider extends ProviderRef { + logos?: DeviceAsset[] + primary: boolean + integrationStatus: 'supported' | 'unsupported' | 'unknown' + capabilities: Capability[] + requirements: PresentedDevice['requirements'] +} + +export type RequirementKind = + | 'subscription' + | 'bridge' + | 'keypad' + | 'legal' + | 'other' + +/** UI aliases for source prose, which can describe more than one prerequisite. */ +export function getRequirementKinds(message: string): RequirementKind[] { + const known: Record = { + 'A compatible bridge is required.': 'bridge', + 'An external keypad is required.': 'keypad', + 'A paying Nuki SmartHosting subscription is required.': 'subscription', + } + const kinds: RequirementKind[] = [] + const knownKind = known[message.trim()] + if (knownKind != null) kinds.push(knownKind) + if (/\b(?:eula|waiver|end[- ]user licen[cs]e agreement)\b/i.test(message)) + kinds.push('legal') + if (/\binstall\s+(?:a\s+)?seam\s+bridge\b/i.test(message)) + kinds.push('bridge') + return kinds.length === 0 ? ['other'] : [...new Set(kinds)] +} + +export function presentDevice( + source: DeviceModelSummary, + providers: ProviderRequirements[] = [], + allProvidersSource: DeviceModelSummary = source, + primaryProvider?: string +): PresentedDevice { + const capabilities = reviewCapabilities(source) + const primarySlug = primaryProvider + return { + source, + name: getDeviceModelName(source), + capabilities, + requirements: reviewRequirements(capabilities, source), + providers: allProvidersSource.providers + .map((provider) => { + const details = providers.find((entry) => entry.slug === provider.slug) + const providerCapabilities = + allProvidersSource.capabilities_by_provider.filter( + (entry) => entry.provider.slug === provider.slug + ) + const providerSource = { + ...allProvidersSource, + capabilities_by_provider: providerCapabilities, + } + const capabilities = reviewCapabilities(providerSource) + // Provider metadata distinguishes a missing integration from missing + // device capabilities. Connectivity alone does not imply Seam support. + const levels = + details?.integrations != null && details.integrations.length > 0 + ? details.integrations.map( + (entry) => entry.integration_support_level + ) + : allProvidersSource.capabilities_stale + ? [] + : providerCapabilities.flatMap((entry) => + entry.capabilities + .filter( + (capability) => capability.name !== 'device_connectivity' + ) + .map((capability) => capability.integration_support_level) + ) + return { + ...provider, + display_name: details?.display_name ?? provider.display_name, + logos: details?.logos, + primary: provider.slug === primarySlug, + integrationStatus: getIntegrationStatus(levels), + capabilities, + requirements: reviewRequirements(capabilities, providerSource), + } + }) + .sort((a, b) => { + const unsupported = + Number(a.integrationStatus === 'unsupported') - + Number(b.integrationStatus === 'unsupported') + if (unsupported !== 0) return unsupported + if (a.primary !== b.primary) return a.primary ? -1 : 1 + return (a.display_name ?? a.slug).localeCompare( + b.display_name ?? b.slug + ) + }), + } +} + +function getIntegrationStatus( + levels: Array +): PresentedProvider['integrationStatus'] { + if (levels.some((level) => level === 'stable' || level === 'beta')) + return 'supported' + if (levels.length === 0 || levels.some((level) => level == null)) + return 'unknown' + return 'unsupported' +} + +function reviewCapabilities(source: DeviceModelSummary): Capability[] { + return getSupportedCapabilities(source) + .filter((capability) => capability.name !== 'device_connectivity') + .map((capability) => ({ + name: capability.name, + label: `${getCapabilityLabel(capability.name)}${capability.integrationSupportLevel === 'beta' ? ' (beta)' : ''}`, + messages: getCapabilityRequirementMessages(capability), + })) +} + +function reviewRequirements( + capabilities: Capability[], + source: DeviceModelSummary +): PresentedDevice['requirements'] { + const requirements = new Map() + for (const capability of capabilities) + for (const message of capability.messages) + requirements.set(message, [ + ...(requirements.get(message) ?? []), + capability.label, + ]) + return [...requirements].map(([message, labels]) => ({ + message, + capabilities: labels, + deviceSlugs: [ + ...new Set( + source.capabilities_by_provider.flatMap( + ({ capabilities: rawCapabilities }) => + rawCapabilities + .filter((capability) => + capabilities.some( + (supported) => + supported.name === capability.name && + supported.messages.includes(message) + ) + ) + .flatMap((capability) => + getRequirementDeviceSlugs(capability, rawCapabilities, message) + ) + ) + ), + ], + })) +} + +/** Follow capability dependencies within the same model/provider only. */ +function getRequirementDeviceSlugs( + capability: CapabilitySummary, + capabilities: CapabilitySummary[], + message: string, + visited = new Set() +): string[] { + if (visited.has(capability.name)) return [] + visited.add(capability.name) + const slugs = Object.values(capability.report?.requirements ?? {}) + .filter((requirement) => requirement.message === message) + .flatMap((requirement) => requirement.device_slugs ?? []) + for (const name of capability.report?.dependencies ?? []) { + const dependency = capabilities.find((entry) => entry.name === name) + if (dependency != null) + slugs.push( + ...getRequirementDeviceSlugs(dependency, capabilities, message, visited) + ) + } + return slugs +} + +/** Presentation aliases for known requirements; unfamiliar source copy stays explicit. */ +export function getRequirementPills( + device: Pick +): Array<{ label: string; messages: string[] }> { + const labels: Record = { + bridge: 'Accessories Required', + keypad: 'Accessories Required', + subscription: 'Subscription Required', + legal: 'Legal Requirements', + other: 'Other Requirements', + } + const groups = new Map() + for (const { message } of device.requirements) { + for (const kind of getRequirementKinds(message)) { + const label = labels[kind] + groups.set(label, [...(groups.get(label) ?? []), message]) + } + } + return [ + 'Accessories Required', + 'Subscription Required', + 'Legal Requirements', + 'Other Requirements', + ].flatMap((label) => { + const messages = groups.get(label) + return messages == null ? [] : [{ label, messages }] + }) +} diff --git a/src/lib/seam/ecosystem/types.ts b/src/lib/seam/ecosystem/types.ts index f9d0ed445..e6435de90 100644 --- a/src/lib/seam/ecosystem/types.ts +++ b/src/lib/seam/ecosystem/types.ts @@ -182,10 +182,14 @@ export interface ProviderRef { display_name: string | null } -/** Fields used from the public `providers/list` response to recover legacy report text. */ +/** Fields used from public `providers/list` for branding, support, and report text. */ export interface ProviderRequirements extends ProviderRef { has_requirements: boolean | null requirements_md: string | null + logos?: DeviceAsset[] + integrations?: Array<{ + integration_support_level: IntegrationSupportLevel | null + }> } export type CapabilityName = 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 7c89c6592..a03c68af4 100644 --- a/src/lib/seam/ecosystem/use-ecosystem-device-model-summaries.ts +++ b/src/lib/seam/ecosystem/use-ecosystem-device-model-summaries.ts @@ -13,7 +13,10 @@ import { } from 'lib/seam/ecosystem/use-ecosystem-query-result.js' export type UseEcosystemDeviceModelSummariesParams = - UseEcosystemDeviceModelsParams + UseEcosystemDeviceModelsParams & { + /** Fetch specific models, including accessories from other manufacturers. */ + slugs?: string[] | undefined + } export type UseEcosystemDeviceModelSummariesData = DeviceModelSummary[] @@ -30,6 +33,7 @@ export function useEcosystemDeviceModelSummaries({ deviceType = '', provider = '', capability = '', + slugs = [], enabled = true, }: UseEcosystemDeviceModelSummariesParams = {}): UseEcosystemQueryResult< 'deviceModels', @@ -42,6 +46,9 @@ export function useEcosystemDeviceModelSummaries({ device_type: deviceType.trim(), provider: provider.trim(), capability: capability.trim(), + slugs: [ + ...new Set(slugs.map((slug) => slug.trim()).filter(Boolean)), + ].sort(), } const { data, ...rest } = useQuery< diff --git a/src/lib/seam/ecosystem/use-ecosystem-provider-requirements.ts b/src/lib/seam/ecosystem/use-ecosystem-provider-requirements.ts index 3c7437a30..7d88aaefe 100644 --- a/src/lib/seam/ecosystem/use-ecosystem-provider-requirements.ts +++ b/src/lib/seam/ecosystem/use-ecosystem-provider-requirements.ts @@ -11,7 +11,7 @@ import { type UseEcosystemQueryResult, } from 'lib/seam/ecosystem/use-ecosystem-query-result.js' -/** Only fetch providers whose legacy capability reports lost their free-text requirement. */ +/** Fetch provider branding, integration status, and legacy free-text requirements. */ export function useEcosystemProviderRequirements( slugs: string[] ): UseEcosystemQueryResult<'providers', ProviderRequirements[]> { diff --git a/src/styles/_supported-devices.scss b/src/styles/_supported-devices.scss index 979aaeca9..494de8dd9 100644 --- a/src/styles/_supported-devices.scss +++ b/src/styles/_supported-devices.scss @@ -1,5 +1,48 @@ @mixin all { .seam-supported-devices { + --seam-ecosystem-fg-1: #090a0b; + --seam-ecosystem-fg-2: #5c6475; + --seam-ecosystem-fg-3: #848b9a; + --seam-ecosystem-surface-1: #fff; + --seam-ecosystem-surface-2: #f5f6f8; + --seam-ecosystem-line-1: #e5e7eb; + --seam-ecosystem-line-2: #ccd0d7; + --seam-ecosystem-accent: #4c45d9; + --seam-supported-device-green: #137413; + --seam-supported-device-green-bg: #edf8ed; + --seam-supported-device-orange: #945313; + --seam-supported-device-orange-bg: #fcf2e9; + --seam-supported-device-status-amber: #946200; + --seam-supported-device-status-amber-bg: #fff8e5; + --seam-supported-device-tile: #fff; + --seam-supported-device-tile-shadow: + 0 0 0 0.5px rgb(9 10 11 / 8%), 0 0 2px rgb(9 10 11 / 12%), + 0 2px 4px rgb(9 10 11 / 4%); + + color: var(--seam-ecosystem-fg-1); + font-size: 15px; + line-height: 1.45; + + &.seam-ecosystem-theme-dark { + --seam-ecosystem-fg-1: #eeeff2; + --seam-ecosystem-fg-2: #acb2be; + --seam-ecosystem-fg-3: #848b9a; + --seam-ecosystem-surface-1: #090a0b; + --seam-ecosystem-surface-2: #191b1f; + --seam-ecosystem-line-1: #282a30; + --seam-ecosystem-line-2: #31353f; + --seam-ecosystem-accent: #b5b1f1; + --seam-supported-device-green: #89d189; + --seam-supported-device-green-bg: rgb(25 143 25 / 16%); + --seam-supported-device-orange: #eaad70; + --seam-supported-device-orange-bg: rgb(154 89 25 / 16%); + --seam-supported-device-status-amber: #f0c462; + --seam-supported-device-status-amber-bg: rgb(240 196 98 / 10%); + --seam-supported-device-tile: #191b1f; + --seam-supported-device-tile-shadow: + 0 0 0 0.5px rgb(255 255 255 / 12%), 0 2px 4px rgb(0 0 0 / 12%); + } + container-type: inline-size; .seam-ecosystem-controls { @@ -12,276 +55,1221 @@ min-width: min(200px, 100%); } - .seam-supported-devices-legend { + .seam-supported-devices-sections { + display: grid; + gap: 24px; + } + + .seam-supported-devices-section { + min-width: 0; + } + + .seam-supported-devices-section-header { display: flex; - flex-wrap: wrap; - gap: 6px 16px; - margin-bottom: 12px; + align-items: center; + gap: 10px; + padding: 12px 0; + } + + .seam-supported-devices-section-title { + margin: 0; + font-size: 16px; + font-weight: 600; + } + + .seam-supported-devices-count { + margin-left: auto; color: var(--seam-ecosystem-fg-2); font-size: 12px; + } - span { - display: inline-flex; - align-items: center; - gap: 4px; - } + .seam-supported-devices-section-footer { + padding: 12px 0; + text-align: center; + } - svg { - width: 16px; - height: 16px; + @media (prefers-color-scheme: dark) { + &.seam-ecosystem-theme-auto { + --seam-ecosystem-fg-1: #eeeff2; + --seam-ecosystem-fg-2: #acb2be; + --seam-ecosystem-fg-3: #848b9a; + --seam-ecosystem-surface-1: #090a0b; + --seam-ecosystem-surface-2: #191b1f; + --seam-ecosystem-line-1: #282a30; + --seam-ecosystem-line-2: #31353f; + --seam-ecosystem-accent: #b5b1f1; + --seam-supported-device-green: #89d189; + --seam-supported-device-green-bg: rgb(25 143 25 / 16%); + --seam-supported-device-orange: #eaad70; + --seam-supported-device-orange-bg: rgb(154 89 25 / 16%); + --seam-supported-device-status-amber: #f0c462; + --seam-supported-device-status-amber-bg: rgb(240 196 98 / 10%); + --seam-supported-device-tile: #191b1f; + --seam-supported-device-tile-shadow: + 0 0 0 0.5px rgb(255 255 255 / 12%), 0 2px 4px rgb(0 0 0 / 12%); } } - .seam-supported-devices-sections { - display: flex; - flex-direction: column; - gap: 24px; + & :is(button, input, select) { + font: inherit; } - .seam-supported-devices-section { - overflow: hidden; - border: 1px solid var(--seam-ecosystem-line-1); - border-radius: 12px; + & :is(button, select, summary) { + cursor: pointer; + } + + & :focus-visible { + outline: 2px solid var(--seam-ecosystem-accent); + outline-offset: 3px; + } + + & :is(h1, h2, h3, h4, p) { + margin: 0; + } + + & h1 { + font-size: 21px; + line-height: 1.3; + letter-spacing: -0.4px; + font-weight: 550; + } + + & h2 { + font-size: 22px; + font-weight: 550; + letter-spacing: -0.5px; + } + + & strong { + font-weight: 550; + } + + .seam-supported-device-muted { + color: var(--seam-ecosystem-fg-2); + font-size: 13px; + } + + & a { + color: var(--seam-ecosystem-accent); + text-underline-offset: 3px; + } + + & :is(input, select) { + color: var(--seam-ecosystem-fg-1); background: var(--seam-ecosystem-surface-1); + border: 1px solid var(--seam-ecosystem-line-2); + border-radius: 7px; + min-height: 36px; + padding: 6px 10px; } - .seam-supported-devices-section-header { + .seam-supported-device-filters { display: flex; - align-items: center; + align-items: end; gap: 12px; - padding: 12px 16px; - border-bottom: 1px solid var(--seam-ecosystem-line-1); + margin-top: 16px; } - .seam-supported-devices-logo { + .seam-supported-device-filters label { display: flex; - flex-shrink: 0; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - overflow: hidden; - border-radius: 8px; - - // Catalog monochrome logos are often black, not dark-mode variants. - background: #fff; - color: #434a54; - font-weight: 600; + flex-direction: column; + gap: 4px; + font-size: 11px; + color: var(--seam-ecosystem-fg-2); + min-width: 0; + } - img { - width: 100%; - height: 100%; - padding: 4px; - object-fit: contain; - } + .seam-supported-device-filters :is(input, select) { + width: 100%; + font-size: 13px; } - .seam-supported-devices-section-title { - flex: 1; + .seam-supported-device-capability-filter { + position: relative; + display: flex; + flex: 0 0 200px; + flex-direction: column; + gap: 4px; min-width: 0; - margin: 0; + color: var(--seam-ecosystem-fg-2); + font-size: 11px; + } + + & .seam-supported-device-capability-trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + min-height: 36px; + padding: 7px 12px; + border: 1px solid var(--seam-ecosystem-line-2); + border-radius: 7px; + color: var(--seam-ecosystem-fg-1); + background: var(--seam-ecosystem-surface-1); + font-size: 13px; + text-align: left; + } + + .seam-supported-device-capability-trigger > span { overflow: hidden; - font-size: 18px; - font-weight: 600; - white-space: nowrap; text-overflow: ellipsis; + white-space: nowrap; } - .seam-supported-devices-count { + .seam-supported-device-filter-chevron { flex-shrink: 0; - color: var(--seam-ecosystem-fg-2); - font-size: 14px; } - .seam-supported-devices-rows { + .seam-supported-device-capability-trigger[aria-expanded='true'] + .seam-supported-device-filter-chevron { + transform: rotate(180deg); + } + + .seam-supported-device-capability-options { + position: absolute; + z-index: 10; + top: calc(100% + 6px); + right: 0; + width: 280px; + max-width: calc(100vw - 28px); + max-height: 60vh; + overflow-y: auto; + padding: 8px; + border: 1px solid var(--seam-ecosystem-line-2); + border-radius: 10px; + background: var(--seam-ecosystem-surface-1); + box-shadow: 0 8px 24px #0002; + } + + .seam-supported-device-capability-options > p { + padding: 4px 8px 8px; + font-size: 12px; + line-height: 1.5; + } + + .seam-supported-device-capability-options fieldset { margin: 0; padding: 0; - list-style: none; + border: 0; } - .seam-supported-devices-row { - display: grid; - grid-template-columns: 48px minmax(0, 1fr); - align-items: start; - gap: 8px 12px; - padding: 20px 16px; + .seam-supported-device-filters .seam-supported-device-capability-option { + flex-direction: row; + align-items: center; + gap: 10px; + min-height: 40px; + padding: 8px; + border-radius: 6px; + color: var(--seam-ecosystem-fg-1); + font-size: 13px; + cursor: pointer; + } - &:not(:last-child) { - border-bottom: 1px solid var(--seam-ecosystem-line-1); - } + .seam-supported-device-capability-option:hover { + background: var(--seam-ecosystem-surface-2); } - .seam-supported-devices-image { - display: flex; - grid-row: 1 / span 2; + .seam-supported-device-filters + .seam-supported-device-capability-option + input { + flex: 0 0 16px; + width: 16px; + height: 16px; + min-height: 0; + margin: 0; + padding: 0; + accent-color: var(--seam-ecosystem-accent); + } + + & .seam-supported-device-capability-clear { + min-height: 36px; + margin-top: 4px; + padding: 6px 8px; + border: 0; + border-radius: 6px; + color: var(--seam-ecosystem-accent); + background: transparent; + font-size: 12px; + } + + .seam-supported-device-capability-clear:disabled { + opacity: 0.5; + cursor: default; + } + + .seam-supported-device-brand-mark { + display: inline-flex; align-items: center; justify-content: center; - width: 48px; - height: 48px; - overflow: hidden; - border-radius: 8px; - background: var(--seam-ecosystem-surface-2); - color: var(--seam-ecosystem-fg-3); + flex: 0 0 32px; + width: 32px; + height: 32px; + padding: 4px; + border: 1px solid #e5e6e8; + border-radius: 7px; + background: #fff; + color: #33373b; + font-size: 15px; font-weight: 600; + } - img { - width: 100%; - height: 100%; - object-fit: contain; - } + .seam-supported-device-brand-mark img { + width: 100%; + height: 100%; + object-fit: contain; } - .seam-supported-devices-info { + .seam-supported-device-legend { display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; + flex-wrap: wrap; + gap: 8px 18px; + margin-bottom: 14px; + font-size: 11px; + color: var(--seam-ecosystem-fg-2); } - .seam-supported-devices-name { - color: var(--seam-ecosystem-fg-1); - font-weight: 600; + .seam-supported-device-legend > span { + display: inline-flex; + align-items: center; + gap: 5px; + } + + .seam-supported-device-legend > span:first-child svg { + color: var(--seam-supported-device-green); } - a.seam-supported-devices-name:hover { - text-decoration: underline; + .seam-supported-device-legend > span:last-child svg { + color: var(--seam-supported-device-orange); } - .seam-supported-devices-meta { + .seam-supported-device-list-heading { + display: grid; + grid-template-columns: minmax(230px, 1fr) minmax(0, 1.5fr); + padding: 10px 12px; + font-size: 11px; color: var(--seam-ecosystem-fg-2); - font-size: 13px; + border-block: 1px solid var(--seam-ecosystem-line-1); + } + + .seam-supported-device-row { + border-bottom: 1px solid var(--seam-ecosystem-line-1); } - .seam-supported-devices-capabilities { + .seam-supported-device-row-summary { display: grid; - grid-column: 2; - gap: 4px; + grid-template-columns: minmax(210px, 1fr) auto auto; + align-items: center; + gap: 14px; + min-height: 89px; + padding: 16px 12px; + list-style: none; + } + + .seam-supported-device-row-summary::-webkit-details-marker { + display: none; + } + + .seam-supported-device-row-summary > * { + min-width: 0; + } + + .seam-supported-device-identity { + display: flex; + align-items: center; + gap: 16px; + min-width: 0; + } + + .seam-supported-device-image { + display: flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + padding: 6px; + flex-shrink: 0; + border-radius: 8px; + background: var(--seam-supported-device-tile); + box-shadow: var(--seam-supported-device-tile-shadow); + } + + .seam-supported-device-image img { + width: 44px; + height: 44px; + object-fit: contain; + } + + .seam-supported-device-identity-text { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + } + + .seam-supported-device-name { + font-size: 15px; + line-height: 1.4; + font-weight: 500; + overflow-wrap: anywhere; } - .seam-supported-devices-capabilities-label { + .seam-supported-device-status { + font-size: 11px; color: var(--seam-ecosystem-fg-2); - font-size: 12px; - font-weight: 600; } - .seam-supported-devices-features { + .seam-supported-device-features { display: flex; flex-wrap: wrap; - gap: 6px 16px; - margin: 0; - padding: 0; - list-style: none; - font-size: 13px; + gap: 5px; + align-items: center; + } - li { - display: flex; - align-items: center; - gap: 4px; - } + .seam-supported-device-feature { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 7px; + border-radius: 6px; + color: var(--seam-supported-device-green); + background: var(--seam-supported-device-green-bg); + box-shadow: inset 0 0 0 0.5px rgb(9 10 11 / 8%); + font-size: 12px; + line-height: 16px; + white-space: nowrap; + } - svg { - flex-shrink: 0; - width: 16px; - height: 16px; - } + .seam-supported-device-feature svg { + width: 15px; + height: 15px; + flex-shrink: 0; + } - .seam-supported-devices-conditional-icon path { - fill: #d48c23; - } + .seam-supported-device-feature[data-conditional] { + color: var(--seam-supported-device-orange); + background: var(--seam-supported-device-orange-bg); } - .seam-supported-devices-requirement-toggle { + .seam-supported-device-disclosure { display: inline-flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; - margin: -4px 0 -4px -4px; - padding: 4px; - border: 0; - border-radius: 4px; - background: transparent; - cursor: pointer; + gap: 5px; + justify-self: end; + padding: 5px 7px; + min-height: 30px; + border-radius: 6px; + font-size: 12px; + color: var(--seam-ecosystem-fg-2); + white-space: nowrap; + } - &:hover { - background: var(--seam-ecosystem-surface-2); - } + .seam-supported-device-disclosure[data-conditional] { + color: var(--seam-supported-device-orange); + background: var(--seam-supported-device-orange-bg); + box-shadow: inset 0 0 0 0.5px rgb(9 10 11 / 8%); + } - &:focus-visible { - outline: 2px solid var(--seam-ecosystem-accent); - outline-offset: 2px; - } + .seam-supported-device-chevron { + flex-shrink: 0; } - .seam-supported-devices-section-footer { + .seam-supported-device-row[open] > summary .seam-supported-device-chevron { + transform: rotate(180deg); + } + + .seam-supported-device-row[open] > summary { + background: var(--seam-ecosystem-surface-2); + } + + .seam-supported-device-expanded { + position: relative; + margin: 0 12px 20px 84px; + padding: 18px 20px 2px; + border-left: 1px dashed var(--seam-ecosystem-line-2); + } + + .seam-supported-device-expanded::before { + position: absolute; + content: ''; + top: 0; + left: -37px; + width: 36px; + height: 36px; + border-left: 1px dashed var(--seam-ecosystem-line-2); + border-bottom: 1px dashed var(--seam-ecosystem-line-2); + border-bottom-left-radius: 12px; + } + + .seam-supported-device-detail-heading { display: flex; - justify-content: center; - padding: 12px 16px; - border-top: 1px solid var(--seam-ecosystem-line-1); + align-items: center; + gap: 10px; + font-size: 14px; } - .seam-supported-devices-status, - .seam-supported-devices-requirements, - .seam-supported-devices-details { - grid-column: 2 / -1; + .seam-supported-device-detail-heading p { + font-size: 12px; color: var(--seam-ecosystem-fg-2); - font-size: 13px; + margin-top: 2px; } - .seam-supported-devices-requirements { + .seam-supported-device-requirements { + list-style: none; + margin: 16px 0; + padding: 0; display: grid; gap: 12px; - margin: 12px 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-device-hardware > h3, + .seam-supported-device-providers > h3 { + font-size: 14px; + font-weight: 550; + } - .seam-supported-devices-requirement-icon { - flex-shrink: 0; - margin-top: 2px; - color: var(--seam-ecosystem-fg-1); - } + .seam-supported-device-gallery { + min-width: 0; } - .seam-supported-devices-requirement-features { - display: block; - margin-top: 2px; - color: var(--seam-ecosystem-fg-2); - font-size: 12px; + .seam-supported-device-gallery-swatches { + display: flex; + flex-wrap: wrap; + max-width: 128px; + gap: 0; } - .seam-supported-devices-details { + .seam-supported-device-gallery-swatch { + display: flex; + flex: 0 0 32px; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 6px; + border: 0; + border-radius: 50%; + background: transparent; + -webkit-tap-highlight-color: transparent; + } + + .seam-supported-device-gallery-swatch:hover:not(:disabled) { + box-shadow: inset 0 0 0 1px var(--seam-ecosystem-line-2); + } + + .seam-supported-device-gallery-color { + width: 18px; + height: 18px; + border: 1px solid rgb(127 127 127 / 40%); + border-radius: 50%; + } + + .seam-supported-device-gallery-color[data-unknown] { + background: repeating-linear-gradient( + 135deg, + transparent, + transparent 3px, + var(--seam-ecosystem-line-2) 3px, + var(--seam-ecosystem-line-2) 4px + ); + } + + .seam-supported-device-gallery-photos { + min-height: 32px; + padding: 0; + border: 0; + background: transparent; + color: var(--seam-ecosystem-accent); + font-size: 12px; + } + + .seam-supported-device-gallery-hover { + position: fixed; + z-index: 40; + width: 280px; + padding: 10px; + border: 1px solid #d8dce1; + border-radius: 12px; + background: #fff; + color: #33373b; + box-shadow: 0 12px 36px #0003; + pointer-events: none; + } + + .seam-supported-device-gallery-hover img { + display: block; + width: 100%; + height: 260px; + object-fit: contain; + } + + .seam-supported-device-gallery-hover > span { + display: block; + margin-top: 6px; + font-size: 12px; + text-align: center; + } + + .seam-supported-device-gallery-dialog { + inset: auto; + top: calc( + var(--seam-supported-device-viewport-top, 0px) + + var(--seam-supported-device-viewport-height, 100dvh) / 2 + ); + left: 50%; + margin: 0; + transform: translate(-50%, -50%); + width: min(720px, calc(100vw - 32px)); + max-width: none; + max-height: calc( + var(--seam-supported-device-viewport-height, 100dvh) - 24px + ); + overflow-y: auto; + padding: 16px; + border: 1px solid var(--seam-ecosystem-line-2); + border-radius: 14px; + background: var(--seam-ecosystem-surface-1); + color: var(--seam-ecosystem-fg-1); + } + + .seam-supported-device-gallery-dialog::backdrop { + background: #0009; + } + + .seam-supported-device-gallery-dialog header, + .seam-supported-device-gallery-dialog footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + + .seam-supported-device-gallery-dialog h4 { + font-size: 14px; + font-weight: 550; + } + + .seam-supported-device-gallery-dialog button { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + min-width: 44px; + min-height: 44px; + border: 1px solid var(--seam-ecosystem-line-2); + border-radius: 8px; + background: var(--seam-ecosystem-surface-1); + color: var(--seam-ecosystem-fg-1); + } + + .seam-supported-device-gallery-dialog footer { + margin-top: 12px; + font-size: 12px; + } + + .seam-supported-device-gallery-dialog footer > span { + min-width: 0; + text-align: center; + } + + .seam-supported-device-gallery-large-image { + display: block; + width: 100%; + height: min( + 600px, + calc(var(--seam-supported-device-viewport-height, 100dvh) - 176px) + ); + margin-top: 12px; + border-radius: 8px; + background: #fff; + object-fit: contain; + } + + .seam-supported-device-hardware { + container-type: inline-size; + } + + .seam-supported-device-hardware-facts { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto auto; + align-items: start; + gap: 10px 20px; + margin: 0; + } + + .seam-supported-device-hardware-facts > div { + min-width: 0; + } + + .seam-supported-device-hardware-facts dt { + display: flex; + align-items: center; + gap: 6px; + color: var(--seam-ecosystem-fg-2); + font-size: 11px; + } + + .seam-supported-device-hardware-facts dt a { + text-decoration: none; + } + + .seam-supported-device-hardware-facts dd { + margin: 3px 0 0; overflow-wrap: anywhere; + font-size: 12px; + line-height: 1.5; + } - summary { - width: fit-content; - cursor: pointer; + .seam-supported-device-hardware-colors dd { + margin-top: -3px; + } + + .seam-supported-device-providers { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--seam-ecosystem-line-1); + } + + .seam-supported-device-provider-list { + display: grid; + gap: 20px; + margin: 20px 0 0 6px; + padding: 0 0 0 20px; + list-style: none; + } + + .seam-supported-device-provider { + position: relative; + min-width: 0; + padding: 16px; + border: 1px solid var(--seam-ecosystem-line-1); + border-radius: 8px; + background: var(--seam-ecosystem-surface-2); + container-type: inline-size; + } + + .seam-supported-device-provider::before { + position: absolute; + top: -20px; + bottom: 0; + left: -20px; + content: ''; + border-left: 1px dashed var(--seam-ecosystem-line-2); + } + + .seam-supported-device-provider::after { + position: absolute; + top: 32px; + left: -20px; + width: 20px; + content: ''; + border-top: 1px dashed var(--seam-ecosystem-line-2); + } + + .seam-supported-device-provider:last-child::before { + bottom: auto; + height: 52px; + } + + .seam-supported-device-provider > .seam-supported-device-requirements { + margin-bottom: 0; + } - &:focus-visible { - outline: 2px solid var(--seam-ecosystem-accent); - outline-offset: 4px; - } + .seam-supported-device-provider-name { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + } + + .seam-supported-device-provider-name > span { + padding: 3px 6px; + border-radius: 5px; + background: var(--seam-ecosystem-surface-1); + color: var(--seam-ecosystem-fg-2); + font-size: 10px; + } + + .seam-supported-device-request-integration { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 44px; + margin-top: 4px; + color: var(--seam-ecosystem-accent); + font-size: 12px; + text-underline-offset: 3px; + } + + .seam-supported-device-no-requirements, + .seam-supported-device-provider-empty { + color: var(--seam-ecosystem-fg-2); + } + + .seam-supported-device-provider-empty { + margin-top: 12px !important; + font-size: 12px; + } + + .seam-supported-device-capability-requirements { + width: min(100%, 560px); + table-layout: fixed; + border-collapse: collapse; + margin: 12px 0 0; + font-size: 12px; + } + + .seam-supported-device-capability-requirements th, + .seam-supported-device-capability-requirements td { + padding: 10px 0; + border-bottom: 1px solid var(--seam-ecosystem-line-1); + text-align: left; + vertical-align: top; + overflow-wrap: anywhere; + } + + .seam-supported-device-capability-requirements th:first-child { + padding-right: 12px; + } + + .seam-supported-device-capability-name-column { + width: 36%; + } + + .seam-supported-device-capability-supported-column { + width: 72px; + } + + .seam-supported-device-capability-requirements + .seam-supported-device-capability-support-cell { + text-align: center; + padding-right: 12px; + } + + .seam-supported-device-capability-supported { + display: inline-flex; + align-items: center; + min-height: 22px; + color: var(--seam-supported-device-green); + } + + .seam-supported-device-capability-requirements thead th { + color: var(--seam-ecosystem-fg-2); + font-size: 11px; + font-weight: 400; + } + + .seam-supported-device-capability-requirements tbody th { + font-weight: 500; + line-height: 22px; + } + + .seam-supported-device-capability-pills { + display: flex; + flex-wrap: wrap; + gap: 5px; + align-items: flex-start; + } + + .seam-supported-device-inline-requirement { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 5px; + border-radius: 5px; + background: var(--seam-supported-device-status-amber-bg); + color: var(--seam-supported-device-status-amber); + font-size: 11px; + } + + .seam-supported-device-inline-requirement svg { + width: 13px; + height: 13px; + flex-shrink: 0; + } + + @container (max-width: 500px) { + .seam-supported-device-capability-name-column { + width: 33%; + } + + .seam-supported-device-capability-supported-column { + width: 60px; } - dl { - display: grid; + .seam-supported-device-capability-requirements + .seam-supported-device-capability-support-cell { + padding-right: 4px; + } + + .seam-supported-device-hardware-facts { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; + gap: 10px 12px; + } + + .seam-supported-device-hardware-facts + .seam-supported-device-model-numbers { + grid-column: 1 / -1; + } + + .seam-supported-device-gallery-swatches { + max-width: 96px; + } + + .seam-supported-device-capability-pills { + flex-flow: column nowrap; + } + } + + .seam-supported-device-requirements li { + display: flex; + align-items: start; + gap: 9px; + font-size: 14px; + line-height: 1.5; + } + + .seam-supported-device-requirements + .seam-supported-devices-requirement-icon { + width: 17px; + height: 19px; + color: var(--seam-supported-device-orange); + flex-shrink: 0; + } + + .seam-supported-device-requirements li span { + display: block; + color: var(--seam-ecosystem-fg-2); + font-size: 11px; + margin-top: 2px; + } + + .seam-supported-device-requirement-body { + flex: 1; + min-width: 0; + } + + .seam-supported-device-accessories { + max-width: 400px; + margin-top: 10px; + } + + .seam-supported-device-accessories-label, + .seam-supported-device-accessories-state { + color: var(--seam-ecosystem-fg-2); + font-size: 11px; + } + + .seam-supported-device-accessory-list { + margin: 4px 0 0; + padding: 0; + list-style: none; + } + + .seam-supported-device-accessory-list > li { + display: block; + } + + .seam-supported-device-accessory-list > li + li { + border-top: 1px solid var(--seam-ecosystem-line-1); + } + + .seam-supported-device-accessory-link { + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 6px 0; + color: var(--seam-ecosystem-fg-1); + font-size: 12px; + line-height: 1.4; + text-decoration: none; + } + + a.seam-supported-device-accessory-link:hover { + color: var(--seam-ecosystem-accent); + } + + .seam-supported-device-accessory-link img { + flex-shrink: 0; + object-fit: contain; + } + + .seam-supported-device-accessory-link > div { + min-width: 0; + overflow-wrap: anywhere; + } + + .seam-supported-device-requirements .seam-supported-device-accessory-arrow { + margin: 0 0 0 auto; + font-size: 14px; + } + + .seam-supported-device-sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + + .seam-supported-device-stacked > summary { + grid-template-columns: minmax(0, 1fr) auto; + } + + .seam-supported-device-stacked .seam-supported-device-identity-text { + gap: 7px; + } + + .seam-supported-device-stacked .seam-supported-device-features { + gap: 14px; + } + + .seam-supported-device-stacked .seam-supported-device-feature { + background: none; + box-shadow: none; + padding: 0; + font-size: 12px; + } + + @container (max-width: 850px) { + .seam-supported-device-list-heading { + grid-template-columns: 1fr; + } + + .seam-supported-device-list-heading > span:nth-child(2) { + display: none; + } + } + + @container (max-width: 600px) { + & h1 { + font-size: 19px; + } + + .seam-supported-device-filters { + flex-wrap: wrap; gap: 8px; - margin-top: 10px; } - dt { - font-weight: 600; + .seam-supported-device-filters > label, + .seam-supported-device-capability-filter { + width: 100%; + flex-basis: 100%; + } + + .seam-supported-device-filters :is(input, select) { + font-size: 16px; + min-height: 40px; + } + + & .seam-supported-device-capability-trigger { + min-height: 40px; + font-size: 16px; + } + + .seam-supported-device-filters .seam-supported-device-capability-option { + min-height: 44px; + } + + .seam-supported-device-row-summary { + min-height: 81px; + gap: 10px; + padding: 12px 14px; + } + + .seam-supported-device-image { + width: 48px; + height: 48px; + padding: 6px; + } + + .seam-supported-device-image img { + width: 36px; + height: 36px; + } + + .seam-supported-device-identity { + gap: 12px; + } + + .seam-supported-device-name { + font-size: 14px; + } + + .seam-supported-device-disclosure { + font-size: 11px; + gap: 4px; + padding: 5px; + } + + .seam-supported-device-list-heading { + padding: 9px 14px; + } + + .seam-supported-device-feature { + padding: 3px 5px; + font-size: 11px; + gap: 4px; + } + + .seam-supported-device-feature svg { + width: 14px; + height: 14px; + } + + .seam-supported-device-expanded { + margin: 0 14px 16px; + padding: 16px 12px 0; + } + + .seam-supported-device-expanded::before { + display: none; + } + + .seam-supported-device-provider-list { + gap: 16px; + margin-left: 0; + padding-left: 0; + } + + .seam-supported-device-provider { + padding: 14px 10px; + } + + .seam-supported-device-provider::before, + .seam-supported-device-provider::after { + display: none; + } + + .seam-supported-device-stacked > summary { + grid-template-columns: 48px minmax(0, 1fr) auto; + } + + .seam-supported-device-stacked .seam-supported-device-identity, + .seam-supported-device-stacked .seam-supported-device-identity-text { + display: contents; + } + + .seam-supported-device-stacked .seam-supported-device-image { + grid-column: 1; + grid-row: 1 / 3; + align-self: start; + } + + .seam-supported-device-stacked .seam-supported-device-name { + grid-column: 2; + grid-row: 1; + } + + .seam-supported-device-stacked .seam-supported-device-disclosure { + grid-column: 3; + grid-row: 1; + } + + .seam-supported-device-stacked .seam-supported-device-status { + grid-column: 2 / -1; + } + + .seam-supported-device-stacked .seam-supported-device-features { + grid-column: 2 / -1; + grid-row: 2; + gap: 6px 10px; + } + + .seam-supported-device-stacked .seam-supported-device-feature { + font-size: 11px; + } + } + + @media (hover: hover) and (pointer: fine) { + .seam-supported-device-row-summary:hover { + background: var(--seam-ecosystem-surface-2); + } + + & a:hover { + text-decoration: underline; + } + } + + .seam-supported-device-feature.seam-supported-device-status-feature { + color: var(--seam-ecosystem-fg-1); + } + + .seam-supported-device-status-feature > svg { + color: var(--seam-supported-device-green); + } + + .seam-supported-device-status-feature[data-conditional] > svg { + color: var(--seam-supported-device-status-amber); + } + + .seam-supported-device-pill-disclosure { + display: flex; + align-items: center; + justify-content: end; + gap: 8px; + max-width: 405px; + color: var(--seam-supported-device-status-amber); + } + + .seam-supported-device-requirement-pills { + display: flex; + flex-wrap: wrap; + justify-content: end; + gap: 5px; + } + + .seam-supported-device-requirement-pill { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 3px 7px; + border-radius: 6px; + background: var(--seam-supported-device-status-amber-bg); + color: var(--seam-supported-device-status-amber); + box-shadow: inset 0 0 0 0.5px currentcolor; + font-size: 11px; + line-height: 16px; + white-space: nowrap; + } + + @container (max-width: 850px) and (min-width: 601px) { + .seam-supported-device-pill-disclosure { + max-width: 280px; + } + } + + @container (max-width: 600px) { + .seam-supported-device-with-pills .seam-supported-device-pill-disclosure { + display: contents; + } + + .seam-supported-device-with-pills + .seam-supported-device-requirement-pills { + grid-column: 2 / -1; + grid-row: 3; + justify-content: start; + } + + .seam-supported-device-with-pills .seam-supported-device-image { + grid-row: 1 / 4; + } + + .seam-supported-device-with-pills + .seam-supported-device-pill-disclosure + > .seam-supported-device-chevron { + grid-column: 3; + grid-row: 1; + align-self: center; } - dd { - margin: 2px 0 0; + .seam-supported-device-requirement-pill { + min-height: 22px; + padding: 2px 5px; + font-size: 11px; } } }