diff --git a/examples/supported-devices-prototypes/index.html b/examples/supported-devices-prototypes/index.html new file mode 100644 index 000000000..66e519da7 --- /dev/null +++ b/examples/supported-devices-prototypes/index.html @@ -0,0 +1,13 @@ + + + + + + + Supported devices · 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..308cc2cc4 --- /dev/null +++ b/examples/supported-devices-prototypes/src/App.tsx @@ -0,0 +1,296 @@ +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 { Expand } from './Expand.js' +import { Groups } from './Groups.js' +import { Inline } from './Inline.js' +import { reviewDevice } from './model.js' +import { Notes } from './Notes.js' +import { Picker } from './Picker.js' +import { Table } from './Table.js' + +const variants = [ + { + name: 'Inline', + title: 'Requirements beside each capability', + description: + 'The most direct reading path. Everything is visible, with repetition when capabilities share requirements.', + component: Inline, + }, + { + name: 'Notes', + title: 'One shared list of requirements', + description: + 'A shorter device row. Numbered references connect each capability to what you’ll need below.', + component: Notes, + }, + { + name: 'Groups', + title: 'Plan around the things you need', + description: + 'Subscriptions and accessories appear once, each with the capabilities it enables. Clearer for planning a setup.', + component: Groups, + }, + { + name: 'Table', + title: 'Compare capabilities across models', + description: + 'Scan down a capability column to compare devices. Wider than the other options; scroll the table on a phone.', + component: Table, + }, + { + name: 'Expand', + title: 'Open details for one capability', + description: + 'Short requirement labels stay visible. Open a capability to read the full wording without expanding unrelated information.', + component: Expand, + }, +] +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 5 · {variant.name} + +

{variant.title}

+

{variant.description}

+

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

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

Supported devices

+

+ Capabilities available through Seam. Requirements below describe any + additional setup. +

