mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-31 01:11:53 +08:00
feat(landing): enterprise page redesign with platform-loop hero and feature graphics (#5535)
* feat(landing): add enterprise link to navbar and footer * feat(landing): redesign enterprise page with platform-loop hero and feature graphics - New enterprise hero with animated platform loop (sidebar + home stage) and hero background - Nine redesigned feature tiles under enterprise/components/feature-graphics with a shared monochrome design vocabulary, per-tile tones, and CSS-module animations - Shared hero-header component and landing-layout constants; hero/platform/solutions pages aligned to the same layout system Co-authored-by: Cursor <cursoragent@cursor.com> * chore(dev): allow 127.0.0.1 dev origins and skip root redirect in dev Co-authored-by: Cursor <cursoragent@cursor.com> * chore(skills): install make-interfaces-feel-better skill Co-authored-by: Cursor <cursoragent@cursor.com> * cleanup(landing): enterprise redesign polish + asset compression - Fix ChipLink chrome overrides in navbar/mobile-nav to use variant='border' - Dedup elapsed-time reveal effects in enterprise-home-stage into one hook - Remove unused FeatureGraphicNode component and barrel export - Convert enterprise-hero-background.png (8.9MB lossless) to WebP q90 (1.07MB, near-lossless) - Fix team-avatar-1/2/3.png mislabeling (actual JPEG bytes) to correct .jpg extension * fix(landing): keep feature-tile graphics uncropped at small breakpoints Feature tiles shrank their min-height on small screens while the tallest vignettes (audit ledger, staging panel) still needed ~300px of visual slot, cropping their tops against the slot's overflow-hidden. Tiles now hold one 440px min-height everywhere. The 3-up grid also collapses to two columns below lg instead of md, and the fixed-width canvases (access graph, standards seal, ops router) scale down on the narrow two-column band and the 3-up row just past lg so outer labels and chips are never cut off. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(landing): regroup enterprise feature cards into 4/4/2/2 at two-column band Merge the four enterprise feature sections into one EnterpriseFeatureGrid so the 640-1023px two-column layout fills 4/4/2/2 with no orphan empty cell; lg+ and <sm layouts are unchanged via sm:max-lg order utilities. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: andresdjasso <andresdjasso@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
---
|
||||
name: make-interfaces-feel-better
|
||||
description: Design engineering principles for making interfaces feel polished. Use when building UI components, reviewing frontend code, implementing animations, hover states, shadows, borders, typography, micro-interactions, enter/exit animations, or any visual detail work. Triggers on UI polish, design details, "make it feel better", "feels off", stagger animations, border radius, optical alignment, font smoothing, tabular numbers, image outlines, box shadows.
|
||||
---
|
||||
|
||||
# Details that make interfaces feel better
|
||||
|
||||
Great interfaces rarely come from a single thing. It's usually a collection of small details that compound into a great experience. Apply these principles when building or reviewing UI code.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Category | When to Use |
|
||||
| --- | --- |
|
||||
| [Typography](typography.md) | Text wrapping, font smoothing, tabular numbers |
|
||||
| [Surfaces](surfaces.md) | Border radius, optical alignment, shadows, image outlines, hit areas |
|
||||
| [Animations](animations.md) | Interruptible animations, enter/exit transitions, icon animations, scale on press |
|
||||
| [Performance](performance.md) | Transition specificity, `will-change` usage |
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. Concentric Border Radius
|
||||
|
||||
Outer radius = inner radius + padding. Mismatched radii on nested elements is the most common thing that makes interfaces feel off.
|
||||
|
||||
### 2. Optical Over Geometric Alignment
|
||||
|
||||
When geometric centering looks off, align optically. Buttons with icons, play triangles, and asymmetric icons all need manual adjustment.
|
||||
|
||||
### 3. Shadows Over Borders
|
||||
|
||||
Layer multiple transparent `box-shadow` values for natural depth. Shadows adapt to any background; solid borders don't.
|
||||
|
||||
### 4. Interruptible Animations
|
||||
|
||||
Use CSS transitions for interactive state changes — they can be interrupted mid-animation. Reserve keyframes for staged sequences that run once.
|
||||
|
||||
### 5. Split and Stagger Enter Animations
|
||||
|
||||
Don't animate a single container. Break content into semantic chunks and stagger each with ~100ms delay.
|
||||
|
||||
### 6. Subtle Exit Animations
|
||||
|
||||
Use a small fixed `translateY` instead of full height. Exits should be softer than enters.
|
||||
|
||||
### 7. Contextual Icon Animations
|
||||
|
||||
Animate icons with `opacity`, `scale`, and `blur` instead of toggling visibility. Use exactly these values: scale from `0.25` to `1`, opacity from `0` to `1`, blur from `4px` to `0px`. If the project has `motion` or `framer-motion` in `package.json`, use `transition: { type: "spring", duration: 0.3, bounce: 0 }` — bounce must always be `0`. If no motion library is installed, keep both icons in the DOM (one absolute-positioned) and cross-fade with CSS transitions using `cubic-bezier(0.2, 0, 0, 1)` — this gives both enter and exit animations without any dependency.
|
||||
|
||||
### 8. Font Smoothing
|
||||
|
||||
Apply `-webkit-font-smoothing: antialiased` to the root layout on macOS for crisper text.
|
||||
|
||||
### 9. Tabular Numbers
|
||||
|
||||
Use `font-variant-numeric: tabular-nums` for any dynamically updating numbers to prevent layout shift.
|
||||
|
||||
### 10. Text Wrapping
|
||||
|
||||
Use `text-wrap: balance` on headings. Use `text-wrap: pretty` for body text to avoid orphans.
|
||||
|
||||
### 11. Image Outlines
|
||||
|
||||
Add a subtle `1px` outline with low opacity to images for consistent depth. The color must be pure black in light mode (`rgba(0, 0, 0, 0.1)`) and pure white in dark mode (`rgba(255, 255, 255, 0.1)`) — never a near-black like slate, zinc, or any tinted neutral. A tinted outline picks up the surface color underneath it and reads as dirt on the image edge.
|
||||
|
||||
### 12. Scale on Press
|
||||
|
||||
A subtle `scale(0.96)` on click gives buttons tactile feedback. Always use `0.96`. Never use a value smaller than `0.95` — anything below feels exaggerated. Add a `static` prop to disable it when motion would be distracting.
|
||||
|
||||
### 13. Skip Animation on Page Load
|
||||
|
||||
Use `initial={false}` on `AnimatePresence` to prevent enter animations on first render. Verify it doesn't break intentional entrance animations.
|
||||
|
||||
### 14. Never Use `transition: all`
|
||||
|
||||
Always specify exact properties: `transition-property: scale, opacity`. Tailwind's `transition-transform` covers `transform, translate, scale, rotate`.
|
||||
|
||||
### 15. Use `will-change` Sparingly
|
||||
|
||||
Only for `transform`, `opacity`, `filter` — properties the GPU can composite. Never use `will-change: all`. Only add when you notice first-frame stutter.
|
||||
|
||||
### 16. Minimum Hit Area
|
||||
|
||||
Interactive elements need at least 40×40px hit area. Extend with a pseudo-element if the visible element is smaller. Never let hit areas of two elements overlap.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
| --- | --- |
|
||||
| Same border radius on parent and child | Calculate `outerRadius = innerRadius + padding` |
|
||||
| Icons look off-center | Adjust optically with padding or fix SVG directly |
|
||||
| Hard borders between sections | Use layered `box-shadow` with transparency |
|
||||
| Jarring enter/exit animations | Split, stagger, and keep exits subtle |
|
||||
| Numbers cause layout shift | Apply `tabular-nums` |
|
||||
| Heavy text on macOS | Apply `antialiased` to root |
|
||||
| Animation plays on page load | Add `initial={false}` to `AnimatePresence` |
|
||||
| `transition: all` on elements | Specify exact properties |
|
||||
| First-frame animation stutter | Add `will-change: transform` (sparingly) |
|
||||
| Tiny hit areas on small controls | Extend with pseudo-element to 40×40px |
|
||||
|
||||
## Review Output Format
|
||||
|
||||
Always present changes as a markdown table with **Before** and **After** columns. Include every change you made — not just a subset. Never list findings as separate "Before:" / "After:" lines outside of a table. Group changes by principle using a heading above each table, and keep each row focused on a single diff so the reader can scan the whole list quickly.
|
||||
|
||||
### Example
|
||||
|
||||
#### Concentric border radius
|
||||
| Before | After |
|
||||
| --- | --- |
|
||||
| `rounded-xl` on card + `rounded-xl` on inner button (`p-2`) | `rounded-2xl` on card (`12 + 8`), `rounded-lg` on inner button |
|
||||
| `border-radius: 16px` on both nested surfaces | Outer `24px`, inner `16px` with `8px` padding |
|
||||
|
||||
#### Tabular numbers
|
||||
| Before | After |
|
||||
| --- | --- |
|
||||
| `<span>{count}</span>` on animated counter | `<span className="tabular-nums">{count}</span>` |
|
||||
| Default numerals on timer | Added `font-variant-numeric: tabular-nums` to root |
|
||||
|
||||
#### Scale on press
|
||||
| Before | After |
|
||||
| --- | --- |
|
||||
| `<button className="...">` | Added `active:scale-[0.96] transition-transform` |
|
||||
| `scale(0.9)` on press | Raised to `scale(0.96)` — anything below `0.95` feels exaggerated |
|
||||
|
||||
Rows should cite the specific file and the specific property that changed when it isn't obvious from the snippet. If a principle was reviewed but nothing needed to change, omit that table entirely — empty tables add noise.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- [ ] Nested rounded elements use concentric border radius
|
||||
- [ ] Icons are optically centered, not just geometrically
|
||||
- [ ] Shadows used instead of borders where appropriate
|
||||
- [ ] Enter animations are split and staggered
|
||||
- [ ] Exit animations are subtle
|
||||
- [ ] Dynamic numbers use tabular-nums
|
||||
- [ ] Font smoothing is applied
|
||||
- [ ] Headings use text-wrap: balance
|
||||
- [ ] Images have subtle outlines
|
||||
- [ ] Buttons use scale on press where appropriate
|
||||
- [ ] AnimatePresence uses `initial={false}` for default-state elements
|
||||
- [ ] No `transition: all` — only specific properties
|
||||
- [ ] `will-change` only on transform/opacity/filter, never `all`
|
||||
- [ ] Interactive elements have at least 40×40px hit area
|
||||
|
||||
## Reference Files
|
||||
|
||||
- [typography.md](typography.md) — Text wrapping, font smoothing, tabular numbers
|
||||
- [surfaces.md](surfaces.md) — Border radius, optical alignment, shadows, image outlines
|
||||
- [animations.md](animations.md) — Interruptible animations, enter/exit transitions, icon animations, scale on press
|
||||
- [performance.md](performance.md) — Transition specificity, `will-change` usage
|
||||
@@ -0,0 +1,379 @@
|
||||
# Animations
|
||||
|
||||
Interruptible animations, enter/exit transitions, and contextual icon animations.
|
||||
|
||||
## Interruptible Animations
|
||||
|
||||
Users change intent mid-interaction. If animations aren't interruptible, the interface feels broken.
|
||||
|
||||
### CSS Transitions vs. Keyframes
|
||||
|
||||
| | CSS Transitions | CSS Keyframe Animations |
|
||||
| --- | --- | --- |
|
||||
| **Behavior** | Interpolate toward latest state | Run on a fixed timeline |
|
||||
| **Interruptible** | Yes — retargets mid-animation | No — restarts from beginning |
|
||||
| **Use for** | Interactive state changes (hover, toggle, open/close) | Staged sequences that run once (enter animations, loading) |
|
||||
| **Duration** | Adapts to remaining distance | Fixed regardless of state |
|
||||
|
||||
```css
|
||||
/* Good — interruptible transition for a toggle */
|
||||
.drawer {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 200ms ease-out;
|
||||
}
|
||||
.drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Clicking again mid-animation smoothly reverses — no jank */
|
||||
```
|
||||
|
||||
```css
|
||||
/* Bad — keyframe animation for interactive element */
|
||||
.drawer.open {
|
||||
animation: slideIn 200ms ease-out forwards;
|
||||
}
|
||||
|
||||
/* Closing mid-animation snaps or restarts — feels broken */
|
||||
```
|
||||
|
||||
**Rule:** Always prefer CSS transitions for interactive elements. Reserve keyframes for one-shot sequences.
|
||||
|
||||
## Enter Animations: Split and Stagger
|
||||
|
||||
Don't animate a single large container. Break content into semantic chunks and animate each individually.
|
||||
|
||||
### Step by Step
|
||||
|
||||
1. **Split** into logical groups (title, description, buttons)
|
||||
2. **Stagger** with ~100ms delay between groups
|
||||
3. **For titles**, consider splitting into individual words with ~80ms stagger
|
||||
4. **Combine** `opacity`, `blur`, and `translateY` for the enter effect
|
||||
|
||||
### Code Example
|
||||
|
||||
```tsx
|
||||
// Motion (Framer Motion) — staggered enter
|
||||
function PageHeader() {
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
visible: { transition: { staggerChildren: 0.1 } },
|
||||
}}
|
||||
>
|
||||
<motion.h1
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
|
||||
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
|
||||
}}
|
||||
>
|
||||
Welcome
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
|
||||
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
|
||||
}}
|
||||
>
|
||||
A description of the page.
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
|
||||
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
|
||||
}}
|
||||
>
|
||||
<Button>Get started</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### CSS-Only Stagger
|
||||
|
||||
```css
|
||||
.stagger-item {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
filter: blur(4px);
|
||||
animation: fadeInUp 400ms ease-out forwards;
|
||||
}
|
||||
|
||||
.stagger-item:nth-child(1) { animation-delay: 0ms; }
|
||||
.stagger-item:nth-child(2) { animation-delay: 100ms; }
|
||||
.stagger-item:nth-child(3) { animation-delay: 200ms; }
|
||||
|
||||
@keyframes fadeInUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Exit Animations
|
||||
|
||||
Exit animations should be softer and less attention-grabbing than enter animations. The user's focus is moving to the next thing — don't fight for attention.
|
||||
|
||||
### Subtle Exit (Recommended)
|
||||
|
||||
```tsx
|
||||
// Small fixed translateY — indicates direction without drama
|
||||
<motion.div
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: -12,
|
||||
filter: "blur(4px)",
|
||||
transition: { duration: 0.15, ease: "easeIn" },
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
```
|
||||
|
||||
### Full Exit (When Context Matters)
|
||||
|
||||
```tsx
|
||||
// Slide fully out — use when spatial context is important
|
||||
// (e.g., a card returning to a list, a drawer closing)
|
||||
<motion.div
|
||||
exit={{
|
||||
opacity: 0,
|
||||
x: "-100%",
|
||||
transition: { duration: 0.2, ease: "easeIn" },
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
```
|
||||
|
||||
### Good vs. Bad
|
||||
|
||||
```css
|
||||
/* Good — subtle exit */
|
||||
.item-exit {
|
||||
opacity: 0;
|
||||
transform: translateY(-12px);
|
||||
transition: opacity 150ms ease-in, transform 150ms ease-in;
|
||||
}
|
||||
|
||||
/* Bad — dramatic exit that steals focus */
|
||||
.item-exit {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%) scale(0.5);
|
||||
transition: all 400ms ease-in;
|
||||
}
|
||||
|
||||
/* Bad — no exit animation at all (element just vanishes) */
|
||||
.item-exit {
|
||||
display: none;
|
||||
}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- Use a small fixed `translateY` (e.g., `-12px`) instead of the full container height
|
||||
- Keep some directional movement to indicate where the element went
|
||||
- Exit duration should be shorter than enter duration (150ms vs 300ms)
|
||||
- Don't remove exit animations entirely — subtle motion preserves context
|
||||
|
||||
## Contextual Icon Animations
|
||||
|
||||
When icons appear or disappear contextually (on hover, on state change), animate them with `opacity`, `scale`, and `blur` rather than just toggling visibility.
|
||||
|
||||
### Motion Example
|
||||
|
||||
```tsx
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
function IconButton({ isActive, icon: Icon }) {
|
||||
return (
|
||||
<button>
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.span
|
||||
key={isActive ? "active" : "inactive"}
|
||||
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
|
||||
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
|
||||
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
|
||||
>
|
||||
<Icon />
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### CSS Transition Approach (No Motion)
|
||||
|
||||
If the project doesn't use Motion (Framer Motion), keep both icons in the DOM and cross-fade them with CSS transitions. Because neither icon unmounts, both enter and exit animate smoothly.
|
||||
|
||||
The trick: one icon is absolutely positioned on top of the other. Toggling state cross-fades them — the entering icon scales up from `0.25` while the exiting icon scales down to `0.25`, both with opacity and blur.
|
||||
|
||||
```tsx
|
||||
function IconButton({ isActive, ActiveIcon, InactiveIcon }) {
|
||||
return (
|
||||
<button>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center",
|
||||
"transition-[opacity,filter,scale] duration-300",
|
||||
"cubic-bezier(0.2, 0, 0, 1)",
|
||||
isActive
|
||||
? "scale-100 opacity-100 blur-0"
|
||||
: "scale-[0.25] opacity-0 blur-[4px]"
|
||||
)}
|
||||
>
|
||||
<ActiveIcon />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"transition-[opacity,filter,scale] duration-300",
|
||||
"cubic-bezier(0.2, 0, 0, 1)",
|
||||
isActive
|
||||
? "scale-[0.25] opacity-0 blur-[4px]"
|
||||
: "scale-100 opacity-100 blur-0"
|
||||
)}
|
||||
>
|
||||
<InactiveIcon />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The non-absolute icon (InactiveIcon) defines the layout size. The absolute icon (ActiveIcon) overlays it without affecting flow.
|
||||
|
||||
### Choosing Between Motion and CSS
|
||||
|
||||
| | Motion (Framer Motion) | CSS transitions (both icons in DOM) |
|
||||
| --- | --- | --- |
|
||||
| **Enter animation** | Yes | Yes |
|
||||
| **Exit animation** | Yes (via `AnimatePresence`) | Yes (cross-fade — icon never unmounts) |
|
||||
| **Spring physics** | Yes | No — use `cubic-bezier(0.2, 0, 0, 1)` as approximation |
|
||||
| **When to use** | Project already uses `motion/react` | No motion dependency, or keeping bundle small |
|
||||
|
||||
**Rule:** Check the project's `package.json` for `motion` or `framer-motion`. If present, use the Motion approach. If not, use the CSS cross-fade pattern — don't add a dependency just for icon transitions.
|
||||
|
||||
### When to Animate Icons
|
||||
|
||||
| Animate | Don't animate |
|
||||
| --- | --- |
|
||||
| Icons that appear on hover (action buttons) | Static navigation icons |
|
||||
| State change icons (play → pause, like → liked) | Decorative icons |
|
||||
| Icons in contextual toolbars | Icons that are always visible |
|
||||
| Loading/success state indicators | Icon labels (text next to icon) |
|
||||
|
||||
**Important:** Always use exactly these values for contextual icon animations — do not deviate:
|
||||
- `scale`: `0.25` → `1` (never use `0.5` or `0.6`)
|
||||
- `opacity`: `0` → `1`
|
||||
- `filter`: `"blur(4px)"` → `"blur(0px)"`
|
||||
- `transition`: `{ type: "spring", duration: 0.3, bounce: 0 }` — **bounce must always be `0`**, never `0.1` or any other value
|
||||
|
||||
## Scale on Press
|
||||
|
||||
A subtle scale-down on click gives buttons tactile feedback. Always use `scale(0.96)`. Never use a value smaller than `0.95` — anything below feels exaggerated. Use CSS transitions for interruptibility — if the user releases mid-press, it should smoothly return.
|
||||
|
||||
Not every button needs this. Add a `static` prop to your button component that disables the scale effect when the motion would be distracting.
|
||||
|
||||
### CSS Example
|
||||
|
||||
```css
|
||||
.button {
|
||||
transition-property: scale;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.button:active {
|
||||
scale: 0.96;
|
||||
}
|
||||
```
|
||||
|
||||
### Tailwind Example
|
||||
|
||||
```tsx
|
||||
<button className="transition-transform duration-150 ease-out active:scale-[0.96]">
|
||||
Click me
|
||||
</button>
|
||||
```
|
||||
|
||||
### Motion Example
|
||||
|
||||
```tsx
|
||||
<motion.button whileTap={{ scale: 0.96 }}>
|
||||
Click me
|
||||
</motion.button>
|
||||
```
|
||||
|
||||
### Static Prop Pattern
|
||||
|
||||
Extract the scale class into a variable and conditionally apply it based on a `static` prop:
|
||||
|
||||
```tsx
|
||||
const tapScale = "active:not-disabled:scale-[0.96]";
|
||||
|
||||
function Button({ static: isStatic, className, children, ...props }) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"transition-transform duration-150 ease-out",
|
||||
!isStatic && tapScale,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
<Button>Click me</Button> {/* scales on press */}
|
||||
<Button static>Submit</Button> {/* no scale */}
|
||||
```
|
||||
|
||||
## Skip Animation on Page Load
|
||||
|
||||
Use `initial={false}` on `AnimatePresence` to prevent enter animations from firing on first render. Elements that are already in their default state shouldn't animate in on page load — only on subsequent state changes.
|
||||
|
||||
### When It Works
|
||||
|
||||
```tsx
|
||||
// Good — icon doesn't animate in on mount, only on state change
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
<motion.span
|
||||
key={isActive ? "active" : "inactive"}
|
||||
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
|
||||
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
|
||||
>
|
||||
<Icon />
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
```
|
||||
|
||||
Works well for: icon swaps, toggles, tabs, segmented controls — anything that has a default state on page load.
|
||||
|
||||
### When It Breaks
|
||||
|
||||
Don't use `initial={false}` when the component relies on its `initial` prop to set up a first-time enter animation, like a staggered page hero or a loading state. In those cases, removing the initial animation skips the entire entrance.
|
||||
|
||||
```tsx
|
||||
// Bad — initial={false} would skip the staggered page enter entirely
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.div initial="hidden" animate="visible" variants={...}>
|
||||
...
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
```
|
||||
|
||||
Verify the component still looks right on a full page refresh before applying this.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Performance
|
||||
|
||||
Transition specificity and GPU compositing hints.
|
||||
|
||||
## Transition Only What Changes
|
||||
|
||||
Never use `transition: all` or Tailwind's `transition` shorthand (which maps to `transition-property: all`). Always specify the exact properties that change.
|
||||
|
||||
### Why
|
||||
|
||||
- `transition: all` forces the browser to watch every property for changes
|
||||
- Causes unexpected transitions on properties you didn't intend to animate (colors, padding, shadows)
|
||||
- Prevents browser optimizations
|
||||
|
||||
### CSS Example
|
||||
|
||||
```css
|
||||
/* Good — only transition what changes */
|
||||
.button {
|
||||
transition-property: scale, background-color;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
/* Bad — transition everything */
|
||||
.button {
|
||||
transition: all 150ms ease-out;
|
||||
}
|
||||
```
|
||||
|
||||
### Tailwind
|
||||
|
||||
```tsx
|
||||
// Good — explicit properties
|
||||
<button className="transition-[scale,background-color] duration-150 ease-out">
|
||||
|
||||
// Bad — transition all
|
||||
<button className="transition duration-150 ease-out">
|
||||
```
|
||||
|
||||
### Tailwind `transition-transform` Note
|
||||
|
||||
`transition-transform` in Tailwind maps to `transition-property: transform, translate, scale, rotate` — it covers all transform-related properties, not just `transform`. Use this when you're only animating transforms. For multiple non-transform properties, use the bracket syntax: `transition-[scale,opacity,filter]`.
|
||||
|
||||
## Use `will-change` Sparingly
|
||||
|
||||
`will-change` hints the browser to pre-promote an element to its own GPU compositing layer. Without it, the browser promotes the element only when the animation starts — that one-time layer promotion can cause a micro-stutter on the first frame.
|
||||
|
||||
This particularly helps when an element is changing `scale`, `rotation`, or moving around with `transform`. For other properties, it doesn't help much — the browser can't composite them on the GPU anyway.
|
||||
|
||||
### Rules
|
||||
|
||||
```css
|
||||
/* Good — specific property that benefits from GPU compositing */
|
||||
.animated-card {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Good — multiple compositor-friendly properties */
|
||||
.animated-card {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Bad — never use will-change: all */
|
||||
.animated-card {
|
||||
will-change: all;
|
||||
}
|
||||
|
||||
/* Bad — properties that can't be GPU-composited anyway */
|
||||
.animated-card {
|
||||
will-change: background-color, padding;
|
||||
}
|
||||
```
|
||||
|
||||
### Useful Properties
|
||||
|
||||
| Property | GPU-compositable | Worth using `will-change` |
|
||||
| --- | --- | --- |
|
||||
| `transform` | Yes | Yes |
|
||||
| `opacity` | Yes | Yes |
|
||||
| `filter` (blur, brightness) | Yes | Yes |
|
||||
| `clip-path` | Yes | Yes |
|
||||
| `top`, `left`, `width`, `height` | No | No |
|
||||
| `background`, `border`, `color` | No | No |
|
||||
|
||||
### When to Skip
|
||||
|
||||
Modern browsers are already good at optimizing on their own. Only add `will-change` when you notice first-frame stutter — Safari in particular benefits from it. Don't add it preemptively to every animated element; each extra compositing layer costs memory.
|
||||
@@ -0,0 +1,256 @@
|
||||
# Surfaces
|
||||
|
||||
Border radius, optical alignment, shadows, and image outlines.
|
||||
|
||||
## Concentric Border Radius
|
||||
|
||||
When nesting rounded elements, the outer radius must equal the inner radius plus the padding between them:
|
||||
|
||||
```
|
||||
outerRadius = innerRadius + padding
|
||||
```
|
||||
|
||||
This rule is most useful when nested surfaces are close together. If padding is larger than `24px`, treat the layers as separate surfaces and choose each radius independently instead of forcing strict concentric math.
|
||||
|
||||
### Example
|
||||
|
||||
```css
|
||||
/* Good — concentric radii */
|
||||
.card {
|
||||
border-radius: 20px; /* 12 + 8 */
|
||||
padding: 8px;
|
||||
}
|
||||
.card-inner {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Bad — same radius on both */
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
.card-inner {
|
||||
border-radius: 12px;
|
||||
}
|
||||
```
|
||||
|
||||
### Tailwind Example
|
||||
|
||||
```tsx
|
||||
// Good — outer radius accounts for padding
|
||||
<div className="rounded-2xl p-2"> {/* 16px radius, 8px padding */}
|
||||
<div className="rounded-lg"> {/* 8px radius = 16 - 8 ✓ */}
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Bad — same radius on both
|
||||
<div className="rounded-xl p-2">
|
||||
<div className="rounded-xl"> {/* same radius, looks off */}
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Mismatched border radii on nested elements is one of the most common things that makes interfaces feel off. Always calculate concentrically.
|
||||
|
||||
## Optical Alignment
|
||||
|
||||
When geometric centering looks off, align optically instead.
|
||||
|
||||
### Buttons with Text + Icon
|
||||
|
||||
Use slightly less padding on the icon side to make the button feel balanced. A reliable rule of thumb is:
|
||||
`icon-side padding = text-side padding - 2px`.
|
||||
|
||||
```css
|
||||
/* Good — less padding on icon side */
|
||||
.button-with-icon {
|
||||
padding-left: 16px;
|
||||
padding-right: 14px; /* icon side = text side - 2px */
|
||||
}
|
||||
|
||||
/* Bad — equal padding looks like icon is pushed too far right */
|
||||
.button-with-icon {
|
||||
padding: 0 16px;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Tailwind
|
||||
<button className="pl-4 pr-3.5 flex items-center gap-2">
|
||||
<span>Continue</span>
|
||||
<ArrowRightIcon />
|
||||
</button>
|
||||
```
|
||||
|
||||
### Play Button Triangles
|
||||
|
||||
Play icons are triangular and their geometric center is not their visual center. Shift slightly right:
|
||||
|
||||
```css
|
||||
/* Good — optically centered */
|
||||
.play-button svg {
|
||||
margin-left: 2px; /* shift right to account for triangle shape */
|
||||
}
|
||||
|
||||
/* Bad — geometrically centered but looks off */
|
||||
.play-button svg {
|
||||
/* no adjustment */
|
||||
}
|
||||
```
|
||||
|
||||
### Asymmetric Icons (Stars, Arrows, Carets)
|
||||
|
||||
Some icons have uneven visual weight. The best fix is adjusting the SVG directly so no extra margin/padding is needed in the component code.
|
||||
|
||||
```tsx
|
||||
// Best — fix in the SVG itself
|
||||
// Adjust the viewBox or path to visually center the icon
|
||||
|
||||
// Fallback — adjust with margin
|
||||
<span className="ml-px">
|
||||
<StarIcon />
|
||||
</span>
|
||||
```
|
||||
|
||||
## Shadows Instead of Borders
|
||||
|
||||
For **buttons, cards, and containers** that use a border for depth or elevation, prefer replacing it with a subtle `box-shadow`. Shadows adapt to any background since they use transparency; solid borders don't. This also helps when using images or multiple colors as backgrounds — solid border colors don't work well on backgrounds other than the ones they were designed for.
|
||||
|
||||
**Do not apply this to dividers** (`border-b`, `border-t`, side borders) or any border whose purpose is layout separation rather than element depth. Those should stay as borders.
|
||||
|
||||
### Shadow as Border (Light Mode)
|
||||
|
||||
The shadow is comprised of three layers. The first acts as a 1px border ring, the second adds subtle lift, and the third provides ambient depth:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--shadow-border:
|
||||
0px 0px 0px 1px rgba(0, 0, 0, 0.06),
|
||||
0px 1px 2px -1px rgba(0, 0, 0, 0.06),
|
||||
0px 2px 4px 0px rgba(0, 0, 0, 0.04);
|
||||
--shadow-border-hover:
|
||||
0px 0px 0px 1px rgba(0, 0, 0, 0.08),
|
||||
0px 1px 2px -1px rgba(0, 0, 0, 0.08),
|
||||
0px 2px 4px 0px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
```
|
||||
|
||||
### Shadow as Border (Dark Mode)
|
||||
|
||||
In dark mode, simplify to a single white ring — layered depth shadows aren't visible on dark backgrounds:
|
||||
|
||||
```css
|
||||
/* Dark mode — adapt to whatever setup the project uses
|
||||
(prefers-color-scheme, class, data attribute, etc.) */
|
||||
--shadow-border: 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
--shadow-border-hover: 0 0 0 1px rgba(255, 255, 255, 0.13);
|
||||
```
|
||||
|
||||
### Usage with Hover Transition
|
||||
|
||||
Apply the variable and add `transition-[box-shadow]` for a smooth hover:
|
||||
|
||||
```css
|
||||
.card {
|
||||
box-shadow: var(--shadow-border);
|
||||
transition-property: box-shadow;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-border-hover);
|
||||
}
|
||||
```
|
||||
|
||||
### When to Use Shadows vs. Borders
|
||||
|
||||
| Use shadows | Use borders |
|
||||
| --- | --- |
|
||||
| Cards, containers with depth | Dividers between list items |
|
||||
| Buttons with bordered styles | Table cell boundaries |
|
||||
| Elevated elements (dropdowns, modals) | Form input outlines (for accessibility) |
|
||||
| Elements on varied backgrounds | Hairline separators in dense UI |
|
||||
| Hover/focus states for lift effect | |
|
||||
|
||||
## Image Outlines
|
||||
|
||||
Add a subtle `1px` outline with low opacity to images. This creates consistent depth, especially in design systems where other elements use borders or shadows.
|
||||
|
||||
### Color rules (non-negotiable)
|
||||
|
||||
- **Light mode**: pure black — `rgba(0, 0, 0, 0.1)`. Exact values: R=0, G=0, B=0.
|
||||
- **Dark mode**: pure white — `rgba(255, 255, 255, 0.1)`. Exact values: R=255, G=255, B=255.
|
||||
- Never use a near-black or near-white from the project palette (e.g. slate-900, zinc-900, `#0a0a0a`, `#111827`, `#f5f5f7`). Tinted outlines pick up the surrounding surface color and read as dirt on the image edge.
|
||||
- Never match the outline to the project's accent or ink color. The outline is a neutral separator, not a themed element.
|
||||
|
||||
### Light Mode
|
||||
|
||||
```css
|
||||
img {
|
||||
outline: 1px solid rgba(0, 0, 0, 0.1);
|
||||
outline-offset: -1px; /* inset so it doesn't add to layout */
|
||||
}
|
||||
```
|
||||
|
||||
### Dark Mode
|
||||
|
||||
```css
|
||||
img {
|
||||
outline: 1px solid rgba(255, 255, 255, 0.1);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
```
|
||||
|
||||
### Tailwind with Dark Mode
|
||||
|
||||
```tsx
|
||||
<img
|
||||
className="outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10"
|
||||
src={src}
|
||||
alt={alt}
|
||||
/>
|
||||
```
|
||||
|
||||
Use `outline-black/10` and `outline-white/10` specifically — not `outline-slate-*`, `outline-zinc-*`, `outline-neutral-*`, or any tinted scale.
|
||||
|
||||
**Why outline instead of border?** `outline` doesn't affect layout (no added width/height), and `outline-offset: -1px` keeps it inset so images stay their intended size.
|
||||
|
||||
## Minimum Hit Area
|
||||
|
||||
Interactive elements should have a minimum hit area of 44×44px (WCAG) or at least 40×40px. If the visible element is smaller (e.g., a 20×20 checkbox), extend the hit area with a pseudo-element.
|
||||
|
||||
### CSS Example
|
||||
|
||||
```css
|
||||
/* Small checkbox with expanded hit area */
|
||||
.checkbox {
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.checkbox::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
```
|
||||
|
||||
### Tailwind Example
|
||||
|
||||
```tsx
|
||||
<button className="relative size-5 after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2">
|
||||
<CheckIcon />
|
||||
</button>
|
||||
```
|
||||
|
||||
### Collision Rule
|
||||
|
||||
If the extended hit area overlaps another interactive element, shrink the pseudo-element — but make it as large as possible without colliding. Two interactive elements should never have overlapping hit areas.
|
||||
@@ -0,0 +1,135 @@
|
||||
# Typography
|
||||
|
||||
Typography rendering details that make interfaces feel better.
|
||||
|
||||
## Text Wrapping
|
||||
|
||||
### text-wrap: balance
|
||||
|
||||
Distributes text evenly across lines, preventing orphaned words on headings and short text blocks. **Only works on blocks of 6 lines or fewer** (Chromium) or 10 lines or fewer (Firefox) — the balancing algorithm is computationally expensive, so browsers limit it to short text.
|
||||
|
||||
```css
|
||||
/* Good — even line lengths on short text */
|
||||
h1, h2, h3 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
/* Bad — default wrapping leaves orphans */
|
||||
h1 {
|
||||
/* no text-wrap rule → "Read our
|
||||
blog" instead of balanced lines */
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
/* Bad — balance on long paragraphs (silently ignored, wastes intent) */
|
||||
.article-body p {
|
||||
text-wrap: balance;
|
||||
}
|
||||
```
|
||||
|
||||
**Tailwind:** `text-balance`
|
||||
|
||||
### text-wrap: pretty
|
||||
|
||||
Prevents orphaned words (a single word dangling on the last line) by adjusting line breaks throughout the paragraph. Unlike `balance`, it doesn't try to equalize line lengths — it just ensures the last line isn't embarrassingly short. Works on text of any length with no line-count limit.
|
||||
|
||||
This should be your **default for short-to-medium text** — paragraphs, descriptions, captions, list items, card text. For very long text (10+ lines), skip both `pretty` and `balance` — the browser's default wrapping is fine and you avoid unnecessary layout cost.
|
||||
|
||||
```css
|
||||
/* Good — descriptions, captions, short paragraphs */
|
||||
p, li, figcaption, blockquote {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Tailwind
|
||||
<p className="text-pretty">
|
||||
A short paragraph that won't leave an orphan on the last line.
|
||||
</p>
|
||||
```
|
||||
|
||||
**Tailwind:** `text-pretty`
|
||||
|
||||
### When to Use Which
|
||||
|
||||
| Scenario | Use |
|
||||
| --- | --- |
|
||||
| Headings, titles where even distribution matters | `text-wrap: balance` |
|
||||
| Short-to-medium text — paragraphs, descriptions, captions, UI text | `text-wrap: pretty` |
|
||||
| Long text (10+ lines), code blocks, pre-formatted text | Neither — leave default |
|
||||
|
||||
## Font Smoothing (macOS)
|
||||
|
||||
On macOS, text renders heavier than intended by default. Apply antialiased smoothing to the root layout so all text renders crisper and thinner.
|
||||
|
||||
```css
|
||||
/* CSS */
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Tailwind — apply to root layout
|
||||
<html className="antialiased">
|
||||
```
|
||||
|
||||
### Good vs. Bad
|
||||
|
||||
```css
|
||||
/* Good — applied once at the root */
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Bad — applied per-element, inconsistent */
|
||||
.heading {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.body {
|
||||
/* no smoothing → heavier than heading */
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** This only affects macOS rendering. Other platforms ignore these properties, so it's safe to apply universally.
|
||||
|
||||
## Tabular Numbers
|
||||
|
||||
When numbers update dynamically (counters, prices, timers, table columns), use tabular-nums to make all digits equal width. This prevents layout shift as values change.
|
||||
|
||||
```css
|
||||
/* CSS */
|
||||
.counter {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Tailwind
|
||||
<span className="tabular-nums">{count}</span>
|
||||
```
|
||||
|
||||
### When to Use
|
||||
|
||||
| Use tabular-nums | Don't use tabular-nums |
|
||||
| --- | --- |
|
||||
| Counters and timers | Static display numbers |
|
||||
| Prices that update | Decorative large numbers |
|
||||
| Table columns with numbers | Phone numbers, zip codes |
|
||||
| Animated number transitions | Version numbers (v2.1.0) |
|
||||
| Scoreboards, dashboards | |
|
||||
|
||||
### Caveat
|
||||
|
||||
Some fonts (like Inter) change the visual appearance of numerals with this property — specifically, the digit `1` becomes wider and centered. This is expected behavior and usually desirable for alignment, but verify it looks right in your specific font.
|
||||
|
||||
```css
|
||||
/* With Inter font:
|
||||
Default: 1234 → proportional, "1" is narrow
|
||||
Tabular: 1234 → all digits equal width, "1" centered */
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
../../.agents/skills/make-interfaces-feel-better
|
||||
@@ -27,6 +27,7 @@ interface FooterItem {
|
||||
}
|
||||
|
||||
const PRODUCT_LINKS: FooterItem[] = [
|
||||
{ label: 'Enterprise', href: '/enterprise' },
|
||||
{ label: 'Mothership', href: 'https://docs.sim.ai/mothership', external: true },
|
||||
{ label: 'Workflows', href: 'https://docs.sim.ai', external: true },
|
||||
{ label: 'Knowledge Base', href: 'https://docs.sim.ai/knowledgebase', external: true },
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { HeroStat } from '@/app/(landing)/components/hero/components/hero-stat'
|
||||
import { HeroCta } from '@/app/(landing)/components/hero-cta'
|
||||
import { LANDING_HERO_CTA_GAP } from '@/app/(landing)/components/landing-layout'
|
||||
|
||||
interface LandingHeroHeaderProps {
|
||||
description: string
|
||||
eyebrow?: ReactNode
|
||||
heading: ReactNode
|
||||
headingId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared homepage hero header geometry. Marketing routes use this component so
|
||||
* the headline measure, CTA stack, and right-side global-work stat cannot drift.
|
||||
*/
|
||||
export function LandingHeroHeader({
|
||||
description,
|
||||
eyebrow,
|
||||
heading,
|
||||
headingId,
|
||||
}: LandingHeroHeaderProps) {
|
||||
return (
|
||||
<div className='flex w-full items-end justify-between gap-8'>
|
||||
<div className='flex min-w-0 flex-1 flex-col items-start gap-[22px] text-left'>
|
||||
{eyebrow}
|
||||
|
||||
<h1
|
||||
id={headingId}
|
||||
className='max-w-[1120px] text-balance text-[64px] text-[var(--text-primary)] leading-[1.05] tracking-[-0.01em] max-sm:text-[36px] max-xl:text-[52px] [&>br]:max-sm:hidden'
|
||||
>
|
||||
{heading}
|
||||
</h1>
|
||||
|
||||
<p className='w-full min-w-0 max-w-[58ch] text-pretty text-[var(--text-muted)] text-base leading-[1.5]'>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className={cn('max-sm:w-full', LANDING_HERO_CTA_GAP)}>
|
||||
<HeroCta />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HeroStat />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { LandingHeroHeader } from './hero-header'
|
||||
+38
-21
@@ -10,7 +10,10 @@ import {
|
||||
STAGE_EDGES,
|
||||
verticalSmoothStep,
|
||||
} from '@/app/(landing)/components/hero/components/hero-platform-loop/stage-data'
|
||||
import { BLOCK_WIDTH } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
import {
|
||||
BLOCK_WIDTH,
|
||||
type BlockDef,
|
||||
} from '@/app/(landing)/components/hero/components/hero-visual/workflow-data'
|
||||
|
||||
/** Upper bound on the canvas render scale (the scale at the full 1300px cap). */
|
||||
const MAX_STAGE_SCALE = 0.71
|
||||
@@ -18,12 +21,16 @@ const MAX_STAGE_SCALE = 0.71
|
||||
const STAGE_MARGIN = 20
|
||||
|
||||
interface HeroWorkflowStageProps {
|
||||
/** How many of {@link STAGE_BLOCKS} (in build order) are on canvas. */
|
||||
/** How many of the stage's blocks (in build order) are on canvas. */
|
||||
builtCount: number
|
||||
/** Blocks to stage, in build order. Defaults to the homepage's lead flow. */
|
||||
blocks?: BlockDef[]
|
||||
/** Source → target pairs among {@link blocks}. Defaults with them. */
|
||||
edges?: ReadonlyArray<readonly [string, string]>
|
||||
/** Design-space bounding box of the block layout. Defaults with them. */
|
||||
canvas?: { width: number; height: number }
|
||||
}
|
||||
|
||||
const STAGE_BLOCKS_BY_ID = new Map(STAGE_BLOCKS.map((b) => [b.id, b]))
|
||||
|
||||
/**
|
||||
* The hero window's live workflow canvas - the right-pane counterpart of the
|
||||
* chat loop. Blocks pop in one by one as `builtCount` advances (staggered
|
||||
@@ -38,10 +45,20 @@ const STAGE_BLOCKS_BY_ID = new Map(STAGE_BLOCKS.map((b) => [b.id, b]))
|
||||
* Blocks reuse the hero-visual's {@link WorkflowBlockContent} (the faithful
|
||||
* icon-tile + rows card body) in a card shell with vertical-flow handle nubs
|
||||
* (top in / bottom out), matching the real editor's vertical layout.
|
||||
*
|
||||
* The staged flow is injectable (`blocks`/`edges`/`canvas`), defaulting to the
|
||||
* homepage's lead-enrichment flow - the enterprise loop stages its own flow
|
||||
* through the same component.
|
||||
*/
|
||||
export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
|
||||
export function HeroWorkflowStage({
|
||||
builtCount,
|
||||
blocks = STAGE_BLOCKS,
|
||||
edges = STAGE_EDGES,
|
||||
canvas = STAGE_CANVAS,
|
||||
}: HeroWorkflowStageProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [scale, setScale] = useState(MAX_STAGE_SCALE)
|
||||
const blocksById = useMemo(() => new Map(blocks.map((b) => [b.id, b])), [blocks])
|
||||
|
||||
// Fit the design canvas to the card: scale down when the pane narrows so the
|
||||
// branch blocks never clip, capped at the full-width scale. Measures LAYOUT
|
||||
@@ -58,8 +75,8 @@ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
|
||||
setScale(
|
||||
Math.min(
|
||||
MAX_STAGE_SCALE,
|
||||
(w - STAGE_MARGIN) / STAGE_CANVAS.width,
|
||||
(h - STAGE_MARGIN) / STAGE_CANVAS.height
|
||||
(w - STAGE_MARGIN) / canvas.width,
|
||||
(h - STAGE_MARGIN) / canvas.height
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -67,11 +84,11 @@ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
}, [canvas.width, canvas.height])
|
||||
|
||||
const builtIds = useMemo(
|
||||
() => new Set(STAGE_BLOCKS.slice(0, builtCount).map((b) => b.id)),
|
||||
[builtCount]
|
||||
() => new Set(blocks.slice(0, builtCount).map((b) => b.id)),
|
||||
[blocks, builtCount]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -82,30 +99,30 @@ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
|
||||
<div
|
||||
className='relative shrink-0'
|
||||
style={{
|
||||
width: STAGE_CANVAS.width * scale,
|
||||
height: STAGE_CANVAS.height * scale,
|
||||
width: canvas.width * scale,
|
||||
height: canvas.height * scale,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='absolute top-0 left-0'
|
||||
style={{
|
||||
width: STAGE_CANVAS.width,
|
||||
height: STAGE_CANVAS.height,
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className='pointer-events-none absolute inset-0 overflow-visible'
|
||||
width={STAGE_CANVAS.width}
|
||||
height={STAGE_CANVAS.height}
|
||||
viewBox={`0 0 ${STAGE_CANVAS.width} ${STAGE_CANVAS.height}`}
|
||||
width={canvas.width}
|
||||
height={canvas.height}
|
||||
viewBox={`0 0 ${canvas.width} ${canvas.height}`}
|
||||
fill='none'
|
||||
aria-hidden='true'
|
||||
>
|
||||
{STAGE_EDGES.map(([from, to]) => {
|
||||
const source = STAGE_BLOCKS_BY_ID.get(from)
|
||||
const target = STAGE_BLOCKS_BY_ID.get(to)
|
||||
{edges.map(([from, to]) => {
|
||||
const source = blocksById.get(from)
|
||||
const target = blocksById.get(to)
|
||||
if (!source || !target) return null
|
||||
const visible = builtIds.has(from) && builtIds.has(to)
|
||||
const s = handleAnchors(source).out
|
||||
@@ -125,7 +142,7 @@ export function HeroWorkflowStage({ builtCount }: HeroWorkflowStageProps) {
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{STAGE_BLOCKS.map((block) => {
|
||||
{blocks.map((block) => {
|
||||
const built = builtIds.has(block.id)
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import Image from 'next/image'
|
||||
import { LandingHeroHeader } from '@/app/(landing)/components/hero/components/hero-header'
|
||||
import { HeroPlatformLoop } from '@/app/(landing)/components/hero/components/hero-platform-loop'
|
||||
import { HeroStat } from '@/app/(landing)/components/hero/components/hero-stat'
|
||||
import { HeroCta } from '@/app/(landing)/components/hero-cta'
|
||||
import {
|
||||
LANDING_CONTENT_WIDTH,
|
||||
LANDING_GUTTER,
|
||||
LANDING_HERO_TOP_PADDING,
|
||||
} from '@/app/(landing)/components/landing-layout'
|
||||
import { TrustedBy } from '@/app/(landing)/components/trusted-by'
|
||||
|
||||
/**
|
||||
@@ -63,7 +68,12 @@ export function Hero() {
|
||||
<section
|
||||
id='hero'
|
||||
aria-labelledby='hero-heading'
|
||||
className='mx-auto flex w-full max-w-[1460px] flex-col items-start gap-[22px] px-20 pt-[112px] text-left max-sm:px-5 max-sm:pt-12 max-lg:px-8 max-xl:pt-20'
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-[22px] text-left',
|
||||
LANDING_CONTENT_WIDTH,
|
||||
LANDING_GUTTER,
|
||||
LANDING_HERO_TOP_PADDING
|
||||
)}
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Connect
|
||||
@@ -72,25 +82,16 @@ export function Hero() {
|
||||
production-ready for teams of every size.
|
||||
</p>
|
||||
|
||||
<div className='flex w-full items-end justify-between gap-8'>
|
||||
<div className='flex flex-col gap-[22px]'>
|
||||
<h1
|
||||
id='hero-heading'
|
||||
className='text-balance text-[64px] text-[var(--text-primary)] leading-[1.05] tracking-[-0.01em] max-sm:text-[36px] max-xl:text-[52px] [&>br]:max-sm:hidden'
|
||||
>
|
||||
<LandingHeroHeader
|
||||
headingId='hero-heading'
|
||||
heading={
|
||||
<>
|
||||
Sim is your AI workspace <br />
|
||||
for building agentic workflows.
|
||||
</h1>
|
||||
|
||||
<p className='text-[var(--text-muted)] text-base leading-[1.5]'>
|
||||
The open-source workspace where teams build, deploy, and manage AI agents.
|
||||
</p>
|
||||
|
||||
<HeroCta />
|
||||
</div>
|
||||
|
||||
<HeroStat />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
description='The open-source workspace where teams build, deploy, and manage AI agents.'
|
||||
/>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Shared centered width used by the landing navbar and every primary section. */
|
||||
export const LANDING_CONTENT_WIDTH = 'mx-auto w-full max-w-[1460px]'
|
||||
|
||||
/** Shared responsive horizontal gutter used across the landing family. */
|
||||
export const LANDING_GUTTER = 'px-20 max-sm:px-5 max-lg:px-8'
|
||||
|
||||
/** Shared responsive top clearance for the first landing hero beneath the navbar. */
|
||||
export const LANDING_HERO_TOP_PADDING = 'pt-[112px] max-sm:pt-12 max-xl:pt-20'
|
||||
|
||||
/** Shared vertical rhythm between top-level landing sections. */
|
||||
export const LANDING_SECTION_RHYTHM = 'gap-[120px] max-sm:gap-16 max-lg:gap-[88px]'
|
||||
|
||||
/**
|
||||
* Extra top separation for a hero CTA over the hero stack gap. Headline and
|
||||
* description are one copy group and keep the tight 22px stack gap; the CTA is
|
||||
* a separate action group, so its description→CTA gap lands at 34px (22 + 12),
|
||||
* roughly 1.5× the headline→description gap.
|
||||
*/
|
||||
export const LANDING_HERO_CTA_GAP = 'mt-3'
|
||||
@@ -38,7 +38,10 @@ interface MobileNavProps {
|
||||
* {@link NAV_MENUS} automatically expands them here as grouped sections too - the
|
||||
* sheet mirrors the desktop nav's information architecture with no extra edit.
|
||||
*/
|
||||
const STANDALONE_LINKS = [{ label: 'Pricing', href: '/pricing' }] as const
|
||||
const STANDALONE_LINKS = [
|
||||
{ label: 'Enterprise', href: '/enterprise' },
|
||||
{ label: 'Pricing', href: '/pricing' },
|
||||
] as const
|
||||
|
||||
/** Shared row chrome for every tappable text link in the sheet. */
|
||||
const SHEET_ROW =
|
||||
@@ -151,11 +154,12 @@ export function MobileNav({ stars }: MobileNavProps) {
|
||||
|
||||
<div className='mt-3 flex flex-col gap-2'>
|
||||
<ChipLink
|
||||
variant='border'
|
||||
href='/login'
|
||||
fullWidth
|
||||
flush
|
||||
prefetch={false}
|
||||
className='h-[40px] justify-center border border-[var(--border-1)] [&>span]:flex-none'
|
||||
className='h-[40px] justify-center [&>span]:flex-none'
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Log in
|
||||
|
||||
@@ -37,13 +37,14 @@ interface NavbarShellProps {
|
||||
* Sticky navbar chrome that frosts to glass once the page scrolls (or, on mobile,
|
||||
* while the dropdown menu is open).
|
||||
*
|
||||
* At the very top the bar is transparent, seamless over the hero canvas. A 1px
|
||||
* sentinel at the top of the scroll content is watched by an
|
||||
* {@link IntersectionObserver} - no scroll listener (landing perf rules) and no
|
||||
* per-frame work; it fires once when the sentinel leaves the viewport. Past that
|
||||
* point the bar gains the shared {@link NAVBAR_GLASS_SURFACE} (`--bg` at 92% via
|
||||
* `color-mix` plus a strong 40px backdrop blur) - a white/glass surface built
|
||||
* entirely from the platform's light tokens, not invented colors.
|
||||
* At the very top the bar uses the same solid canvas token as the hero, so it is
|
||||
* visually seamless while still preventing route content from painting through
|
||||
* the sticky header. A 1px sentinel at the top of the landing shell's internal
|
||||
* scroll port is watched by an {@link IntersectionObserver} - no scroll listener
|
||||
* and no per-frame work. Past that point the bar gains the shared
|
||||
* {@link NAVBAR_GLASS_SURFACE} (`--bg` at 92% via `color-mix` plus a strong 40px
|
||||
* backdrop blur) - a white/glass surface built entirely from the platform's
|
||||
* light tokens, not invented colors.
|
||||
*
|
||||
* Only `background-color` is transitioned, NOT `backdrop-filter`: animating the
|
||||
* blur re-runs every time the threshold is re-crossed, which on mobile reads as a
|
||||
@@ -78,7 +79,12 @@ export function NavbarShell({ children }: NavbarShellProps) {
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current
|
||||
if (!sentinel) return
|
||||
const observer = new IntersectionObserver(([entry]) => setScrolled(!entry.isIntersecting))
|
||||
const scrollPort = sentinel.parentElement
|
||||
if (!scrollPort) return
|
||||
|
||||
const observer = new IntersectionObserver(([entry]) => setScrolled(!entry.isIntersecting), {
|
||||
root: scrollPort,
|
||||
})
|
||||
observer.observe(sentinel)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
@@ -92,8 +98,8 @@ export function NavbarShell({ children }: NavbarShellProps) {
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={cn(
|
||||
'-z-10 absolute inset-0 transition-[background-color] duration-200 motion-reduce:transition-none',
|
||||
scrolled || menuOpen ? NAVBAR_GLASS_SURFACE : 'bg-transparent'
|
||||
'-z-10 pointer-events-none absolute inset-0 transition-[background-color] duration-200 motion-reduce:transition-none',
|
||||
scrolled || menuOpen ? NAVBAR_GLASS_SURFACE : 'bg-[var(--bg)]'
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
|
||||
@@ -74,6 +74,9 @@ export function Navbar({ stars, logoOnly = false }: NavbarProps) {
|
||||
{NAV_MENUS.map((menu) => (
|
||||
<NavMenuChip key={menu.label} menu={menu} />
|
||||
))}
|
||||
<ChipLink href='/enterprise' itemProp='url'>
|
||||
Enterprise
|
||||
</ChipLink>
|
||||
<ChipLink href='/pricing' itemProp='url'>
|
||||
Pricing
|
||||
</ChipLink>
|
||||
@@ -84,7 +87,7 @@ export function Navbar({ stars, logoOnly = false }: NavbarProps) {
|
||||
<ChipLink href='/login' prefetch={false}>
|
||||
Log in
|
||||
</ChipLink>
|
||||
<ChipLink href='/demo' className='border border-[var(--border-1)]'>
|
||||
<ChipLink variant='border' href='/demo'>
|
||||
Contact sales
|
||||
</ChipLink>
|
||||
<ChipLink variant='primary' href='/signup' prefetch={false}>
|
||||
|
||||
+4
-1
@@ -1,5 +1,6 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import { HeroCta } from '@/app/(landing)/components/hero-cta'
|
||||
import { LANDING_HERO_CTA_GAP } from '@/app/(landing)/components/landing-layout'
|
||||
import { PlatformVisualFrame } from '@/app/(landing)/components/platform-page/components/platform-visual-frame'
|
||||
import { PLATFORM_SPACING } from '@/app/(landing)/components/platform-page/constants'
|
||||
import type { PlatformHeroConfig } from '@/app/(landing)/components/platform-page/types'
|
||||
@@ -49,7 +50,9 @@ export function PlatformHero({ hero }: PlatformHeroProps) {
|
||||
{hero.description}
|
||||
</p>
|
||||
|
||||
<HeroCta />
|
||||
<div className={cn('max-sm:w-full', LANDING_HERO_CTA_GAP)}>
|
||||
<HeroCta />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PlatformVisualFrame size='hero'>{hero.visual}</PlatformVisualFrame>
|
||||
|
||||
+1
@@ -1,2 +1,3 @@
|
||||
export { SolutionsCard } from './solutions-card'
|
||||
export { SolutionsCardRowHeader } from './solutions-card-row-header'
|
||||
export { SolutionsPillCta } from './solutions-pill-cta'
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { SolutionsCardRowHeader } from './solutions-card-row-header'
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import { SolutionsPillCta } from '@/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-pill-cta'
|
||||
import {
|
||||
SOLUTIONS_SPACING,
|
||||
SOLUTIONS_TEXT_MEASURE,
|
||||
} from '@/app/(landing)/components/solutions-page/constants'
|
||||
import type { SolutionsCardRowConfig } from '@/app/(landing)/components/solutions-page/types'
|
||||
|
||||
/**
|
||||
* The header block of a card row - an `<h2>` title, a body-color subtitle, and
|
||||
* a single pill CTA, stacked with named spacing constants. Extracted from
|
||||
* {@link SolutionsCardRow} so layouts that place row headers inside a shared
|
||||
* grid (the enterprise feature grid) render the exact same header chrome.
|
||||
*
|
||||
* Only the row header stack can opt into centered text; the `feature` variant
|
||||
* is the tighter, smaller treatment used by feature-tile pages.
|
||||
*/
|
||||
|
||||
interface SolutionsCardRowHeaderProps {
|
||||
row: SolutionsCardRowConfig
|
||||
/** Stable id wiring the `<h2>` into the page outline. */
|
||||
headingId: string
|
||||
/** Header stack alignment. Defaults to the original left-aligned layout. */
|
||||
align?: 'left' | 'center'
|
||||
/** Header typography treatment. Defaults to the original larger solutions header. */
|
||||
variant?: 'standard' | 'feature'
|
||||
}
|
||||
|
||||
export function SolutionsCardRowHeader({
|
||||
row,
|
||||
headingId,
|
||||
align = 'left',
|
||||
variant = 'standard',
|
||||
}: SolutionsCardRowHeaderProps) {
|
||||
const centered = align === 'center'
|
||||
const featureHeader = variant === 'feature'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col',
|
||||
centered ? 'items-center text-center' : 'items-start text-left',
|
||||
featureHeader ? 'gap-3' : SOLUTIONS_SPACING.cardRowHeaderStack
|
||||
)}
|
||||
>
|
||||
<h2
|
||||
id={headingId}
|
||||
className={cn(
|
||||
'text-balance text-[var(--text-primary)] leading-[1.3]',
|
||||
featureHeader
|
||||
? 'max-w-[540px] font-medium text-[22px] max-sm:text-[20px]'
|
||||
: 'max-w-[760px] text-[32px] max-sm:text-[24px]'
|
||||
)}
|
||||
>
|
||||
{row.title}
|
||||
</h2>
|
||||
<p
|
||||
className={cn(
|
||||
featureHeader ? 'w-full min-w-0 max-w-[48ch]' : SOLUTIONS_TEXT_MEASURE.rowSubtitle,
|
||||
'text-pretty',
|
||||
featureHeader
|
||||
? 'text-[15px] text-[var(--text-muted)] leading-[1.6]'
|
||||
: 'text-[20px] text-[var(--text-body)] leading-[1.5]'
|
||||
)}
|
||||
>
|
||||
{row.subtitle}
|
||||
</p>
|
||||
<div
|
||||
className={
|
||||
featureHeader
|
||||
? SOLUTIONS_SPACING.cardRowHeaderCtaGapFeature
|
||||
: SOLUTIONS_SPACING.cardRowHeaderCtaGap
|
||||
}
|
||||
>
|
||||
<SolutionsPillCta cta={row.cta} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+74
-17
@@ -1,40 +1,97 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import { SolutionsVisualFrame } from '@/app/(landing)/components/solutions-page/components/solutions-visual-frame'
|
||||
import { SOLUTIONS_SPACING } from '@/app/(landing)/components/solutions-page/constants'
|
||||
import {
|
||||
SOLUTIONS_FEATURE_TILE_TONE,
|
||||
SOLUTIONS_SPACING,
|
||||
SOLUTIONS_TEXT_MEASURE,
|
||||
SOLUTIONS_VISUAL,
|
||||
} from '@/app/(landing)/components/solutions-page/constants'
|
||||
import type { SolutionsCardConfig } from '@/app/(landing)/components/solutions-page/types'
|
||||
|
||||
/**
|
||||
* A single solutions card - an `<article>` with an `<h3>` title, a body-color
|
||||
* description, and a reserved visual panel beneath. Text sits directly on the
|
||||
* canvas (matching the hero and every other landing section); only the visual
|
||||
* carries the `--surface-2` panel chrome, so the product mock reads as the one
|
||||
* elevated surface and never blends into a competing card fill.
|
||||
* description, and a reserved visual area. The default split variant keeps copy
|
||||
* on the page canvas with a framed visual beneath it. The feature-tile variant
|
||||
* moves copy and the visual slot into one larger bordered surface for pages that
|
||||
* need a unified callout card.
|
||||
*
|
||||
* The card owns the gap between its text and visual (`cardTextToVisual`) and the
|
||||
* title→description stack (`cardTextStack`) - both from named spacing constants.
|
||||
* The text block grows (`flex-1`) so the visual pins to the bottom of the
|
||||
* grid-stretched cell: every card's visual aligns on one baseline regardless of
|
||||
* description length. The visual lands in a fixed-height {@link SolutionsVisualFrame}
|
||||
* so the row stays uniform and CLS is zero.
|
||||
* The split variant lands the visual in a fixed-height
|
||||
* {@link SolutionsVisualFrame}; the feature-tile variant reserves a larger
|
||||
* flexible slot inside its own frame for future UI.
|
||||
*
|
||||
* A content unit only: it accepts copy and a visual node, never any layout knob.
|
||||
* A content unit only: it accepts copy and a visual node plus a controlled visual
|
||||
* treatment, never arbitrary spacing or class overrides.
|
||||
*/
|
||||
|
||||
interface SolutionsCardProps {
|
||||
card: SolutionsCardConfig
|
||||
/** Stable id wiring the `<h3>` into the page outline. */
|
||||
headingId: string
|
||||
/** Visual treatment. Defaults to the original split text + framed visual layout. */
|
||||
variant?: 'split' | 'featureTile'
|
||||
}
|
||||
|
||||
export function SolutionsCard({ card, headingId }: SolutionsCardProps) {
|
||||
export function SolutionsCard({ card, headingId, variant = 'split' }: SolutionsCardProps) {
|
||||
const featureTile = variant === 'featureTile'
|
||||
const featureTileTone = SOLUTIONS_FEATURE_TILE_TONE[card.featureTileTone ?? 'light']
|
||||
const featureTileDescription =
|
||||
card.featureTileDescriptionTone === 'soft' && card.featureTileTone === 'dark'
|
||||
? SOLUTIONS_FEATURE_TILE_TONE.dark.descriptionSoft
|
||||
: featureTileTone.description
|
||||
const textBlock = (
|
||||
<div className={cn('flex flex-col', SOLUTIONS_SPACING.cardTextStack)}>
|
||||
<h3
|
||||
id={headingId}
|
||||
className={cn(
|
||||
'leading-[1.3]',
|
||||
featureTile
|
||||
? cn('font-medium text-[16px]', featureTileTone.title)
|
||||
: 'text-[18px] text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
{card.title}
|
||||
</h3>
|
||||
<p
|
||||
className={cn(
|
||||
SOLUTIONS_TEXT_MEASURE.cardDescription,
|
||||
'text-pretty leading-[1.5]',
|
||||
featureTile
|
||||
? cn('text-[14px]', featureTileDescription)
|
||||
: 'text-[15px] text-[var(--text-body)]'
|
||||
)}
|
||||
>
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (featureTile) {
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'flex h-full flex-col overflow-hidden rounded-lg',
|
||||
featureTileTone.surface,
|
||||
SOLUTIONS_VISUAL.featureTileMinHeight,
|
||||
SOLUTIONS_SPACING.cardFeatureTilePadding
|
||||
)}
|
||||
>
|
||||
{textBlock}
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='-mr-8 -mb-8 max-lg:-mr-6 max-lg:-mb-6 mt-8 min-h-[240px] w-[calc(100%+2rem)] flex-1 max-lg:min-h-[210px] max-lg:w-[calc(100%+1.5rem)]'
|
||||
>
|
||||
<div className='h-full w-full'>{card.visual}</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={cn('flex h-full flex-col', SOLUTIONS_SPACING.cardTextToVisual)}>
|
||||
<div className={cn('flex flex-1 flex-col', SOLUTIONS_SPACING.cardTextStack)}>
|
||||
<h3 id={headingId} className='text-[18px] text-[var(--text-primary)] leading-[1.3]'>
|
||||
{card.title}
|
||||
</h3>
|
||||
<p className='text-[15px] text-[var(--text-body)] leading-[1.5]'>{card.description}</p>
|
||||
</div>
|
||||
<div className='flex flex-1 flex-col'>{textBlock}</div>
|
||||
|
||||
<SolutionsVisualFrame size='card'>{card.visual}</SolutionsVisualFrame>
|
||||
</article>
|
||||
|
||||
+23
-16
@@ -1,7 +1,7 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import {
|
||||
SolutionsCard,
|
||||
SolutionsPillCta,
|
||||
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'
|
||||
@@ -15,11 +15,18 @@ import type { SolutionsCardRowConfig } from '@/app/(landing)/components/solution
|
||||
* Rendered as a labelled `<section>` for a clean, crawlable landmark; each card
|
||||
* is an `<article>` with an `<h3>`, keeping the strict H2 → H3 hierarchy. Every
|
||||
* gap (header sub-stack, header-to-grid, and inter-card) is owned by named
|
||||
* spacing constants; this component exposes no layout prop.
|
||||
* spacing constants. Only the row header stack can opt into centered text; card
|
||||
* text remains left aligned.
|
||||
*/
|
||||
|
||||
interface SolutionsCardRowProps {
|
||||
row: SolutionsCardRowConfig
|
||||
/** Header stack alignment. Defaults to the original left-aligned layout. */
|
||||
align?: 'left' | 'center'
|
||||
/** Card treatment. Defaults to the original split copy + visual layout. */
|
||||
cardVariant?: 'split' | 'featureTile'
|
||||
/** Header typography treatment. Defaults to the original larger solutions header. */
|
||||
headerVariant?: 'standard' | 'feature'
|
||||
}
|
||||
|
||||
/** Maps a supported card count to its grid column class; anything else falls back to three-up. */
|
||||
@@ -28,7 +35,12 @@ const GRID_COLS: Record<number, string> = {
|
||||
4: 'grid-cols-4',
|
||||
}
|
||||
|
||||
export function SolutionsCardRow({ row }: SolutionsCardRowProps) {
|
||||
export function SolutionsCardRow({
|
||||
row,
|
||||
align = 'left',
|
||||
cardVariant = 'split',
|
||||
headerVariant = 'standard',
|
||||
}: SolutionsCardRowProps) {
|
||||
const headingId = `solutions-row-${row.id}-heading`
|
||||
const gridCols = GRID_COLS[row.cards.length] ?? GRID_COLS[3]
|
||||
|
||||
@@ -38,24 +50,18 @@ export function SolutionsCardRow({ row }: SolutionsCardRowProps) {
|
||||
aria-labelledby={headingId}
|
||||
className={cn('flex flex-col', SOLUTIONS_SPACING.cardRowHeaderToGrid)}
|
||||
>
|
||||
<div className={cn('flex flex-col items-start', SOLUTIONS_SPACING.cardRowHeaderStack)}>
|
||||
<h2
|
||||
id={headingId}
|
||||
className='max-w-[760px] text-balance text-[32px] text-[var(--text-primary)] leading-[1.3] max-sm:text-[24px]'
|
||||
>
|
||||
{row.title}
|
||||
</h2>
|
||||
<p className='max-w-[640px] text-[20px] text-[var(--text-body)] leading-[1.5]'>
|
||||
{row.subtitle}
|
||||
</p>
|
||||
<SolutionsPillCta cta={row.cta} />
|
||||
</div>
|
||||
<SolutionsCardRowHeader
|
||||
row={row}
|
||||
headingId={headingId}
|
||||
align={align}
|
||||
variant={headerVariant}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'grid',
|
||||
gridCols,
|
||||
'max-sm:grid-cols-1 max-md:grid-cols-2',
|
||||
'max-sm:grid-cols-1 max-lg:grid-cols-2',
|
||||
SOLUTIONS_SPACING.cardGridGap
|
||||
)}
|
||||
>
|
||||
@@ -64,6 +70,7 @@ export function SolutionsCardRow({ row }: SolutionsCardRowProps) {
|
||||
key={`${row.id}-${card.title}`}
|
||||
card={card}
|
||||
headingId={`solutions-row-${row.id}-card-${index}-heading`}
|
||||
variant={cardVariant}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+81
-12
@@ -1,30 +1,84 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import { ChipTag, cn } from '@sim/emcn'
|
||||
import { LandingHeroHeader } from '@/app/(landing)/components/hero/components/hero-header'
|
||||
import { HeroCta } from '@/app/(landing)/components/hero-cta'
|
||||
import {
|
||||
LANDING_CONTENT_WIDTH,
|
||||
LANDING_GUTTER,
|
||||
LANDING_HERO_CTA_GAP,
|
||||
LANDING_HERO_TOP_PADDING,
|
||||
} from '@/app/(landing)/components/landing-layout'
|
||||
import { SolutionsVisualFrame } from '@/app/(landing)/components/solutions-page/components/solutions-visual-frame'
|
||||
import { SOLUTIONS_SPACING } from '@/app/(landing)/components/solutions-page/constants'
|
||||
import {
|
||||
SOLUTIONS_SPACING,
|
||||
SOLUTIONS_TEXT_MEASURE,
|
||||
} from '@/app/(landing)/components/solutions-page/constants'
|
||||
import type { SolutionsHeroConfig } from '@/app/(landing)/components/solutions-page/types'
|
||||
|
||||
/**
|
||||
* Solutions hero - the only `<h1>` on a solutions page. Left-aligned header copy
|
||||
* (headline + supporting description) sits above the same CTA as the landing
|
||||
* hero ({@link HeroCta}, the single source of truth), then a full-width solutions
|
||||
* visual underneath.
|
||||
* Solutions hero - the only `<h1>` on a solutions page. Header copy (headline +
|
||||
* supporting description) sits above the same CTA as the landing hero
|
||||
* ({@link HeroCta}, the single source of truth), then a full-width solutions visual
|
||||
* underneath. Pages can optionally center this header stack, use the home hero
|
||||
* top layout, and show a mono chip above the headline, matching the home landing
|
||||
* feature tags.
|
||||
*
|
||||
* The header column and the visual are stacked in one flex column; the header's
|
||||
* own sub-stack (headline → description → CTA) and the gap down to the visual are
|
||||
* own sub-stack (tag → headline → description → CTA) and the gap down to the visual are
|
||||
* both owned by named spacing constants, so a consumer page passes only copy and
|
||||
* a visual node - never any spacing. The visual renders into a reserved-aspect
|
||||
* {@link SolutionsVisualFrame} (CLS = 0).
|
||||
*
|
||||
* Carries the page's sr-only ~50-word product summary for AI citation (GEO). The
|
||||
* section's horizontal gutter is owned by `SolutionsPage`; this component sets none.
|
||||
* standard variant inherits its gutter from `SolutionsPage`; the home variant
|
||||
* owns the exact shared homepage cap and gutter because enterprise renders it
|
||||
* outside the solutions content wrapper.
|
||||
*/
|
||||
|
||||
interface SolutionsHeroProps {
|
||||
hero: SolutionsHeroConfig
|
||||
/** Header stack alignment. Defaults to the original left-aligned layout. */
|
||||
align?: 'left' | 'center'
|
||||
/** Visual treatment for the top hero. Defaults to the original solutions layout. */
|
||||
variant?: 'solutions' | 'home'
|
||||
}
|
||||
|
||||
export function SolutionsHero({ hero }: SolutionsHeroProps) {
|
||||
export function SolutionsHero({ hero, align = 'left', variant = 'solutions' }: SolutionsHeroProps) {
|
||||
const centered = align === 'center'
|
||||
const homeVariant = variant === 'home'
|
||||
|
||||
const eyebrow = hero.eyebrow ? <ChipTag variant='mono'>{hero.eyebrow}</ChipTag> : null
|
||||
|
||||
if (homeVariant) {
|
||||
return (
|
||||
<section
|
||||
id='solutions-hero'
|
||||
aria-labelledby='solutions-hero-heading'
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-[22px] text-left',
|
||||
LANDING_CONTENT_WIDTH,
|
||||
LANDING_GUTTER,
|
||||
LANDING_HERO_TOP_PADDING
|
||||
)}
|
||||
>
|
||||
<p className='sr-only'>{hero.summary}</p>
|
||||
|
||||
<LandingHeroHeader
|
||||
eyebrow={eyebrow}
|
||||
heading={hero.heading}
|
||||
headingId='solutions-hero-heading'
|
||||
description={hero.description}
|
||||
/>
|
||||
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='relative mt-[34px] aspect-[1300/720] w-full overflow-hidden rounded-lg bg-[var(--surface-3)] max-sm:aspect-[4/3]'
|
||||
>
|
||||
{hero.visual}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
id='solutions-hero'
|
||||
@@ -37,7 +91,15 @@ export function SolutionsHero({ hero }: SolutionsHeroProps) {
|
||||
>
|
||||
<p className='sr-only'>{hero.summary}</p>
|
||||
|
||||
<div className={cn('flex flex-col items-start text-left', SOLUTIONS_SPACING.heroStack)}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col',
|
||||
centered ? 'items-center text-center' : 'items-start text-left',
|
||||
SOLUTIONS_SPACING.heroStack
|
||||
)}
|
||||
>
|
||||
{eyebrow}
|
||||
|
||||
<h1
|
||||
id='solutions-hero-heading'
|
||||
className='max-w-[900px] text-balance text-[48px] text-[var(--text-primary)] leading-[1.1] max-sm:text-[32px] max-xl:text-[40px]'
|
||||
@@ -45,11 +107,18 @@ export function SolutionsHero({ hero }: SolutionsHeroProps) {
|
||||
{hero.heading}
|
||||
</h1>
|
||||
|
||||
<p className='max-w-[640px] text-[20px] text-[var(--text-body)] leading-[1.5]'>
|
||||
<p
|
||||
className={cn(
|
||||
SOLUTIONS_TEXT_MEASURE.heroDescription,
|
||||
'text-pretty text-[20px] text-[var(--text-body)] leading-[1.5]'
|
||||
)}
|
||||
>
|
||||
{hero.description}
|
||||
</p>
|
||||
|
||||
<HeroCta />
|
||||
<div className={cn('max-sm:w-full', LANDING_HERO_CTA_GAP)}>
|
||||
<HeroCta />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SolutionsVisualFrame size='hero'>{hero.visual}</SolutionsVisualFrame>
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import {
|
||||
LANDING_GUTTER,
|
||||
LANDING_HERO_TOP_PADDING,
|
||||
LANDING_SECTION_RHYTHM,
|
||||
} from '@/app/(landing)/components/landing-layout'
|
||||
|
||||
/**
|
||||
* Solutions-layout spacing - the single source of truth for every gutter, gap,
|
||||
* inset, and reserved dimension used across the solutions-page components.
|
||||
*
|
||||
* The padding fortress lives here. No solutions component accepts a `className`,
|
||||
* `style`, or any layout-override prop, and none hard-codes a spacing value
|
||||
* inline. Every measurement a reviewer might want to audit - the horizontal
|
||||
* gutter, the inter-section rhythm, the card-grid gaps, the card stack, and the
|
||||
* fixed visual-slot dimensions - is named in this one file. To change spacing
|
||||
* you edit a constant here; a consumer page literally cannot reach it.
|
||||
* The padding fortress lives here. No solutions component accepts a `className`
|
||||
* or `style`, and none hard-codes a spacing value inline. Every measurement a
|
||||
* reviewer might want to audit - the horizontal gutter, the inter-section rhythm,
|
||||
* the card-grid gaps, the card stack, and the fixed visual-slot dimensions - is
|
||||
* named in this one file. To change spacing you edit a constant here; consumer
|
||||
* pages only choose from controlled component variants.
|
||||
*
|
||||
* All values are Tailwind class fragments (not raw numbers) so they compose
|
||||
* directly into `className` strings inside the components and stay legible.
|
||||
@@ -19,7 +25,7 @@ export const SOLUTIONS_SPACING = {
|
||||
* solutions content starts on the wordmark's vertical line at every
|
||||
* breakpoint. Sections and cards never set their own gutter.
|
||||
*/
|
||||
gutter: 'px-20 max-lg:px-8 max-sm:px-5',
|
||||
gutter: LANDING_GUTTER,
|
||||
/**
|
||||
* Inter-section vertical rhythm - the gap of the `<main>` flex column that
|
||||
* `SolutionsPage` owns. Sections carry no vertical margin/padding of their own,
|
||||
@@ -27,9 +33,9 @@ export const SOLUTIONS_SPACING = {
|
||||
* card row. Tightens on smaller screens in lockstep with the landing `<main>`
|
||||
* (`gap-[120px] max-lg:gap-[88px] max-sm:gap-16`).
|
||||
*/
|
||||
sectionRhythm: 'gap-[120px] max-lg:gap-[88px] max-sm:gap-16',
|
||||
/** Hero text top padding, matching the landing hero (`pt-[112px] max-sm:pt-12`). */
|
||||
heroTopPadding: 'pt-[112px] max-sm:pt-12',
|
||||
sectionRhythm: LANDING_SECTION_RHYTHM,
|
||||
/** Hero text top padding, matching the landing hero at every breakpoint. */
|
||||
heroTopPadding: LANDING_HERO_TOP_PADDING,
|
||||
/** Vertical stack gap inside the hero header column (headline → description → CTA). */
|
||||
heroStack: 'gap-[22px]',
|
||||
/** Gap between the hero header column and the full-width hero visual beneath it. */
|
||||
@@ -38,12 +44,37 @@ export const SOLUTIONS_SPACING = {
|
||||
cardRowHeaderToGrid: 'gap-12',
|
||||
/** Vertical stack gap inside a card row's header (title → subtitle → CTA). */
|
||||
cardRowHeaderStack: 'gap-5',
|
||||
/**
|
||||
* Extra top separation for the header CTA over the stack gap. Title and
|
||||
* subtitle are one copy group and stay tight; the CTA is a separate action
|
||||
* group, so its subtitle→CTA gap lands at 2× the title→subtitle gap
|
||||
* (standard: 20px + 20px = 40px; feature: 12px + 12px = 24px).
|
||||
*/
|
||||
cardRowHeaderCtaGap: 'mt-5',
|
||||
cardRowHeaderCtaGapFeature: 'mt-3',
|
||||
/** Gap between cards within a card-row grid (both axes). */
|
||||
cardGridGap: 'gap-8',
|
||||
/** Minimum gap between a card's text block and its visual panel. */
|
||||
cardTextToVisual: 'gap-5',
|
||||
/** Vertical stack gap inside a card's text block (title → description). */
|
||||
cardTextStack: 'gap-2',
|
||||
/** Inner padding for feature tiles where copy and the visual slot share one frame. */
|
||||
cardFeatureTilePadding: 'p-8 max-lg:p-6',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Readable text measures for the recurring solutions-page copy surfaces.
|
||||
* Paragraph width is expressed in characters so line length tracks type size
|
||||
* instead of the viewport. `min-w-0` keeps flex items wrapping cleanly on narrow
|
||||
* screens, and `w-full` gives centered copy a stable measure.
|
||||
*/
|
||||
export const SOLUTIONS_TEXT_MEASURE = {
|
||||
/** Hero support copy: broad enough for the primary value prop, still under long-form prose width. */
|
||||
heroDescription: 'w-full min-w-0 max-w-[58ch]',
|
||||
/** Section subtitles: slightly tighter than hero copy so centered headers feel intentional. */
|
||||
rowSubtitle: 'w-full min-w-0 max-w-[52ch]',
|
||||
/** Card descriptions: short scan lines inside three-up card grids. */
|
||||
cardDescription: 'min-w-0 max-w-[38ch]',
|
||||
} as const
|
||||
|
||||
/**
|
||||
@@ -57,4 +88,32 @@ export const SOLUTIONS_VISUAL = {
|
||||
heroAspect: 'aspect-[16/9]',
|
||||
/** Fixed height of a card's visual panel - uniform across every card. */
|
||||
cardHeight: 'h-[240px]',
|
||||
/**
|
||||
* Minimum height for framed feature tiles with copy and future UI in one
|
||||
* surface. One value at every breakpoint: the tallest tile vignettes (audit
|
||||
* ledger, staging panel) need ~300px of visual slot below the copy block, so
|
||||
* shrinking the tile on small screens crops their tops against the slot's
|
||||
* `overflow-hidden`.
|
||||
*/
|
||||
featureTileMinHeight: 'min-h-[440px]',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Feature-tile surface + copy colors. Pages pick a tone per card via
|
||||
* {@link SolutionsCardConfig.featureTileTone}; the component maps it here so
|
||||
* each tile can diverge without shared hard-coded classes.
|
||||
*/
|
||||
export const SOLUTIONS_FEATURE_TILE_TONE = {
|
||||
light: {
|
||||
surface: 'bg-[var(--surface-3)]',
|
||||
title: 'text-[var(--text-primary)]',
|
||||
description: 'text-[var(--text-muted)]',
|
||||
},
|
||||
dark: {
|
||||
surface: 'bg-[var(--text-secondary)]',
|
||||
title: 'text-[var(--text-inverse)]',
|
||||
description: 'text-[var(--surface-3)]',
|
||||
/** Softer body copy on dark tiles — `#E6E6E6` via `--surface-6` (`#E5E5E5`). */
|
||||
descriptionSoft: 'text-[var(--surface-6)]',
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -18,8 +18,10 @@ export interface SolutionsPillCta {
|
||||
href: string
|
||||
}
|
||||
|
||||
/** The solutions hero - header copy, the shared CTA, and a full-width visual. */
|
||||
/** The solutions hero - optional tag, header copy, the shared CTA, and a full-width visual. */
|
||||
export interface SolutionsHeroConfig {
|
||||
/** Optional mono chip shown above the page's single `<h1>`. */
|
||||
eyebrow?: string
|
||||
/**
|
||||
* The page's single `<h1>`. Per the constitution it should name the module and
|
||||
* "Sim"/"AI workspace" (e.g. "Workflows - the visual builder in Sim, the AI workspace").
|
||||
@@ -56,8 +58,22 @@ export interface SolutionsCardConfig {
|
||||
* owns the spacing around both the text and this frame.
|
||||
*/
|
||||
visual: ReactNode
|
||||
/**
|
||||
* Feature-tile surface tone - only read when the row renders
|
||||
* `cardVariant='featureTile'`. Defaults to `'light'` so tiles can mix light
|
||||
* and dark backgrounds within the same row.
|
||||
*/
|
||||
featureTileTone?: SolutionsFeatureTileTone
|
||||
/**
|
||||
* Optional description color on feature tiles. `'soft'` is for lighter body
|
||||
* copy on dark surfaces without changing the row's default tone map.
|
||||
*/
|
||||
featureTileDescriptionTone?: 'soft'
|
||||
}
|
||||
|
||||
/** Controlled surface tones for {@link SolutionsCardConfig.featureTileTone}. */
|
||||
export type SolutionsFeatureTileTone = 'light' | 'dark'
|
||||
|
||||
/**
|
||||
* A card row - the core repeating unit. A header (title + subtitle + CTA) above a
|
||||
* grid of 3 or 4 cards. The grid column count is derived from `cards.length`, so
|
||||
|
||||
+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>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +1,108 @@
|
||||
import { ChipLink, cn } from '@sim/emcn'
|
||||
import { cn } from '@sim/emcn'
|
||||
import Image from 'next/image'
|
||||
import { Cta } from '@/app/(landing)/components/cta/cta'
|
||||
import { LANDING_CONTENT_WIDTH, LANDING_GUTTER } from '@/app/(landing)/components/landing-layout'
|
||||
import {
|
||||
SolutionsCardRow,
|
||||
SolutionsHero,
|
||||
SolutionsLogosRow,
|
||||
SolutionsStructuredData,
|
||||
} from '@/app/(landing)/components/solutions-page/components'
|
||||
import { SOLUTIONS_SPACING } from '@/app/(landing)/components/solutions-page/constants'
|
||||
import type { SolutionsPageConfig } from '@/app/(landing)/components/solutions-page/types'
|
||||
import { EnterpriseFeatureGrid } from '@/app/(landing)/enterprise/components/enterprise-feature-grid'
|
||||
import { EnterprisePlatformLoop } from '@/app/(landing)/enterprise/components/enterprise-platform-loop'
|
||||
import {
|
||||
AccessControlGraphic,
|
||||
AuditTrailGraphic,
|
||||
BuildMethodsGraphic,
|
||||
DeployGraphic,
|
||||
ItPlatformTeamsGraphic,
|
||||
LifecycleGraphic,
|
||||
OperationsTeamsGraphic,
|
||||
RollbackGraphic,
|
||||
RunMonitoringGraphic,
|
||||
StagingGraphic,
|
||||
StandardsGraphic,
|
||||
TechnicalTeamsGraphic,
|
||||
} from '@/app/(landing)/enterprise/components/feature-graphics'
|
||||
|
||||
/**
|
||||
* Enterprise landing page (`/enterprise`) - the flagship surface for teams
|
||||
* evaluating Sim as their enterprise AI agent platform.
|
||||
*
|
||||
* Structurally it mirrors {@link SolutionsPage} (hero → logos → card rows) but
|
||||
* composes its own `<main>` so it can append a trailing CTA band that
|
||||
* `SolutionsPage` does not render. The shared `SOLUTIONS_SPACING` constants own
|
||||
* the gutter and inter-section rhythm, and the shared solutions components own
|
||||
* the hero, logos, and card-row chrome - so this file is pure content plus the
|
||||
* closing CTA.
|
||||
* composes its own `<main>` so it can append the shared homepage CTA that
|
||||
* `SolutionsPage` does not render. The four feature rows render through
|
||||
* {@link EnterpriseFeatureGrid} - one shared grid that regroups the 12 cards
|
||||
* into 4/4/2/2 in the two-column band so no section leaves an orphan cell.
|
||||
* The shared `SOLUTIONS_SPACING` constants own the enterprise content gutter
|
||||
* and inter-section rhythm, while the homepage {@link Cta} owns the closing
|
||||
* conversion band.
|
||||
*
|
||||
* The strict heading outline is H1 (hero) → H2 (each card row + the CTA) → H3
|
||||
* (each card), never skipped. Server Component; the only interactive leaves are
|
||||
* the shared `HeroCta` (inside `SolutionsHero`) and the CTA's `ChipLink`s.
|
||||
* (each card), never skipped. Server Component; the interactive leaves live in
|
||||
* the shared landing components.
|
||||
*/
|
||||
const ENTERPRISE_CONFIG: SolutionsPageConfig = {
|
||||
module: 'Enterprise',
|
||||
path: '/enterprise',
|
||||
hero: {
|
||||
heading: 'The Enterprise AI Agent Platform for Teams',
|
||||
description:
|
||||
'Sim is the enterprise AI workspace where teams build, deploy, and govern AI agents. Connect 1,000+ integrations and every major LLM to run agents that automate real work, with security, approvals, and audit trails built into the enterprise AI agent platform.',
|
||||
heading: 'The AI Agent Platform for Enterprise Teams',
|
||||
description: 'Build, deploy, and govern enterprise AI agents in one workspace.',
|
||||
summary:
|
||||
'Sim is the open-source enterprise AI agent platform where IT, operations, and technical teams build, deploy, and govern enterprise AI agents in one AI workspace. Connect 1,000+ integrations and every major LLM, with security, role-based access, approvals, observability, versioning, and audit trails for reliable deployment across teams.',
|
||||
visual: null,
|
||||
/**
|
||||
* The enterprise architectural backdrop with the enterprise-specific
|
||||
* platform loop framed in front of it: the same white demo window the
|
||||
* homepage hero uses, filled by the {@link EnterprisePlatformLoop} - a
|
||||
* sibling of the homepage `HeroPlatformLoop` that renders the whole
|
||||
* interior live (Brightwave sidebar + the real new-chat home view) and
|
||||
* replays an enterprise prompt. The `variant='home'` hero renders this
|
||||
* into the same `aspect-[1300/720]` media frame the homepage uses.
|
||||
*/
|
||||
visual: (
|
||||
<>
|
||||
<Image
|
||||
fill
|
||||
priority
|
||||
alt=''
|
||||
className='object-cover object-center'
|
||||
sizes='(max-width: 1024px) 100vw, 1300px'
|
||||
src='/landing/enterprise-hero-background.webp'
|
||||
/>
|
||||
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2 flex aspect-[1080/620] w-[83.08%] flex-col overflow-hidden rounded-[10px] bg-[var(--surface-1)] shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_2px_6px_0_rgba(0,0,0,0.05),0_4px_42px_0_rgba(0,0,0,0.06)]'>
|
||||
<div className='relative flex-1'>
|
||||
<EnterprisePlatformLoop />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
rows: [
|
||||
{
|
||||
id: 'build',
|
||||
title: 'Build, Deploy, and Manage Enterprise AI Agents in One Workspace',
|
||||
subtitle:
|
||||
'Sim gives teams one workspace to build enterprise AI agents, ship them to production, and manage every enterprise AI agent across its lifecycle.',
|
||||
'Build enterprise AI agents, ship to production, and manage every version from one workspace.',
|
||||
cta: { label: 'Start building', href: '/signup' },
|
||||
cards: [
|
||||
{
|
||||
title: 'Build visually or with code',
|
||||
description:
|
||||
'Sim lets teams build enterprise AI agents in a visual workflow builder, in natural language with Mothership, or with code.',
|
||||
visual: null,
|
||||
description: 'Create agents in the visual builder, through chat, or directly in code.',
|
||||
visual: <BuildMethodsGraphic />,
|
||||
},
|
||||
{
|
||||
title: 'Deploy in one click',
|
||||
description:
|
||||
'Sim deploys an enterprise AI agent to staging or production from the same workspace, with no separate infrastructure to manage.',
|
||||
visual: null,
|
||||
'Move agents from staging to production without managing separate infrastructure.',
|
||||
featureTileTone: 'dark',
|
||||
featureTileDescriptionTone: 'soft',
|
||||
visual: <DeployGraphic />,
|
||||
},
|
||||
{
|
||||
title: 'Manage the full lifecycle',
|
||||
description:
|
||||
'Sim keeps every enterprise AI agent versioned, monitored, and editable, so teams manage changes without rebuilding from scratch.',
|
||||
visual: null,
|
||||
description: 'Version, monitor, and edit every agent as your workflows evolve.',
|
||||
visual: <LifecycleGraphic />,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -66,53 +110,50 @@ const ENTERPRISE_CONFIG: SolutionsPageConfig = {
|
||||
id: 'governance',
|
||||
title: 'Governance and Security for Enterprise AI Agents',
|
||||
subtitle:
|
||||
'Sim is the enterprise AI agent platform built for security and control, so teams can trust every enterprise AI agent they run in production.',
|
||||
'Security, approvals, and controls keep enterprise AI agents trusted in production.',
|
||||
cta: { label: 'See security', href: '/demo' },
|
||||
cards: [
|
||||
{
|
||||
title: 'Control who can do what',
|
||||
description:
|
||||
'Sim gives administrators role-based access and approvals, so the right people build enterprise AI agents and the right people sign off before they go live.',
|
||||
visual: null,
|
||||
'Set roles and approval paths so the right people build, review, and launch agents.',
|
||||
visual: <AccessControlGraphic />,
|
||||
},
|
||||
{
|
||||
title: 'Prove every action',
|
||||
description:
|
||||
'Sim logs each agent run block by block, giving teams a complete audit trail of what every enterprise AI agent did and why.',
|
||||
visual: null,
|
||||
description: 'Trace every run block by block with a complete audit trail.',
|
||||
visual: <AuditTrailGraphic />,
|
||||
},
|
||||
{
|
||||
title: 'Meet enterprise standards',
|
||||
description:
|
||||
'Sim is SOC2 compliant and open source, so security teams can review how the enterprise AI agent platform works before they adopt it.',
|
||||
visual: null,
|
||||
'SOC2 compliance and open source give security teams a clear path to review.',
|
||||
visual: <StandardsGraphic />,
|
||||
featureTileTone: 'dark',
|
||||
featureTileDescriptionTone: 'soft',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'deploy',
|
||||
title: 'Deploy Enterprise Workflow Agents with Confidence',
|
||||
subtitle:
|
||||
'Sim ships enterprise workflow agents from staging to production with the observability and versioning teams need to deploy reliably across the organization.',
|
||||
subtitle: 'Stage, observe, and version workflow agents before they reach production.',
|
||||
cta: { label: 'Explore deployment', href: '/signup' },
|
||||
cards: [
|
||||
{
|
||||
title: 'Stage before you ship',
|
||||
description:
|
||||
'Sim runs enterprise workflow agents in staging first, so teams test changes safely before they reach production.',
|
||||
visual: null,
|
||||
description: 'Test changes in staging before they affect live workflows.',
|
||||
visual: <StagingGraphic />,
|
||||
},
|
||||
{
|
||||
title: 'Watch every run',
|
||||
description:
|
||||
'Sim gives teams full observability across live logs, run history, and monitoring for every enterprise workflow agent in production.',
|
||||
visual: null,
|
||||
description: 'See live logs, run history, and monitoring in one place.',
|
||||
visual: <RunMonitoringGraphic />,
|
||||
},
|
||||
{
|
||||
title: 'Roll back safely',
|
||||
description:
|
||||
'Sim versions every workflow, so teams deploy with confidence and revert any enterprise workflow agent in seconds.',
|
||||
visual: null,
|
||||
description: 'Version workflows and roll back production agents in seconds.',
|
||||
visual: <RollbackGraphic />,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -120,26 +161,25 @@ const ENTERPRISE_CONFIG: SolutionsPageConfig = {
|
||||
id: 'teams',
|
||||
title: 'Built for Enterprise Teams',
|
||||
subtitle:
|
||||
'Sim is built for the teams that run enterprise AI agents, and the governance, lifecycle management, and collaboration an enterprise AI agent demands.',
|
||||
'Built for the teams that own enterprise AI agents across IT, operations, and engineering.',
|
||||
cta: { label: 'Talk to sales', href: '/demo' },
|
||||
cards: [
|
||||
{
|
||||
title: 'IT and platform teams',
|
||||
description:
|
||||
'IT teams use Sim to build enterprise AI agents with the access controls, governance, and audit trails their organization requires.',
|
||||
visual: null,
|
||||
description: 'Give IT the access controls, governance, and audit trails they need.',
|
||||
visual: <ItPlatformTeamsGraphic />,
|
||||
},
|
||||
{
|
||||
title: 'Operations teams',
|
||||
description:
|
||||
'Operations teams use Sim to deploy enterprise AI agents that automate real work across the tools they already run.',
|
||||
visual: null,
|
||||
description: 'Automate real work across the tools operations teams already use.',
|
||||
featureTileTone: 'dark',
|
||||
featureTileDescriptionTone: 'soft',
|
||||
visual: <OperationsTeamsGraphic />,
|
||||
},
|
||||
{
|
||||
title: 'Technical teams',
|
||||
description:
|
||||
'Engineering and technical teams collaborate in Sim to ship, review, and maintain every enterprise AI agent together.',
|
||||
visual: null,
|
||||
description: 'Let technical teams ship, review, and maintain agents together.',
|
||||
visual: <TechnicalTeamsGraphic />,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -152,38 +192,23 @@ export default function EnterprisePage() {
|
||||
<SolutionsStructuredData config={ENTERPRISE_CONFIG} />
|
||||
<main
|
||||
id='main-content'
|
||||
className={cn(
|
||||
'mx-auto flex w-full max-w-[1460px] flex-col',
|
||||
SOLUTIONS_SPACING.sectionRhythm,
|
||||
SOLUTIONS_SPACING.gutter
|
||||
)}
|
||||
className={cn('flex w-full flex-col', SOLUTIONS_SPACING.sectionRhythm)}
|
||||
>
|
||||
<SolutionsHero hero={ENTERPRISE_CONFIG.hero} />
|
||||
<SolutionsLogosRow />
|
||||
{ENTERPRISE_CONFIG.rows.map((row) => (
|
||||
<SolutionsCardRow key={row.id} row={row} />
|
||||
))}
|
||||
<SolutionsHero hero={ENTERPRISE_CONFIG.hero} variant='home' />
|
||||
|
||||
<section
|
||||
id='enterprise-cta'
|
||||
aria-labelledby='enterprise-cta-heading'
|
||||
className='flex flex-col items-center gap-[22px] text-center'
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col',
|
||||
LANDING_CONTENT_WIDTH,
|
||||
LANDING_GUTTER,
|
||||
SOLUTIONS_SPACING.sectionRhythm
|
||||
)}
|
||||
>
|
||||
<h2
|
||||
id='enterprise-cta-heading'
|
||||
className='max-w-[860px] text-balance text-[48px] text-[var(--text-primary)] leading-[1.1] max-sm:text-[32px] max-xl:text-[40px]'
|
||||
>
|
||||
Build enterprise AI agents in Sim
|
||||
</h2>
|
||||
<div className='flex items-center gap-3'>
|
||||
<ChipLink variant='primary' href='/signup'>
|
||||
Get started
|
||||
</ChipLink>
|
||||
<ChipLink href='/demo' className='border border-[var(--border-1)]'>
|
||||
Contact sales
|
||||
</ChipLink>
|
||||
</div>
|
||||
</section>
|
||||
<SolutionsLogosRow />
|
||||
<EnterpriseFeatureGrid rows={ENTERPRISE_CONFIG.rows} />
|
||||
</div>
|
||||
|
||||
<Cta />
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import {
|
||||
Cta,
|
||||
Features,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
Mothership,
|
||||
ProductDemo,
|
||||
} from '@/app/(landing)/components'
|
||||
import { LANDING_SECTION_RHYTHM } from '@/app/(landing)/components/landing-layout'
|
||||
|
||||
/**
|
||||
* Landing page root - owns the section order and the `<main>` content region.
|
||||
@@ -23,7 +25,7 @@ import {
|
||||
*/
|
||||
export default function Landing() {
|
||||
return (
|
||||
<main id='main-content' className='flex flex-col gap-[120px] max-sm:gap-16 max-lg:gap-[88px]'>
|
||||
<main id='main-content' className={cn('flex flex-col', LANDING_SECTION_RHYTHM)}>
|
||||
<HomeStructuredData />
|
||||
<Hero />
|
||||
<ProductDemo />
|
||||
|
||||
@@ -170,6 +170,9 @@ const nextConfig: NextConfig = {
|
||||
: []),
|
||||
'localhost:3000',
|
||||
'localhost:3001',
|
||||
'127.0.0.1',
|
||||
'127.0.0.1:3011',
|
||||
'127.0.0.1:3012',
|
||||
],
|
||||
}),
|
||||
transpilePackages: [
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@ import { getSessionCookie } from 'better-auth/cookies'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { sendToProfound } from './lib/analytics/profound'
|
||||
import { getEnv } from './lib/core/config/env'
|
||||
import { isAuthDisabled, isHosted } from './lib/core/config/env-flags'
|
||||
import { isAuthDisabled, isDev, isHosted } from './lib/core/config/env-flags'
|
||||
import { generateRuntimeCSP } from './lib/core/security/csp'
|
||||
import { getClientIp } from './lib/core/utils/request'
|
||||
|
||||
@@ -138,8 +138,8 @@ function handleRootPathRedirects(
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isHosted) {
|
||||
// Self-hosted: Always redirect based on session
|
||||
if (!isHosted && !isDev) {
|
||||
// Self-hosted production: Always redirect based on session.
|
||||
if (hasActiveSession) {
|
||||
return NextResponse.redirect(new URL('/workspace', request.url))
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -12,6 +12,12 @@
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/emil-design-eng/SKILL.md",
|
||||
"computedHash": "8bdf9e4e6de7a4969147bf4828a4ad2c5aacd9fba4b690b250a85e0467ca387d"
|
||||
},
|
||||
"make-interfaces-feel-better": {
|
||||
"source": "jakubkrehel/make-interfaces-feel-better",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/make-interfaces-feel-better/SKILL.md",
|
||||
"computedHash": "54b6e221bbe4e2d1a8461c3f9ecabe69daf891f964e9ee5e699c323daa018f2e"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user