diff --git a/examples/supported-devices-prototypes/index.html b/examples/supported-devices-prototypes/index.html new file mode 100644 index 000000000..129951e88 --- /dev/null +++ b/examples/supported-devices-prototypes/index.html @@ -0,0 +1,13 @@ + + + + + + + Two-line DeviceDB · Design review + + +
Loading design review…
+ + + diff --git a/examples/supported-devices-prototypes/src/App.tsx b/examples/supported-devices-prototypes/src/App.tsx new file mode 100644 index 000000000..a2bd019f2 --- /dev/null +++ b/examples/supported-devices-prototypes/src/App.tsx @@ -0,0 +1,282 @@ +import { useEffect, useState } from 'react' + +import { + getCapabilityLabel, + getProvidersWithMissingRequirements, + resolveProviderRequirements, + scopeDeviceModelsToProvider, +} from 'lib/seam/components/SupportedDevices/device-models.js' +import { useEcosystemDeviceModelSummaries } from 'lib/seam/ecosystem/use-ecosystem-device-model-summaries.js' +import { useEcosystemProviderRequirements } from 'lib/seam/ecosystem/use-ecosystem-provider-requirements.js' + +import { CheckInfo } from './CheckInfo.js' +import { reviewDevice } from './model.js' +import { Picker } from './Picker.js' +import { RequirementPills } from './RequirementPills.js' +import { Legend } from './shared.js' +import { Stacked } from './Stacked.js' + +const variants = [ + { + name: 'Current', + title: 'Two-line rows · current version', + description: + 'The selected layout, with capability-specific icons and one requirements disclosure.', + component: Stacked, + }, + { + name: 'Checks', + title: 'Two-line rows · checks and info', + description: + 'Green checks indicate support. Amber info icons indicate additional requirements. Capability names stay visible.', + component: CheckInfo, + }, + { + name: 'Pills', + title: 'Two-line rows · requirement pills', + description: + 'The same check/info treatment, with explicit bridge, keypad, and subscription pills. Open a row for the full requirements.', + component: RequirementPills, + }, +] +const brands = [ + { slug: 'nuki', label: 'Nuki', provider: 'nuki-web' }, + { slug: 'august', label: 'August', provider: 'august-access' }, + { slug: 'keyincode', label: 'KeyInCode', provider: 'keyincode' }, +] +const params = new URLSearchParams(window.location.search) +const requestedVariant = Number(params.get('v')) - 1 +const initialVariant = + Number.isInteger(requestedVariant) && + requestedVariant >= 0 && + requestedVariant < variants.length + ? requestedVariant + : 0 + +export function App(): JSX.Element { + const [active, setActive] = useState(initialVariant) + const [replay, setReplay] = useState(0) + const [brandSlug, setBrandSlug] = useState(params.get('brand') ?? 'nuki') + const [dark, setDark] = useState(params.get('theme') === 'dark') + const [search, setSearch] = useState('') + const [capability, setCapability] = useState('') + const brand = brands.find((entry) => entry.slug === brandSlug) ?? brands[0] + const variant = variants[active] ?? variants[0] + + useEffect(() => { + const url = new URL(window.location.href) + url.searchParams.set('v', String(active + 1)) + url.searchParams.set('brand', brandSlug) + url.searchParams.set('theme', dark ? 'dark' : 'light') + window.history.replaceState(null, '', url) + document.documentElement.style.colorScheme = dark ? 'dark' : 'light' + }, [active, brandSlug, dark]) + + const modelsQuery = useEcosystemDeviceModelSummaries({ + manufacturer: brand?.slug, + provider: brand?.provider, + deviceType: 'smartlock', + }) + const scopedModels = scopeDeviceModelsToProvider( + modelsQuery.deviceModels ?? [], + brand?.provider + ) + const missing = getProvidersWithMissingRequirements(scopedModels) + const requirementsQuery = useEcosystemProviderRequirements(missing) + const devices = resolveProviderRequirements( + scopedModels, + requirementsQuery.providers ?? [] + ).map(reviewDevice) + const allCapabilities = [ + ...new Set( + devices.flatMap((device) => device.capabilities.map((item) => item.name)) + ), + ] + const filtered = devices.filter((device) => { + const text = [ + device.name, + ...device.source.variants.map((item) => item.manufacturer_model_id), + ] + .join(' ') + .toLowerCase() + return ( + text.includes(search.toLowerCase().trim()) && + (capability === '' || + device.capabilities.some((item) => item.name === capability)) + ) + }) + const loading = + modelsQuery.isLoading || (missing.length > 0 && requirementsQuery.isLoading) + const error = modelsQuery.isError || requirementsQuery.isError + if (variant == null || brand == null) return

Design review unavailable.

+ const Variant = variant.component + + return ( +
+ +
+
+ + Option {active + 1} of {variants.length} · {variant.name} + +

{variant.title}

+

{variant.description}

+

+ Switch below or use keys 1–3 / ← →. R resets open details. +

+
+
+
+ Brand guides / {brand.label} + + View live guide ↗ + +
+

Supported devices

+

+ Supported capabilities at a glance. Open a row for setup + requirements and model details. +

+
+ + +
+
+ {brand.label} + + {loading ? 'Loading…' : `${filtered.length} devices`} + +
+ + {loading ? ( +

+ Loading capabilities and requirements… +

+ ) : error ? ( +
+

We couldn’t load the complete device information.

+ +
+ ) : filtered.length === 0 ? ( +
+

No devices match these filters.

+ +
+ ) : ( +
+ +
+ )} + + View {brand.label} in the device catalog ↗ + +
+

+ Review prototype · Live Ecosystem catalog ·{' '} + + Dawn’s design feedback ↗ + +

+
+ item.name)} + active={active} + onChange={setActive} + onReplay={() => { + setReplay((value) => value + 1) + }} + /> +
+ ) +} diff --git a/examples/supported-devices-prototypes/src/CheckInfo.tsx b/examples/supported-devices-prototypes/src/CheckInfo.tsx new file mode 100644 index 000000000..a31d348df --- /dev/null +++ b/examples/supported-devices-prototypes/src/CheckInfo.tsx @@ -0,0 +1,23 @@ +import type { VariantProps } from './model.js' +import { CompactRow, DeviceIdentity, Features } from './shared.js' + +export function CheckInfo({ devices }: VariantProps): JSX.Element { + return ( +
+
+ Device & supported capabilities +
+ {devices.map((device) => ( + + + + + + ))} +
+ ) +} diff --git a/examples/supported-devices-prototypes/src/Picker.tsx b/examples/supported-devices-prototypes/src/Picker.tsx new file mode 100644 index 000000000..3b3390680 --- /dev/null +++ b/examples/supported-devices-prototypes/src/Picker.tsx @@ -0,0 +1,96 @@ +import { useEffect, useLayoutEffect, useRef } from 'react' + +export function Picker({ + names, + active, + onChange, + onReplay, +}: { + names: string[] + active: number + onChange: (index: number) => void + onReplay: () => void +}): JSX.Element { + const nav = useRef(null) + useLayoutEffect(() => { + const element = nav.current + if (element == null) return + const measure = (): void => { + const button = element.querySelector('[data-active]') + const highlight = element.querySelector( + '.proto-picker-highlight' + ) + if (button == null || highlight == null) return + highlight.style.transform = `translateX(${button.offsetLeft}px)` + highlight.style.width = `${button.offsetWidth}px` + } + measure() + const observer = new ResizeObserver(measure) + observer.observe(element) + let secondFrame = 0 + const frame = requestAnimationFrame(() => { + secondFrame = requestAnimationFrame(() => { + element.dataset['ready'] = '' + }) + }) + return () => { + observer.disconnect() + cancelAnimationFrame(frame) + cancelAnimationFrame(secondFrame) + } + }, [active]) + + useEffect(() => { + const onKey = (event: KeyboardEvent): void => { + const target = event.target + if ( + event.metaKey || + event.ctrlKey || + event.altKey || + event.shiftKey || + (target instanceof HTMLElement && + (target.closest('input, textarea, select') != null || + target.isContentEditable)) + ) + return + const number = Number(event.key) + if (number >= 1 && number <= names.length) { + event.preventDefault() + onChange(number - 1) + } else if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { + event.preventDefault() + onChange( + (active + (event.key === 'ArrowRight' ? 1 : names.length - 1)) % + names.length + ) + } else if (event.key.toLowerCase() === 'r') { + event.preventDefault() + onReplay() + } + } + window.addEventListener('keydown', onKey) + return () => { + window.removeEventListener('keydown', onKey) + } + }, [active, names.length, onChange, onReplay]) + + return ( + + ) +} diff --git a/examples/supported-devices-prototypes/src/RequirementPills.tsx b/examples/supported-devices-prototypes/src/RequirementPills.tsx new file mode 100644 index 000000000..d63ac7195 --- /dev/null +++ b/examples/supported-devices-prototypes/src/RequirementPills.tsx @@ -0,0 +1,24 @@ +import type { VariantProps } from './model.js' +import { CompactRow, DeviceIdentity, Features } from './shared.js' + +export function RequirementPills({ devices }: VariantProps): JSX.Element { + return ( +
+
+ Device & supported capabilities +
+ {devices.map((device) => ( + + + + + + ))} +
+ ) +} diff --git a/examples/supported-devices-prototypes/src/Stacked.tsx b/examples/supported-devices-prototypes/src/Stacked.tsx new file mode 100644 index 000000000..0ced2f213 --- /dev/null +++ b/examples/supported-devices-prototypes/src/Stacked.tsx @@ -0,0 +1,23 @@ +import type { VariantProps } from './model.js' +import { CompactRow, DeviceIdentity, Features } from './shared.js' + +export function Stacked({ devices }: VariantProps): JSX.Element { + return ( +
+
+ Device & supported capabilities +
+ {devices.map((device) => ( + + + + + + ))} +
+ ) +} diff --git a/examples/supported-devices-prototypes/src/main.tsx b/examples/supported-devices-prototypes/src/main.tsx new file mode 100644 index 000000000..34ce7ef17 --- /dev/null +++ b/examples/supported-devices-prototypes/src/main.tsx @@ -0,0 +1,20 @@ +import './theme.scss' +import './style.css' + +import { createRoot } from 'react-dom/client' + +import { EcosystemProvider } from 'lib/seam/ecosystem/EcosystemProvider.js' + +import { App } from './App.js' + +const root = document.getElementById('root') +if (root != null) + createRoot(root).render( + + + + ) diff --git a/examples/supported-devices-prototypes/src/model.ts b/examples/supported-devices-prototypes/src/model.ts new file mode 100644 index 000000000..716660393 --- /dev/null +++ b/examples/supported-devices-prototypes/src/model.ts @@ -0,0 +1,76 @@ +import { + getCapabilityLabel, + getCapabilityRequirementMessages, + getDeviceModelName, + getSupportedCapabilities, +} from 'lib/seam/components/SupportedDevices/device-models.js' +import type { DeviceModelSummary } from 'lib/seam/ecosystem/types.js' + +export interface Capability { + name: string + label: string + messages: string[] +} + +export interface ReviewDevice { + source: DeviceModelSummary + name: string + capabilities: Capability[] + requirements: Array<{ message: string; capabilities: string[] }> +} + +export interface VariantProps { + devices: ReviewDevice[] +} + +export function reviewDevice(source: DeviceModelSummary): ReviewDevice { + const capabilities = getSupportedCapabilities(source) + .filter((capability) => capability.name !== 'device_connectivity') + .map((capability) => ({ + name: capability.name, + label: `${getCapabilityLabel(capability.name)}${capability.integrationSupportLevel === 'beta' ? ' (beta)' : ''}`, + messages: getCapabilityRequirementMessages(capability), + })) + const requirements = new Map() + for (const capability of capabilities) + for (const message of capability.messages) + requirements.set(message, [ + ...(requirements.get(message) ?? []), + capability.label, + ]) + return { + source, + name: getDeviceModelName(source), + capabilities, + requirements: [...requirements].map(([message, labels]) => ({ + message, + capabilities: labels, + })), + } +} + +/** Presentation aliases for known requirements; unfamiliar source copy stays explicit. */ +export function getRequirementPills( + device: ReviewDevice +): Array<{ label: string; messages: string[] }> { + const known: Record = { + 'A compatible bridge is required.': 'Bridge Requirements', + 'An external keypad is required.': 'Keypad Required', + 'A paying Nuki SmartHosting subscription is required.': + 'Subscription Required', + } + const groups = new Map() + for (const { message } of device.requirements) { + const label = known[message] ?? 'Other Requirements' + groups.set(label, [...(groups.get(label) ?? []), message]) + } + return [ + 'Bridge Requirements', + 'Keypad Required', + 'Subscription Required', + 'Other Requirements', + ].flatMap((label) => { + const messages = groups.get(label) + return messages == null ? [] : [{ label, messages }] + }) +} diff --git a/examples/supported-devices-prototypes/src/shared.tsx b/examples/supported-devices-prototypes/src/shared.tsx new file mode 100644 index 000000000..c30514703 --- /dev/null +++ b/examples/supported-devices-prototypes/src/shared.tsx @@ -0,0 +1,362 @@ +import type { PropsWithChildren } from 'react' + +import { getDeviceModelImageUrl } from 'lib/seam/components/SupportedDevices/device-models.js' +import { RequirementIcon } from 'lib/seam/components/SupportedDevices/RequirementIcon.js' + +import { + type Capability, + getRequirementPills, + type ReviewDevice, +} from './model.js' + +export function CapabilityIcon({ name }: { name: string }): JSX.Element { + return ( + + {name.includes('access_codes') ? ( + <> + + + + ) : name.includes('unlock') ? ( + <> + + + + ) : name.includes('lock') ? ( + <> + + + + ) : ( + <> + + + + )} + + ) +} + +export function InfoMark(): JSX.Element { + return ( + + + + + ) +} + +export function CheckMark(): JSX.Element { + return ( + + + + ) +} + +export function Feature({ + capability, + iconOnly = false, + statusIcons = false, +}: { + capability: Capability + iconOnly?: boolean + statusIcons?: boolean +}): JSX.Element { + const status = capability.messages.length > 0 ? 'Requires setup' : 'Supported' + return ( + 0 ? '' : undefined} + title={`${capability.label} · ${status}`} + > + {statusIcons ? ( + capability.messages.length > 0 ? ( + + ) : ( + + ) + ) : ( + + )} + + {capability.label} + + · {status} + + ) +} + +export function Features({ + device, + statusIcons = false, +}: { + device: ReviewDevice + statusIcons?: boolean +}): JSX.Element { + return ( + + {device.capabilities.map((capability) => ( + + + + ))} + + ) +} + +export function DeviceIdentity({ + device, + children, + showType = true, +}: PropsWithChildren<{ + device: ReviewDevice + showType?: boolean +}>): JSX.Element { + const image = getDeviceModelImageUrl(device.source, null) + return ( + + + {image == null ? ( + {device.name.slice(0, 1)} + ) : ( + + )} + + + {showType && ( + + {device.source.device_type?.display_name ?? 'Device'} + + )} + {device.name} + {device.source.capabilities_stale ? ( + + Compatibility is being refreshed. + + ) : device.capabilities.length === 0 ? ( + + Compatibility not confirmed. + + ) : null} + {children} + + + ) +} + +export function CompactRow({ + device, + layout, + children, + showRequirementPills = false, +}: PropsWithChildren<{ + device: ReviewDevice + layout: string + showRequirementPills?: boolean +}>): JSX.Element { + const conditional = device.requirements.length > 0 + return ( +
+ + {children} + {showRequirementPills && conditional ? ( + + ) : ( + + {conditional && } + {conditional ? 'Requirements' : 'Details'} + + + + + )} + + +
+ ) +} + +function PillDisclosure({ device }: { device: ReviewDevice }): JSX.Element { + return ( + + + {getRequirementPills(device).map((pill) => ( + + {pill.label} + + ))} + + + + + + Open requirements and model details + + + ) +} + +function ExpandedDetails({ device }: { device: ReviewDevice }): JSX.Element { + const modelIds = [ + ...new Set( + device.source.variants + .map((variant) => variant.manufacturer_model_id) + .filter((id): id is string => id != null && id.length > 0) + ), + ] + const integration = device.source.providers + .map((provider) => provider.display_name ?? provider.slug) + .join(', ') + return ( +
+
+ + {integration.slice(0, 1)} + +
+ {integration} +

+ {device.requirements.length > 0 + ? 'Additional setup for these capabilities' + : 'Model and integration details'} +

+
+
+ {device.requirements.length > 0 && ( +
    + {device.requirements.map((requirement) => ( +
  • + +
    +

    {requirement.message}

    + For {requirement.capabilities.join(', ')} +
    +
  • + ))} +
+ )} +
+ {modelIds.length > 0 && ( +
+
Model numbers
+
{modelIds.join(', ')}
+
+ )} +
+
Integration
+
{integration}
+
+
+ {device.source.product_url != null && ( + + Manufacturer’s product page ↗ + + )} +
+ ) +} + +export function Legend(): JSX.Element { + return ( +
+ + + + + Supported + + + + Requires setup · open requirements + +
+ ) +} diff --git a/examples/supported-devices-prototypes/src/style.css b/examples/supported-devices-prototypes/src/style.css new file mode 100644 index 000000000..265d1b6e9 --- /dev/null +++ b/examples/supported-devices-prototypes/src/style.css @@ -0,0 +1,817 @@ +* { + box-sizing: border-box; +} +body { + margin: 0; +} +.seam-proto-page { + min-height: 100vh; + background: var(--seam-ecosystem-surface-2); + font-family: + Roboto, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; +} +.seam-proto-page :is(button, input, select) { + font: inherit; +} +.seam-proto-page :is(button, select, summary) { + cursor: pointer; +} +.seam-proto-page :focus-visible { + outline: 2px solid var(--seam-ecosystem-accent); + outline-offset: 3px; +} +.seam-proto-page :is(h1, h2, h3, h4, p) { + margin: 0; +} +.seam-proto-page h1 { + font-size: 21px; + line-height: 1.3; + letter-spacing: -0.4px; + font-weight: 550; +} +.seam-proto-page h2 { + font-size: 22px; + font-weight: 550; + letter-spacing: -0.5px; +} +.seam-proto-page strong { + font-weight: 550; +} +.seam-proto-muted { + color: var(--seam-ecosystem-fg-2); + font-size: 13px; +} +.seam-proto-page a { + color: var(--seam-ecosystem-accent); + text-underline-offset: 3px; +} +.seam-proto-review-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 24px; + gap: 16px; + border-bottom: 1px solid var(--seam-ecosystem-line-1); + background: var(--seam-ecosystem-surface-1); + font-size: 14px; +} +.seam-proto-review-controls { + display: flex; + align-items: center; + gap: 12px; +} +.seam-proto-review-controls label { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--seam-ecosystem-fg-2); +} +.seam-proto-page :is(input, select), +.seam-proto-review-controls button, +.seam-proto-state button { + 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-proto-review-controls button[aria-pressed='true'] { + background: var(--seam-ecosystem-surface-2); +} +.seam-proto-workspace { + max-width: 1010px; + margin: 0 auto; + padding: 24px 24px 100px; +} +.seam-proto-intro { + margin-bottom: 20px; +} +.seam-proto-eyebrow { + display: block; + font-size: 11px; + font-weight: 550; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--seam-ecosystem-fg-2); + margin-bottom: 6px; +} +.seam-proto-intro p { + font-size: 13px; + color: var(--seam-ecosystem-fg-2); + margin-top: 6px; +} +.seam-proto-intro .seam-proto-shortcuts { + font-size: 11px; +} +.seam-proto-stage { + background: var(--seam-ecosystem-surface-1); + border: 1px solid var(--seam-ecosystem-line-1); + border-radius: 10px; + padding: 24px 20px; +} +.seam-proto-context { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 8px; + font-size: 11px; + color: var(--seam-ecosystem-fg-2); + margin-bottom: 12px; +} +.seam-proto-stage-description { + font-size: 13px; + color: var(--seam-ecosystem-fg-2); + margin-top: 4px !important; +} +.seam-proto-filters { + display: flex; + align-items: end; + gap: 12px; + margin-top: 16px; +} +.seam-proto-filters label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 11px; + color: var(--seam-ecosystem-fg-2); + min-width: 0; +} +.seam-proto-filters :is(input, select) { + width: 100%; + font-size: 13px; +} +.seam-proto-search { + flex: 1; +} +.seam-proto-catalog-heading { + display: flex; + justify-content: space-between; + padding: 18px 0 10px; + font-size: 16px; +} +.seam-proto-catalog-heading > span { + font-size: 12px; + color: var(--seam-ecosystem-fg-2); +} +.seam-proto-catalog-link { + display: inline-block; + font-size: 12px; + margin-top: 18px; +} +.seam-proto-review-note { + font-size: 11px; + color: var(--seam-ecosystem-fg-2); + text-align: center; + margin-top: 16px !important; +} +.seam-proto-state { + padding: 28px 0; + font-size: 14px; + color: var(--seam-ecosystem-fg-2); +} +.seam-proto-state button { + margin-top: 12px; +} +.seam-compact-legend { + display: flex; + flex-wrap: wrap; + gap: 8px 18px; + margin-bottom: 14px; + font-size: 11px; + color: var(--seam-ecosystem-fg-2); +} +.seam-compact-legend > span { + display: inline-flex; + align-items: center; + gap: 5px; +} +.seam-compact-legend > span:first-child svg { + color: var(--seam-compact-green); +} +.seam-compact-legend > span:last-child svg { + color: var(--seam-compact-orange); +} +.seam-compact-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); + border-block: 1px solid var(--seam-ecosystem-line-1); +} +.seam-compact-row { + border-bottom: 1px solid var(--seam-ecosystem-line-1); +} +.seam-compact-row-summary { + display: grid; + grid-template-columns: minmax(210px, 1fr) auto auto; + align-items: center; + gap: 14px; + min-height: 89px; + padding: 16px 12px; + list-style: none; +} +.seam-compact-row-summary::-webkit-details-marker { + display: none; +} +.seam-compact-row-summary > * { + min-width: 0; +} +.seam-compact-identity { + display: flex; + align-items: center; + gap: 16px; + min-width: 0; +} +.seam-compact-image { + display: flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + padding: 6px; + flex-shrink: 0; + border-radius: 8px; + background: var(--seam-compact-tile); + box-shadow: var(--seam-compact-tile-shadow); +} +.seam-compact-image img { + width: 44px; + height: 44px; + object-fit: contain; +} +.seam-compact-identity-text { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} +.seam-compact-device-type { + font-size: 11px; + color: var(--seam-ecosystem-fg-2); +} +.seam-compact-device-name { + font-size: 15px; + line-height: 1.4; + font-weight: 500; + overflow-wrap: anywhere; +} +.seam-compact-device-status { + font-size: 11px; + color: var(--seam-ecosystem-fg-2); +} +.seam-compact-features { + display: flex; + flex-wrap: wrap; + gap: 5px; + align-items: center; +} +.seam-compact-feature { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 7px; + border-radius: 6px; + color: var(--seam-compact-green); + background: var(--seam-compact-green-bg); + box-shadow: inset 0 0 0 0.5px rgb(9 10 11 / 8%); + font-size: 12px; + line-height: 16px; + white-space: nowrap; +} +.seam-compact-feature svg { + width: 15px; + height: 15px; + flex-shrink: 0; +} +.seam-compact-feature[data-conditional] { + color: var(--seam-compact-orange); + background: var(--seam-compact-orange-bg); +} +.seam-compact-disclosure { + display: inline-flex; + align-items: center; + justify-content: center; + 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; +} +.seam-compact-disclosure[data-conditional] { + color: var(--seam-compact-orange); + background: var(--seam-compact-orange-bg); + box-shadow: inset 0 0 0 0.5px rgb(9 10 11 / 8%); +} +.seam-compact-chevron { + flex-shrink: 0; +} +.seam-compact-row[open] > summary .seam-compact-chevron { + transform: rotate(180deg); +} +.seam-compact-row[open] > summary { + background: var(--seam-ecosystem-surface-2); +} +.seam-compact-expanded { + position: relative; + margin: 0 12px 20px 84px; + padding: 18px 20px 2px; + border-left: 1px dashed var(--seam-ecosystem-line-2); +} +.seam-compact-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-compact-detail-heading { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; +} +.seam-compact-detail-heading p { + font-size: 12px; + color: var(--seam-ecosystem-fg-2); + margin-top: 2px; +} +.seam-compact-provider-mark { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + flex-shrink: 0; + border-radius: 7px; + background: var(--seam-compact-tile); + box-shadow: var(--seam-compact-tile-shadow); + color: var(--seam-ecosystem-fg-2); + font-size: 15px; + font-weight: 600; +} +.seam-compact-requirements { + list-style: none; + margin: 16px 0; + padding: 0; + display: grid; + gap: 12px; +} +.seam-compact-requirements li { + display: flex; + align-items: start; + gap: 9px; + font-size: 14px; + line-height: 1.5; +} +.seam-compact-requirements .seam-supported-devices-requirement-icon { + width: 17px; + height: 19px; + color: var(--seam-compact-orange); + flex-shrink: 0; +} +.seam-compact-requirements li span { + display: block; + color: var(--seam-ecosystem-fg-2); + font-size: 11px; + margin-top: 2px; +} +.seam-compact-model-info { + display: grid; + grid-template-columns: 1.5fr 1fr; + gap: 18px; + margin: 16px 0 8px; + padding-top: 12px; + border-top: 1px solid var(--seam-ecosystem-line-1); + font-size: 12px; + line-height: 1.5; +} +.seam-compact-model-info dt { + color: var(--seam-ecosystem-fg-2); + font-size: 11px; + margin-bottom: 4px; +} +.seam-compact-model-info dd { + margin: 0; + overflow-wrap: anywhere; +} +.seam-compact-product-link { + display: inline-block; + font-size: 12px; + margin: 8px 0; +} +.seam-compact-columns > summary, +.seam-compact-column-heading { + grid-template-columns: minmax(210px, 1fr) auto 120px; +} +.seam-compact-column-heading { + display: grid; + align-items: center; + gap: 14px; + padding: 10px 12px; + font-size: 11px; + color: var(--seam-ecosystem-fg-2); + border-block: 1px solid var(--seam-ecosystem-line-1); +} +.seam-compact-column-labels, +.seam-compact-column-cells { + display: grid; + grid-template-columns: repeat(var(--seam-compact-column-count), 76px); + align-items: center; + text-align: center; +} +.seam-compact-column-labels > span { + padding: 0 6px; +} +.seam-compact-column-cell { + display: inline-flex; + justify-content: center; +} +.seam-compact-feature-icon-only { + width: 32px; + height: 30px; + padding: 0; + justify-content: center; +} +.seam-compact-feature-icon-only svg { + width: 18px; + height: 18px; +} +.seam-compact-icon-label, +.seam-compact-sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} +.seam-compact-stacked > summary { + grid-template-columns: minmax(0, 1fr) auto; +} +.seam-compact-stacked .seam-compact-identity-text { + gap: 7px; +} +.seam-compact-stacked .seam-compact-features { + gap: 14px; +} +.seam-compact-stacked .seam-compact-feature { + background: none; + box-shadow: none; + padding: 0; + font-size: 12px; +} +@media (max-width: 850px) { + .seam-compact-chips > summary { + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px 12px; + } + .seam-compact-chips .seam-compact-features { + grid-row: 2; + grid-column: 1 / -1; + padding-left: 72px; + } + .seam-compact-chips .seam-compact-disclosure { + grid-column: 2; + grid-row: 1; + } + .seam-compact-list-heading { + grid-template-columns: 1fr; + } + .seam-compact-list-heading > span:nth-child(2) { + display: none; + } +} +@media (max-width: 600px) { + .seam-proto-review-bar { + padding: 10px 14px; + flex-wrap: wrap; + } + .seam-proto-review-controls { + width: 100%; + justify-content: space-between; + } + .seam-proto-workspace { + padding: 20px 10px 100px; + } + .seam-proto-intro { + padding: 0 4px; + } + .seam-proto-page h1 { + font-size: 19px; + } + .seam-proto-stage { + padding: 20px 0; + border-radius: 8px; + } + .seam-proto-stage > :not(.seam-proto-variant) { + margin-left: 14px; + margin-right: 14px; + } + .seam-proto-filters { + flex-wrap: wrap; + gap: 8px; + } + .seam-proto-filters label { + width: 100%; + flex-basis: 100%; + } + .seam-proto-filters :is(input, select), + .seam-proto-review-controls select { + font-size: 16px; + min-height: 40px; + } + .seam-compact-row-summary { + min-height: 81px; + gap: 10px; + padding: 12px 14px; + } + .seam-compact-image { + width: 48px; + height: 48px; + padding: 6px; + } + .seam-compact-image img { + width: 36px; + height: 36px; + } + .seam-compact-identity { + gap: 12px; + } + .seam-compact-device-name { + font-size: 14px; + } + .seam-compact-disclosure { + font-size: 11px; + gap: 4px; + padding: 5px; + } + .seam-compact-list-heading { + padding: 9px 14px; + } + .seam-compact-chips .seam-compact-features { + padding-left: 60px; + } + .seam-compact-feature { + padding: 3px 5px; + font-size: 11px; + gap: 4px; + } + .seam-compact-feature svg { + width: 14px; + height: 14px; + } + .seam-compact-expanded { + margin: 0 14px 16px 38px; + padding: 16px 12px 0; + } + .seam-compact-expanded::before { + display: none; + } + .seam-compact-model-info { + grid-template-columns: 1fr; + gap: 12px; + } + .seam-compact-column-heading { + grid-template-columns: 1fr; + padding: 9px 14px; + } + .seam-compact-column-heading > :not(:first-child) { + display: none; + } + .seam-compact-columns > summary { + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px 12px; + } + .seam-compact-column-cells { + grid-column: 1 / -1; + grid-row: 2; + display: flex; + flex-wrap: wrap; + gap: 5px; + padding-left: 60px; + text-align: left; + } + .seam-compact-columns .seam-compact-disclosure { + grid-column: 2; + grid-row: 1; + } + .seam-compact-feature-icon-only { + width: auto; + height: auto; + padding: 3px 5px; + } + .seam-compact-icon-label { + position: static; + width: auto; + height: auto; + overflow: visible; + clip-path: none; + white-space: normal; + } + .seam-compact-stacked > summary { + grid-template-columns: 48px minmax(0, 1fr) auto; + } + .seam-compact-stacked .seam-compact-identity, + .seam-compact-stacked .seam-compact-identity-text { + display: contents; + } + .seam-compact-stacked .seam-compact-image { + grid-column: 1; + grid-row: 1 / 3; + align-self: start; + } + .seam-compact-stacked .seam-compact-device-name { + grid-column: 2; + grid-row: 1; + } + .seam-compact-stacked .seam-compact-disclosure { + grid-column: 3; + grid-row: 1; + } + .seam-compact-stacked .seam-compact-device-status { + grid-column: 2 / -1; + } + .seam-compact-stacked .seam-compact-features { + grid-column: 2 / -1; + grid-row: 2; + gap: 6px 10px; + } + .seam-compact-stacked .seam-compact-feature { + font-size: 11px; + } +} +@media (hover: hover) and (pointer: fine) { + .seam-compact-row-summary:hover { + background: var(--seam-ecosystem-surface-2); + } + .seam-proto-page a:hover { + text-decoration: underline; + } +} +.seam-compact-feature.seam-compact-status-feature { + color: var(--seam-ecosystem-fg-1); +} +.seam-compact-status-feature > svg { + color: var(--seam-compact-green); +} +.seam-compact-status-feature[data-conditional] > svg { + color: var(--seam-compact-status-amber); +} +.seam-compact-pill-disclosure { + display: flex; + align-items: center; + justify-content: end; + gap: 8px; + max-width: 405px; + color: var(--seam-compact-status-amber); +} +.seam-compact-requirement-pills { + display: flex; + flex-wrap: wrap; + justify-content: end; + gap: 5px; +} +.seam-compact-requirement-pill { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 3px 7px; + border-radius: 6px; + background: var(--seam-compact-status-amber-bg); + color: var(--seam-compact-status-amber); + box-shadow: inset 0 0 0 0.5px currentColor; + font-size: 11px; + line-height: 16px; + white-space: nowrap; +} +@media (max-width: 850px) and (min-width: 601px) { + .seam-compact-pill-disclosure { + max-width: 280px; + } +} +@media (max-width: 600px) { + .seam-compact-with-pills .seam-compact-pill-disclosure { + display: contents; + } + .seam-compact-with-pills .seam-compact-requirement-pills { + grid-column: 2 / -1; + grid-row: 3; + justify-content: start; + } + .seam-compact-with-pills .seam-compact-image { + grid-row: 1 / 4; + } + .seam-compact-with-pills + .seam-compact-pill-disclosure + > .seam-compact-chevron { + grid-column: 3; + grid-row: 1; + align-self: center; + } + .seam-compact-requirement-pill { + min-height: 22px; + padding: 2px 5px; + font-size: 11px; + } +} +/* Emil's prototype picker: intentionally separate from the product styling. */ +.proto-picker { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 2147483647; + display: flex; + align-items: center; + gap: 2px; + padding: 4px; + border-radius: 999px; + background: rgba(10, 10, 10, 0.82); + -webkit-backdrop-filter: blur(12px) saturate(1.4); + backdrop-filter: blur(12px) saturate(1.4); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.08) inset, + 0 8px 24px rgba(0, 0, 0, 0.24), + 0 2px 6px rgba(0, 0, 0, 0.12); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 13px; + line-height: 1; + -webkit-font-smoothing: antialiased; + user-select: none; + -webkit-user-select: none; +} +.proto-picker-highlight { + position: absolute; + top: 4px; + left: 0; + height: 28px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); + will-change: transform; +} +.proto-picker[data-ready] .proto-picker-highlight { + transition: + transform 250ms cubic-bezier(0.23, 1, 0.32, 1), + width 250ms cubic-bezier(0.23, 1, 0.32, 1); +} +@media (prefers-reduced-motion: reduce) { + .proto-picker[data-ready] .proto-picker-highlight { + transition: none; + } +} +.proto-picker-item { + position: relative; + display: flex; + align-items: center; + height: 28px; + padding: 0 12px; + border: 0; + border-radius: 999px; + background: transparent; + color: rgba(255, 255, 255, 0.55); + font: inherit; + cursor: pointer; + transition: color 150ms ease-out; +} +.proto-picker-item:hover { + color: rgba(255, 255, 255, 0.85); +} +.proto-picker-item:active { + transform: scale(0.97); +} +.proto-picker-item:focus-visible { + outline: 2px solid rgba(255, 255, 255, 0.4); + outline-offset: 2px; +} +.proto-picker-item[data-active] { + color: #fff; +} +.proto-picker-divider { + width: 1px; + height: 16px; + margin: 0 4px; + background: rgba(255, 255, 255, 0.12); +} +.proto-picker-replay { + padding: 0 10px; + font-size: 14px; +} +.proto-picker[data-position='top'] { + bottom: auto; + top: 24px; +} diff --git a/examples/supported-devices-prototypes/src/theme.scss b/examples/supported-devices-prototypes/src/theme.scss new file mode 100644 index 000000000..7843cf569 --- /dev/null +++ b/examples/supported-devices-prototypes/src/theme.scss @@ -0,0 +1,45 @@ +// Match the original DeviceDB semantic neutrals and status colors. +.seam-proto-page { + --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-compact-green: #137413; + --seam-compact-green-bg: #edf8ed; + --seam-compact-orange: #945313; + --seam-compact-orange-bg: #fcf2e9; + --seam-compact-status-amber: #946200; + --seam-compact-status-amber-bg: #fff8e5; + --seam-compact-tile: #fff; + --seam-compact-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-compact-green: #89d189; + --seam-compact-green-bg: rgb(25 143 25 / 16%); + --seam-compact-orange: #eaad70; + --seam-compact-orange-bg: rgb(154 89 25 / 16%); + --seam-compact-status-amber: #f0c462; + --seam-compact-status-amber-bg: rgb(240 196 98 / 10%); + --seam-compact-tile: #191b1f; + --seam-compact-tile-shadow: + 0 0 0 0.5px rgb(255 255 255 / 12%), 0 2px 4px rgb(0 0 0 / 12%); + } +} diff --git a/vercel.json b/vercel.json index 537459a2c..c3b6375ee 100644 --- a/vercel.json +++ b/vercel.json @@ -184,6 +184,19 @@ "value": "noindex, follow" } ] + }, + { + "source": "/examples/supported-devices-prototypes(/.*)?", + "headers": [ + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self' data: https://seam-ecosystem-media.fly.storage.tigris.dev https://strapi-media-bucket.fly.storage.tigris.dev; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'" + }, + { + "key": "X-Robots-Tag", + "value": "noindex, nofollow" + } + ] } ] }