+
+ + +
+
+ {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/Expand.tsx b/examples/supported-devices-prototypes/src/Expand.tsx new file mode 100644 index 000000000..5d50f67be --- /dev/null +++ b/examples/supported-devices-prototypes/src/Expand.tsx @@ -0,0 +1,39 @@ +import { requirementSummary, type VariantProps } from './model.js' +import { DeviceCard, RequirementList } from './shared.js' + +export function Expand({ devices }: VariantProps): JSX.Element { + return ( + <> + {devices.map((device) => ( + +

Supported capabilities

+
+ {device.capabilities.map((capability) => + capability.messages.length > 0 ? ( +
+ + + {capability.label} + + {requirementSummary(capability)} + + + + +
+ ) : ( +
+ {capability.label} + Supported +
+ ) + )} +
+
+ ))} + + ) +} diff --git a/examples/supported-devices-prototypes/src/Groups.tsx b/examples/supported-devices-prototypes/src/Groups.tsx new file mode 100644 index 000000000..f4ebf4540 --- /dev/null +++ b/examples/supported-devices-prototypes/src/Groups.tsx @@ -0,0 +1,44 @@ +import { RequirementIcon } from 'lib/seam/components/SupportedDevices/RequirementIcon.js' + +import { requirementLabel, type VariantProps } from './model.js' +import { DeviceCard } from './shared.js' + +export function Groups({ devices }: VariantProps): JSX.Element { + return ( + <> + {devices.map((device) => ( + +

Supported capabilities

+ + {device.requirements.length > 0 && ( +
+

What you’ll need

+ {device.requirements.map((requirement) => ( +
+ +
+ {requirementLabel(requirement.message)} +

{requirement.message}

+ + For {requirement.capabilities.join(', ')} + +
+
+ ))} +
+ )} +
+ ))} + + ) +} diff --git a/examples/supported-devices-prototypes/src/Inline.tsx b/examples/supported-devices-prototypes/src/Inline.tsx new file mode 100644 index 000000000..b248d1404 --- /dev/null +++ b/examples/supported-devices-prototypes/src/Inline.tsx @@ -0,0 +1,24 @@ +import type { VariantProps } from './model.js' +import { DeviceCard, RequirementList } from './shared.js' + +export function Inline({ devices }: VariantProps): JSX.Element { + return ( + <> + {devices.map((device) => ( + +

Supported capabilities

+ +
+ ))} + + ) +} diff --git a/examples/supported-devices-prototypes/src/Notes.tsx b/examples/supported-devices-prototypes/src/Notes.tsx new file mode 100644 index 000000000..fa9349cc2 --- /dev/null +++ b/examples/supported-devices-prototypes/src/Notes.tsx @@ -0,0 +1,57 @@ +import { useId } from 'react' + +import type { ReviewDevice, VariantProps } from './model.js' +import { DeviceCard } from './shared.js' + +export function Notes({ devices }: VariantProps): JSX.Element { + return ( + <> + {devices.map((device) => ( + + ))} + + ) +} + +function NoteDevice({ device }: { device: ReviewDevice }): JSX.Element { + const id = useId() + return ( + +

Supported capabilities

+ + {device.requirements.length > 0 && ( +
+

What you’ll need

+
    + {device.requirements.map((requirement, index) => ( +
  1. + {requirement.message} +
  2. + ))} +
+
+ )} +
+ ) +} 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/Table.tsx b/examples/supported-devices-prototypes/src/Table.tsx new file mode 100644 index 000000000..1e63b4f92 --- /dev/null +++ b/examples/supported-devices-prototypes/src/Table.tsx @@ -0,0 +1,69 @@ +import type { VariantProps } from './model.js' +import { DeviceHeading, ModelDetails, RequirementList } from './shared.js' + +export function Table({ devices }: VariantProps): JSX.Element { + const capabilities = [ + ...new Map( + devices.flatMap((device) => + device.capabilities.map( + (capability) => [capability.name, capability.label] as const + ) + ) + ), + ] + return ( +
+ + + + + + {capabilities.map(([name, label]) => ( + + ))} + + + + {devices.map((device) => ( + + + {capabilities.map(([name]) => { + const capability = device.capabilities.find( + (item) => item.name === name + ) + return ( + + ) + })} + + ))} + +
Capabilities and requirements by model
Device + {label} +
+ + + + {capability == null ? ( + + Not listed as supported + + ) : ( + <> + + Supported + {capability.label.includes('(beta)') ? ' (beta)' : ''} + + {capability.messages.length > 0 && ( + + )} + + )} +
+
+ ) +} 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..0956baa3d --- /dev/null +++ b/examples/supported-devices-prototypes/src/model.ts @@ -0,0 +1,68 @@ +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, + })), + } +} + +// Short labels only for exact known copy; preserve unfamiliar constraints in full. +export function requirementLabel(message: string): string { + const labels: Record = { + 'A paying Nuki SmartHosting subscription is required.': + 'Nuki SmartHosting subscription', + 'A compatible bridge is required.': 'Compatible bridge', + 'An external keypad is required.': 'External keypad', + 'Additional setup is required.': 'Additional setup', + } + return labels[message] ?? message +} + +export function requirementSummary(capability: Capability): string { + return capability.messages.length === 0 + ? 'Supported' + : `Requires: ${capability.messages.map(requirementLabel).join(' + ')}` +} diff --git a/examples/supported-devices-prototypes/src/shared.tsx b/examples/supported-devices-prototypes/src/shared.tsx new file mode 100644 index 000000000..86f8ef6ec --- /dev/null +++ b/examples/supported-devices-prototypes/src/shared.tsx @@ -0,0 +1,103 @@ +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 { ReviewDevice } from './model.js' + +export function DeviceHeading({ + device, +}: { + device: ReviewDevice +}): JSX.Element { + const image = getDeviceModelImageUrl(device.source, null) + return ( +
+ + {image != null && ( + + )} + +
+

{device.name}

