chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
+121
@@ -0,0 +1,121 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import {
|
||||
SolutionsCard,
|
||||
SolutionsCardRowHeader,
|
||||
} from '@/app/(landing)/components/solutions-page/components/solutions-card-row/components'
|
||||
import { SOLUTIONS_SPACING } from '@/app/(landing)/components/solutions-page/constants'
|
||||
import type { SolutionsCardRowConfig } from '@/app/(landing)/components/solutions-page/types'
|
||||
|
||||
/**
|
||||
* The enterprise feature sections rendered as ONE shared CSS grid so cards can
|
||||
* reflow across section boundaries in the two-column band (`sm`..`lg`,
|
||||
* 640-1023px). Separate per-section grids (what {@link SolutionsCardRow}
|
||||
* renders) leave an orphan cell there: each 3-card section breaks 2 + 1.
|
||||
*
|
||||
* Layout per breakpoint:
|
||||
* - `lg`+ (3 columns): source order - every header is followed by its own 3
|
||||
* cards, matching {@link SolutionsCardRow} exactly.
|
||||
* - `sm`..`lg` (2 columns): the `sm:max-lg:order-*` classes regroup the 12
|
||||
* cards into balanced blocks of 4 / 4 / 2 / 2 beneath the four headers, so
|
||||
* no grid cell is ever empty. Cards borrowed from the next section render
|
||||
* under the previous header in this band only.
|
||||
* - below `sm` (1 column): source order again - each header stacks above its
|
||||
* own 3 cards.
|
||||
*
|
||||
* Each section keeps its `<section aria-labelledby>` landmark via
|
||||
* `display: contents`, so its header and cards participate directly in the
|
||||
* outer grid while the document outline (H2 -> H3) and anchor ids stay
|
||||
* identical to the `SolutionsCardRow` markup.
|
||||
*
|
||||
* Vertical rhythm is reproduced from the flex-column layout it replaces: the
|
||||
* grid's own `gap-8` (32px) supplies the inter-card gap, and headers carry
|
||||
* margins that top the gap up to the original values - `mb-4` lands the
|
||||
* header->cards distance at 48px (`cardRowHeaderToGrid`), and the top margins
|
||||
* land the section->section distance at 120/88/64px (`sectionRhythm`).
|
||||
*/
|
||||
|
||||
interface EnterpriseFeatureGridProps {
|
||||
/** The four 3-card feature rows, in source order. */
|
||||
rows: SolutionsCardRowConfig[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Header top margins recreating `LANDING_SECTION_RHYTHM` (120/88/64px) on top
|
||||
* of the grid's 32px row gap; `mb-4` recreates `cardRowHeaderToGrid` (48px).
|
||||
*/
|
||||
const HEADER_RHYTHM = 'mt-[88px] mb-4 max-lg:mt-14 max-sm:mt-8'
|
||||
const FIRST_HEADER_RHYTHM = 'mb-4'
|
||||
|
||||
/** Two-column-band ordering for the four headers (positions 1, 6, 11, 14). */
|
||||
const TABLET_HEADER_ORDER = [
|
||||
'sm:max-lg:order-1',
|
||||
'sm:max-lg:order-6',
|
||||
'sm:max-lg:order-11',
|
||||
'sm:max-lg:order-[14]',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Two-column-band ordering for the 12 cards (flat index = rowIndex * 3 +
|
||||
* cardIndex), interleaved with {@link TABLET_HEADER_ORDER} to produce the
|
||||
* 4 / 4 / 2 / 2 grouping.
|
||||
*/
|
||||
const TABLET_CARD_ORDER = [
|
||||
'sm:max-lg:order-2',
|
||||
'sm:max-lg:order-3',
|
||||
'sm:max-lg:order-4',
|
||||
'sm:max-lg:order-5',
|
||||
'sm:max-lg:order-7',
|
||||
'sm:max-lg:order-8',
|
||||
'sm:max-lg:order-9',
|
||||
'sm:max-lg:order-10',
|
||||
'sm:max-lg:order-12',
|
||||
'sm:max-lg:order-[13]',
|
||||
'sm:max-lg:order-[15]',
|
||||
'sm:max-lg:order-[16]',
|
||||
] as const
|
||||
|
||||
export function EnterpriseFeatureGrid({ rows }: EnterpriseFeatureGridProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-3 max-sm:grid-cols-1 max-lg:grid-cols-2',
|
||||
SOLUTIONS_SPACING.cardGridGap
|
||||
)}
|
||||
>
|
||||
{rows.map((row, rowIndex) => {
|
||||
const headingId = `solutions-row-${row.id}-heading`
|
||||
|
||||
return (
|
||||
<section
|
||||
key={row.id}
|
||||
id={`solutions-row-${row.id}`}
|
||||
aria-labelledby={headingId}
|
||||
className='contents'
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'col-span-full',
|
||||
rowIndex === 0 ? FIRST_HEADER_RHYTHM : HEADER_RHYTHM,
|
||||
TABLET_HEADER_ORDER[rowIndex]
|
||||
)}
|
||||
>
|
||||
<SolutionsCardRowHeader row={row} headingId={headingId} variant='feature' />
|
||||
</div>
|
||||
{row.cards.map((card, cardIndex) => (
|
||||
<div
|
||||
key={`${row.id}-${card.title}`}
|
||||
className={cn('min-w-0', TABLET_CARD_ORDER[rowIndex * 3 + cardIndex])}
|
||||
>
|
||||
<SolutionsCard
|
||||
card={card}
|
||||
headingId={`solutions-row-${row.id}-card-${cardIndex}-heading`}
|
||||
variant='featureTile'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { EnterpriseFeatureGrid } from './enterprise-feature-grid'
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ChevronDown, cn } from '@sim/emcn'
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
ClipboardList,
|
||||
Files,
|
||||
Mic,
|
||||
Paperclip,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
Shuffle,
|
||||
Slash,
|
||||
Table,
|
||||
} from '@sim/emcn/icons'
|
||||
import { ThinkingLoader } from '@/components/ui'
|
||||
import {
|
||||
COMPOSER_PLACEHOLDER,
|
||||
ENTERPRISE_GREETING,
|
||||
ENTERPRISE_PROMPT,
|
||||
ENTERPRISE_REPLY,
|
||||
type EnterpriseLoopPhase,
|
||||
PROMPT_CHAR_MS,
|
||||
REPLY_WORD_MS,
|
||||
SUGGESTED_ACTIONS,
|
||||
} from '@/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data'
|
||||
|
||||
const REPLY_WORDS = ENTERPRISE_REPLY.split(' ')
|
||||
|
||||
/**
|
||||
* Reveals an incrementing count (typed chars, streamed words) at a fixed
|
||||
* step interval while `active`, deriving progress from ELAPSED time so a
|
||||
* throttled background tab catches up instead of stalling mid-reveal.
|
||||
* Resets to 0 when inactive; jumps straight to `total` under
|
||||
* `prefers-reduced-motion`.
|
||||
*/
|
||||
function useElapsedReveal(active: boolean, stepMs: number, total: number) {
|
||||
const [revealed, setRevealed] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setRevealed(0)
|
||||
return
|
||||
}
|
||||
|
||||
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const run = () => {
|
||||
const startedAt = performance.now()
|
||||
interval = setInterval(() => {
|
||||
const elapsed = performance.now() - startedAt
|
||||
const n = Math.min(Math.floor(elapsed / stepMs) + 1, total)
|
||||
setRevealed(n)
|
||||
if (n >= total && interval) clearInterval(interval)
|
||||
}, stepMs)
|
||||
}
|
||||
|
||||
const syncMotionPreference = () => {
|
||||
if (interval) clearInterval(interval)
|
||||
if (media.matches) {
|
||||
setRevealed(total)
|
||||
return
|
||||
}
|
||||
run()
|
||||
}
|
||||
|
||||
syncMotionPreference()
|
||||
media.addEventListener('change', syncMotionPreference)
|
||||
return () => {
|
||||
media.removeEventListener('change', syncMotionPreference)
|
||||
if (interval) clearInterval(interval)
|
||||
}
|
||||
}, [active, stepMs, total])
|
||||
|
||||
return revealed
|
||||
}
|
||||
|
||||
/** Greyscale leading icons for the suggested-action rows, in row order. */
|
||||
const ACTION_ICONS = [Table, ShieldCheck, ClipboardList, Files] as const
|
||||
|
||||
const Caret = () => (
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='ml-px inline-block h-[16px] w-px translate-y-[2px] animate-caret-blink bg-[var(--text-primary)]'
|
||||
/>
|
||||
)
|
||||
|
||||
interface ComposerProps {
|
||||
/** Rendered in the text region (placeholder span or typed prompt). */
|
||||
children: React.ReactNode
|
||||
/** Fills the send disc with the active ink once the prompt has text. */
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The Mothership composer chrome - white rounded field, text region on top,
|
||||
* icon rail beneath (add / attach / skills left, mic + send disc right) -
|
||||
* matching the homepage loop's composer and the real `UserInput`.
|
||||
*/
|
||||
function Composer({ children, active }: ComposerProps) {
|
||||
return (
|
||||
<div className='w-full rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 shadow-[0_1px_2px_0_rgba(18,18,18,0.05)]'>
|
||||
<p className='min-h-[24px] px-1.5 pt-1 text-[15px] text-[var(--text-primary)] leading-[24px]'>
|
||||
{children}
|
||||
</p>
|
||||
<div className='mt-2 flex items-center gap-1.5'>
|
||||
<span className='flex size-[28px] items-center justify-center rounded-full'>
|
||||
<Plus className='size-[16px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
<span className='flex size-[28px] items-center justify-center rounded-full'>
|
||||
<Paperclip className='size-[16px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
<span className='flex size-[28px] items-center justify-center rounded-full'>
|
||||
<Slash className='size-[16px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
<span className='ml-auto flex items-center gap-1.5'>
|
||||
<span className='flex size-[28px] items-center justify-center rounded-full'>
|
||||
<Mic className='size-[16px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-[28px] items-center justify-center rounded-full transition-colors duration-200',
|
||||
active ? 'bg-[#383838]' : 'bg-[#808080]'
|
||||
)}
|
||||
>
|
||||
<ArrowUp className='size-[16px] text-white' />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface EnterpriseHomeStageProps {
|
||||
/** Current beat, driven by the parent {@link EnterprisePlatformLoop} clock. */
|
||||
phase: EnterpriseLoopPhase
|
||||
/** True during the brief fade-out before the cycle restarts. */
|
||||
fading: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The main pane of the enterprise loop - the real workspace's NEW-CHAT home
|
||||
* view, replayed: the centered greeting, the composer (placeholder, then the
|
||||
* enterprise prompt typing out, then the send disc arming), and the
|
||||
* "Suggested actions" rows beneath. On `dispatch` the home layer exits first
|
||||
* and the conversation layer (the sent prompt as a user bubble over the goo
|
||||
* {@link ThinkingLoader}, with the composer docked at the bottom) enters after
|
||||
* a beat; the loader thinks while the parent's stage pane builds the workflow,
|
||||
* then the reply streams in word by word on `reply`.
|
||||
*
|
||||
* Purely presentational; the clock lives in the parent so the chat and the
|
||||
* workflow stage animate off one timeline. Both typewriters derive from
|
||||
* ELAPSED time so throttled background tabs catch up instead of stalling
|
||||
* mid-sentence.
|
||||
*/
|
||||
export function EnterpriseHomeStage({ phase, fading }: EnterpriseHomeStageProps) {
|
||||
const isTyping = phase === 'typing'
|
||||
const isReply = phase === 'reply'
|
||||
const inConversation = phase === 'dispatch' || isReply
|
||||
const promptDone = phase === 'typed' || inConversation
|
||||
const typedChars = useElapsedReveal(isTyping, PROMPT_CHAR_MS, ENTERPRISE_PROMPT.length)
|
||||
const revealedWords = useElapsedReveal(isReply, REPLY_WORD_MS, REPLY_WORDS.length)
|
||||
|
||||
const visiblePrompt = promptDone ? ENTERPRISE_PROMPT : ENTERPRISE_PROMPT.slice(0, typedChars)
|
||||
const hasText = visiblePrompt.length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-full w-full bg-[var(--bg)] transition-opacity duration-300 ease-out',
|
||||
fading ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{/* Home layer - greeting, composer, suggested actions. Exits FIRST on
|
||||
dispatch (no delay) so the swap never reads as two stacked layers. */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 flex flex-col items-center justify-center px-10 pb-[6vh] transition-opacity duration-200 ease-out',
|
||||
inConversation ? 'pointer-events-none opacity-0' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<p className='mb-7 text-[30px] text-[var(--text-primary)]'>{ENTERPRISE_GREETING}</p>
|
||||
|
||||
<div className='w-full max-w-[576px]'>
|
||||
<Composer active={hasText}>
|
||||
{hasText ? (
|
||||
<>
|
||||
{visiblePrompt}
|
||||
{isTyping && <Caret />}
|
||||
</>
|
||||
) : (
|
||||
<span className='font-[380] text-[var(--text-muted)]'>{COMPOSER_PLACEHOLDER}</span>
|
||||
)}
|
||||
</Composer>
|
||||
|
||||
<div className='mt-7'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='flex items-center gap-2'>
|
||||
<span className='text-[13px] text-[var(--text-muted)]'>Suggested actions</span>
|
||||
<ChevronDown className='h-[7px] w-[9px] text-[var(--text-muted)]' />
|
||||
</span>
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<span className='text-[13px] text-[var(--text-muted)]'>Shuffle</span>
|
||||
<Shuffle className='size-[14px] text-[var(--text-muted)]' />
|
||||
</span>
|
||||
</div>
|
||||
<div className='mt-2 flex flex-col'>
|
||||
{SUGGESTED_ACTIONS.map((action, i) => {
|
||||
const Icon = ACTION_ICONS[i]
|
||||
return (
|
||||
<span
|
||||
key={action}
|
||||
className={cn(
|
||||
'flex items-center gap-2 border-[var(--border-1)] px-2 py-2',
|
||||
i > 0 && 'border-t'
|
||||
)}
|
||||
>
|
||||
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-muted)]' />
|
||||
<span className='flex-1 truncate text-[var(--text-primary)] text-sm'>
|
||||
{action}
|
||||
</span>
|
||||
<ArrowRight className='size-[16px] shrink-0 text-[var(--text-muted)]' />
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversation layer - the sent exchange. Enters AFTER the home layer's
|
||||
200ms exit so the handoff is a choreographed swap, not a crossfade.
|
||||
The loader thinks through the workflow build, then the reply streams. */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 flex flex-col transition-opacity duration-200 ease-out',
|
||||
inConversation ? 'opacity-100 [transition-delay:220ms]' : 'pointer-events-none opacity-0'
|
||||
)}
|
||||
>
|
||||
<div className='mx-auto flex min-h-0 w-full max-w-[640px] flex-1 flex-col gap-6 overflow-hidden px-6 pt-6'>
|
||||
<div className='max-w-[82%] self-end rounded-2xl bg-[var(--surface-3)] px-4 py-3 text-[15px] text-[var(--text-primary)] leading-[1.5]'>
|
||||
{ENTERPRISE_PROMPT}
|
||||
</div>
|
||||
{phase === 'dispatch' && (
|
||||
<ThinkingLoader size={26} phase labelRatio={0.58} className='mt-1' />
|
||||
)}
|
||||
<p
|
||||
className={cn(
|
||||
'text-[15px] text-[var(--text-primary)] leading-[1.6] transition-opacity duration-200 ease-out',
|
||||
isReply ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
{REPLY_WORDS.slice(0, revealedWords).join(' ')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='mx-auto mb-5 w-[calc(100%-40px)] max-w-[600px] shrink-0'>
|
||||
<Composer active={false}>
|
||||
<span className='font-[380] text-[var(--text-muted)]'>Send message to Sim</span>
|
||||
</Composer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { HeroWorkflowStage } from '@/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage'
|
||||
import { EnterpriseHomeStage } from '@/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-home-stage'
|
||||
import { EnterpriseSidebar } from '@/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-sidebar'
|
||||
import {
|
||||
BUILD_STEP_MS,
|
||||
ENTERPRISE_STAGE_BLOCKS,
|
||||
ENTERPRISE_STAGE_CANVAS,
|
||||
ENTERPRISE_STAGE_EDGES,
|
||||
type EnterpriseLoopPhase,
|
||||
LOOP_TIMELINE,
|
||||
RESET_FADE_MS,
|
||||
} from '@/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data'
|
||||
|
||||
/**
|
||||
* The window interior's design space, matching the homepage loop's capture
|
||||
* geometry (the 2560x1470 shot is a 1280x735 CSS layout shown in the 1080x620
|
||||
* window, so the app's native type reads at the same ~84.4% "mini app" scale):
|
||||
* the sidebar column is 249px, and the workspace container is inset 7-8px.
|
||||
*/
|
||||
const DESIGN = { width: 1280, height: 735 } as const
|
||||
|
||||
/**
|
||||
* The enterprise hero's platform loop - a sibling of the homepage
|
||||
* `HeroPlatformLoop` that shares its architecture (fixed design-space layer
|
||||
* scaled to the window via ResizeObserver + `transform: scale`, a parent-owned
|
||||
* timeline clock driving presentational stages, reduced-motion showing a
|
||||
* static finished frame) but diverges in content: where the homepage overlays
|
||||
* a live chat over a baked screenshot, this variant renders the WHOLE interior
|
||||
* live - the {@link EnterpriseSidebar} (a filled-out Brightwave workspace) and
|
||||
* the {@link EnterpriseHomeStage} (the real new-chat home view, replaying an
|
||||
* enterprise prompt) - because its sidebar content differs from the shot's.
|
||||
*
|
||||
* Timeline (see `stage-data.ts` - later stages append beats there): idle
|
||||
* new-chat view → prompt types out → send arms → dispatch (user bubble +
|
||||
* thinking, full-width) → the stage pane slides in from the right (the real
|
||||
* `MothershipView` `w-0 ↔ w-1/2` width transition) → the invoice workflow
|
||||
* assembles block by block (the shared {@link HeroWorkflowStage}, staged with
|
||||
* the enterprise flow) → the reply streams in → hold → fade → restart.
|
||||
*
|
||||
* Everything is `pointer-events-none` decorative, matching the hero's
|
||||
* `aria-hidden` frame. Under `prefers-reduced-motion` the loop never starts:
|
||||
* the finished exchange, open stage, and fully-built workflow render
|
||||
* statically.
|
||||
*/
|
||||
export function EnterprisePlatformLoop() {
|
||||
const regionRef = useRef<HTMLDivElement>(null)
|
||||
const [phase, setPhase] = useState<EnterpriseLoopPhase>('idle')
|
||||
const [stageOpen, setStageOpen] = useState(false)
|
||||
const [builtCount, setBuiltCount] = useState(0)
|
||||
const [fading, setFading] = useState(false)
|
||||
const [cycleId, setCycleId] = useState(0)
|
||||
const [scale, setScale] = useState(1)
|
||||
|
||||
// Track the rendered region width and scale the design-space layer to fill
|
||||
// it, keeping the live layer's proportions locked to the window's.
|
||||
useLayoutEffect(() => {
|
||||
const el = regionRef.current
|
||||
if (!el) return
|
||||
const measure = () => {
|
||||
const w = el.getBoundingClientRect().width
|
||||
if (w > 40) setScale(w / DESIGN.width)
|
||||
}
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
let timers: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
const clearScheduled = () => {
|
||||
timers.forEach(clearTimeout)
|
||||
timers = []
|
||||
}
|
||||
|
||||
const showFinished = () => {
|
||||
clearScheduled()
|
||||
setFading(false)
|
||||
setPhase('reply')
|
||||
setStageOpen(true)
|
||||
setBuiltCount(ENTERPRISE_STAGE_BLOCKS.length)
|
||||
}
|
||||
|
||||
const runCycle = () => {
|
||||
setFading(false)
|
||||
setPhase('idle')
|
||||
setStageOpen(false)
|
||||
setBuiltCount(0)
|
||||
setCycleId((c) => c + 1)
|
||||
timers = [
|
||||
setTimeout(() => setPhase('typing'), LOOP_TIMELINE.typing),
|
||||
setTimeout(() => setPhase('typed'), LOOP_TIMELINE.typed),
|
||||
setTimeout(() => setPhase('dispatch'), LOOP_TIMELINE.dispatch),
|
||||
setTimeout(() => setStageOpen(true), LOOP_TIMELINE.stageOpen),
|
||||
...ENTERPRISE_STAGE_BLOCKS.map((_, i) =>
|
||||
setTimeout(() => setBuiltCount(i + 1), LOOP_TIMELINE.buildStart + i * BUILD_STEP_MS)
|
||||
),
|
||||
setTimeout(() => setPhase('reply'), LOOP_TIMELINE.reply),
|
||||
setTimeout(() => setFading(true), LOOP_TIMELINE.total - RESET_FADE_MS),
|
||||
setTimeout(runCycle, LOOP_TIMELINE.total),
|
||||
]
|
||||
}
|
||||
|
||||
const syncMotionPreference = () => {
|
||||
clearScheduled()
|
||||
if (media.matches) {
|
||||
showFinished()
|
||||
return
|
||||
}
|
||||
runCycle()
|
||||
}
|
||||
|
||||
syncMotionPreference()
|
||||
media.addEventListener('change', syncMotionPreference)
|
||||
return () => {
|
||||
media.removeEventListener('change', syncMotionPreference)
|
||||
clearScheduled()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={regionRef} className='pointer-events-none absolute inset-0 overflow-hidden'>
|
||||
<div
|
||||
className='flex origin-top-left bg-[var(--surface-1)]'
|
||||
style={{
|
||||
width: DESIGN.width,
|
||||
height: DESIGN.height,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
<EnterpriseSidebar />
|
||||
<div className='h-full min-w-0 flex-1 py-[7px] pr-[8px]'>
|
||||
<div className='flex h-full w-full overflow-hidden rounded-[6px] border border-[var(--border)] bg-[var(--bg)]'>
|
||||
<div className='relative h-full min-w-0 flex-1'>
|
||||
<EnterpriseHomeStage phase={phase} fading={fading} />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full shrink-0 overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-200 ease-[cubic-bezier(0.25,0.1,0.25,1)]',
|
||||
stageOpen ? 'w-1/2 border-l' : 'w-0 min-w-0 border-l-0'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full w-full transition-opacity duration-300 ease-out',
|
||||
fading ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<HeroWorkflowStage
|
||||
key={cycleId}
|
||||
builtCount={builtCount}
|
||||
blocks={ENTERPRISE_STAGE_BLOCKS}
|
||||
edges={ENTERPRISE_STAGE_EDGES}
|
||||
canvas={ENTERPRISE_STAGE_CANVAS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import { ChevronDown, cn, Home, Library } from '@sim/emcn'
|
||||
import {
|
||||
Calendar,
|
||||
Database,
|
||||
File,
|
||||
HelpCircle,
|
||||
Integration,
|
||||
MoreHorizontal,
|
||||
PanelLeft,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Table,
|
||||
} from '@sim/emcn/icons'
|
||||
import Image from 'next/image'
|
||||
import {
|
||||
SIDEBAR_CHATS,
|
||||
SIDEBAR_WORKFLOWS,
|
||||
} from '@/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data'
|
||||
|
||||
const WORKSPACE_NAV = [
|
||||
{ label: 'Tables', icon: Table },
|
||||
{ label: 'Files', icon: File },
|
||||
{ label: 'Knowledge base', icon: Database },
|
||||
{ label: 'Scheduled tasks', icon: Calendar },
|
||||
{ label: 'Logs', icon: Library },
|
||||
] as const
|
||||
|
||||
interface IconRowProps {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
/** A sidebar nav row with a leading icon, like the real workspace sidebar. */
|
||||
function IconRow({ icon: Icon, label, active = false }: IconRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2',
|
||||
active && 'bg-[var(--surface-active)]'
|
||||
)}
|
||||
>
|
||||
<Icon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='truncate font-[450] text-[13px] text-[var(--text-body)]'>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A bare text row - the real sidebar's chat and workflow entries. */
|
||||
function TextRow({ label }: { label: string }) {
|
||||
return (
|
||||
<div className='mx-0.5 flex h-[28px] items-center rounded-[8px] px-2'>
|
||||
<span className='truncate font-[450] text-[13px] text-[var(--text-body)]'>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Muted section heading (Chats / Workspace / Workflows). */
|
||||
function SectionLabel({ label, actions }: { label: string; actions?: boolean }) {
|
||||
return (
|
||||
<div className='flex items-center justify-between px-4 pb-1.5'>
|
||||
<span className='text-[12px] text-[var(--text-icon)]'>{label}</span>
|
||||
{actions && (
|
||||
<span className='flex items-center gap-2 text-[var(--text-icon)]'>
|
||||
<MoreHorizontal className='size-[14px]' />
|
||||
<Plus className='size-[14px]' />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Brightwave workspace sidebar, rendered live (the homepage loop keeps its
|
||||
* baked-screenshot sidebar; the enterprise loop draws its own so the content
|
||||
* can read like a large tenured deployment): the workspace header, New chat /
|
||||
* Search / Integrations, a filled-out Chats history, the Workspace nav, a full
|
||||
* Workflows section, and the Help / Settings footer. Purely decorative -
|
||||
* hover/click behavior is owned by the parent's `pointer-events-none` frame.
|
||||
*/
|
||||
export function EnterpriseSidebar() {
|
||||
return (
|
||||
<div className='flex h-full w-[249px] flex-shrink-0 flex-col bg-[var(--surface-1)] pt-3'>
|
||||
{/* Workspace header, matching the real product's WorkspaceHeader chip
|
||||
(borderless `chipVariants()` geometry: h-[30px] rounded-lg px-2 with
|
||||
mx-0.5, 16px logo, text-sm name, 6x10 chevron) and therefore the
|
||||
homepage's baked sidebar pixels - logo + name + chevron as a bare
|
||||
row, panel toggle right-aligned outside it. */}
|
||||
<div className='flex flex-shrink-0 items-center justify-between px-2'>
|
||||
<div className='mx-0.5 flex h-[30px] min-w-0 items-center gap-2 rounded-lg px-2'>
|
||||
{/* The exact Brightwave mark the homepage capture seeds
|
||||
(`readme-tour-capture` sets `logoUrl: '/landing/rivian-logo.svg'`),
|
||||
so both platform previews show the same company logo. */}
|
||||
<Image
|
||||
src='/landing/rivian-logo.svg'
|
||||
alt=''
|
||||
width={16}
|
||||
height={16}
|
||||
className='size-[16px] flex-shrink-0 rounded-sm'
|
||||
/>
|
||||
<span className='min-w-0 truncate text-[var(--text-body)] text-sm'>Brightwave</span>
|
||||
<ChevronDown className='h-[6px] w-[10px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</div>
|
||||
<PanelLeft className='mr-1.5 size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</div>
|
||||
|
||||
<div className='mt-2.5 flex flex-shrink-0 flex-col gap-0.5 px-2'>
|
||||
<IconRow icon={Home} label='New chat' active />
|
||||
<IconRow icon={Search} label='Search' />
|
||||
<IconRow icon={Integration} label='Integrations' />
|
||||
</div>
|
||||
|
||||
<div className='mt-3.5 flex flex-shrink-0 flex-col'>
|
||||
<SectionLabel label='Chats' />
|
||||
<div className='flex flex-col gap-0.5 px-2'>
|
||||
{SIDEBAR_CHATS.map((chat) => (
|
||||
<TextRow key={chat} label={chat} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-3.5 flex flex-shrink-0 flex-col'>
|
||||
<SectionLabel label='Workspace' />
|
||||
<div className='flex flex-col gap-0.5 px-2'>
|
||||
{WORKSPACE_NAV.map((item) => (
|
||||
<IconRow key={item.label} icon={item.icon} label={item.label} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex min-h-0 flex-1 flex-col overflow-hidden pt-3.5'>
|
||||
<SectionLabel label='Workflows' actions />
|
||||
<div className='flex flex-col gap-0.5 px-2'>
|
||||
{SIDEBAR_WORKFLOWS.map((workflow) => (
|
||||
<TextRow key={workflow} label={workflow} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-shrink-0 flex-col gap-0.5 px-2 pt-[9px] pb-2'>
|
||||
<IconRow icon={HelpCircle} label='Help' />
|
||||
<IconRow icon={Settings} label='Settings' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { EnterpriseHomeStage } from '@/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-home-stage'
|
||||
export { EnterprisePlatformLoop } from '@/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-platform-loop'
|
||||
export { EnterpriseSidebar } from '@/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-sidebar'
|
||||
@@ -0,0 +1,180 @@
|
||||
import { AgentIcon, ConditionalIcon, MailIcon, StartIcon, TableIcon } from '@/components/icons'
|
||||
import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
/**
|
||||
* Content + timing config for the enterprise hero's platform loop. Kept in one
|
||||
* place (and phase starts derived, not hardcoded) so later stages - tables,
|
||||
* files, knowledge base, logs walkthroughs - extend the timeline by appending
|
||||
* beats here rather than reworking the clock.
|
||||
*/
|
||||
|
||||
/** The new-chat greeting, personalized like the real workspace Home. */
|
||||
export const ENTERPRISE_GREETING = 'What should we get done, Morgan?'
|
||||
|
||||
/** Placeholder shown in the composer before the prompt types out. */
|
||||
export const COMPOSER_PLACEHOLDER = 'Ask Sim to automate a process.'
|
||||
|
||||
/**
|
||||
* The prompt the loop types - a large company's multi-system operational
|
||||
* workflow (AP + NetSuite + finance review + audit trail), concise enough to
|
||||
* type on screen.
|
||||
*/
|
||||
export const ENTERPRISE_PROMPT =
|
||||
'When a vendor invoice lands in AP, match it against the PO in NetSuite, flag exceptions for finance review, and log the approval trail.'
|
||||
|
||||
/** Typewriter cadence for the composer prompt. */
|
||||
export const PROMPT_CHAR_MS = 28
|
||||
|
||||
/** Sim's reply, streamed word by word once the workflow finishes building. */
|
||||
export const ENTERPRISE_REPLY =
|
||||
"On it. I'll match each AP invoice to its PO in NetSuite, route exceptions to finance review, and log the full approval trail."
|
||||
|
||||
/** Word-reveal cadence for the streamed reply. */
|
||||
export const REPLY_WORD_MS = 55
|
||||
|
||||
/**
|
||||
* Recent chats - enterprise-flavored, so Brightwave reads long-tenured. Four
|
||||
* entries: together with the five workflows this fills the sidebar's 735px
|
||||
* design height exactly, without clipping the Workflows section.
|
||||
*/
|
||||
export const SIDEBAR_CHATS = [
|
||||
'Vendor invoice exceptions',
|
||||
'Q3 access review',
|
||||
'NetSuite sync errors',
|
||||
'Supplier onboarding docs',
|
||||
] as const
|
||||
|
||||
/** Deployed workflows - a fuller section than the homepage's three. */
|
||||
export const SIDEBAR_WORKFLOWS = [
|
||||
'Invoice exception routing',
|
||||
'Employee onboarding',
|
||||
'Vendor risk scoring',
|
||||
'IT access provisioning',
|
||||
'Weekly compliance report',
|
||||
] as const
|
||||
|
||||
/** Suggested actions under the composer, mirroring the real Home rows. */
|
||||
export const SUGGESTED_ACTIONS = [
|
||||
'Reconcile vendor invoices in NetSuite',
|
||||
'Triage pending IT access requests',
|
||||
'Summarize open compliance exceptions',
|
||||
'Draft the weekly ops readiness report',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* The workflow the chat "builds" on the stage pane - the invoice-matching flow
|
||||
* the prompt describes, distilled to what reads at mini-app scale: Start feeds
|
||||
* the NetSuite PO match, exceptions are flagged, and the flow fans out to
|
||||
* finance review and the audit log. Same geometry conventions as the homepage
|
||||
* stage (250px blocks, vertical spine at x=155, terminals fanned at y=580);
|
||||
* tiles use the platform's grey text ramp - color is reserved for real
|
||||
* third-party marks, and none of these carry one.
|
||||
*
|
||||
* Ordered by build sequence; an edge draws once both endpoints are on canvas.
|
||||
*/
|
||||
export const ENTERPRISE_STAGE_BLOCKS: BlockDef[] = [
|
||||
{
|
||||
id: 'start',
|
||||
name: 'Start',
|
||||
icon: StartIcon,
|
||||
bgColor: 'var(--text-muted)',
|
||||
isTrigger: true,
|
||||
rows: [{ title: 'Inputs', value: '-' }],
|
||||
x: 155,
|
||||
y: 12,
|
||||
},
|
||||
{
|
||||
id: 'match',
|
||||
name: 'Match PO',
|
||||
icon: AgentIcon,
|
||||
bgColor: 'var(--text-primary)',
|
||||
rows: [
|
||||
{ title: 'Messages', value: '-' },
|
||||
{ title: 'Model', value: '-' },
|
||||
],
|
||||
x: 155,
|
||||
y: 172,
|
||||
},
|
||||
{
|
||||
id: 'exceptions',
|
||||
name: 'Flag exceptions',
|
||||
icon: ConditionalIcon,
|
||||
bgColor: 'var(--text-secondary)',
|
||||
rows: [{ title: 'Conditions', value: '-' }],
|
||||
x: 155,
|
||||
y: 372,
|
||||
},
|
||||
{
|
||||
id: 'review',
|
||||
name: 'Finance review',
|
||||
icon: MailIcon,
|
||||
bgColor: 'var(--text-body)',
|
||||
isTerminal: true,
|
||||
rows: [
|
||||
{ title: 'To', value: '-' },
|
||||
{ title: 'Subject', value: '-' },
|
||||
],
|
||||
x: 0,
|
||||
y: 560,
|
||||
},
|
||||
{
|
||||
id: 'audit',
|
||||
name: 'Audit log',
|
||||
icon: TableIcon,
|
||||
bgColor: 'var(--text-muted)',
|
||||
isTerminal: true,
|
||||
rows: [
|
||||
{ title: 'Table', value: '-' },
|
||||
{ title: 'Operation', value: '-' },
|
||||
],
|
||||
x: 310,
|
||||
y: 560,
|
||||
},
|
||||
]
|
||||
|
||||
/** Source → target pairs, drawn in order as their endpoints land on canvas. */
|
||||
export const ENTERPRISE_STAGE_EDGES: ReadonlyArray<readonly [string, string]> = [
|
||||
['start', 'match'],
|
||||
['match', 'exceptions'],
|
||||
['exceptions', 'review'],
|
||||
['exceptions', 'audit'],
|
||||
]
|
||||
|
||||
/** Design-space bounding box of the layout above. */
|
||||
export const ENTERPRISE_STAGE_CANVAS = { width: 560, height: 680 } as const
|
||||
|
||||
/** Where the main pane is within one loop pass. */
|
||||
export type EnterpriseLoopPhase = 'idle' | 'typing' | 'typed' | 'dispatch' | 'reply'
|
||||
|
||||
/** The idle new-chat view holds this long before typing starts. */
|
||||
const IDLE_HOLD_MS = 1400
|
||||
/** Rest on the fully-typed prompt before "send". */
|
||||
const TYPED_HOLD_MS = 700
|
||||
/** Thinking runs alone this long before the stage pane slides open. */
|
||||
const STAGE_OPEN_AFTER_MS = 900
|
||||
/** First block lands this long after the pane opens. */
|
||||
const BUILD_START_AFTER_MS = 500
|
||||
/** Block N (build order) pops in at buildStart + N * BUILD_STEP_MS. */
|
||||
export const BUILD_STEP_MS = 620
|
||||
/** The reply starts streaming this long after the last block lands. */
|
||||
const REPLY_AFTER_MS = 500
|
||||
/** The finished scene (reply + built canvas) holds this long. */
|
||||
const REPLY_HOLD_MS = 4800
|
||||
/** Fade-out length before the cycle restarts. */
|
||||
export const RESET_FADE_MS = 300
|
||||
|
||||
/**
|
||||
* Derived phase starts. Later stages (tables, files, knowledge base, logs)
|
||||
* slot in after `reply` by extending {@link EnterpriseLoopPhase} and appending
|
||||
* starts here.
|
||||
*/
|
||||
export const LOOP_TIMELINE = (() => {
|
||||
const typing = IDLE_HOLD_MS
|
||||
const typed = typing + ENTERPRISE_PROMPT.length * PROMPT_CHAR_MS
|
||||
const dispatch = typed + TYPED_HOLD_MS
|
||||
const stageOpen = dispatch + STAGE_OPEN_AFTER_MS
|
||||
const buildStart = stageOpen + BUILD_START_AFTER_MS
|
||||
const reply = buildStart + (ENTERPRISE_STAGE_BLOCKS.length - 1) * BUILD_STEP_MS + REPLY_AFTER_MS
|
||||
const total = reply + REPLY_HOLD_MS
|
||||
return { typing, typed, dispatch, stageOpen, buildStart, reply, total } as const
|
||||
})()
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* The AccessControlGraphic's motion, all on one shared 6s cycle: the six
|
||||
* connector edges draw in with a dash-normalized stroke sweep (every path
|
||||
* carries `pathLength=1`, so a dasharray/dashoffset of 1 spans the whole
|
||||
* curve regardless of geometry — the deploy tile's pattern), staggered so
|
||||
* the grants wire up one after another, quiet edges first and the
|
||||
* emphasized Engineering → Deploy edge last and slowest. Each edge's
|
||||
* stagger is baked into its own keyframe percentages rather than an
|
||||
* `animation-delay`, so every edge resets on the same loop boundary and
|
||||
* the cycle reads draw-once-then-hold. As the emphasized edge lands, the
|
||||
* grant node blooms the family's soft ring pulse. Under
|
||||
* prefers-reduced-motion everything is static: edges fully drawn, no
|
||||
* pulse.
|
||||
*/
|
||||
|
||||
.edgeDraw {
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
}
|
||||
|
||||
.edgeDraw0 {
|
||||
animation: access-edge-draw-0 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.edgeDraw1 {
|
||||
animation: access-edge-draw-1 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.edgeDraw2 {
|
||||
animation: access-edge-draw-2 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.edgeDraw3 {
|
||||
animation: access-edge-draw-3 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.edgeDraw4 {
|
||||
animation: access-edge-draw-4 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.edgeDrawEmphasized {
|
||||
animation: access-edge-draw-emphasized 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes access-edge-draw-0 {
|
||||
0% {
|
||||
stroke-dashoffset: 1;
|
||||
}
|
||||
14%,
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes access-edge-draw-1 {
|
||||
0%,
|
||||
7% {
|
||||
stroke-dashoffset: 1;
|
||||
}
|
||||
21%,
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes access-edge-draw-2 {
|
||||
0%,
|
||||
14% {
|
||||
stroke-dashoffset: 1;
|
||||
}
|
||||
28%,
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes access-edge-draw-3 {
|
||||
0%,
|
||||
21% {
|
||||
stroke-dashoffset: 1;
|
||||
}
|
||||
35%,
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes access-edge-draw-4 {
|
||||
0%,
|
||||
28% {
|
||||
stroke-dashoffset: 1;
|
||||
}
|
||||
42%,
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes access-edge-draw-emphasized {
|
||||
0%,
|
||||
38% {
|
||||
stroke-dashoffset: 1;
|
||||
}
|
||||
56%,
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.grantPulse {
|
||||
animation: access-grant-pulse 6s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes access-grant-pulse {
|
||||
0%,
|
||||
54% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 35%, transparent);
|
||||
}
|
||||
72% {
|
||||
box-shadow: 0 0 0 5px color-mix(in srgb, var(--text-secondary) 0%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 0%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.edgeDraw {
|
||||
animation: none;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
.grantPulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
import { ChipTag, cn } from '@sim/emcn'
|
||||
import Image from 'next/image'
|
||||
import styles from '@/app/(landing)/enterprise/components/feature-graphics/access-control-graphic.module.css'
|
||||
import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell'
|
||||
|
||||
/** Fixed pixel canvas the vignette is drawn on, centered inside the shell. */
|
||||
const CANVAS = { WIDTH: 320, HEIGHT: 250 } as const
|
||||
|
||||
/** Vertical anchors: team output ports on top, chip input ports below. */
|
||||
const PORT_Y = { TEAM: 72, CHIP: 208 } as const
|
||||
|
||||
const TEAMS = [
|
||||
{ avatar: '/landing/team-avatar-1.jpg', name: 'Engineering', x: 64, leftClass: 'left-[64px]' },
|
||||
{ avatar: '/landing/team-avatar-2.jpg', name: 'Support', x: 160, leftClass: 'left-[160px]' },
|
||||
{ avatar: '/landing/team-avatar-3.jpg', name: 'Ops', x: 256, leftClass: 'left-[256px]' },
|
||||
] as const
|
||||
|
||||
const CHIPS = [
|
||||
{ label: 'Build', x: 80, leftClass: 'left-[80px]' },
|
||||
{ label: 'Review', x: 160, leftClass: 'left-[160px]' },
|
||||
{ label: 'Deploy', x: 240, leftClass: 'left-[240px]' },
|
||||
] as const
|
||||
|
||||
interface Edge {
|
||||
/** Team output-port x coordinate. */
|
||||
from: number
|
||||
/** Chip input-port x coordinate. */
|
||||
to: number
|
||||
/** Whether this is the emphasized live grant (stronger ink). */
|
||||
emphasized?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Team → permission grants: Engineering builds, reviews, and deploys
|
||||
* (Deploy is the live grant); Support builds and reviews; Ops reviews.
|
||||
*/
|
||||
const EDGES: readonly Edge[] = [
|
||||
{ from: 64, to: 80 },
|
||||
{ from: 64, to: 160 },
|
||||
{ from: 160, to: 80 },
|
||||
{ from: 160, to: 160 },
|
||||
{ from: 256, to: 160 },
|
||||
{ from: 64, to: 240, emphasized: true },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Per-index draw classes for the quiet edges — the stagger order is baked
|
||||
* into each class's keyframes so all edges share one 6s loop boundary.
|
||||
* The emphasized edge uses its own dedicated class instead.
|
||||
*/
|
||||
const EDGE_DRAW_CLASSES = [
|
||||
styles.edgeDraw0,
|
||||
styles.edgeDraw1,
|
||||
styles.edgeDraw2,
|
||||
styles.edgeDraw3,
|
||||
styles.edgeDraw4,
|
||||
] as const
|
||||
|
||||
/** Builds a node-graph edge: vertical tangents easing into both ports. */
|
||||
function edgePath(edge: Edge): string {
|
||||
const { from, to } = edge
|
||||
const bend = 62
|
||||
return `M ${from} ${PORT_Y.TEAM} C ${from} ${PORT_Y.TEAM + bend} ${to} ${PORT_Y.CHIP - bend} ${to} ${PORT_Y.CHIP}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace access told as a vertical role graph, with no window framing:
|
||||
* three gradient-circle team avatars across the top flow down through
|
||||
* curved node-graph edges — 1px bezier strokes in the deploy tile's faint
|
||||
* guide-line grey, each bending toward and landing on the specific
|
||||
* permission chip that team holds — so who-can-do-what reads as directed
|
||||
* wiring. Scopes narrow across the teams (Engineering builds, reviews, and
|
||||
* deploys; Support builds and reviews; Ops reviews). Engineering's Deploy
|
||||
* grant is the emphasized element: its curve carries the stronger
|
||||
* `--text-secondary` ink into a filled junction node that blooms the row's
|
||||
* shared 6s ring pulse; the other chips sit behind quiet hollow
|
||||
* port nodes and mono chips (fills stepped up to `--surface-6` so the
|
||||
* pills stay legible on the grey ground).
|
||||
*
|
||||
* Motion (from `access-control-graphic.module.css`, one shared 6s cycle):
|
||||
* the edges draw in with a dash-normalized stroke sweep (`pathLength=1`,
|
||||
* the deploy tile's pattern), staggered so the grants wire up one after
|
||||
* another — quiet edges first, the emphasized Deploy edge last — then
|
||||
* hold drawn for the rest of the loop, and the grant node's ring pulse
|
||||
* blooms as the emphasized edge lands. Under `prefers-reduced-motion` the
|
||||
* graph renders fully drawn and static.
|
||||
*
|
||||
* The avatar assets are grey radial gradients on a black square, so each
|
||||
* sits in a `rounded-full overflow-hidden` clip with a slight scale-up to
|
||||
* crop the black canvas past the circle's edge.
|
||||
*
|
||||
* The feature tile's visual slot bleeds `2rem` right (`1.5rem` under
|
||||
* `max-lg`) but not left, so this centered vignette adds matching right
|
||||
* padding to land on the tile's visible center instead of the bled slot's
|
||||
* center. The fixed-size canvas is `shrink-0`: if it were allowed to
|
||||
* shrink at narrow tile widths its absolutely-positioned columns (fixed
|
||||
* `left-*` coordinates) would drift right of the box's true midline;
|
||||
* instead it keeps its geometry and overflows both edges equally, so the
|
||||
* Support → Review axis always sits on the tile's center. On the narrow
|
||||
* grid bands where the tile drops below the canvas width (small two-column
|
||||
* screens and the 3-up row just past `lg`) the whole canvas scales down via
|
||||
* transform — preserving that centering — so the outer team labels are
|
||||
* never cropped.
|
||||
*/
|
||||
export function AccessControlGraphic() {
|
||||
return (
|
||||
<FeatureGraphicShell>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute inset-0 flex items-center justify-center pr-8 max-lg:pr-6'
|
||||
>
|
||||
<div className='relative h-[250px] w-[320px] shrink-0 max-sm:scale-100 max-md:scale-[0.8] lg:max-[1180px]:scale-[0.8]'>
|
||||
<svg
|
||||
className='absolute inset-0'
|
||||
fill='none'
|
||||
viewBox={`0 0 ${CANVAS.WIDTH} ${CANVAS.HEIGHT}`}
|
||||
width={CANVAS.WIDTH}
|
||||
height={CANVAS.HEIGHT}
|
||||
>
|
||||
{EDGES.map((edge, index) => (
|
||||
<path
|
||||
key={`${edge.from}-${edge.to}`}
|
||||
d={edgePath(edge)}
|
||||
pathLength={1}
|
||||
className={cn(
|
||||
styles.edgeDraw,
|
||||
edge.emphasized ? styles.edgeDrawEmphasized : EDGE_DRAW_CLASSES[index]
|
||||
)}
|
||||
stroke={
|
||||
edge.emphasized
|
||||
? 'var(--text-secondary)'
|
||||
: 'color-mix(in srgb, var(--text-muted) 35%, transparent)'
|
||||
}
|
||||
strokeWidth='1'
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{TEAMS.map((team, index) => (
|
||||
<div
|
||||
key={team.name}
|
||||
className={cn(
|
||||
'-translate-x-1/2 absolute top-2 flex w-24 flex-col items-center gap-1.5',
|
||||
team.leftClass
|
||||
)}
|
||||
>
|
||||
<span className='relative size-8 overflow-hidden rounded-full shadow-sm'>
|
||||
<Image
|
||||
src={team.avatar}
|
||||
alt=''
|
||||
width={32}
|
||||
height={32}
|
||||
className='size-full scale-110 object-cover'
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-caption',
|
||||
index === 0
|
||||
? 'font-medium text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-secondary)]'
|
||||
)}
|
||||
>
|
||||
{team.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{CHIPS.map((chip) => {
|
||||
const emphasized = EDGES.some((edge) => edge.emphasized && edge.to === chip.x)
|
||||
return (
|
||||
<div
|
||||
key={chip.label}
|
||||
className={cn(
|
||||
'-translate-x-1/2 absolute top-[203px] flex flex-col items-center gap-1.5',
|
||||
chip.leftClass
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
emphasized
|
||||
? cn('size-2.5 rounded-full bg-[var(--text-primary)]', styles.grantPulse)
|
||||
: 'size-2 rounded-full border border-[var(--text-muted)] bg-[var(--surface-3)]'
|
||||
)}
|
||||
/>
|
||||
<ChipTag
|
||||
variant={emphasized ? 'solid' : 'mono'}
|
||||
className={cn(!emphasized && 'bg-[var(--surface-6)]')}
|
||||
>
|
||||
{chip.label}
|
||||
</ChipTag>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</FeatureGraphicShell>
|
||||
)
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* The AuditTrailGraphic's motion: the newest ledger entry stamps in once on
|
||||
* load (a short settle from above — an append, never a re-append, since the
|
||||
* record is immutable), and its actor avatar then carries the row's shared
|
||||
* quiet 6s ring-pulse beat (same bloom as the access tile's live grant and
|
||||
* the lifecycle tile's live node) to mark the trail as live. Under
|
||||
* prefers-reduced-motion both are removed and the ledger sits static.
|
||||
*/
|
||||
|
||||
.stampIn {
|
||||
animation: audit-stamp-in 0.9s cubic-bezier(0.22, 1, 0.36, 1) 0.35s backwards;
|
||||
}
|
||||
|
||||
@keyframes audit-stamp-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sealPulse {
|
||||
animation: audit-seal-pulse 6s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes audit-seal-pulse {
|
||||
0%,
|
||||
20% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 35%, transparent);
|
||||
}
|
||||
36% {
|
||||
box-shadow: 0 0 0 5px color-mix(in srgb, var(--text-secondary) 0%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 0%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stampIn,
|
||||
.sealPulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import { ChipTag, cn } from '@sim/emcn'
|
||||
import Image from 'next/image'
|
||||
import styles from '@/app/(landing)/enterprise/components/feature-graphics/audit-trail-graphic.module.css'
|
||||
import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell'
|
||||
|
||||
interface AuditEntry {
|
||||
/** Human-readable action label, matching how the audit UI renders `AuditAction` codes. */
|
||||
action: string
|
||||
/** Attributed actor shown on the detail line. */
|
||||
actor: string
|
||||
/** Resource the action touched. */
|
||||
resource: string
|
||||
/** Relative or absolute timestamp, newest first. */
|
||||
time: string
|
||||
/** Gradient team avatar attributing the entry to its actor. */
|
||||
avatar: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ledger records newest-first, drawn from the real `AuditAction` codes in
|
||||
* `packages/audit` (`workflow.deployed`, `permission_group.updated`,
|
||||
* `credential.accessed`, `workflow.created`) rendered as spaced words the
|
||||
* way the workspace audit UI formats them, and threading the running
|
||||
* Support-agent story: Maya ships v3, the Support permission group is
|
||||
* tuned, a Zendesk credential access is recorded, and the trail reaches
|
||||
* back to the workflow's creation.
|
||||
*/
|
||||
const ENTRIES: readonly AuditEntry[] = [
|
||||
{
|
||||
action: 'Workflow deployed',
|
||||
actor: 'Maya Chen',
|
||||
resource: 'Support agent v3',
|
||||
time: 'Now',
|
||||
avatar: '/landing/team-avatar-1.jpg',
|
||||
},
|
||||
{
|
||||
action: 'Permission group updated',
|
||||
actor: 'Jordan Lee',
|
||||
resource: 'Support team',
|
||||
time: '2 min ago',
|
||||
avatar: '/landing/team-avatar-2.jpg',
|
||||
},
|
||||
{
|
||||
action: 'Credential accessed',
|
||||
actor: 'Sam Ortiz',
|
||||
resource: 'Zendesk OAuth',
|
||||
time: '26 min ago',
|
||||
avatar: '/landing/team-avatar-3.jpg',
|
||||
},
|
||||
{
|
||||
action: 'Workflow created',
|
||||
actor: 'Maya Chen',
|
||||
resource: 'Support agent',
|
||||
time: 'Jun 14',
|
||||
avatar: '/landing/team-avatar-1.jpg',
|
||||
},
|
||||
] as const
|
||||
|
||||
/** Per-row ink treatments, quieter with age like the lifecycle timeline. */
|
||||
const ROW_TONES = [
|
||||
{ action: 'text-[var(--text-primary)]', avatar: 'opacity-100' },
|
||||
{ action: 'text-[var(--text-secondary)]', avatar: 'opacity-80' },
|
||||
{ action: 'text-[var(--text-muted)]', avatar: 'opacity-60' },
|
||||
{ action: 'text-[var(--text-muted)]', avatar: 'opacity-40' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Sim's audit trail told as an append-only ledger rather than a product
|
||||
* window: a frameless, centered vignette (the access tile's composition,
|
||||
* which sits beside it in the row) where each record is a plain row —
|
||||
* gradient actor avatar, the action label in the row's regular sans face
|
||||
* (`font-medium text-small`, the same treatment the standards tile gives
|
||||
* its row titles), an "actor · resource" attribution line, and a
|
||||
* right-aligned timestamp. The newest record is the selected event: it
|
||||
* sits on a solid white card wearing the build tile's window chrome
|
||||
* exactly — `--white` fill, 1px `--border-1` hairline, `rounded-xl`,
|
||||
* `shadow-sm` — while the older records rest directly on the tile,
|
||||
* quietening with age until a mask gradient dissolves the oldest —
|
||||
* implying the trail continues into history. A small "Audit log"
|
||||
* header with an `Append-only` mono ChipTag (fill stepped up to
|
||||
* `--surface-6` so the pill stays legible on the grey ground) names the
|
||||
* surface without window chrome.
|
||||
*
|
||||
* Motion (from `audit-trail-graphic.module.css`): the newest record stamps
|
||||
* in once from above — an append, never re-played, since the record is
|
||||
* immutable — and its avatar then carries the row's shared 6s ring-pulse
|
||||
* beat (matching the access tile's grant node and the lifecycle tile's
|
||||
* live node). Both are removed under `prefers-reduced-motion`.
|
||||
*
|
||||
* The avatar assets are grey radial gradients on a black square, so each
|
||||
* sits in a `rounded-full overflow-hidden` clip with a slight scale-up to
|
||||
* crop the black canvas past the circle's edge.
|
||||
*
|
||||
* The feature tile's visual slot bleeds `2rem` right (`1.5rem` under
|
||||
* `max-lg`) but not left, so this centered vignette adds matching right
|
||||
* padding to land on the tile's visible center instead of the bled slot's
|
||||
* center.
|
||||
*/
|
||||
export function AuditTrailGraphic() {
|
||||
return (
|
||||
<FeatureGraphicShell>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute inset-0 flex items-center justify-center pr-8 max-lg:pr-6'
|
||||
>
|
||||
<div className='w-full max-w-[312px]'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<span className='font-medium text-[var(--text-primary)] text-base'>Audit log</span>
|
||||
<ChipTag variant='mono' className='bg-[var(--surface-6)]'>
|
||||
Append-only
|
||||
</ChipTag>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-1.5 [mask-image:linear-gradient(to_bottom,black_55%,transparent_100%)]'>
|
||||
{ENTRIES.map((entry, index) => {
|
||||
const tone = ROW_TONES[index]
|
||||
const newest = index === 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.action}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5',
|
||||
newest &&
|
||||
cn(
|
||||
'rounded-xl border border-[var(--border-1)] bg-[var(--white)] shadow-sm',
|
||||
styles.stampIn
|
||||
)
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'relative size-7 shrink-0 overflow-hidden rounded-full shadow-sm',
|
||||
tone.avatar,
|
||||
newest && styles.sealPulse
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={entry.avatar}
|
||||
alt=''
|
||||
width={28}
|
||||
height={28}
|
||||
className='size-full scale-110 object-cover'
|
||||
/>
|
||||
</span>
|
||||
<span className='min-w-0 flex-1'>
|
||||
<span className={cn('block truncate font-medium text-small', tone.action)}>
|
||||
{entry.action}
|
||||
</span>
|
||||
<span className='block truncate text-[var(--text-muted)] text-caption'>
|
||||
{entry.actor} · {entry.resource}
|
||||
</span>
|
||||
</span>
|
||||
<span className='shrink-0 self-start pt-px text-[var(--text-muted)] text-caption'>
|
||||
{entry.time}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureGraphicShell>
|
||||
)
|
||||
}
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { ArrowUp, FolderCode, Mic, Paperclip, Plus, Slash } from '@sim/emcn/icons'
|
||||
import { ThinkingLoader } from '@/components/ui'
|
||||
import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell'
|
||||
import { FeaturePlatformPanel } from '@/app/(landing)/enterprise/components/feature-graphics/feature-platform-panel'
|
||||
|
||||
interface CodeSegment {
|
||||
text: string
|
||||
tone?: 'muted' | 'primary'
|
||||
}
|
||||
|
||||
/** The `support-agent.ts` contents, split into tone-colored typewriter segments. */
|
||||
const CODE_LINES: CodeSegment[][] = [
|
||||
[
|
||||
{ text: 'import', tone: 'muted' },
|
||||
{ text: ' ' },
|
||||
{ text: '{ agent }', tone: 'primary' },
|
||||
{ text: ' ' },
|
||||
{ text: 'from', tone: 'muted' },
|
||||
{ text: ' ' },
|
||||
{ text: "'@sim/sdk'", tone: 'primary' },
|
||||
],
|
||||
[
|
||||
{ text: 'const', tone: 'muted' },
|
||||
{ text: ' ' },
|
||||
{ text: 'supportAgent', tone: 'primary' },
|
||||
{ text: ' ' },
|
||||
{ text: '= await', tone: 'muted' },
|
||||
{ text: ' ' },
|
||||
{ text: 'agent', tone: 'primary' },
|
||||
],
|
||||
[{ text: ' .workflow({' }],
|
||||
[
|
||||
{ text: ' ' },
|
||||
{ text: 'name:', tone: 'muted' },
|
||||
{ text: ' ' },
|
||||
{ text: "'Support agent'", tone: 'primary' },
|
||||
{ text: ',' },
|
||||
],
|
||||
[{ text: ' ' }, { text: 'instructions:', tone: 'muted' }],
|
||||
[{ text: ' ' }, { text: "'Answer customer questions'", tone: 'primary' }, { text: ',' }],
|
||||
[
|
||||
{ text: ' ' },
|
||||
{ text: 'tools:', tone: 'muted' },
|
||||
{ text: ' ' },
|
||||
{ text: '[zendesk, slack]', tone: 'primary' },
|
||||
{ text: ',' },
|
||||
],
|
||||
[{ text: ' })' }],
|
||||
]
|
||||
|
||||
const CODE_LINE_LENGTHS = CODE_LINES.map((line) =>
|
||||
line.reduce((total, segment) => total + segment.text.length, 0)
|
||||
)
|
||||
const CODE_LINE_STARTS = CODE_LINE_LENGTHS.map((_, index) =>
|
||||
CODE_LINE_LENGTHS.slice(0, index).reduce((total, length) => total + length, 0)
|
||||
)
|
||||
const CODE_TOTAL_CHARS = CODE_LINE_LENGTHS.reduce((total, length) => total + length, 0)
|
||||
|
||||
const PROMPT = 'Create a support agent that answers customer questions'
|
||||
const REPLY =
|
||||
'On it — scaffolding a support agent with Zendesk and Slack that answers customer questions.'
|
||||
const REPLY_WORDS = REPLY.split(' ')
|
||||
|
||||
const CODE_START_MS = 500
|
||||
const CODE_CHAR_MS = 24
|
||||
const COMPOSER_IN_MS = 5300
|
||||
const PROMPT_START_MS = 6000
|
||||
const PROMPT_CHAR_MS = 55
|
||||
const SEND_MS = 9600
|
||||
const CHAT_ENTER_MS = 10100
|
||||
const REPLY_MS = 11600
|
||||
const REPLY_WORD_MS = 80
|
||||
const FADE_MS = 15100
|
||||
const LOOP_MS = 15700
|
||||
|
||||
type BuildMethodsPhase =
|
||||
| 'idle'
|
||||
| 'code'
|
||||
| 'composer'
|
||||
| 'prompt'
|
||||
| 'exit'
|
||||
| 'chat'
|
||||
| 'reply'
|
||||
| 'fade'
|
||||
|
||||
const SEGMENT_TONE_CLASS = {
|
||||
muted: 'text-[var(--text-muted)]',
|
||||
primary: 'text-[var(--text-primary)]',
|
||||
} as const
|
||||
|
||||
/** Renders one code line clipped to the number of characters typed so far. */
|
||||
function renderCodeLine(segments: CodeSegment[], visibleChars: number) {
|
||||
const rendered = []
|
||||
let remaining = visibleChars
|
||||
for (let index = 0; index < segments.length && remaining > 0; index++) {
|
||||
const segment = segments[index]
|
||||
rendered.push(
|
||||
<span key={index} className={segment.tone && SEGMENT_TONE_CLASS[segment.tone]}>
|
||||
{segment.text.slice(0, remaining)}
|
||||
</span>
|
||||
)
|
||||
remaining -= segment.text.length
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorative loop for the "Build visually or with code" tile: the
|
||||
* `support-agent.ts` editor types itself out, the platform composer slides up
|
||||
* and receives a typed prompt, and the send beat swaps the editor for a
|
||||
* headerless Mothership chat surface — the same agent built from code or from
|
||||
* a message. All window bodies sit on `--white`, matching the hero surfaces.
|
||||
*
|
||||
* Window handoffs are exit-before-enter orchestration, never crossfades: the
|
||||
* editor fully exits (slides down and fades over 380ms) during the `exit`
|
||||
* phase, the stage holds empty for a beat, and only then does the chat enter
|
||||
* (slide-up fade, 420ms). The composer is a pure transform slide from below
|
||||
* the shell's clipped edge — its opacity never animates, so it never reads as
|
||||
* a translucent layer over the editor. Panel transitions are disabled on the
|
||||
* `idle` reset so state snaps back while the scene-level fade has the tile at
|
||||
* zero opacity. Local timers + CSS transitions only, mirroring the other
|
||||
* landing loops; under `prefers-reduced-motion` it renders the finished
|
||||
* editor + composer as a static frame.
|
||||
*/
|
||||
export function BuildMethodsGraphic() {
|
||||
const [phase, setPhase] = useState<BuildMethodsPhase>('idle')
|
||||
const [typedCodeChars, setTypedCodeChars] = useState(0)
|
||||
const [typedPromptChars, setTypedPromptChars] = useState(0)
|
||||
const [revealedReplyWords, setRevealedReplyWords] = useState(0)
|
||||
const [reducedMotion, setReducedMotion] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
const timeouts: ReturnType<typeof setTimeout>[] = []
|
||||
const intervals: ReturnType<typeof setInterval>[] = []
|
||||
|
||||
const clearScheduled = () => {
|
||||
for (const timeout of timeouts) clearTimeout(timeout)
|
||||
for (const interval of intervals) clearInterval(interval)
|
||||
timeouts.length = 0
|
||||
intervals.length = 0
|
||||
}
|
||||
|
||||
const showFinished = () => {
|
||||
setReducedMotion(true)
|
||||
setPhase('composer')
|
||||
setTypedCodeChars(CODE_TOTAL_CHARS)
|
||||
setTypedPromptChars(0)
|
||||
setRevealedReplyWords(0)
|
||||
}
|
||||
|
||||
const startCodeTyping = () => {
|
||||
setPhase('code')
|
||||
const startedAt = performance.now()
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = performance.now() - startedAt
|
||||
const next = Math.min(Math.floor(elapsed / CODE_CHAR_MS) + 1, CODE_TOTAL_CHARS)
|
||||
setTypedCodeChars(next)
|
||||
if (next >= CODE_TOTAL_CHARS) clearInterval(interval)
|
||||
}, CODE_CHAR_MS)
|
||||
intervals.push(interval)
|
||||
}
|
||||
|
||||
const startPromptTyping = () => {
|
||||
setPhase('prompt')
|
||||
const startedAt = performance.now()
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = performance.now() - startedAt
|
||||
const next = Math.min(Math.floor(elapsed / PROMPT_CHAR_MS) + 1, PROMPT.length)
|
||||
setTypedPromptChars(next)
|
||||
if (next >= PROMPT.length) clearInterval(interval)
|
||||
}, PROMPT_CHAR_MS)
|
||||
intervals.push(interval)
|
||||
}
|
||||
|
||||
const startReply = () => {
|
||||
setPhase('reply')
|
||||
const startedAt = performance.now()
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = performance.now() - startedAt
|
||||
const next = Math.min(Math.floor(elapsed / REPLY_WORD_MS) + 1, REPLY_WORDS.length)
|
||||
setRevealedReplyWords(next)
|
||||
if (next >= REPLY_WORDS.length) clearInterval(interval)
|
||||
}, REPLY_WORD_MS)
|
||||
intervals.push(interval)
|
||||
}
|
||||
|
||||
const runLoop = () => {
|
||||
clearScheduled()
|
||||
setReducedMotion(false)
|
||||
setPhase('idle')
|
||||
setTypedCodeChars(0)
|
||||
setTypedPromptChars(0)
|
||||
setRevealedReplyWords(0)
|
||||
|
||||
timeouts.push(setTimeout(startCodeTyping, CODE_START_MS))
|
||||
timeouts.push(setTimeout(() => setPhase('composer'), COMPOSER_IN_MS))
|
||||
timeouts.push(setTimeout(startPromptTyping, PROMPT_START_MS))
|
||||
timeouts.push(setTimeout(() => setPhase('exit'), SEND_MS))
|
||||
timeouts.push(setTimeout(() => setPhase('chat'), CHAT_ENTER_MS))
|
||||
timeouts.push(setTimeout(startReply, REPLY_MS))
|
||||
timeouts.push(setTimeout(() => setPhase('fade'), FADE_MS))
|
||||
timeouts.push(setTimeout(runLoop, LOOP_MS))
|
||||
}
|
||||
|
||||
const syncMotionPreference = () => {
|
||||
clearScheduled()
|
||||
if (media.matches) {
|
||||
showFinished()
|
||||
return
|
||||
}
|
||||
runLoop()
|
||||
}
|
||||
|
||||
syncMotionPreference()
|
||||
media.addEventListener('change', syncMotionPreference)
|
||||
return () => {
|
||||
media.removeEventListener('change', syncMotionPreference)
|
||||
clearScheduled()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const codeTypingActive = phase === 'code' && typedCodeChars < CODE_TOTAL_CHARS
|
||||
const composerVisible = reducedMotion || !['idle', 'code'].includes(phase)
|
||||
const editorExited = !reducedMotion && ['exit', 'chat', 'reply', 'fade'].includes(phase)
|
||||
const chatOpen = !reducedMotion && ['chat', 'reply', 'fade'].includes(phase)
|
||||
const promptText = phase === 'prompt' || phase === 'exit' ? PROMPT.slice(0, typedPromptChars) : ''
|
||||
const replyText = REPLY_WORDS.slice(0, revealedReplyWords).join(' ')
|
||||
const lastStartedLine = CODE_LINE_STARTS.reduce(
|
||||
(last, start, index) => (typedCodeChars > start ? index : last),
|
||||
-1
|
||||
)
|
||||
|
||||
return (
|
||||
<FeatureGraphicShell>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 transition-opacity duration-300 ease-out motion-reduce:transition-none',
|
||||
phase === 'fade' ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<FeaturePlatformPanel
|
||||
className={cn(
|
||||
'top-5 bg-[var(--white)] transition-[transform,opacity] duration-[380ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
|
||||
phase === 'idle' && 'transition-none',
|
||||
editorExited ? 'translate-y-16 opacity-0' : 'translate-y-0 opacity-100'
|
||||
)}
|
||||
icon={FolderCode}
|
||||
title='support-agent.ts'
|
||||
>
|
||||
<div className='min-h-[190px] space-y-2 p-4 font-mono text-caption leading-[1.7]'>
|
||||
{CODE_LINES.map((line, index) =>
|
||||
typedCodeChars > CODE_LINE_STARTS[index] ? (
|
||||
<div key={index} className='flex gap-3'>
|
||||
<span className='w-3 select-none text-right text-[var(--text-muted)]'>
|
||||
{index + 1}
|
||||
</span>
|
||||
<code>
|
||||
{renderCodeLine(line, typedCodeChars - CODE_LINE_STARTS[index])}
|
||||
{codeTypingActive && index === lastStartedLine && (
|
||||
<span className='ml-px inline-block h-[1.1em] w-px translate-y-[2px] animate-pulse bg-[var(--text-primary)] align-text-bottom' />
|
||||
)}
|
||||
</code>
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
</FeaturePlatformPanel>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={cn(
|
||||
'absolute top-5 right-0 bottom-0 left-0 overflow-hidden rounded-tl-xl border-[var(--border-1)] border-t border-l bg-[var(--white)] shadow-sm transition-[transform,opacity] duration-[420ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
|
||||
phase === 'idle' && 'transition-none',
|
||||
chatOpen ? 'translate-y-0 opacity-100' : 'translate-y-6 opacity-0'
|
||||
)}
|
||||
>
|
||||
<div className='flex flex-col gap-3 p-4'>
|
||||
<div className='max-w-[80%] self-end rounded-lg bg-[var(--surface-3)] px-3 py-2 text-[var(--text-primary)] text-caption leading-[1.5]'>
|
||||
{PROMPT}
|
||||
</div>
|
||||
{phase === 'chat' && <ThinkingLoader size={18} phase labelRatio={0.6} />}
|
||||
<p
|
||||
className={cn(
|
||||
'text-[var(--text-primary)] text-caption leading-[1.6] transition-opacity duration-200 ease-out',
|
||||
phase === 'reply' || phase === 'fade' ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
{replyText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-3 bottom-5 left-5 rounded-xl border border-[var(--border-1)] bg-[var(--white)] px-3 py-2.5 shadow-sm transition-transform duration-[450ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
|
||||
phase === 'idle' && 'transition-none',
|
||||
composerVisible ? 'translate-y-0' : 'translate-y-[130%]'
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'px-1 text-caption',
|
||||
promptText ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'
|
||||
)}
|
||||
>
|
||||
{promptText || 'Send message to Sim'}
|
||||
{phase === 'prompt' && typedPromptChars < PROMPT.length && (
|
||||
<span className='ml-px inline-block h-[1.1em] w-px translate-y-[2px] animate-pulse bg-[var(--text-primary)] align-text-bottom' />
|
||||
)}
|
||||
</p>
|
||||
<div className='mt-2 flex items-center gap-1'>
|
||||
<span className='flex size-6 items-center justify-center'>
|
||||
<Plus className='size-[14px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
<span className='flex size-6 items-center justify-center'>
|
||||
<Paperclip className='size-[14px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
<span className='flex size-6 items-center justify-center'>
|
||||
<Slash className='size-[14px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
<span className='ml-auto flex size-6 items-center justify-center'>
|
||||
<Mic className='size-[14px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
<span className='flex size-7 items-center justify-center rounded-full bg-[var(--text-primary)]'>
|
||||
<ArrowUp className='size-[14px] text-[var(--text-inverse)]' />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureGraphicShell>
|
||||
)
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Shared 4.5s animation timeline for the DeployGraphic vignette: the connector
|
||||
* line sweep and the browser outline trace. Each connector line is a 1px
|
||||
* faint grey track (styled in the TSX) clipping an absolutely-positioned
|
||||
* white sweep that travels top to bottom; the lower line's sweep is delayed
|
||||
* so the two read as one continuous flow. The moment the lower sweep's
|
||||
* leading edge lands on the browser's top edge the outline trace takes over:
|
||||
* four rounded-rect border overlay copies (a top pair and a side pair,
|
||||
* mirrored left/right) are revealed by animated clip-path inset windows, so
|
||||
* the white line splits at top-center, draws outward along the top border,
|
||||
* bends around the exact rounded-corner geometry, and continues down the
|
||||
* sides while the tail releases from center and follows the head — a
|
||||
* traveling pulse that runs out and off the outline. Under
|
||||
* prefers-reduced-motion everything is static: plain faint lines, no sweep,
|
||||
* no trace.
|
||||
*
|
||||
* Timeline (percent of 4.5s): each sweep's full pass spans 22% (~1s); the
|
||||
* lower sweep is delayed 0.9s, so its leading edge reaches the line bottom at
|
||||
* 0.9s + 0.5s = 1.395s = 31%, where the trace begins with no gap. Trace head
|
||||
* runs center → corner over 31→43%, head descends the sides while the tail
|
||||
* crosses the top over 43→55%, and the tail descends and exits the open
|
||||
* bottom by 67%; the loop rests until 100%. The top windows keep a 14px-tall
|
||||
* strip (12px radius + 1px border on each box edge) so the corner arc
|
||||
* belongs to the top pair; the side pair starts 13px down, exactly where the
|
||||
* arc ends.
|
||||
*/
|
||||
|
||||
.sweep {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--text-inverse);
|
||||
transform: translateY(-101%);
|
||||
animation: deploy-line-sweep 4.5s linear infinite;
|
||||
}
|
||||
|
||||
.sweepLower {
|
||||
animation-delay: 0.9s;
|
||||
}
|
||||
|
||||
@keyframes deploy-line-sweep {
|
||||
0% {
|
||||
transform: translateY(-101%);
|
||||
}
|
||||
22% {
|
||||
transform: translateY(101%);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(101%);
|
||||
}
|
||||
}
|
||||
|
||||
.traceTopLeft {
|
||||
clip-path: inset(0 50% calc(100% - 14px) 50%);
|
||||
animation: deploy-trace-top-left 4.5s linear infinite;
|
||||
}
|
||||
|
||||
.traceTopRight {
|
||||
clip-path: inset(0 50% calc(100% - 14px) 50%);
|
||||
animation: deploy-trace-top-right 4.5s linear infinite;
|
||||
}
|
||||
|
||||
.traceSideLeft {
|
||||
clip-path: inset(13px calc(100% - 14px) calc(100% - 13px) 0);
|
||||
animation: deploy-trace-side-left 4.5s linear infinite;
|
||||
}
|
||||
|
||||
.traceSideRight {
|
||||
clip-path: inset(13px 0 calc(100% - 13px) calc(100% - 14px));
|
||||
animation: deploy-trace-side-right 4.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes deploy-trace-top-left {
|
||||
0%,
|
||||
31% {
|
||||
clip-path: inset(0 50% calc(100% - 14px) 50%);
|
||||
}
|
||||
43% {
|
||||
clip-path: inset(0 50% calc(100% - 14px) 0);
|
||||
}
|
||||
55%,
|
||||
100% {
|
||||
clip-path: inset(0 100% calc(100% - 14px) 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes deploy-trace-top-right {
|
||||
0%,
|
||||
31% {
|
||||
clip-path: inset(0 50% calc(100% - 14px) 50%);
|
||||
}
|
||||
43% {
|
||||
clip-path: inset(0 0 calc(100% - 14px) 50%);
|
||||
}
|
||||
55%,
|
||||
100% {
|
||||
clip-path: inset(0 0 calc(100% - 14px) 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes deploy-trace-side-left {
|
||||
0%,
|
||||
43% {
|
||||
clip-path: inset(13px calc(100% - 14px) calc(100% - 13px) 0);
|
||||
}
|
||||
55% {
|
||||
clip-path: inset(13px calc(100% - 14px) 0 0);
|
||||
}
|
||||
67%,
|
||||
100% {
|
||||
clip-path: inset(100% calc(100% - 14px) 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes deploy-trace-side-right {
|
||||
0%,
|
||||
43% {
|
||||
clip-path: inset(13px 0 calc(100% - 13px) calc(100% - 14px));
|
||||
}
|
||||
55% {
|
||||
clip-path: inset(13px 0 0 calc(100% - 14px));
|
||||
}
|
||||
67%,
|
||||
100% {
|
||||
clip-path: inset(100% 0 0 calc(100% - 14px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sweep {
|
||||
display: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.traceTopLeft,
|
||||
.traceTopRight,
|
||||
.traceSideLeft,
|
||||
.traceSideRight {
|
||||
display: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import { ChipTag, chipContentLabelClass, chipGeometryClass, cn } from '@sim/emcn'
|
||||
import { CircleCheck, Lock } from '@sim/emcn/icons'
|
||||
import { ThinkingLoader } from '@/components/ui'
|
||||
import styles from '@/app/(landing)/enterprise/components/feature-graphics/deploy-graphic.module.css'
|
||||
import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell'
|
||||
|
||||
/**
|
||||
* The ThinkingLoader's light-grey material (its dark-surface theme from
|
||||
* `thinking-loader.module.css`), asserted inline because the always-light
|
||||
* landing would otherwise ink the loader dark — invisible on the dark
|
||||
* Deploy button.
|
||||
*/
|
||||
const DEPLOY_LOADER_INK = {
|
||||
'--tl-grad-inner': '#a7a7a7',
|
||||
'--tl-grad-outer': '#d6d6d6',
|
||||
'--tl-glow': 'rgba(255, 255, 255, 0.9)',
|
||||
} as CSSProperties
|
||||
|
||||
/**
|
||||
* The moment of a one-click deploy, told top to bottom: the agent being
|
||||
* shipped, the Deploy button, and a
|
||||
* minimal dark browser window rising from the bottom edge with the hosted
|
||||
* endpoint's URL in its address bar — the deployed agent is literally a live
|
||||
* web address, no infrastructure in between. The window is an outlined shell
|
||||
* (grey hairline, tile shows through) whose address bar carries the Deploy
|
||||
* button's `--text-muted` fill, so click and outcome read as one system. A
|
||||
* single causal vignette instead of a workflow canvas, so the click itself is
|
||||
* the subject. Faint `--text-muted-inverse` hairlines (mixed toward the dark
|
||||
* tile background so they read as guides) link agent → button → browser, and
|
||||
* a white progress sweep (from `deploy-graphic.module.css`) loops down the
|
||||
* two hairlines in sequence on a shared 4.5s timeline — pill → button, then
|
||||
* button → browser. The instant the sweep's leading edge lands on the
|
||||
* browser's top edge, the outline trace takes over with no gap: the white
|
||||
* line splits at the top-center connection point and draws outward both
|
||||
* ways along the top border, bends around the rounded corners, and
|
||||
* continues down the sides while its tail releases from center and follows
|
||||
* the head — a traveling pulse that runs out and off the outline rather
|
||||
* than painting it permanently. It is implemented as four rounded-rect
|
||||
* border overlay copies (top pair + side pair, mirrored) revealed by
|
||||
* animated `clip-path` inset windows, so the trace follows the exact
|
||||
* rounded-corner geometry at any width. Under `prefers-reduced-motion`
|
||||
* everything is static — plain faint lines, no sweep or trace.
|
||||
*
|
||||
* The Deploy button's icon is the gooey {@link ThinkingLoader} pinned to
|
||||
* `relay` — the Return shape (work coming back) — looping its ball pass,
|
||||
* inked light so it reads on the dark button.
|
||||
*
|
||||
* The agent header follows the nested-corner radius rule: the `v3` ChipTag is
|
||||
* `rounded-md` (6px) and sits 6px (`py-1.5` / `pr-1.5`) from the container
|
||||
* edge, so the container radius is 6px + 6px = 12px.
|
||||
*
|
||||
* The feature tile's visual slot bleeds `2rem` right (`1.5rem` under `max-lg`)
|
||||
* but not left, so this centered vignette adds matching right padding to land
|
||||
* on the tile's visible center instead of the bled slot's center.
|
||||
*
|
||||
* The browser window is pinned to `h-24` (96px) so its top edge lands on the
|
||||
* same line as the neighboring build-methods tile's composer (76px tall +
|
||||
* `bottom-5`), keeping the tile row horizontally aligned; both connector
|
||||
* lines are `flex-1` with mirrored margins, so the Deploy button stays
|
||||
* equidistant between the agent pill and the browser at any tile height.
|
||||
*/
|
||||
export function DeployGraphic() {
|
||||
return (
|
||||
<FeatureGraphicShell>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute inset-0 flex flex-col items-center pr-8 max-lg:pr-6'
|
||||
>
|
||||
<div className='mt-1 flex items-center gap-1.5 rounded-[12px] bg-[var(--surface-2)] py-1.5 pr-1.5 pl-2.5 shadow-sm'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>
|
||||
Support agent
|
||||
</span>
|
||||
<ChipTag variant='mono'>v3</ChipTag>
|
||||
</div>
|
||||
|
||||
<span className='relative mt-1.5 min-h-3 w-px flex-1 overflow-hidden bg-[color:color-mix(in_srgb,var(--text-muted-inverse)_45%,transparent)]'>
|
||||
<span className={styles.sweep} />
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
chipGeometryClass,
|
||||
'mx-0 mt-2.5 inline-flex h-9 rounded-[10px] bg-[var(--text-muted)] px-3 text-[var(--text-inverse)]'
|
||||
)}
|
||||
>
|
||||
<ThinkingLoader variant='relay' size={18} style={DEPLOY_LOADER_INK} />
|
||||
<span className={cn(chipContentLabelClass, 'text-[15px] text-current')}>Deploy</span>
|
||||
</span>
|
||||
|
||||
<span className='relative mt-2.5 mb-1.5 min-h-3 w-px flex-1 overflow-hidden bg-[color:color-mix(in_srgb,var(--text-muted-inverse)_45%,transparent)]'>
|
||||
<span className={cn(styles.sweep, styles.sweepLower)} />
|
||||
</span>
|
||||
|
||||
<div className='relative h-24 w-full rounded-t-xl border border-[color:color-mix(in_srgb,var(--text-muted-inverse)_45%,transparent)] border-b-0'>
|
||||
<span
|
||||
className={cn(
|
||||
'-inset-px absolute rounded-t-xl border border-[var(--text-inverse)] border-b-0',
|
||||
styles.traceTopLeft
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'-inset-px absolute rounded-t-xl border border-[var(--text-inverse)] border-b-0',
|
||||
styles.traceTopRight
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'-inset-px absolute rounded-t-xl border border-[var(--text-inverse)] border-b-0',
|
||||
styles.traceSideLeft
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'-inset-px absolute rounded-t-xl border border-[var(--text-inverse)] border-b-0',
|
||||
styles.traceSideRight
|
||||
)}
|
||||
/>
|
||||
<div className='flex items-center gap-2 px-2.5 pt-2.5 pb-1'>
|
||||
<span className='flex shrink-0 gap-1'>
|
||||
<span className='size-2 rounded-full bg-[var(--text-muted-inverse)]' />
|
||||
<span className='size-2 rounded-full bg-[var(--text-muted-inverse)]' />
|
||||
<span className='size-2 rounded-full bg-[var(--text-muted-inverse)]' />
|
||||
</span>
|
||||
<span className='flex min-w-0 flex-1 items-center gap-1.5 rounded-md bg-[var(--text-muted)] px-2 py-1'>
|
||||
<Lock className='size-[10px] shrink-0 text-[var(--text-muted-inverse)]' />
|
||||
<span className='truncate text-[var(--text-inverse)] text-caption'>
|
||||
sim.ai/agents/support
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 px-3 pt-2.5 pb-4'>
|
||||
<CircleCheck className='size-[14px] shrink-0 text-[var(--text-muted-inverse)]' />
|
||||
<span className='min-w-0 flex-1 font-medium text-[var(--text-inverse)] text-small'>
|
||||
Live in production
|
||||
</span>
|
||||
<span className='shrink-0 text-[var(--text-muted-inverse)] text-caption'>Just now</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureGraphicShell>
|
||||
)
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface FeatureGraphicShellProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** Shared crop canvas for platform-faithful enterprise feature previews. */
|
||||
export function FeatureGraphicShell({ children }: FeatureGraphicShellProps) {
|
||||
return (
|
||||
<div className='relative mx-auto h-full min-h-[260px] w-full max-w-[420px] overflow-hidden'>
|
||||
<div className='relative h-full min-h-[260px]'>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import type { ComponentType, ReactNode, SVGProps } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
|
||||
interface FeaturePlatformPanelProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A cropped workspace surface using the same lightweight header treatment as
|
||||
* Sim's product views. Individual graphics provide real platform controls as
|
||||
* the content rather than drawing a separate illustration language. The
|
||||
* window anchors to the slot's left edge (`left-0`) so it left-aligns with
|
||||
* the tile's title/description text column, bleeding off the right edge.
|
||||
*/
|
||||
export function FeaturePlatformPanel({
|
||||
children,
|
||||
className,
|
||||
icon: Icon,
|
||||
title,
|
||||
}: FeaturePlatformPanelProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={cn(
|
||||
'absolute right-0 bottom-0 left-0 overflow-hidden rounded-tl-xl border-[var(--border-1)] border-t border-l bg-[var(--surface-2)] shadow-sm',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='flex h-12 items-center gap-2 border-[var(--border)] border-b px-4'>
|
||||
<span className='flex size-6 items-center justify-center rounded-md bg-[var(--surface-5)]'>
|
||||
<Icon className='size-[14px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
<span className='font-medium text-[var(--text-primary)] text-base'>{title}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export { AccessControlGraphic } from './access-control-graphic'
|
||||
export { AuditTrailGraphic } from './audit-trail-graphic'
|
||||
export { BuildMethodsGraphic } from './build-methods-graphic'
|
||||
export { DeployGraphic } from './deploy-graphic'
|
||||
export { FeatureGraphicShell } from './feature-graphic-shell'
|
||||
export { FeaturePlatformPanel } from './feature-platform-panel'
|
||||
export { ItPlatformTeamsGraphic } from './it-platform-teams-graphic'
|
||||
export { LifecycleGraphic } from './lifecycle-graphic'
|
||||
export { OperationsTeamsGraphic } from './operations-teams-graphic'
|
||||
export { RollbackGraphic } from './rollback-graphic'
|
||||
export { RunMonitoringGraphic } from './run-monitoring-graphic'
|
||||
export { StagingGraphic } from './staging-graphic'
|
||||
export { StandardsGraphic } from './standards-graphic'
|
||||
export { TechnicalTeamsGraphic } from './technical-teams-graphic'
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* The ItPlatformTeamsGraphic's only motion: a soft ring pulse blooming
|
||||
* from the Active policy tag — the family's shared quiet 6s
|
||||
* state-confirmation beat (the staging tile's Promote button, the
|
||||
* technical tile's Approved tag). The tag itself is borderless
|
||||
* (`shadow-none` strips the gray variant's inset ring, matching the
|
||||
* Approved tag), so the keyframes bloom from a bare pill. Under
|
||||
* prefers-reduced-motion the pulse is removed and the tag sits static.
|
||||
*/
|
||||
|
||||
.activePulse {
|
||||
animation: it-platform-active-pulse 6s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes it-platform-active-pulse {
|
||||
0%,
|
||||
20% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 30%, transparent);
|
||||
}
|
||||
36% {
|
||||
box-shadow: 0 0 0 5px color-mix(in srgb, var(--text-secondary) 0%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 0%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.activePulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { ChipTag, cn } from '@sim/emcn'
|
||||
import { CircleCheck } from '@sim/emcn/icons'
|
||||
import Image from 'next/image'
|
||||
import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell'
|
||||
import styles from '@/app/(landing)/enterprise/components/feature-graphics/it-platform-teams-graphic.module.css'
|
||||
|
||||
interface PolicyControl {
|
||||
/** Enforced control name, one per pillar of the tile's claim. */
|
||||
label: string
|
||||
/** Right-aligned enforcement detail. */
|
||||
detail: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The three enforced controls mirror the tile's claim word for word —
|
||||
* access controls (SSO), governance (reviewer approval), and audit
|
||||
* trails (retention).
|
||||
*/
|
||||
const CONTROLS: readonly PolicyControl[] = [
|
||||
{ label: 'Single sign-on', detail: 'Enforced' },
|
||||
{ label: 'Reviewer approval', detail: 'Required' },
|
||||
{ label: 'Audit history', detail: '90 days' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* IT governance told as a frameless policy vignette (the audit and
|
||||
* monitoring tiles' composition): no window chrome — a small "Workspace
|
||||
* policies" header sits directly on the tile ground, attributed on the
|
||||
* right to the managing team: a 16px gradient avatar (the access and
|
||||
* audit tiles' team-avatar treatment) beside a `Managed by IT` mono
|
||||
* ChipTag, its fill stepped up to `--surface-6` so the pill stays
|
||||
* legible on the grey ground (the staging and rollback tiles'
|
||||
* environment-pill treatment). Below it, the tile's highlight: the active Production
|
||||
* policy lifted onto a white card in the audit tile's exact chrome
|
||||
* (`--white` fill, 1px `--border-1` hairline, `rounded-xl`, `shadow-sm`)
|
||||
* pairing the policy name and its scope line with an `Active` tag that
|
||||
* carries the tile's one motion, the family's shared quiet 6s ring pulse
|
||||
* (from `it-platform-teams-graphic.module.css`, removed under
|
||||
* `prefers-reduced-motion`). Under the card, the policy's enforced
|
||||
* controls read as quiet hairline-ruled rows — a passing circle-check,
|
||||
* the control name, and a right-aligned enforcement detail — one row per
|
||||
* pillar of the tile's claim: access controls, governance, audit trails.
|
||||
*
|
||||
* The avatar asset is a grey radial gradient on a black square, so it
|
||||
* sits in a `rounded-full overflow-hidden` clip with a slight scale-up
|
||||
* to crop the black canvas past the circle's edge.
|
||||
*
|
||||
* The feature tile's visual slot bleeds `2rem` right (`1.5rem` under
|
||||
* `max-lg`) but not left, so this centered vignette adds matching right
|
||||
* padding to land on the tile's visible center instead of the bled
|
||||
* slot's center. The column is fluid (`w-full max-w-[312px]`) so it
|
||||
* never exceeds the compensated slot at narrow tile widths — the policy
|
||||
* name truncates instead of clipping.
|
||||
*/
|
||||
export function ItPlatformTeamsGraphic() {
|
||||
return (
|
||||
<FeatureGraphicShell>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute inset-0 flex items-center justify-center pr-8 max-lg:pr-6'
|
||||
>
|
||||
<div className='w-full max-w-[312px]'>
|
||||
<div className='mb-3 flex items-center justify-between gap-2'>
|
||||
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-base'>
|
||||
Workspace
|
||||
</span>
|
||||
<span className='flex shrink-0 items-center gap-1.5'>
|
||||
<span className='relative size-4 overflow-hidden rounded-full shadow-sm'>
|
||||
<Image
|
||||
src='/landing/team-avatar-3.jpg'
|
||||
alt=''
|
||||
width={16}
|
||||
height={16}
|
||||
className='size-full scale-110 object-cover'
|
||||
/>
|
||||
</span>
|
||||
<ChipTag variant='mono' className='bg-[var(--surface-6)]'>
|
||||
Managed by IT
|
||||
</ChipTag>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-3 rounded-xl border border-[var(--border-1)] bg-[var(--white)] px-3 py-2.5 shadow-sm'>
|
||||
<span className='min-w-0 flex-1'>
|
||||
<span className='block truncate font-medium text-[var(--text-primary)] text-small'>
|
||||
Production policy
|
||||
</span>
|
||||
<span className='block truncate text-[var(--text-muted)] text-caption'>
|
||||
Applies to every workspace
|
||||
</span>
|
||||
</span>
|
||||
<ChipTag variant='gray' className={cn('shrink-0 shadow-none', styles.activePulse)}>
|
||||
Active
|
||||
</ChipTag>
|
||||
</div>
|
||||
|
||||
<div className='mt-1.5 px-3'>
|
||||
{CONTROLS.map((control, index) => (
|
||||
<div
|
||||
key={control.label}
|
||||
className={cn(
|
||||
'flex h-9 items-center gap-2',
|
||||
index > 0 && 'border-[var(--border-1)] border-t'
|
||||
)}
|
||||
>
|
||||
<CircleCheck className='size-[13px] shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='min-w-0 flex-1 truncate text-[var(--text-secondary)] text-caption'>
|
||||
{control.label}
|
||||
</span>
|
||||
<span className='shrink-0 text-[var(--text-muted)] text-caption'>
|
||||
{control.detail}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureGraphicShell>
|
||||
)
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* The LifecycleGraphic's only motion: a soft ring pulse blooming from the
|
||||
* live version node every 6s — quiet state confirmation rather than travel,
|
||||
* keeping this tile stiller than the deploy tile beside it. The spine's
|
||||
* connector lines are static (styled in the TSX). Under
|
||||
* prefers-reduced-motion the pulse is removed and the node sits static.
|
||||
*/
|
||||
|
||||
.livePulse {
|
||||
animation: lifecycle-live-pulse 6s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes lifecycle-live-pulse {
|
||||
0%,
|
||||
20% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 35%, transparent);
|
||||
}
|
||||
36% {
|
||||
box-shadow: 0 0 0 5px color-mix(in srgb, var(--text-secondary) 0%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 0%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.livePulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ChipTag, cn } from '@sim/emcn'
|
||||
import { Clock } from '@sim/emcn/icons'
|
||||
import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell'
|
||||
import styles from '@/app/(landing)/enterprise/components/feature-graphics/lifecycle-graphic.module.css'
|
||||
|
||||
/** Older timeline entries below the live/saved pair, quietest first to render. */
|
||||
const OLDER_VERSIONS = [
|
||||
{ detail: 'Published Jun 28', label: 'v1' },
|
||||
{ detail: 'Saved Jun 21', label: 'v0.9' },
|
||||
{ detail: 'Saved Jun 14', label: 'v0.8' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* A version-history timeline housed in an outlined window shell that mirrors
|
||||
* the build tile's editor window exactly — same slot geometry (`top-5`,
|
||||
* `left-0` so the window left-aligns with the tile's text column, bled
|
||||
* right/bottom), same `rounded-tl-xl` radius, same top + left
|
||||
* hairline `--border-1` edges — but drawn with no fill so the tile shows
|
||||
* through: the two side-by-side tiles read as the same window in the same
|
||||
* place, one filled, one outlined. An outlined title bar ("Versions" with a
|
||||
* `Clock` icon in the build header's `size-6` `rounded-md` icon box — drawn
|
||||
* as a `--border-1` hairline outline instead of the filled grey — no fill,
|
||||
* `--border-1` bottom rule at the build header's `h-12` height) crowns the
|
||||
* window. Inside, a quiet vertical spine of faint
|
||||
* static 1px connector segments (the light analog of the deploy tile's guide
|
||||
* lines) links version nodes bottom-up — older versions lower and
|
||||
* progressively quieter, v3 at the top as the live entry. The live row is a
|
||||
* white pill card following the nested-corner radius rule (6px ChipTag + 6px
|
||||
* gap = 12px container), echoing the deploy tile's agent header; older rows
|
||||
* are bare text rows that fade toward `--text-muted` (the v2 `Saved`
|
||||
* tag's fill stepped up to `--surface-6` so the pill stays legible on
|
||||
* the grey ground), and a mask gradient
|
||||
* dissolves the oldest entries so the timeline reads as continuing into
|
||||
* history past the window's bottom edge.
|
||||
*
|
||||
* The only motion is a soft ring pulse on the live node (from
|
||||
* `lifecycle-graphic.module.css`), removed under `prefers-reduced-motion`.
|
||||
*/
|
||||
export function LifecycleGraphic() {
|
||||
return (
|
||||
<FeatureGraphicShell>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-5 right-0 bottom-0 left-0 rounded-tl-xl border-[var(--border-1)] border-t border-l'
|
||||
>
|
||||
<div className='flex h-12 items-center gap-2 border-[var(--border-1)] border-b px-4'>
|
||||
<span className='flex size-6 items-center justify-center rounded-md border border-[var(--border-1)]'>
|
||||
<Clock className='size-[14px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
<span className='font-medium text-[var(--text-primary)] text-base'>Versions</span>
|
||||
</div>
|
||||
<div className='flex flex-col p-4 [mask-image:linear-gradient(to_bottom,black_45%,transparent_98%)]'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='flex w-2.5 justify-center'>
|
||||
<span
|
||||
className={cn('size-2.5 rounded-full bg-[var(--text-primary)]', styles.livePulse)}
|
||||
/>
|
||||
</span>
|
||||
<span className='flex min-w-0 flex-1 items-center gap-2'>
|
||||
<span className='flex items-center gap-1.5 rounded-[12px] bg-[var(--white)] py-1.5 pr-1.5 pl-2.5 shadow-sm'>
|
||||
<span className='font-medium text-[var(--text-primary)] text-small'>v3</span>
|
||||
<ChipTag variant='solid'>Live</ChipTag>
|
||||
</span>
|
||||
<span className='truncate text-[var(--text-muted)] text-caption'>
|
||||
Published today
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-3'>
|
||||
<span className='flex w-2.5 justify-center'>
|
||||
<span className='h-9 w-px bg-[color:color-mix(in_srgb,var(--text-muted)_35%,transparent)]' />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='flex w-2.5 justify-center'>
|
||||
<span className='size-2 rounded-full border border-[var(--text-muted)] bg-[var(--surface-3)]' />
|
||||
</span>
|
||||
<span className='flex min-w-0 flex-1 items-center gap-2'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-small'>v2</span>
|
||||
<ChipTag variant='mono' className='bg-[var(--surface-6)]'>
|
||||
Saved
|
||||
</ChipTag>
|
||||
<span className='truncate text-[var(--text-muted)] text-caption'>
|
||||
Published yesterday
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{OLDER_VERSIONS.map((version) => (
|
||||
<div key={version.label} className='flex flex-col'>
|
||||
<div className='flex gap-3'>
|
||||
<span className='flex w-2.5 justify-center'>
|
||||
<span className='h-9 w-px bg-[color:color-mix(in_srgb,var(--text-muted)_35%,transparent)]' />
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='flex w-2.5 justify-center'>
|
||||
<span className='size-2 rounded-full border border-[color:color-mix(in_srgb,var(--text-muted)_60%,transparent)] bg-[var(--surface-3)]' />
|
||||
</span>
|
||||
<span className='flex min-w-0 flex-1 items-center gap-2'>
|
||||
<span className='text-[var(--text-muted)] text-small'>{version.label}</span>
|
||||
<span className='truncate text-[var(--text-muted)] text-caption'>
|
||||
{version.detail}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FeatureGraphicShell>
|
||||
)
|
||||
}
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* One shared 16s timeline of four 4s route phases, a precomputed shuffle
|
||||
* so the routing feels varied without true randomness:
|
||||
*
|
||||
* phase 0 (0%–25%): Zendesk -> Slack
|
||||
* phase 1 (25%–50%): Sheets -> Jira
|
||||
* phase 2 (50%–75%): Gmail -> Salesforce
|
||||
* phase 3 (75%–100%): Zendesk -> Jira
|
||||
*
|
||||
* Within each 4s phase: the source tag's white filled state crossfades
|
||||
* in over its outline (0–0.5s), a white pulse falls down its wire
|
||||
* (0.4–1.2s), dwells in the router while the hub blooms (1.2–2s),
|
||||
* travels out to the destination (2–2.8s), the destination tag's fill
|
||||
* crossfades in as the route connects (2.7–3.1s), both hold (to 3.6s),
|
||||
* then fade back to outlined (to 4s).
|
||||
*
|
||||
* Tag fill overlays rest transparent (opacity-0, set inline in the TSX)
|
||||
* so under prefers-reduced-motion — where every animation here is
|
||||
* disabled — all six tags render outlined and static.
|
||||
*/
|
||||
|
||||
.pulse {
|
||||
stroke-dasharray: 0.28 1;
|
||||
stroke-dashoffset: 0.28;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.pulseInA {
|
||||
animation: ops-pulse-in-a 16s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ops-pulse-in-a {
|
||||
0%,
|
||||
2.4% {
|
||||
stroke-dashoffset: 0.28;
|
||||
opacity: 0;
|
||||
}
|
||||
2.5% {
|
||||
stroke-dashoffset: 0.28;
|
||||
opacity: 1;
|
||||
}
|
||||
7.5% {
|
||||
stroke-dashoffset: -1;
|
||||
opacity: 1;
|
||||
}
|
||||
7.6%,
|
||||
77.4% {
|
||||
stroke-dashoffset: 0.28;
|
||||
opacity: 0;
|
||||
}
|
||||
77.5% {
|
||||
stroke-dashoffset: 0.28;
|
||||
opacity: 1;
|
||||
}
|
||||
82.5% {
|
||||
stroke-dashoffset: -1;
|
||||
opacity: 1;
|
||||
}
|
||||
82.6%,
|
||||
100% {
|
||||
stroke-dashoffset: -1;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pulseInB {
|
||||
animation: ops-pulse-in-b 16s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ops-pulse-in-b {
|
||||
0%,
|
||||
27.4% {
|
||||
stroke-dashoffset: 0.28;
|
||||
opacity: 0;
|
||||
}
|
||||
27.5% {
|
||||
stroke-dashoffset: 0.28;
|
||||
opacity: 1;
|
||||
}
|
||||
32.5% {
|
||||
stroke-dashoffset: -1;
|
||||
opacity: 1;
|
||||
}
|
||||
32.6%,
|
||||
100% {
|
||||
stroke-dashoffset: -1;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pulseInC {
|
||||
animation: ops-pulse-in-c 16s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ops-pulse-in-c {
|
||||
0%,
|
||||
52.4% {
|
||||
stroke-dashoffset: 0.28;
|
||||
opacity: 0;
|
||||
}
|
||||
52.5% {
|
||||
stroke-dashoffset: 0.28;
|
||||
opacity: 1;
|
||||
}
|
||||
57.5% {
|
||||
stroke-dashoffset: -1;
|
||||
opacity: 1;
|
||||
}
|
||||
57.6%,
|
||||
100% {
|
||||
stroke-dashoffset: -1;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pulseOutA {
|
||||
animation: ops-pulse-out-a 16s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ops-pulse-out-a {
|
||||
0%,
|
||||
12.4% {
|
||||
stroke-dashoffset: 0.28;
|
||||
opacity: 0;
|
||||
}
|
||||
12.5% {
|
||||
stroke-dashoffset: 0.28;
|
||||
opacity: 1;
|
||||
}
|
||||
17.5% {
|
||||
stroke-dashoffset: -1;
|
||||
opacity: 1;
|
||||
}
|
||||
17.6%,
|
||||
100% {
|
||||
stroke-dashoffset: -1;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pulseOutB {
|
||||
animation: ops-pulse-out-b 16s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ops-pulse-out-b {
|
||||
0%,
|
||||
37.4% {
|
||||
stroke-dashoffset: 0.28;
|
||||
opacity: 0;
|
||||
}
|
||||
37.5% {
|
||||
stroke-dashoffset: 0.28;
|
||||
opacity: 1;
|
||||
}
|
||||
42.5% {
|
||||
stroke-dashoffset: -1;
|
||||
opacity: 1;
|
||||
}
|
||||
42.6%,
|
||||
87.4% {
|
||||
stroke-dashoffset: 0.28;
|
||||
opacity: 0;
|
||||
}
|
||||
87.5% {
|
||||
stroke-dashoffset: 0.28;
|
||||
opacity: 1;
|
||||
}
|
||||
92.5% {
|
||||
stroke-dashoffset: -1;
|
||||
opacity: 1;
|
||||
}
|
||||
92.6%,
|
||||
100% {
|
||||
stroke-dashoffset: -1;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pulseOutC {
|
||||
animation: ops-pulse-out-c 16s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ops-pulse-out-c {
|
||||
0%,
|
||||
62.4% {
|
||||
stroke-dashoffset: 0.28;
|
||||
opacity: 0;
|
||||
}
|
||||
62.5% {
|
||||
stroke-dashoffset: 0.28;
|
||||
opacity: 1;
|
||||
}
|
||||
67.5% {
|
||||
stroke-dashoffset: -1;
|
||||
opacity: 1;
|
||||
}
|
||||
67.6%,
|
||||
100% {
|
||||
stroke-dashoffset: -1;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fillSrcA {
|
||||
animation: ops-fill-src-a 16s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ops-fill-src-a {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
3.1% {
|
||||
opacity: 1;
|
||||
}
|
||||
22.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
24.9%,
|
||||
75% {
|
||||
opacity: 0;
|
||||
}
|
||||
78.1% {
|
||||
opacity: 1;
|
||||
}
|
||||
97.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fillSrcB {
|
||||
animation: ops-fill-src-b 16s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ops-fill-src-b {
|
||||
0%,
|
||||
25% {
|
||||
opacity: 0;
|
||||
}
|
||||
28.1% {
|
||||
opacity: 1;
|
||||
}
|
||||
47.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
49.9%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fillSrcC {
|
||||
animation: ops-fill-src-c 16s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ops-fill-src-c {
|
||||
0%,
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
53.1% {
|
||||
opacity: 1;
|
||||
}
|
||||
72.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
74.9%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fillDstA {
|
||||
animation: ops-fill-dst-a 16s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ops-fill-dst-a {
|
||||
0%,
|
||||
16.9% {
|
||||
opacity: 0;
|
||||
}
|
||||
19.4% {
|
||||
opacity: 1;
|
||||
}
|
||||
22.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
24.9%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fillDstB {
|
||||
animation: ops-fill-dst-b 16s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ops-fill-dst-b {
|
||||
0%,
|
||||
41.9% {
|
||||
opacity: 0;
|
||||
}
|
||||
44.4% {
|
||||
opacity: 1;
|
||||
}
|
||||
47.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
49.9%,
|
||||
91.9% {
|
||||
opacity: 0;
|
||||
}
|
||||
94.4% {
|
||||
opacity: 1;
|
||||
}
|
||||
97.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fillDstC {
|
||||
animation: ops-fill-dst-c 16s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ops-fill-dst-c {
|
||||
0%,
|
||||
66.9% {
|
||||
opacity: 0;
|
||||
}
|
||||
69.4% {
|
||||
opacity: 1;
|
||||
}
|
||||
72.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
74.9%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.routerBloom {
|
||||
animation: ops-router-bloom 4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ops-router-bloom {
|
||||
0%,
|
||||
30% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-muted-inverse) 45%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px color-mix(in srgb, var(--text-muted-inverse) 0%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-muted-inverse) 0%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pulseInA,
|
||||
.pulseInB,
|
||||
.pulseInC,
|
||||
.pulseOutA,
|
||||
.pulseOutB,
|
||||
.pulseOutC,
|
||||
.fillSrcA,
|
||||
.fillSrcB,
|
||||
.fillSrcC,
|
||||
.fillDstA,
|
||||
.fillDstB,
|
||||
.fillDstC,
|
||||
.routerBloom {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { ThinkingLoader } from '@/components/ui'
|
||||
import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell'
|
||||
import styles from '@/app/(landing)/enterprise/components/feature-graphics/operations-teams-graphic.module.css'
|
||||
|
||||
/**
|
||||
* Fixed pixel canvas the switchboard is drawn on, centered inside the
|
||||
* shell. Kept to 280px wide (chips reaching only x 32–250) so every chip
|
||||
* stays inside the tile's visible area even at the narrowest three-up
|
||||
* tile width (~274px visible at 1024), and 248px tall — the tallest
|
||||
* canvas that clears the slot's shortest inner box (~257px at 1024 after
|
||||
* bottom-bleed compensation) so the staggered top pills never clip while
|
||||
* the wires keep long vertical runs.
|
||||
*/
|
||||
const CANVAS = { WIDTH: 280, HEIGHT: 248 } as const
|
||||
|
||||
/**
|
||||
* The ThinkingLoader's light-grey material (its dark-surface theme from
|
||||
* `thinking-loader.module.css`), asserted inline exactly as the deploy
|
||||
* tile's Deploy button does — the always-light landing would otherwise
|
||||
* ink the loader dark, invisible on the dark tile ground showing through
|
||||
* the outlined router hub.
|
||||
*/
|
||||
const ROUTER_LOADER_INK = {
|
||||
'--tl-grad-inner': '#a7a7a7',
|
||||
'--tl-grad-outer': '#d6d6d6',
|
||||
'--tl-glow': 'rgba(255, 255, 255, 0.9)',
|
||||
} as CSSProperties
|
||||
|
||||
interface Port {
|
||||
/** Tailwind classes positioning the size-2 port dot, centered on the port's canvas x/y. */
|
||||
dotClass: string
|
||||
/** Tailwind classes placing the tag's center on the port's canvas x, above/below its dot. */
|
||||
chipClass: string
|
||||
/** Tool name rendered as an outlined tag beside the port. */
|
||||
label: string
|
||||
/** CSS-module animation class driving this tag's white connect-fill on its scheduled phases. */
|
||||
fillClass: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbound ops tools across the top, staggered like a constellation rather
|
||||
* than a rank: Zendesk rides highest on the center axis (x 140), Gmail
|
||||
* sits lowest at the left (x 56), Sheets between at the right (x 224).
|
||||
*/
|
||||
const SOURCES: readonly Port[] = [
|
||||
{
|
||||
dotClass: 'top-[48px] left-[52px]',
|
||||
chipClass: 'top-[32px] left-[56px]',
|
||||
label: 'Gmail',
|
||||
fillClass: styles.fillSrcC,
|
||||
},
|
||||
{
|
||||
dotClass: 'top-[28px] left-[136px]',
|
||||
chipClass: 'top-[12px] left-[140px]',
|
||||
label: 'Zendesk',
|
||||
fillClass: styles.fillSrcA,
|
||||
},
|
||||
{
|
||||
dotClass: 'top-[38px] left-[220px]',
|
||||
chipClass: 'top-[22px] left-[224px]',
|
||||
label: 'Sheets',
|
||||
fillClass: styles.fillSrcB,
|
||||
},
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Outbound destinations mirrored below, staggered the same way: Slack at
|
||||
* the left (x 56), Salesforce hanging lowest on the center axis (x 140),
|
||||
* Jira highest at the right (x 224).
|
||||
*/
|
||||
const DESTINATIONS: readonly Port[] = [
|
||||
{
|
||||
dotClass: 'top-[192px] left-[52px]',
|
||||
chipClass: 'top-[216px] left-[56px]',
|
||||
label: 'Slack',
|
||||
fillClass: styles.fillDstA,
|
||||
},
|
||||
{
|
||||
dotClass: 'top-[212px] left-[136px]',
|
||||
chipClass: 'top-[236px] left-[140px]',
|
||||
label: 'Salesforce',
|
||||
fillClass: styles.fillDstC,
|
||||
},
|
||||
{
|
||||
dotClass: 'top-[202px] left-[220px]',
|
||||
chipClass: 'top-[226px] left-[224px]',
|
||||
label: 'Jira',
|
||||
fillClass: styles.fillDstB,
|
||||
},
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Wires from each source port gathering into the router hub's top pole
|
||||
* (140, 93) — all three terminate at the same point so they read as a
|
||||
* tied bundle feeding the agent, with vertical tangents at both ends
|
||||
* (the access tile's edge geometry) and long vertical runs so each curve
|
||||
* travels before it arrives.
|
||||
*/
|
||||
const IN_PATHS = {
|
||||
gmail: 'M 56 52 C 56 74 140 66 140 93',
|
||||
zendesk: 'M 140 32 L 140 93',
|
||||
sheets: 'M 224 42 C 224 70 140 64 140 93',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Wires fanning out from the router hub's bottom pole (140, 155) — the
|
||||
* shared origin splitting to each destination, echoing the deploy tile's
|
||||
* single pipeline branching.
|
||||
*/
|
||||
const OUT_PATHS = {
|
||||
slack: 'M 140 155 C 140 182 56 174 56 196',
|
||||
salesforce: 'M 140 155 L 140 216',
|
||||
jira: 'M 140 155 C 140 184 224 178 224 206',
|
||||
} as const
|
||||
|
||||
/** Faint-ink stroke for the resting wires (the deploy tile's guide-line grey, quieter). */
|
||||
const QUIET_STROKE = 'color-mix(in srgb, var(--text-muted-inverse) 28%, transparent)'
|
||||
|
||||
/** Shared 1px outline ink for the tags, port dots, and router hub ring. */
|
||||
const OUTLINE_INK = 'border-[color:color-mix(in_srgb,var(--text-muted-inverse)_45%,transparent)]'
|
||||
|
||||
/** Shared SVG props for a resting wire. */
|
||||
const WIRE_PROPS = { stroke: QUIET_STROKE, strokeWidth: '1' } as const
|
||||
|
||||
/** Shared SVG props for a traveling white request-pulse overlay on a wire. */
|
||||
const PULSE_PROPS = {
|
||||
pathLength: 1,
|
||||
stroke: 'var(--text-inverse)',
|
||||
strokeWidth: '1.25',
|
||||
strokeLinecap: 'round',
|
||||
} as const
|
||||
|
||||
/** An outlined integration tag whose white connect-fill crossfades in on its scheduled phases. */
|
||||
function PortTag({ port }: { port: Port }) {
|
||||
return (
|
||||
<span
|
||||
className={cn('-translate-x-1/2 -translate-y-1/2 absolute whitespace-nowrap', port.chipClass)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'relative flex h-5 items-center overflow-hidden rounded-md border bg-transparent px-1.5 font-medium text-[var(--text-muted-inverse)] text-caption',
|
||||
OUTLINE_INK
|
||||
)}
|
||||
>
|
||||
{port.label}
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center bg-[var(--white)] px-1.5 text-[var(--text-primary)] opacity-0',
|
||||
port.fillClass
|
||||
)}
|
||||
>
|
||||
{port.label}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Operations automation told as a vertical switchboard — the access tile's
|
||||
* top-to-bottom composition (sources above, curved bezier edges flowing
|
||||
* down to what they feed) in the dark tiles' ink: three ops tools wired in
|
||||
* across the top (Gmail, Zendesk, Sheets), an outlined circular router hub
|
||||
* at center — the agent — and three destinations wired out below (Slack,
|
||||
* Salesforce, Jira). Both rows stagger their tag heights into a loose
|
||||
* constellation, giving each wire its own long arc instead of ranking the
|
||||
* tags into flat rows. Every element is drawn in the same outlined
|
||||
* language: the six tool tags rest as transparent pills with 1px light
|
||||
* hairline borders and light-grey ink, the hub is a transparent circle
|
||||
* with the same hairline ring, and the wires are 1px curved SVG paths
|
||||
* with vertical tangents (the access tile's edge geometry) gathering into
|
||||
* the hub's top pole and fanning from its bottom pole — a tied bundle in,
|
||||
* a split out.
|
||||
*
|
||||
* Inside the hub, the platform's gooey {@link ThinkingLoader} runs its
|
||||
* default full morph cycle (no pinned variant, unlike the deploy button's
|
||||
* `relay`) wearing the deploy button's exact light-grey ink override, so
|
||||
* the page's two dark-tile loaders read as the same material — the agent
|
||||
* visibly churning through work between dispatches. The loader stops
|
||||
* cycling on its own under `prefers-reduced-motion`.
|
||||
*
|
||||
* Motion (from `operations-teams-graphic.module.css`, one shared 16s
|
||||
* timeline of four 4s route phases — Zendesk→Slack, Sheets→Jira,
|
||||
* Gmail→Salesforce, Zendesk→Jira — a precomputed shuffle so the routing
|
||||
* feels varied): in each phase the source tag's white filled state
|
||||
* crossfades in over its outline (ink flipping dark with it), a white
|
||||
* pulse falls down its wire into the router — which blooms the family's
|
||||
* ring pulse while the agent classifies — then out the bottom pole to
|
||||
* the destination tag, whose fill crossfades in as the route connects.
|
||||
* Both ends hold briefly, fade back to outlined, and the next pair
|
||||
* begins. Everything is static and outlined under
|
||||
* `prefers-reduced-motion`.
|
||||
*
|
||||
* The feature tile's visual slot bleeds `2rem` right and bottom (`1.5rem`
|
||||
* under `max-lg`) but not left or top, so the canvas sits inside a
|
||||
* matching right- and bottom-padded box to land on the tile's visible
|
||||
* center. The fixed-size canvas is centered with a transform (`left-1/2`
|
||||
* + `-translate-x-1/2`, the standards tile's approach) so at narrow tile
|
||||
* widths it keeps its wiring geometry and overflows both edges equally
|
||||
* instead of drifting. On the narrow grid bands where the tile drops
|
||||
* below the canvas width (small two-column screens and the 3-up row just
|
||||
* past `lg`) the canvas scales down via transform so the outer tool pills
|
||||
* are never cropped.
|
||||
*/
|
||||
export function OperationsTeamsGraphic() {
|
||||
return (
|
||||
<FeatureGraphicShell>
|
||||
<div aria-hidden='true' className='absolute inset-0 pr-8 pb-8 max-lg:pr-6 max-lg:pb-6'>
|
||||
<div className='relative h-full'>
|
||||
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2 h-[248px] w-[280px] max-sm:scale-100 max-md:scale-[0.85] lg:max-[1180px]:scale-[0.85]'>
|
||||
<svg
|
||||
className='absolute inset-0'
|
||||
fill='none'
|
||||
viewBox={`0 0 ${CANVAS.WIDTH} ${CANVAS.HEIGHT}`}
|
||||
width={CANVAS.WIDTH}
|
||||
height={CANVAS.HEIGHT}
|
||||
>
|
||||
<path d={IN_PATHS.gmail} {...WIRE_PROPS} />
|
||||
<path d={IN_PATHS.zendesk} {...WIRE_PROPS} />
|
||||
<path d={IN_PATHS.sheets} {...WIRE_PROPS} />
|
||||
<path d={OUT_PATHS.slack} {...WIRE_PROPS} />
|
||||
<path d={OUT_PATHS.salesforce} {...WIRE_PROPS} />
|
||||
<path d={OUT_PATHS.jira} {...WIRE_PROPS} />
|
||||
|
||||
<path
|
||||
d={IN_PATHS.zendesk}
|
||||
className={cn(styles.pulse, styles.pulseInA)}
|
||||
{...PULSE_PROPS}
|
||||
/>
|
||||
<path
|
||||
d={IN_PATHS.sheets}
|
||||
className={cn(styles.pulse, styles.pulseInB)}
|
||||
{...PULSE_PROPS}
|
||||
/>
|
||||
<path
|
||||
d={IN_PATHS.gmail}
|
||||
className={cn(styles.pulse, styles.pulseInC)}
|
||||
{...PULSE_PROPS}
|
||||
/>
|
||||
<path
|
||||
d={OUT_PATHS.slack}
|
||||
className={cn(styles.pulse, styles.pulseOutA)}
|
||||
{...PULSE_PROPS}
|
||||
/>
|
||||
<path
|
||||
d={OUT_PATHS.jira}
|
||||
className={cn(styles.pulse, styles.pulseOutB)}
|
||||
{...PULSE_PROPS}
|
||||
/>
|
||||
<path
|
||||
d={OUT_PATHS.salesforce}
|
||||
className={cn(styles.pulse, styles.pulseOutC)}
|
||||
{...PULSE_PROPS}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{SOURCES.map((port) => (
|
||||
<span key={port.label}>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute size-2 rounded-full border bg-[var(--text-secondary)]',
|
||||
OUTLINE_INK,
|
||||
port.dotClass
|
||||
)}
|
||||
/>
|
||||
<PortTag port={port} />
|
||||
</span>
|
||||
))}
|
||||
|
||||
{DESTINATIONS.map((port) => (
|
||||
<span key={port.label}>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute size-2 rounded-full border bg-[var(--text-secondary)]',
|
||||
OUTLINE_INK,
|
||||
port.dotClass
|
||||
)}
|
||||
/>
|
||||
<PortTag port={port} />
|
||||
</span>
|
||||
))}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-[93px] left-[109px] flex size-[62px] items-center justify-center rounded-full border',
|
||||
OUTLINE_INK,
|
||||
styles.routerBloom
|
||||
)}
|
||||
>
|
||||
<ThinkingLoader size={36} style={ROUTER_LOADER_INK} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureGraphicShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Button, ChipTag } from '@sim/emcn'
|
||||
import { Undo } from '@sim/emcn/icons'
|
||||
import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell'
|
||||
|
||||
/**
|
||||
* Rollback told as a "return to known-good" gesture inside the lifecycle
|
||||
* tile's outlined window chrome: the window is a 1px `--border-1` hairline
|
||||
* outline with no fill (tile grey showing through), anchored to the text
|
||||
* column's left edge (`left-0`, `top-5`) and bleeding off the right and
|
||||
* bottom edges with a `rounded-tl-xl` top-left corner — the same slot
|
||||
* geometry as the build and lifecycle windows. A "Version history" title
|
||||
* bar with a right-aligned `Production` mono ChipTag (its fill stepped up
|
||||
* to `--surface-6` so the pill stays legible on the grey ground) crowns
|
||||
* the window at the family's `h-12` header height, ruled by a hairline
|
||||
* divider.
|
||||
*
|
||||
* Inside, a short newest-first version rail runs down the left gutter —
|
||||
* v4 as the quiet current row (its `Current` tag on the grey-ground
|
||||
* `--surface-6` pill fill), then a plain vertical hairline connector
|
||||
* down to v3, the lifecycle timeline's spine language. v3 is the tile's
|
||||
* highlight: the node fills solid and
|
||||
* the row lifts onto a white card (`--white` fill, 1px `--border-1`
|
||||
* hairline, nested `rounded-lg` inside the `rounded-tl-xl` window,
|
||||
* `shadow-sm`) pairing "v3 · Stable · Maya Chen" with the tile's
|
||||
* strongest element, the solid Roll back button led by a small `Undo`
|
||||
* glyph inked in the button's inverse text color. A quieter v2 row
|
||||
* dissolves through a mask gradient below, implying the history continues
|
||||
* past the window's bled bottom edge — the lifecycle tile's fade device.
|
||||
*
|
||||
* The window bleeds `2rem` past the tile's visible right edge (`1.5rem`
|
||||
* under `max-lg`), so the header and body carry matching extra right
|
||||
* padding (`pr-12 max-lg:pr-10` = the bleed plus the usual `1rem` gutter)
|
||||
* to keep the Production tag and the v3 card's Roll back button fully
|
||||
* inside the visible tile.
|
||||
*
|
||||
* The rail is fully static. The connector hairlines are absolutely
|
||||
* positioned inside fixed-height spacer rows so they can extend past the
|
||||
* row box into the adjacent rows' empty gutter space, ending a couple of
|
||||
* pixels from each node — the lifecycle spine's tight node-to-line
|
||||
* clearance — without disturbing the flow layout.
|
||||
*/
|
||||
export function RollbackGraphic() {
|
||||
return (
|
||||
<FeatureGraphicShell>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute top-5 right-0 bottom-0 left-0 rounded-tl-xl border-[var(--border-1)] border-t border-l'
|
||||
>
|
||||
<div className='flex h-12 items-center justify-between gap-2 border-[var(--border-1)] border-b px-4 pr-12 max-lg:pr-10'>
|
||||
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-base'>
|
||||
Version history
|
||||
</span>
|
||||
<ChipTag variant='mono' className='bg-[var(--surface-6)]'>
|
||||
Production
|
||||
</ChipTag>
|
||||
</div>
|
||||
|
||||
<div className='p-4 pr-12 [mask-image:linear-gradient(to_bottom,black_55%,transparent_98%)] max-lg:pr-10'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='flex w-2.5 shrink-0 justify-center'>
|
||||
<span className='size-2 rounded-full border border-[var(--text-muted)] bg-[var(--surface-3)]' />
|
||||
</span>
|
||||
<span className='flex min-w-0 flex-1 items-center gap-2'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-small'>v4</span>
|
||||
<ChipTag variant='mono' className='bg-[var(--surface-6)]'>
|
||||
Current
|
||||
</ChipTag>
|
||||
</span>
|
||||
<span className='shrink-0 text-[var(--text-muted)] text-caption'>Published 2h ago</span>
|
||||
</div>
|
||||
|
||||
<div className='flex'>
|
||||
<span className='relative flex h-[38px] w-2.5 shrink-0 justify-center'>
|
||||
<span className='-top-[4px] -bottom-[24px] absolute w-px bg-[color:color-mix(in_srgb,var(--text-muted)_35%,transparent)]' />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='flex w-2.5 shrink-0 justify-center'>
|
||||
<span className='size-2.5 rounded-full bg-[var(--text-primary)]' />
|
||||
</span>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-3 rounded-lg border border-[var(--border-1)] bg-[var(--white)] px-3 py-2.5 shadow-sm'>
|
||||
<span className='min-w-0 flex-1'>
|
||||
<span className='flex items-center gap-2'>
|
||||
<span className='font-medium text-[var(--text-primary)] text-small'>v3</span>
|
||||
<ChipTag variant='mono'>Stable</ChipTag>
|
||||
</span>
|
||||
<span className='mt-0.5 block truncate text-[var(--text-muted)] text-caption'>
|
||||
Maya Chen · Jun 30
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
variant='primary'
|
||||
size='sm'
|
||||
tabIndex={-1}
|
||||
className='pointer-events-none shrink-0 gap-1'
|
||||
>
|
||||
<Undo className='size-[12px]' />
|
||||
Roll back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex'>
|
||||
<span className='relative flex h-[28px] w-2.5 shrink-0 justify-center'>
|
||||
<span className='-top-[24px] -bottom-[4px] absolute w-px bg-[color:color-mix(in_srgb,var(--text-muted)_35%,transparent)]' />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='flex w-2.5 shrink-0 justify-center'>
|
||||
<span className='size-2 rounded-full border border-[color:color-mix(in_srgb,var(--text-muted)_60%,transparent)] bg-[var(--surface-3)]' />
|
||||
</span>
|
||||
<span className='flex min-w-0 flex-1 items-center gap-2'>
|
||||
<span className='text-[var(--text-muted)] text-small'>v2</span>
|
||||
<span className='truncate text-[var(--text-muted)] text-caption'>Saved Jun 24</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureGraphicShell>
|
||||
)
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* The RunMonitoringGraphic's only motion: a soft ring pulse blooming from
|
||||
* the header's live-status dot every 6s — the family's shared quiet
|
||||
* state-confirmation beat (the lifecycle tile's live node, the staging
|
||||
* tile's Promote button). Under prefers-reduced-motion the pulse is
|
||||
* removed and the dot sits static.
|
||||
*/
|
||||
|
||||
.livePulse {
|
||||
animation: run-monitoring-live-pulse 6s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes run-monitoring-live-pulse {
|
||||
0%,
|
||||
20% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 35%, transparent);
|
||||
}
|
||||
36% {
|
||||
box-shadow: 0 0 0 5px color-mix(in srgb, var(--text-secondary) 0%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 0%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.livePulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ChipTag, cn } from '@sim/emcn'
|
||||
import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell'
|
||||
import styles from '@/app/(landing)/enterprise/components/feature-graphics/run-monitoring-graphic.module.css'
|
||||
|
||||
interface LogField {
|
||||
/** Row label, matching the workspace Log Details panel's key column. */
|
||||
label: string
|
||||
/** Right-aligned value cell — plain text or a quiet mono chip. */
|
||||
value: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* The Log Details keys the real panel leads with, distilled to tile scale:
|
||||
* workflow name, shortened run id, trigger, and duration. The workspace
|
||||
* UI's green Level/Trigger tags become quiet grey mono chips per the
|
||||
* row's monochrome vocabulary.
|
||||
*/
|
||||
const LOG_FIELDS: readonly LogField[] = [
|
||||
{
|
||||
label: 'Workflow',
|
||||
value: (
|
||||
<span className='truncate font-medium text-[var(--text-primary)] text-caption'>
|
||||
Support agent
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Run ID',
|
||||
value: (
|
||||
<ChipTag variant='mono' className='bg-[var(--surface-6)]'>
|
||||
afda69e9
|
||||
</ChipTag>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Trigger',
|
||||
value: (
|
||||
<ChipTag variant='mono' className='bg-[var(--surface-6)]'>
|
||||
Schedule
|
||||
</ChipTag>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Duration',
|
||||
value: <span className='font-mono text-[var(--text-secondary)] text-caption'>1.56s</span>,
|
||||
},
|
||||
] as const
|
||||
|
||||
/**
|
||||
* The workspace's Log Details panel told as a frameless vignette (the
|
||||
* access and standards tiles' composition): no window chrome — a small
|
||||
* "Log details" header with a live-status dot (the tile's one emphasized
|
||||
* motion) sits directly on the tile ground above the panel's key-value
|
||||
* ledger — Workflow, shortened Run ID, Trigger, and Duration as airy
|
||||
* rows ruled by quiet 1px `--border-1` hairlines, the real UI's green
|
||||
* tags quieted to grey mono chips (fills stepped up to `--surface-6` so
|
||||
* the pills stay legible on the grey ground). The closing "Workflow
|
||||
* output" section
|
||||
* lifts its two-line JSON fragment onto the tile's highlight: a white
|
||||
* card in the audit tile's exact chrome (`--white` fill, 1px `--border-1`
|
||||
* hairline, rounded, `shadow-sm`), the run's payload presented as the
|
||||
* artifact worth watching.
|
||||
*
|
||||
* The only motion is the soft ring pulse on the live dot (from
|
||||
* `run-monitoring-graphic.module.css`), the family's shared quiet 6s
|
||||
* beat, removed under `prefers-reduced-motion`.
|
||||
*
|
||||
* The feature tile's visual slot bleeds `2rem` right (`1.5rem` under
|
||||
* `max-lg`) but not left, so this centered vignette adds matching right
|
||||
* padding to land on the tile's visible center instead of the bled
|
||||
* slot's center. The column is fluid (`w-full max-w-[312px]`) so it
|
||||
* never exceeds the compensated slot at narrow tile widths — the
|
||||
* workflow value and JSON lines truncate instead of clipping.
|
||||
*/
|
||||
export function RunMonitoringGraphic() {
|
||||
return (
|
||||
<FeatureGraphicShell>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute inset-0 flex items-center justify-center pr-8 max-lg:pr-6'
|
||||
>
|
||||
<div className='w-full max-w-[312px]'>
|
||||
<div className='mb-1.5 flex items-center justify-between gap-2'>
|
||||
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-base'>
|
||||
Log details
|
||||
</span>
|
||||
<span className='flex shrink-0 items-center gap-1.5'>
|
||||
<span
|
||||
className={cn('size-2 rounded-full bg-[var(--text-primary)]', styles.livePulse)}
|
||||
/>
|
||||
<span className='text-[var(--text-muted)] text-caption'>Live</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{LOG_FIELDS.map((field, index) => (
|
||||
<div
|
||||
key={field.label}
|
||||
className={cn(
|
||||
'flex h-9 items-center justify-between gap-3',
|
||||
index > 0 && 'border-[var(--border-1)] border-t'
|
||||
)}
|
||||
>
|
||||
<span className='shrink-0 text-[var(--text-muted)] text-caption'>{field.label}</span>
|
||||
{field.value}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className='mt-2'>
|
||||
<span className='block text-[var(--text-muted)] text-caption'>Workflow output</span>
|
||||
<div className='mt-1.5 rounded-xl border border-[var(--border-1)] bg-[var(--white)] px-3 py-2 font-mono text-caption leading-[1.6] shadow-sm'>
|
||||
<div className='truncate whitespace-pre'>
|
||||
<span className='text-[var(--text-muted)]'>{'{ "status": '}</span>
|
||||
<span className='text-[var(--text-secondary)]'>"completed"</span>
|
||||
<span className='text-[var(--text-muted)]'>,</span>
|
||||
</div>
|
||||
<div className='truncate whitespace-pre'>
|
||||
<span className='text-[var(--text-muted)]'>{' "resolved": '}</span>
|
||||
<span className='text-[var(--text-secondary)]'>24</span>
|
||||
<span className='text-[var(--text-muted)]'>{' }'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureGraphicShell>
|
||||
)
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* The StagingGraphic's only motion: a soft ring pulse blooming from the
|
||||
* Promote button — the same quiet 6s state-confirmation beat as the
|
||||
* access tile's grant node and the lifecycle tile's live node, keeping
|
||||
* the family's shared rhythm. Under prefers-reduced-motion the pulse is
|
||||
* removed and the button sits static.
|
||||
*/
|
||||
|
||||
.promotePulse {
|
||||
animation: staging-promote-pulse 6s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes staging-promote-pulse {
|
||||
0%,
|
||||
20% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 30%, transparent);
|
||||
}
|
||||
36% {
|
||||
box-shadow: 0 0 0 5px color-mix(in srgb, var(--text-secondary) 0%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 0%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.promotePulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Button, ChipTag, cn } from '@sim/emcn'
|
||||
import { ArrowRight, CircleCheck } from '@sim/emcn/icons'
|
||||
import Image from 'next/image'
|
||||
import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell'
|
||||
import styles from '@/app/(landing)/enterprise/components/feature-graphics/staging-graphic.module.css'
|
||||
|
||||
/** Named pre-promotion checks, each rendered as a passing row. */
|
||||
const CHECKS = ['Evals passed', 'No breaking changes', 'Approved by Ops'] as const
|
||||
|
||||
/**
|
||||
* A GitHub-style promotion surface compressed to tile scale, on the
|
||||
* lifecycle tile's outlined ground: the window is a 1px `--border-1`
|
||||
* hairline outline with no fill (tile grey showing through,
|
||||
* `rounded-xl`), topped by a plain title header ("Support agent" + a
|
||||
* `v4` mono tag on the grey-ground `--surface-6` pill fill). Below,
|
||||
* three hairline-ruled sections tell the
|
||||
* merge-area story with an inverted surface hierarchy — the build being
|
||||
* promoted is the highlight, a white card (`--white` fill, `--border-1`
|
||||
* hairline, nested `rounded-lg`, `shadow-sm`, echoing the audit tile's
|
||||
* selected-record card) pairing a short hash chip with its change
|
||||
* message and a "Maya Chen · 2h ago" attribution line (gradient avatar
|
||||
* shared with the access and audit tiles); the named check gates sit
|
||||
* directly on the grey ground as quiet passing rows; and the promotion
|
||||
* bar pairs `Staging → Live` environment tags (fills stepped up to
|
||||
* `--surface-6` so the pills stay legible on the grey ground) with the
|
||||
* tile's strongest element, the solid Promote button.
|
||||
*
|
||||
* The only motion is a soft ring pulse on the Promote button (from
|
||||
* `staging-graphic.module.css`), the family's shared quiet 6s beat,
|
||||
* removed under `prefers-reduced-motion`.
|
||||
*
|
||||
* The avatar asset is a grey radial gradient on a black square, so it
|
||||
* sits in a `rounded-full overflow-hidden` clip with a slight scale-up to
|
||||
* crop the black canvas past the circle's edge.
|
||||
*
|
||||
* The feature tile's visual slot bleeds `2rem` right and bottom
|
||||
* (`1.5rem` under `max-lg`) but not left or top, so this centered window
|
||||
* adds matching right and bottom padding to land on the tile's visible
|
||||
* center instead of the bled slot's center — keeping comparable air
|
||||
* above and below the window. The section paddings stay compact
|
||||
* (`h-10` header, `py-2.5` sections) so the window's intrinsic height
|
||||
* plus the bottom-bleed compensation fits inside the slot's
|
||||
* `overflow-hidden` bounds — a taller window would push the top hairline
|
||||
* and corners past the slot's top clip line and truncate them. The
|
||||
* window is fluid (`w-full max-w-[312px]`) so it never
|
||||
* exceeds the compensated slot and both edges stay visible at narrow
|
||||
* tile widths — text rows truncate instead of clipping, and the
|
||||
* `Staging → Live` tags yield below `xl` so the promotion bar keeps the
|
||||
* Promote button inside the window.
|
||||
*/
|
||||
export function StagingGraphic() {
|
||||
return (
|
||||
<FeatureGraphicShell>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute inset-0 flex items-center justify-center pr-8 pb-8 max-lg:pr-6 max-lg:pb-6'
|
||||
>
|
||||
<div className='w-full max-w-[312px] rounded-xl border border-[var(--border-1)]'>
|
||||
<div className='flex h-10 items-center gap-2 border-[var(--border-1)] border-b px-4'>
|
||||
<span className='min-w-0 flex-1 truncate font-medium text-[var(--text-primary)] text-base'>
|
||||
Support agent
|
||||
</span>
|
||||
<ChipTag variant='mono' className='bg-[var(--surface-6)]'>
|
||||
v4
|
||||
</ChipTag>
|
||||
</div>
|
||||
|
||||
<div className='px-3 py-2.5'>
|
||||
<div className='rounded-lg border border-[var(--border-1)] bg-[var(--white)] px-3 py-2.5 shadow-sm'>
|
||||
<span className='flex items-center gap-2'>
|
||||
<ChipTag variant='mono'>a3f8c21</ChipTag>
|
||||
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-small'>
|
||||
Tighten refund policy checks
|
||||
</span>
|
||||
</span>
|
||||
<span className='mt-1.5 flex items-center gap-1.5'>
|
||||
<span className='relative size-4 overflow-hidden rounded-full'>
|
||||
<Image
|
||||
src='/landing/team-avatar-1.jpg'
|
||||
alt=''
|
||||
width={16}
|
||||
height={16}
|
||||
className='size-full scale-110 object-cover'
|
||||
/>
|
||||
</span>
|
||||
<span className='text-[var(--text-muted)] text-caption'>Maya Chen · 2h ago</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2 border-[var(--border-1)] border-t px-4 py-2.5'>
|
||||
{CHECKS.map((check) => (
|
||||
<span key={check} className='flex items-center gap-2'>
|
||||
<CircleCheck className='size-[13px] text-[var(--text-icon)]' />
|
||||
<span className='text-[var(--text-secondary)] text-caption'>{check}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between gap-3 border-[var(--border-1)] border-t px-4 py-2.5'>
|
||||
<span className='hidden min-w-0 items-center gap-1.5 xl:flex'>
|
||||
<ChipTag variant='mono' className='bg-[var(--surface-6)]'>
|
||||
Staging
|
||||
</ChipTag>
|
||||
<ArrowRight className='size-[12px] shrink-0 text-[var(--text-icon)]' />
|
||||
<ChipTag variant='mono' className='bg-[var(--surface-6)]'>
|
||||
Live
|
||||
</ChipTag>
|
||||
</span>
|
||||
<Button
|
||||
variant='primary'
|
||||
size='sm'
|
||||
tabIndex={-1}
|
||||
className={cn('pointer-events-none ml-auto', styles.promotePulse)}
|
||||
>
|
||||
Promote
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureGraphicShell>
|
||||
)
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* The StandardsGraphic's only motion: the certification seal blooms the
|
||||
* row's shared 6s ring pulse — the same quiet state-confirmation beat as
|
||||
* the lifecycle tile's live node and the access tile's grant node, inked
|
||||
* with the deploy tile's light `--text-muted-inverse` so it reads on the
|
||||
* dark `--text-secondary` tile surface. Removed under
|
||||
* prefers-reduced-motion so the seal sits static.
|
||||
*/
|
||||
|
||||
.sealPulse {
|
||||
animation: standards-seal-pulse 6s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes standards-seal-pulse {
|
||||
0%,
|
||||
20% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-muted-inverse) 55%, transparent);
|
||||
}
|
||||
36% {
|
||||
box-shadow: 0 0 0 6px color-mix(in srgb, var(--text-muted-inverse) 0%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-muted-inverse) 0%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connector draw loop, following the deploy tile's dash vocabulary:
|
||||
* paths normalized to pathLength=1 draw in, hold, then un-draw forward
|
||||
* (dashoffset continues to -1 so the line exits in its direction of
|
||||
* travel) on the same 6s timeline as the seal pulse. The trailing
|
||||
* connector (seal → Open source) runs the identical keyframes
|
||||
* phase-shifted by 0.7s so the two lines read as a stagger.
|
||||
*/
|
||||
|
||||
.connector {
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
animation: standards-connector-draw 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.connectorTrailing {
|
||||
animation-delay: 0.7s;
|
||||
}
|
||||
|
||||
@keyframes standards-connector-draw {
|
||||
0% {
|
||||
stroke-dashoffset: 1;
|
||||
}
|
||||
16% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
68% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
84%,
|
||||
100% {
|
||||
stroke-dashoffset: -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Orbital ring sweeps — the deploy tile's white progress sweep bent around
|
||||
* the certificate rings: each ring carries a fixed 50° arc stroked with a
|
||||
* comet gradient (bright at the clockwise leading end, transparent at the
|
||||
* tail) inside a group that rotates around the seal center for a slow,
|
||||
* seamless orbit. Rotation replaced the earlier stroke-dash offset loop
|
||||
* because a dash that wraps the circle's path start renders a seam
|
||||
* artifact (a stray cap segment at the wrap point); a rotating group has
|
||||
* no seam. Both orbits share the 14s period; the outer ring runs half a
|
||||
* revolution out of phase so the two sweeps never align.
|
||||
*/
|
||||
|
||||
.ringSweep {
|
||||
transform-box: view-box;
|
||||
transform-origin: 160px 112px;
|
||||
animation: standards-ring-orbit 14s linear infinite;
|
||||
}
|
||||
|
||||
.ringSweepOuter {
|
||||
animation-delay: -7s;
|
||||
}
|
||||
|
||||
@keyframes standards-ring-orbit {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sealPulse {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.connector {
|
||||
animation: none;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
.ringSweep {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { ChipTag, cn } from '@sim/emcn'
|
||||
import { ShieldCheck } from '@sim/emcn/icons'
|
||||
import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell'
|
||||
import styles from '@/app/(landing)/enterprise/components/feature-graphics/standards-graphic.module.css'
|
||||
|
||||
/**
|
||||
* Enterprise standards told as a certification seal, with no window
|
||||
* framing: a shield-check medallion at the center wearing the deploy
|
||||
* tile's Deploy-button chrome — the same `--text-muted` fill with
|
||||
* `--text-inverse` ink — so the two dark tiles' hero elements read as
|
||||
* siblings. It is ringed by two concentric hairlines like a certificate
|
||||
* mark, with the outer ring dissolving through a mask the way the row's
|
||||
* older elements fade. The tile's two claims anchor the seal from either
|
||||
* side over short hairline connectors with port dots (the access tile's
|
||||
* junction vocabulary): a `SOC 2` chip and an `Open source` chip, whose
|
||||
* mono `--surface-5` fills read as light pills on the dark ground
|
||||
* exactly like the deploy tile's ChipTag. Each chip is anchored by its
|
||||
* connector-facing edge to the connector's endpoint (not centered on a
|
||||
* fixed x), so both junctions stay flush regardless of label width. A `Verified` pill beneath the seal carries the emphasized
|
||||
* state on the deploy tile's `--text-muted` chip fill with `--text-inverse`
|
||||
* ink (ChipTag's solid variant would vanish here — its fill is the tile's
|
||||
* own `--text-secondary`). The seal blooms the row's shared 6s ring pulse
|
||||
* (from `standards-graphic.module.css`, removed under
|
||||
* `prefers-reduced-motion`).
|
||||
*
|
||||
* Inks follow the deploy tile's dark-surface palette: hairlines and dots
|
||||
* mix `--text-muted-inverse` toward transparent (45% on the working
|
||||
* lines, fainter on the decorative rings), and the port dots fill with
|
||||
* the tile's `--text-secondary` so the connector reads as passing
|
||||
* beneath them.
|
||||
*
|
||||
* The two connectors are SVG paths normalized to `pathLength=1` so they
|
||||
* draw in with the page's dash vocabulary (the deploy tile's stagger):
|
||||
* SOC 2 draws into the seal first, the seal draws out to Open source a
|
||||
* beat later, both hold, then un-draw forward and loop on the shared 6s
|
||||
* timeline. Under `prefers-reduced-motion` they render fully drawn with
|
||||
* no animation.
|
||||
*
|
||||
* Each ring also carries a comet arc — the deploy tile's white connector
|
||||
* sweep made circular: a fixed 50° arc over the hairline, stroked with a
|
||||
* `userSpaceOnUse` linear gradient that is bright at the clockwise
|
||||
* leading tip and fades to transparent along the trailing side, inside a
|
||||
* group that rotates around the seal center (a rotating group rather
|
||||
* than a dash offset, because a dash wrapping the circle's path start
|
||||
* renders a stray seam-cap artifact). The two orbits share one 14s
|
||||
* period but the outer runs phase-shifted half a revolution, so the
|
||||
* sweeps are never synchronized; the outer sweep sits inside the same
|
||||
* fade mask as its hairline so it dissolves through the bottom of the
|
||||
* tile like the ring it traces. Under `prefers-reduced-motion` the
|
||||
* sweeps are hidden entirely.
|
||||
*
|
||||
* The feature tile's visual slot bleeds `2rem` right (`1.5rem` under
|
||||
* `max-lg`) but not left, so the canvas sits inside a matching
|
||||
* right-padded box to land on the tile's visible center instead of the
|
||||
* bled slot's center. The canvas itself is centered with a transform
|
||||
* (`left-1/2` + `-translate-x-1/2`) rather than flex `justify-center`
|
||||
* because an overflowing fixed-size flex item start-aligns instead of
|
||||
* centering once the tile gets narrower than the canvas. On the narrow
|
||||
* grid bands where the tile drops below the canvas width (small
|
||||
* two-column screens and the 3-up row just past `lg`) the canvas scales
|
||||
* down via transform so the SOC 2 / Open source chips are never cropped.
|
||||
*/
|
||||
export function StandardsGraphic() {
|
||||
return (
|
||||
<FeatureGraphicShell>
|
||||
<div aria-hidden='true' className='absolute inset-0 pr-8 max-lg:pr-6'>
|
||||
<div className='relative h-full'>
|
||||
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2 h-[250px] w-[320px] max-sm:scale-100 max-md:scale-[0.8] lg:max-[1180px]:scale-[0.8]'>
|
||||
<div className='absolute top-0 left-[48px] size-[224px] rounded-full border border-[color:color-mix(in_srgb,var(--text-muted-inverse)_22%,transparent)] [mask-image:linear-gradient(to_bottom,black_30%,transparent_92%)]' />
|
||||
<div className='absolute top-[36px] left-[84px] size-[152px] rounded-full border border-[color:color-mix(in_srgb,var(--text-muted-inverse)_35%,transparent)]' />
|
||||
|
||||
<svg
|
||||
className='absolute inset-0'
|
||||
fill='none'
|
||||
viewBox='0 0 320 250'
|
||||
width={320}
|
||||
height={250}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id='standards-sweep-inner'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
x1='235.5'
|
||||
y1='112'
|
||||
x2='208.53'
|
||||
y2='169.84'
|
||||
>
|
||||
<stop offset='0' stopColor='var(--text-inverse)' stopOpacity='0' />
|
||||
<stop offset='1' stopColor='var(--text-inverse)' stopOpacity='0.9' />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id='standards-sweep-outer'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
x1='271.5'
|
||||
y1='112'
|
||||
x2='231.67'
|
||||
y2='197.41'
|
||||
>
|
||||
<stop offset='0' stopColor='var(--text-inverse)' stopOpacity='0' />
|
||||
<stop offset='1' stopColor='var(--text-inverse)' stopOpacity='0.9' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g className='[mask-image:linear-gradient(to_bottom,black_30%,transparent_92%)]'>
|
||||
<g className={cn(styles.ringSweep, styles.ringSweepOuter)}>
|
||||
<path
|
||||
d='M 271.5 112 A 111.5 111.5 0 0 1 231.67 197.41'
|
||||
stroke='url(#standards-sweep-outer)'
|
||||
strokeWidth='1'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<g className={styles.ringSweep}>
|
||||
<path
|
||||
d='M 235.5 112 A 75.5 75.5 0 0 1 208.53 169.84'
|
||||
stroke='url(#standards-sweep-inner)'
|
||||
strokeWidth='1'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d='M 90 112.5 L 122 112.5'
|
||||
pathLength={1}
|
||||
className={styles.connector}
|
||||
stroke='color-mix(in srgb, var(--text-muted-inverse) 45%, transparent)'
|
||||
strokeWidth='1'
|
||||
/>
|
||||
<path
|
||||
d='M 198 112.5 L 230 112.5'
|
||||
pathLength={1}
|
||||
className={cn(styles.connector, styles.connectorTrailing)}
|
||||
stroke='color-mix(in srgb, var(--text-muted-inverse) 45%, transparent)'
|
||||
strokeWidth='1'
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span className='absolute top-[108px] left-[118px] size-2 rounded-full border border-[color:color-mix(in_srgb,var(--text-muted-inverse)_70%,transparent)] bg-[var(--text-secondary)]' />
|
||||
<span className='absolute top-[108px] left-[194px] size-2 rounded-full border border-[color:color-mix(in_srgb,var(--text-muted-inverse)_70%,transparent)] bg-[var(--text-secondary)]' />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-[74px] left-[122px] flex size-[76px] items-center justify-center rounded-full bg-[var(--text-muted)] shadow-sm',
|
||||
styles.sealPulse
|
||||
)}
|
||||
>
|
||||
<ShieldCheck className='size-7 text-[var(--text-inverse)]' />
|
||||
</div>
|
||||
|
||||
<span className='-translate-x-full -translate-y-1/2 absolute top-[112px] left-[90px] whitespace-nowrap'>
|
||||
<ChipTag variant='mono'>SOC 2</ChipTag>
|
||||
</span>
|
||||
<span className='-translate-y-1/2 absolute top-[112px] left-[230px] whitespace-nowrap'>
|
||||
<ChipTag variant='mono'>Open source</ChipTag>
|
||||
</span>
|
||||
|
||||
<span className='-translate-x-1/2 absolute top-[172px] left-1/2 flex h-5 items-center rounded-md bg-[var(--text-muted)] px-1.5 font-medium text-[var(--text-inverse)] text-caption'>
|
||||
Verified
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureGraphicShell>
|
||||
)
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* The TechnicalTeamsGraphic's only motion: a soft ring pulse blooming
|
||||
* from the Approved tag on the review card — the same quiet 6s
|
||||
* state-confirmation beat as the staging tile's Promote button and the
|
||||
* audit tile's newest-entry avatar, keeping the family's shared rhythm.
|
||||
* Under prefers-reduced-motion the pulse is removed and the tag sits
|
||||
* static.
|
||||
*/
|
||||
|
||||
.approvedPulse {
|
||||
animation: technical-approved-pulse 6s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes technical-approved-pulse {
|
||||
0%,
|
||||
20% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 30%, transparent);
|
||||
}
|
||||
36% {
|
||||
box-shadow: 0 0 0 5px color-mix(in srgb, var(--text-secondary) 0%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--text-secondary) 0%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.approvedPulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { ChipTag, cn } from '@sim/emcn'
|
||||
import { CircleCheck } from '@sim/emcn/icons'
|
||||
import Image from 'next/image'
|
||||
import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell'
|
||||
import styles from '@/app/(landing)/enterprise/components/feature-graphics/technical-teams-graphic.module.css'
|
||||
|
||||
interface DiffLine {
|
||||
/** Gutter marker — space for context, `-` for removed, `+` for added. */
|
||||
marker: ' ' | '-' | '+'
|
||||
/** The code content after the marker. */
|
||||
code: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-word behavior change to the same `Support agent` config the
|
||||
* build tile types out — the surrounding config lines ground the excerpt
|
||||
* as real code while the `-`/`+` pair stays the focal point.
|
||||
*/
|
||||
const DIFF_LINES: readonly DiffLine[] = [
|
||||
{ marker: ' ', code: " name: 'Support agent'," },
|
||||
{ marker: ' ', code: ' instructions:' },
|
||||
{ marker: ' ', code: " 'Answer customer questions'," },
|
||||
{ marker: ' ', code: ' tools: [zendesk, slack],' },
|
||||
{ marker: '-', code: " onError: 'retry'," },
|
||||
{ marker: '+', code: " onError: 'escalate'," },
|
||||
{ marker: ' ', code: '})' },
|
||||
] as const
|
||||
|
||||
/** Per-marker ink treatments: added strongest, removed and context quiet. */
|
||||
const MARKER_TONES: Record<DiffLine['marker'], string> = {
|
||||
' ': 'text-[var(--text-muted)]',
|
||||
'-': 'text-[var(--text-muted)] opacity-70',
|
||||
'+': 'text-[var(--text-primary)]',
|
||||
}
|
||||
|
||||
/**
|
||||
* Technical collaboration told as a distilled code-review vignette, in
|
||||
* the frameless composition its row-mates share (the IT tile's policy
|
||||
* ledger, the operations tile's handoff): no window chrome and no header
|
||||
* — the tile leads straight with the change under review, a seven-line
|
||||
* mono excerpt of the Support-agent config sitting bare on the tile.
|
||||
* Context and removed lines read in quiet `--text-muted`, the single
|
||||
* added line carrying `--text-primary` ink so the edit is the first
|
||||
* thing read. The review's
|
||||
* verdict is the tile's one highlight: a white card in the audit tile's
|
||||
* exact chrome (`--white` fill, 1px `--border-1` hairline, `rounded-xl`,
|
||||
* `shadow-sm`) pairing the reviewer — gradient avatar (shared with the
|
||||
* access, audit, and staging tiles), name, and an "Approved these
|
||||
* changes" attribution line — with an `Approved` tag that carries the
|
||||
* tile's only motion, the family's shared quiet 6s ring pulse (from
|
||||
* `technical-teams-graphic.module.css`, removed under
|
||||
* `prefers-reduced-motion`). A closing hairline-ruled row counts the
|
||||
* resolved review threads, keeping the together-ness of the claim
|
||||
* without a second emphasis.
|
||||
*
|
||||
* The avatar asset is a grey radial gradient on a black square, so it
|
||||
* sits in a `rounded-full overflow-hidden` clip with a slight scale-up
|
||||
* to crop the black canvas past the circle's edge.
|
||||
*
|
||||
* The feature tile's visual slot bleeds `2rem` right (`1.5rem` under
|
||||
* `max-lg`) but not left, so this centered vignette adds matching right
|
||||
* padding to land on the tile's visible center instead of the bled
|
||||
* slot's center. The column is fluid (`w-full max-w-[312px]`) so it
|
||||
* never exceeds the compensated slot at narrow tile widths — diff lines
|
||||
* and the reviewer's attribution truncate instead of clipping.
|
||||
*/
|
||||
export function TechnicalTeamsGraphic() {
|
||||
return (
|
||||
<FeatureGraphicShell>
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='absolute inset-0 flex items-center justify-center pr-8 max-lg:pr-6'
|
||||
>
|
||||
<div className='w-full max-w-[312px]'>
|
||||
<div className='px-3 font-mono text-caption leading-[1.8]'>
|
||||
{DIFF_LINES.map((line) => (
|
||||
<div
|
||||
key={`${line.marker}${line.code}`}
|
||||
className={cn('truncate whitespace-pre', MARKER_TONES[line.marker])}
|
||||
>
|
||||
{line.marker} {line.code}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='mt-3 flex items-center gap-3 rounded-xl border border-[var(--border-1)] bg-[var(--white)] px-3 py-2.5 shadow-sm'>
|
||||
<span className='relative size-7 shrink-0 overflow-hidden rounded-full shadow-sm'>
|
||||
<Image
|
||||
src='/landing/team-avatar-2.jpg'
|
||||
alt=''
|
||||
width={28}
|
||||
height={28}
|
||||
className='size-full scale-110 object-cover'
|
||||
/>
|
||||
</span>
|
||||
<span className='min-w-0 flex-1'>
|
||||
<span className='block truncate font-medium text-[var(--text-primary)] text-small'>
|
||||
Jordan Lee
|
||||
</span>
|
||||
<span className='block truncate text-[var(--text-muted)] text-caption'>
|
||||
Approved these changes
|
||||
</span>
|
||||
</span>
|
||||
<ChipTag variant='gray' className={cn('shrink-0', styles.approvedPulse)}>
|
||||
Approved
|
||||
</ChipTag>
|
||||
</div>
|
||||
|
||||
<div className='mt-1.5 flex h-9 items-center gap-2 px-3'>
|
||||
<CircleCheck className='size-[13px] shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='min-w-0 flex-1 truncate text-[var(--text-secondary)] text-caption'>
|
||||
Review threads resolved
|
||||
</span>
|
||||
<span className='shrink-0 text-[var(--text-muted)] text-caption'>2 of 2</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureGraphicShell>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user