+ {device.source.capabilities_stale ? ( +

Compatibility is being refreshed.

+ ) : device.capabilities.length === 0 ? ( +

+ Compatibility not confirmed for this integration. +

+ ) : null} +
+
+ ) +} + +export function DeviceCard({ + device, + children, +}: PropsWithChildren<{ device: ReviewDevice }>): JSX.Element { + return ( +
+ +
+ {children} + +
+
+ ) +} + +export function ModelDetails({ + device, +}: { + device: ReviewDevice +}): JSX.Element { + const modelIds = [ + ...new Set( + device.source.variants + .map((variant) => variant.manufacturer_model_id) + .filter((id) => id != null && id.length > 0) + ), + ] + return ( +
+ Model details +
+ {modelIds.length > 0 && ( + <> +
Model numbers
+
{modelIds.join(', ')}
+ + )} +
Integration
+
+ {device.source.providers + .map((provider) => provider.display_name ?? provider.slug) + .join(', ')} +
+
+ {device.source.product_url != null && ( + + Manufacturer’s product page ↗ + + )} +
+ ) +} + +export function RequirementList({ + messages, +}: { + messages: string[] +}): JSX.Element { + return ( +
    + {messages.map((message) => ( +
  • + + {message} +
  • + ))} +
+ ) +} diff --git a/examples/supported-devices-prototypes/src/style.css b/examples/supported-devices-prototypes/src/style.css new file mode 100644 index 000000000..e224d5888 --- /dev/null +++ b/examples/supported-devices-prototypes/src/style.css @@ -0,0 +1,605 @@ +* { + box-sizing: border-box; +} +body { + margin: 0; +} +.seam-proto-page { + min-height: 100vh; + background: var(--seam-ecosystem-surface-2); + font-family: + Inter, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; +} +.seam-proto-page button, +.seam-proto-page input, +.seam-proto-page select { + font: inherit; +} +.seam-proto-page button, +.seam-proto-page select, +.seam-proto-page summary { + cursor: pointer; +} +.seam-proto-page :focus-visible { + outline: 2px solid var(--seam-ecosystem-accent); + outline-offset: 3px; +} +.seam-proto-page h1, +.seam-proto-page h2, +.seam-proto-page h3, +.seam-proto-page h4, +.seam-proto-page p { + margin: 0; +} +.seam-proto-page h1 { + font-size: 26px; + line-height: 1.25; + letter-spacing: -0.6px; + font-weight: 600; +} +.seam-proto-page h2 { + font-size: 24px; + line-height: 1.3; + letter-spacing: -0.5px; + font-weight: 600; +} +.seam-proto-page h3 { + font-size: 16px; + line-height: 1.4; + font-weight: 600; +} +.seam-proto-page h4 { + font-size: 13px; + font-weight: 500; + color: var(--seam-ecosystem-fg-2); + margin-bottom: 10px; +} +.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-page a:hover { + text-decoration: underline; +} +.seam-proto-review-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 28px; + 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: end; + 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 input, +.seam-proto-page 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: 40px; + padding: 8px 12px; +} +.seam-proto-review-controls button[aria-pressed='true'] { + background: var(--seam-ecosystem-surface-2); +} +.seam-proto-workspace { + max-width: 890px; + margin: 0 auto; + padding: 42px 24px 112px; +} +.seam-proto-intro { + margin-bottom: 30px; +} +.seam-proto-eyebrow { + display: block; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--seam-ecosystem-fg-2); + margin-bottom: 10px; +} +.seam-proto-intro p { + max-width: 690px; + font-size: 14px; + color: var(--seam-ecosystem-fg-2); + margin-top: 10px; + line-height: 1.6; +} +.seam-proto-intro .seam-proto-shortcuts { + font-size: 12px; + margin-top: 12px; +} +.seam-proto-stage { + background: var(--seam-ecosystem-surface-1); + border: 1px solid var(--seam-ecosystem-line-1); + border-radius: 12px; + padding: 28px; +} +.seam-proto-context { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 8px; + font-size: 12px; + color: var(--seam-ecosystem-fg-2); + margin-bottom: 22px; +} +.seam-proto-stage .seam-proto-stage-description { + font-size: 14px; + color: var(--seam-ecosystem-fg-2); + margin-top: 8px; + line-height: 1.5; +} +.seam-proto-filters { + display: flex; + align-items: end; + gap: 12px; + margin-top: 24px; +} +.seam-proto-filters label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + color: var(--seam-ecosystem-fg-2); + min-width: 0; +} +.seam-proto-filters input, +.seam-proto-filters select { + width: 100%; + font-size: 14px; +} +.seam-proto-search { + flex: 1; +} +.seam-proto-catalog-heading { + display: flex; + justify-content: space-between; + padding: 22px 0 12px; + border-bottom: 1px solid var(--seam-ecosystem-line-1); + font-size: 14px; +} +.seam-proto-catalog-heading span { + font-size: 13px; + color: var(--seam-ecosystem-fg-2); +} +.seam-proto-device { + padding: 24px 0; + border-bottom: 1px solid var(--seam-ecosystem-line-1); +} +.seam-proto-device-heading { + display: flex; + align-items: center; + gap: 12px; +} +.seam-proto-image { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + flex-shrink: 0; + border-radius: 9px; + background: var(--seam-ecosystem-surface-2); +} +.seam-proto-image img { + object-fit: contain; +} +.seam-proto-device-body { + margin: 14px 0 0 60px; + font-size: 14px; +} +.seam-proto-inline-list { + padding: 0 0 0 17px; + margin: 0; +} +.seam-proto-inline-list > li { + padding-left: 2px; + margin-bottom: 16px; +} +.seam-proto-requirement-list { + margin: 8px 0 0; + padding: 0; + list-style: none; +} +.seam-proto-requirement-list li { + display: flex; + align-items: start; + gap: 8px; + margin: 6px 0; + color: var(--seam-ecosystem-fg-2); + font-size: 13px; + line-height: 1.5; +} +.seam-proto-page .seam-supported-devices-requirement-icon { + flex-shrink: 0; + width: 18px; + height: 18px; + margin-top: 1px; + color: var(--seam-ecosystem-fg-2); +} +.seam-proto-model-details { + margin-top: 12px; + font-size: 13px; + color: var(--seam-ecosystem-fg-2); + font-weight: 400; +} +.seam-proto-model-details summary { + width: fit-content; + min-height: 36px; + align-content: center; +} +.seam-proto-model-details[open] { + padding-bottom: 8px; +} +.seam-proto-model-details dl { + margin: 8px 0; + line-height: 1.6; +} +.seam-proto-model-details dt { + color: var(--seam-ecosystem-fg-1); + font-weight: 500; +} +.seam-proto-model-details dd { + margin: 0 0 8px; + overflow-wrap: anywhere; +} +.seam-proto-note-capabilities, +.seam-proto-capability-bullets { + display: flex; + flex-wrap: wrap; + gap: 4px 30px; + margin: 0; + padding-left: 17px; +} +.seam-proto-note-capabilities li { + min-height: 32px; +} +.seam-proto-page .seam-proto-note-ref { + display: inline-flex; + justify-content: center; + align-items: center; + width: 26px; + height: 30px; + margin-left: 3px; + font-size: 11px; + font-weight: 600; + vertical-align: super; + border-radius: 5px; + background: var(--seam-ecosystem-surface-2); +} +.seam-proto-notes { + margin-top: 14px; + padding: 14px 16px; + border-radius: 8px; + background: var(--seam-ecosystem-surface-2); +} +.seam-proto-notes ol { + margin: 0; + padding-left: 19px; + font-size: 13px; + color: var(--seam-ecosystem-fg-2); +} +.seam-proto-notes li { + padding: 3px 4px; + scroll-margin-top: 24px; + border-radius: 4px; +} +.seam-proto-notes li:target { + background: var(--seam-ecosystem-accent-soft); + color: var(--seam-ecosystem-fg-1); + outline: 2px solid var(--seam-ecosystem-accent); +} +.seam-proto-groups { + margin-top: 18px; + padding: 16px; + border-radius: 8px; + background: var(--seam-ecosystem-surface-2); +} +.seam-proto-requirement-group { + display: flex; + gap: 10px; + padding: 12px 0; + border-top: 1px solid var(--seam-ecosystem-line-1); + font-size: 14px; +} +.seam-proto-requirement-group p { + font-size: 13px; + color: var(--seam-ecosystem-fg-2); + margin: 4px 0; +} +.seam-proto-requirement-group .seam-proto-muted { + display: block; + font-size: 12px; +} +.seam-proto-expand-list { + border-top: 1px solid var(--seam-ecosystem-line-1); +} +.seam-proto-capability-details { + border-bottom: 1px solid var(--seam-ecosystem-line-1); +} +.seam-proto-capability-details summary { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + padding: 12px 0; + min-height: 48px; + list-style: none; +} +.seam-proto-capability-details summary::-webkit-details-marker { + display: none; +} +.seam-proto-capability-details summary::after { + content: '+'; + font-size: 20px; + color: var(--seam-ecosystem-fg-2); + padding: 0 6px; +} +.seam-proto-capability-details[open] summary::after { + content: '−'; +} +.seam-proto-capability-details summary .seam-proto-muted { + display: block; + margin-top: 4px; + font-size: 13px; + line-height: 1.5; +} +.seam-proto-capability-details > .seam-proto-requirement-list { + margin: 0 0 14px; + padding: 12px; + background: var(--seam-ecosystem-surface-2); + border-radius: 7px; +} +.seam-proto-unconditional { + padding: 12px 0; + border-bottom: 1px solid var(--seam-ecosystem-line-1); + display: flex; + justify-content: space-between; + gap: 12px; +} +.seam-proto-table-scroll { + overflow-x: auto; + margin-top: 10px; +} +.seam-proto-table { + border-collapse: collapse; + width: 100%; + min-width: 700px; + text-align: left; + font-size: 13px; +} +.seam-proto-table caption { + text-align: left; + font-size: 12px; + color: var(--seam-ecosystem-fg-2); + padding: 8px 0 16px; +} +.seam-proto-table th, +.seam-proto-table td { + padding: 16px 12px; + vertical-align: top; + border-bottom: 1px solid var(--seam-ecosystem-line-1); +} +.seam-proto-table thead th { + background: var(--seam-ecosystem-surface-2); + font-weight: 500; +} +.seam-proto-table th:first-child { + width: 28%; + padding-left: 0; +} +.seam-proto-table .seam-proto-device-heading { + align-items: start; + gap: 8px; +} +.seam-proto-table h3 { + font-size: 14px; +} +.seam-proto-table .seam-proto-image { + height: 36px; + width: 36px; +} +.seam-proto-table .seam-proto-image img { + width: 32px; + height: 32px; +} +.seam-proto-table .seam-proto-requirement-list li { + font-size: 12px; + gap: 5px; + margin-bottom: 10px; +} +.seam-proto-table .seam-supported-devices-requirement-icon { + width: 14px; + height: 16px; +} +.seam-proto-supported { + font-size: 12px; + font-weight: 600; +} +.seam-proto-page .seam-proto-catalog-link { + display: inline-block; + font-size: 13px; + margin-top: 20px; +} +.seam-proto-review-note { + font-size: 12px; + color: var(--seam-ecosystem-fg-2); + text-align: center; + margin-top: 18px !important; +} +.seam-proto-state { + padding: 36px 0; + font-size: 14px; + color: var(--seam-ecosystem-fg-2); +} +.seam-proto-state button { + margin-top: 12px; +} +@media (max-width: 600px) { + .seam-proto-review-bar { + padding: 12px 16px; + flex-wrap: wrap; + } + .seam-proto-review-controls { + width: 100%; + justify-content: space-between; + } + .seam-proto-workspace { + padding: 28px 12px 100px; + } + .seam-proto-intro { + padding: 0 4px; + } + .seam-proto-page h1 { + font-size: 23px; + } + .seam-proto-stage { + padding: 20px 16px; + } + .seam-proto-filters { + flex-wrap: wrap; + } + .seam-proto-filters label { + width: 100%; + flex-basis: 100%; + } + .seam-proto-filters input, + .seam-proto-filters select, + .seam-proto-review-controls select { + font-size: 16px; + } + .seam-proto-device-body { + margin-left: 0; + margin-top: 18px; + } + .seam-proto-note-capabilities { + flex-direction: column; + } + .seam-proto-capability-bullets { + gap: 6px 26px; + } + .seam-proto-model-details summary { + min-height: 44px; + } +} +@media (hover: hover) and (pointer: fine) { + .seam-proto-capability-details summary:hover, + .seam-proto-model-details summary:hover { + color: var(--seam-ecosystem-accent); + } +} + +/* 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..de3fc71f9 --- /dev/null +++ b/examples/supported-devices-prototypes/src/theme.scss @@ -0,0 +1,13 @@ +@use '../../../src/styles/ecosystem'; + +.seam-proto-page { + @include ecosystem.light-tokens; + + color: var(--seam-ecosystem-fg-1); + font-size: 16px; + line-height: 1.4; + + &.seam-ecosystem-theme-dark { + @include ecosystem.dark-tokens; + } +} 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" + } + ] } ] }