diff --git a/.claude/rules/global.md b/.claude/rules/global.md index 90852e76206..3a222b935d5 100644 --- a/.claude/rules/global.md +++ b/.claude/rules/global.md @@ -73,7 +73,7 @@ const filtered = filterUndefined(obj) ``` ## Deployment flags in the browser -Client code inside a workspace reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never the `isHosted`/`isBillingEnabled` constants from `env-flags`. Those constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit, so a tab recovered from one would render Sim Cloud as self-hosted. The reader is seeded from the server-resolved workspace host context. Server code keeps reading `env-flags`. +Client code inside a workspace, organization, or standalone settings surface reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never the `isHosted`/`isBillingEnabled` constants from `env-flags`. Those constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit, so a tab recovered from one would render Sim Cloud as self-hosted. The reader is seeded from the server-resolved workspace host context, organization layout, or standalone settings layout. Server code keeps reading `env-flags`. ## Package Manager Use `bun` and `bunx`, not `npm` and `npx`. diff --git a/.cursor/rules/global.mdc b/.cursor/rules/global.mdc index 09c1388c196..1bf193b00ec 100644 --- a/.cursor/rules/global.mdc +++ b/.cursor/rules/global.mdc @@ -76,7 +76,7 @@ const filtered = filterUndefined(obj) ``` ## Deployment flags in the browser -Client code inside a workspace reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never the `isHosted`/`isBillingEnabled` constants from `env-flags`. Those constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit, so a tab recovered from one would render Sim Cloud as self-hosted. The reader is seeded from the server-resolved workspace host context. Server code keeps reading `env-flags`. +Client code inside a workspace, organization, or standalone settings surface reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never the `isHosted`/`isBillingEnabled` constants from `env-flags`. Those constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit, so a tab recovered from one would render Sim Cloud as self-hosted. The reader is seeded from the server-resolved workspace host context, organization layout, or standalone settings layout. Server code keeps reading `env-flags`. ## Package Manager Use `bun` and `bunx`, not `npm` and `npx`. diff --git a/CLAUDE.md b/CLAUDE.md index b0f73d13053..09dabbc5d78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ You are a professional software engineer. All code must follow best practices: a - `omit(obj, keys)` / `filterUndefined(obj)` from `@sim/utils/object` — object trimming; never `Object.fromEntries(Object.entries(...).filter(...))` - `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — never inline slice + ellipsis - `backoffWithJitter(attempt, retryAfterMs, options?)` / `parseRetryAfter(header)` from `@sim/utils/retry` — shared retry pacing; never reimplement exponential backoff inline -- **Deployment flags in the browser**: client code inside a workspace reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never `isHosted`/`isBillingEnabled`/... from `env-flags`. The constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit; the reader is seeded from the server-resolved workspace host context instead. Server code keeps reading `env-flags` +- **Deployment flags in the browser**: client code inside a workspace, organization, or standalone settings surface reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never `isHosted`/`isBillingEnabled`/... from `env-flags`. The constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit; the reader is seeded from the server-resolved workspace host context, organization layout, or standalone settings layout instead. Server code keeps reading `env-flags` - **Package Manager**: Use `bun` and `bunx`, not `npm` and `npx` - **Type-checking**: Run `bun run type-check` (per workspace) or `bunx turbo run type-check` (all of them). Do not remove the `@typescript/native` alias from the root `devDependencies` — nothing imports it, but it is what makes a bare `tsc` resolve to the native TypeScript 7 compiler instead of the ~10x slower JavaScript TypeScript 6 one that `@typescript/typescript6` pulls in transitively. `bun run check:native-typecheck` enforces this diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index b0f169b33fb..f98df319fc2 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -1013,8 +1013,6 @@ div.flex.flex-row.items-start.bg-fd-secondary.border.rounded-lg.text-xs { display: none !important; } -/* Method+path bar — cleaner, lighter styling like Gumloop. - Override bg-fd-card CSS variable directly for reliability. */ #nd-page:has(.api-page-header) div.flex.flex-row.items-center.rounded-xl.border.not-prose { --color-fd-card: var(--surface-3) !important; background-color: var(--surface-3) !important; diff --git a/apps/docs/components/ui/action-media.tsx b/apps/docs/components/ui/action-media.tsx index 32ce557eae6..79d47f5477a 100644 --- a/apps/docs/components/ui/action-media.tsx +++ b/apps/docs/components/ui/action-media.tsx @@ -1,8 +1,8 @@ 'use client' import { useRef, useState } from 'react' +import { Lightbox } from '@sim/emcn' import { cn, getAssetUrl } from '@/lib/utils' -import { Lightbox } from './lightbox' interface ActionImageProps { src: string @@ -17,10 +17,6 @@ interface ActionVideoProps { } export function ActionImage({ src, alt, enableLightbox = true }: ActionImageProps) { - const [isLightboxOpen, setIsLightboxOpen] = useState(false) - - const openLightbox = () => setIsLightboxOpen(true) - const image = ( ) + if (!enableLightbox) return image + return ( - <> - {enableLightbox ? ( - - ) : ( - image - )} - {enableLightbox && ( - setIsLightboxOpen(false)} - src={src} - alt={alt} - type='image' - /> - )} - + + + ) } export function ActionVideo({ src, alt, enableLightbox = true }: ActionVideoProps) { const videoRef = useRef(null) - const startTimeRef = useRef(0) - const [isLightboxOpen, setIsLightboxOpen] = useState(false) + const [startTime, setStartTime] = useState(0) const resolvedSrc = getAssetUrl(src) const openLightbox = () => { - startTimeRef.current = videoRef.current?.currentTime ?? 0 - setIsLightboxOpen(true) + setStartTime(videoRef.current?.currentTime ?? 0) } const video = ( @@ -85,30 +67,18 @@ export function ActionVideo({ src, alt, enableLightbox = true }: ActionVideoProp /> ) + if (!enableLightbox) return video + return ( - <> - {enableLightbox ? ( - - ) : ( - video - )} - {enableLightbox && ( - setIsLightboxOpen(false)} - src={src} - alt={alt} - type='video' - startTime={startTimeRef.current} - /> - )} - + + + ) } diff --git a/apps/docs/components/ui/image.tsx b/apps/docs/components/ui/image.tsx index ce966c50fef..93bd3a30ac1 100644 --- a/apps/docs/components/ui/image.tsx +++ b/apps/docs/components/ui/image.tsx @@ -1,8 +1,7 @@ 'use client' -import { useState } from 'react' +import { Lightbox } from '@sim/emcn' import NextImage, { type ImageProps as NextImageProps } from 'next/image' -import { Lightbox } from '@/components/ui/lightbox' import { cn } from '@/lib/utils' interface ImageProps extends Omit { @@ -17,9 +16,7 @@ export function Image({ src, ...props }: ImageProps) { - const [isLightboxOpen, setIsLightboxOpen] = useState(false) - - const openLightbox = () => setIsLightboxOpen(true) + const lightboxSrc = typeof src === 'string' ? src : 'default' in src ? src.default.src : src.src const image = ( ) - return ( - <> - {enableLightbox ? ( - - ) : ( - image - )} + if (!enableLightbox) return image - {enableLightbox && ( - setIsLightboxOpen(false)} - src={typeof src === 'string' ? src : String(src)} - alt={alt} - type='image' - /> - )} - + return ( + + + ) } diff --git a/apps/docs/components/ui/lightbox.tsx b/apps/docs/components/ui/lightbox.tsx deleted file mode 100644 index d227f389d5b..00000000000 --- a/apps/docs/components/ui/lightbox.tsx +++ /dev/null @@ -1,104 +0,0 @@ -'use client' - -import { useEffect, useEffectEvent, useLayoutEffect, useRef } from 'react' -import { getAssetUrl } from '@/lib/utils' - -interface LightboxProps { - isOpen: boolean - onClose: () => void - src: string - alt: string - type: 'image' | 'video' - startTime?: number -} - -export function Lightbox({ isOpen, onClose, src, alt, type, startTime }: LightboxProps) { - const overlayRef = useRef(null) - const mediaButtonRef = useRef(null) - const videoRef = useRef(null) - const previouslyFocusedRef = useRef(null) - const closeLightbox = useEffectEvent(onClose) - - useEffect(() => { - if (!isOpen) return - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - closeLightbox() - return - } - if (event.key === 'Tab') { - event.preventDefault() - mediaButtonRef.current?.focus() - } - } - - const handleClickOutside = (event: MouseEvent) => { - if (overlayRef.current && event.target === overlayRef.current) { - closeLightbox() - } - } - - const previousOverflow = document.body.style.overflow - - previouslyFocusedRef.current = document.activeElement as HTMLElement | null - document.addEventListener('keydown', handleKeyDown) - document.addEventListener('click', handleClickOutside) - document.body.style.overflow = 'hidden' - mediaButtonRef.current?.focus() - - return () => { - document.removeEventListener('keydown', handleKeyDown) - document.removeEventListener('click', handleClickOutside) - document.body.style.overflow = previousOverflow - previouslyFocusedRef.current?.focus() - } - }, [isOpen]) - - useLayoutEffect(() => { - if (isOpen && type === 'video' && videoRef.current && startTime != null && startTime > 0) { - videoRef.current.currentTime = startTime - } - }, [isOpen, startTime, type]) - - if (!isOpen) return null - - return ( -
-
- -
-
- ) -} diff --git a/apps/docs/components/ui/video.tsx b/apps/docs/components/ui/video.tsx index f3b1f4f9e0b..10ba67ab168 100644 --- a/apps/docs/components/ui/video.tsx +++ b/apps/docs/components/ui/video.tsx @@ -1,8 +1,8 @@ 'use client' import { useEffect, useRef, useState } from 'react' +import { Lightbox } from '@sim/emcn' import { cn, getAssetUrl } from '@/lib/utils' -import { Lightbox } from './lightbox' interface VideoProps { src: string @@ -28,8 +28,7 @@ export function Video({ height, }: VideoProps) { const videoRef = useRef(null) - const startTimeRef = useRef(0) - const [isLightboxOpen, setIsLightboxOpen] = useState(false) + const [startTime, setStartTime] = useState(0) const [isInView, setIsInView] = useState(false) useEffect(() => { @@ -55,8 +54,7 @@ export function Video({ }, []) const openLightbox = () => { - startTimeRef.current = videoRef.current?.currentTime ?? 0 - setIsLightboxOpen(true) + setStartTime(videoRef.current?.currentTime ?? 0) } const video = ( @@ -78,31 +76,18 @@ export function Video({ /> ) - return ( - <> - {enableLightbox ? ( - - ) : ( - video - )} + if (!enableLightbox) return video - {enableLightbox && ( - setIsLightboxOpen(false)} - src={src} - alt={`Video: ${src}`} - type='video' - startTime={startTimeRef.current} - /> - )} - + return ( + + + ) } diff --git a/apps/docs/content/docs/workflows/blocks/agent.mdx b/apps/docs/content/docs/workflows/blocks/agent.mdx index b880916cd78..dde3a58689c 100644 --- a/apps/docs/content/docs/workflows/blocks/agent.mdx +++ b/apps/docs/content/docs/workflows/blocks/agent.mdx @@ -27,6 +27,10 @@ Answer in two sentences, cite the doc you used, and never guess a price. The model that runs the step. Defaults to `claude-sonnet-4-6`. Type or pick any model from OpenAI, Anthropic, Google, xAI, Groq, Cerebras, DeepSeek, Azure, AWS Bedrock, Google Vertex, or OpenRouter, or a local model through Ollama or VLLM. +For a custom cloud deployment, enter its provider prefix and model ID: `azure/my-deployment`, `azure-anthropic/my-deployment`, `bedrock/my-inference-profile`, or `vertex/my-gemini-model`. The prefix selects the provider and shows its credential fields even when the ID is absent from the catalog. Bedrock accepts full inference profile ARNs after `bedrock/`; Vertex uses the Gemini API and accepts Google model resource names. The deployment must support the selected provider's API. Custom IDs have no catalog pricing or token limits. + +Ollama Cloud, OpenRouter, Fireworks, Together AI, Baseten, Ollama, vLLM, and LiteLLM load their available models from the configured provider. New models appear through that discovery without a Sim catalog release. You can also enter a namespaced ID directly, such as `ollama-cloud/deepseek-v4.1-flash`, `openrouter/provider/model`, or `ollama/my-local-model`. Provider prefixes are case-insensitive; the model ID after the prefix keeps its original casing. + ### Files Files for the model to read: images for a vision-capable model, or documents for text. Upload them on the block, or pass a file from an earlier block, such as an upload trigger or an [API](/workflows/blocks/api) response, with a connection tag. @@ -110,18 +114,19 @@ Live tool-call chips stream for **OpenAI, Anthropic, Azure Anthropic, Google, Ve | Provider | Streamed thinking | Models | |----------|-------------------|--------| -| OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `gpt-6-astra`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5-pro`, `gpt-5.5`, `gpt-5.4-pro`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.2-pro`, `gpt-5.2`, `gpt-5.1`, `gpt-5-pro`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `o4-mini`, `o3`, `o3-mini`, `o1` | +| OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `gpt-6-astra`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5-pro`, `gpt-5.5`, `gpt-5.4-pro`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.3-codex`, `gpt-5.2-pro`, `gpt-5.2`, `gpt-5.1`, `gpt-5-pro`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `o4-mini`, `o3`, `o3-mini`, `o1` | | Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `claude-fable-5-1`, `claude-fable-5`, `claude-sonnet-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-opus-4-5`, `claude-sonnet-4-5`, `claude-haiku-4-5` | -| Azure OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `azure/gpt-5.4`, `azure/gpt-5.4-mini`, `azure/gpt-5.4-nano`, `azure/gpt-5.2`, `azure/gpt-5.1`, `azure/gpt-5.1-codex`, `azure/gpt-5`, `azure/gpt-5-mini`, `azure/gpt-5-nano`, `azure/o3`, `azure/o4-mini` | -| Azure Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `azure-anthropic/claude-opus-4-6`, `azure-anthropic/claude-opus-4-5`, `azure-anthropic/claude-sonnet-4-5`, `azure-anthropic/claude-opus-4-1`, `azure-anthropic/claude-haiku-4-5` | -| Google | Summaries only | `gemini-3.8-flash`, `gemini-3.6-flash`, `gemini-3.5-flash-lite`, `gemini-3.5-flash`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | -| Vertex AI | Summaries only | `vertex/gemini-3.5-flash`, `vertex/gemini-3.1-pro-preview`, `vertex/gemini-3.1-flash-lite`, `vertex/gemini-3-flash-preview`, `vertex/gemini-2.5-pro`, `vertex/gemini-2.5-flash`, `vertex/gemini-2.5-flash-lite` | -| DeepSeek | Full thinking deltas | `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-reasoner` | +| Azure OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `azure/gpt-6-astra`, `azure/gpt-5.6-sol`, `azure/gpt-5.6-terra`, `azure/gpt-5.6-luna`, `azure/gpt-5.5`, `azure/gpt-5.4-pro`, `azure/gpt-5.4`, `azure/gpt-5.4-mini`, `azure/gpt-5.4-nano`, `azure/gpt-5.2`, `azure/gpt-5.1`, `azure/gpt-5.1-codex`, `azure/gpt-5`, `azure/gpt-5-mini`, `azure/gpt-5-nano`, `azure/o3`, `azure/o4-mini` | +| Azure Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `azure-anthropic/claude-fable-5-1`, `azure-anthropic/claude-opus-5`, `azure-anthropic/claude-opus-4-8`, `azure-anthropic/claude-opus-4-7`, `azure-anthropic/claude-opus-4-6`, `azure-anthropic/claude-opus-4-5`, `azure-anthropic/claude-sonnet-5`, `azure-anthropic/claude-sonnet-4-6`, `azure-anthropic/claude-sonnet-4-5`, `azure-anthropic/claude-opus-4-1`, `azure-anthropic/claude-haiku-4-5` | +| Google | Summaries only | `gemini-3.8-flash`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash-lite`, `gemini-3.5-flash`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | +| Vertex AI | Summaries only | `vertex/gemini-3.8-flash`, `vertex/gemini-3.7-flash`, `vertex/gemini-3.6-flash`, `vertex/gemini-3.5-flash-lite`, `vertex/gemini-3.5-flash`, `vertex/gemini-3.1-pro-preview`, `vertex/gemini-3.1-flash-lite`, `vertex/gemini-3-flash-preview`, `vertex/gemini-2.5-pro`, `vertex/gemini-2.5-flash`, `vertex/gemini-2.5-flash-lite` | +| DeepSeek | Full thinking deltas | `deepseek-v4-pro`, `deepseek-flash`, `deepseek-v4-flash`, `deepseek-reasoner` | | xAI | Full thinking deltas | `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309` | -| Groq | Full thinking deltas | `groq/openai/gpt-oss-120b`, `groq/openai/gpt-oss-20b`, `groq/openai/gpt-oss-safeguard-20b`, `groq/qwen/qwen3.6-27b` | +| Cerebras | Full thinking deltas | `cerebras/qwen-3.8-27b` | +| Groq | Full thinking deltas | `groq/openai/gpt-oss-120b`, `groq/openai/gpt-oss-20b`, `groq/openai/gpt-oss-safeguard-20b`, `groq/qwen/qwen3.8-27b`, `groq/qwen/qwen3.6-27b` | | Meta | Not streamed | `muse-spark-1.3`, `muse-spark-1.1` | -| Kimi | Full thinking deltas | `kimi-k2.6` | -| Z.ai | Full thinking deltas | `glm-5.3`, `glm-5.2`, `glm-5.1`, `glm-5`, `glm-5-turbo`, `glm-4.7`, `glm-4.6`, `glm-4.5`, `glm-4.5-air` | +| Kimi | Full thinking deltas | `kimi-k3`, `kimi-k2.6` | +| Z.ai | Full thinking deltas | `glm-5.3`, `glm-5.3-flash`, `glm-5.2`, `glm-5.1`, `glm-5`, `glm-5-turbo`, `glm-4.7`, `glm-4.6`, `glm-4.5`, `glm-4.5-air` | {/* agent-stream-capabilities:end */} diff --git a/apps/docs/lib/source.ts b/apps/docs/lib/source.ts index dc2e655a521..8fb22c4197a 100644 --- a/apps/docs/lib/source.ts +++ b/apps/docs/lib/source.ts @@ -17,8 +17,7 @@ const METHOD_COLORS: Record = { } /** - * Custom openapi plugin that places method badges BEFORE the page name - * in the sidebar (like Mintlify/Gumloop) instead of after. + * Places HTTP method badges before page names in the sidebar. */ function openapiPluginBadgeLeft() { return { diff --git a/apps/sim/app/(landing)/components/content-image/content-image.tsx b/apps/sim/app/(landing)/components/content-image/content-image.tsx index 001430c7a4a..c14d508f9c8 100644 --- a/apps/sim/app/(landing)/components/content-image/content-image.tsx +++ b/apps/sim/app/(landing)/components/content-image/content-image.tsx @@ -1,9 +1,7 @@ 'use client' -import { useState } from 'react' -import { cn } from '@sim/emcn' +import { cn, Lightbox } from '@sim/emcn' import NextImage from 'next/image' -import { Lightbox } from '@/app/(landing)/components/lightbox' interface ContentImageProps { src: string @@ -24,30 +22,27 @@ export function ContentImage({ height = 450, className, }: ContentImageProps) { - const [isLightboxOpen, setIsLightboxOpen] = useState(false) - return ( - <> - setIsLightboxOpen(true)} - /> - setIsLightboxOpen(false)} - src={src} - alt={alt} - /> - + + + ) } diff --git a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx index 18e64a2a105..b142adc61ce 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx @@ -67,6 +67,8 @@ const FIT_MAX_ZOOM = 1 * just fits a 1750px frame and only overflows on narrower ones. */ const FIT_MIN_ZOOM = 0.64 +/** Static overviews must fit on phones, where cropping can leave no visible cards. */ +const REDUCED_MOTION_FIT_MIN_ZOOM = 0.05 const FIT_DURATION_MS = 600 const EMPTY_IDS: ReadonlySet = new Set() @@ -616,7 +618,7 @@ function ProductionWorkflowCanvas({ const zoom = Math.min( FIT_MAX_ZOOM, Math.max( - FIT_MIN_ZOOM, + reducedMotion ? REDUCED_MOTION_FIT_MIN_ZOOM : FIT_MIN_ZOOM, Math.min( (width - 2 * FIT_PADDING_PX) / bounds.width, (height - 2 * FIT_PADDING_PX) / bounds.height @@ -747,7 +749,7 @@ function ProductionWorkflowCanvas({ onNodesChange={handleNodesChange} nodeTypes={NODE_TYPES} edgeTypes={EDGE_TYPES} - minZoom={MIN_ZOOM} + minZoom={scripted && reducedMotion ? REDUCED_MOTION_FIT_MIN_ZOOM : MIN_ZOOM} maxZoom={MAX_ZOOM} defaultViewport={{ x: 0, y: 48, zoom: FOCUSED_NODE_MIN_ZOOM }} panOnDrag={interactive} diff --git a/apps/sim/app/(landing)/components/index.ts b/apps/sim/app/(landing)/components/index.ts index 657a1db4c07..d0ee91b5b1d 100644 --- a/apps/sim/app/(landing)/components/index.ts +++ b/apps/sim/app/(landing)/components/index.ts @@ -15,7 +15,6 @@ export { HomeStructuredData } from './home-structured-data' export type { JsonLdData } from './json-ld' export { JsonLd } from './json-ld' export { LandingShell } from './landing-shell' -export { Lightbox } from './lightbox' export { LogoShell } from './logo-shell' export { Navbar } from './navbar' export { PlatformHeroVisual } from './platform-hero-visual' diff --git a/apps/sim/app/(landing)/components/lightbox/index.ts b/apps/sim/app/(landing)/components/lightbox/index.ts deleted file mode 100644 index 1055bfa3ff7..00000000000 --- a/apps/sim/app/(landing)/components/lightbox/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Lightbox } from './lightbox' diff --git a/apps/sim/app/(landing)/components/lightbox/lightbox.tsx b/apps/sim/app/(landing)/components/lightbox/lightbox.tsx deleted file mode 100644 index 7afd596cd72..00000000000 --- a/apps/sim/app/(landing)/components/lightbox/lightbox.tsx +++ /dev/null @@ -1,65 +0,0 @@ -'use client' - -import { useEffect, useEffectEvent, useRef } from 'react' - -interface LightboxProps { - isOpen: boolean - onClose: () => void - src: string - alt: string -} - -export function Lightbox({ isOpen, onClose, src, alt }: LightboxProps) { - const overlayRef = useRef(null) - - const onCloseEvent = useEffectEvent(onClose) - - useEffect(() => { - if (!isOpen) return - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - onCloseEvent() - } - } - - const handleClickOutside = (event: MouseEvent) => { - if (overlayRef.current && event.target === overlayRef.current) { - onCloseEvent() - } - } - - document.addEventListener('keydown', handleKeyDown) - document.addEventListener('click', handleClickOutside) - document.body.style.overflow = 'hidden' - - return () => { - document.removeEventListener('keydown', handleKeyDown) - document.removeEventListener('click', handleClickOutside) - document.body.style.overflow = 'unset' - } - }, [isOpen]) - - if (!isOpen) return null - - return ( -
-
- -
-
- ) -} diff --git a/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.test.tsx b/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.test.tsx index 580f7484de2..4160191677d 100644 --- a/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.test.tsx +++ b/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.test.tsx @@ -90,6 +90,19 @@ function click(label: string) { act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true }))) } +function announcement(): HTMLElement { + const element = host.querySelector('[data-test-announcement]')?.parentElement + if (!element) throw new Error('Missing announcement') + return element +} + +function scrollTo(position: number) { + act(() => { + host.scrollTop = position + host.dispatchEvent(new Event('scroll')) + }) +} + function unmount() { act(() => root.unmount()) mounted = false @@ -103,8 +116,10 @@ beforeEach(() => { vi.stubGlobal('ResizeObserver', ControlledResizeObserver) vi.stubGlobal('IntersectionObserver', ControlledIntersectionObserver) vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { - return new DOMRect(0, 0, 1440, this.tagName === 'HEADER' ? headerHeight : 0) + const height = this.tagName === 'HEADER' ? headerHeight : 32 + return new DOMRect(0, 0, 1440, height) }) + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(32) host = document.createElement('div') host.style.overflowY = 'scroll' @@ -114,13 +129,21 @@ beforeEach(() => { Object.defineProperties(host, { offsetWidth: { value: 1440 }, clientWidth: { value: 1420 }, + scrollHeight: { value: 2000 }, + clientHeight: { value: 800 }, }) document.body.append(host) root = createRoot(host) mounted = true act(() => { root.render( - + + Read update + + } + > ) @@ -136,7 +159,9 @@ afterEach(() => { describe('NavbarShell menu positioning and scroll containment', () => { it('publishes the current header height before a resize and updates it when header content changes', () => { - expect(header().style.getPropertyValue('--landing-header-height')).toBe('104px') + expect(header().style.getPropertyValue('--landing-header-height')).toBe( + 'calc(104px - var(--landing-announcement-offset, 0px))' + ) expect(host.style.scrollPaddingTop).toBe('104px') expect(resizeObservers).toHaveLength(1) expect(resizeObservers[0].observe).toHaveBeenCalledWith(header()) @@ -144,7 +169,9 @@ describe('NavbarShell menu positioning and scroll containment', () => { headerHeight = 76 act(() => resizeObservers[0].resize(header())) - expect(header().style.getPropertyValue('--landing-header-height')).toBe('76px') + expect(header().style.getPropertyValue('--landing-header-height')).toBe( + 'calc(76px - var(--landing-announcement-offset, 0px))' + ) expect(host.style.scrollPaddingTop).toBe('76px') expect(host.scrollTop).toBe(320) }) @@ -196,3 +223,82 @@ describe('NavbarShell menu positioning and scroll containment', () => { expect(host.style.paddingRight).toBe('12px') }) }) + +describe('NavbarShell announcement scroll behavior', () => { + it('hides on downward scroll and restores on upward scroll without changing the scroll position', () => { + expect(announcement().hasAttribute('inert')).toBe(false) + + scrollTo(400) + + expect(announcement().hasAttribute('inert')).toBe(true) + expect(announcement().getAttribute('aria-hidden')).toBe('true') + expect(host.style.scrollPaddingTop).toBe('104px') + expect(host.scrollTop).toBe(400) + + scrollTo(380) + + expect(announcement().hasAttribute('inert')).toBe(false) + expect(host.style.scrollPaddingTop).toBe('104px') + expect(host.scrollTop).toBe(380) + }) + + it('ignores small direction changes but accumulates slow scrolling', () => { + scrollTo(324) + expect(announcement().hasAttribute('inert')).toBe(false) + scrollTo(329) + expect(announcement().hasAttribute('inert')).toBe(true) + scrollTo(326) + expect(announcement().hasAttribute('inert')).toBe(true) + scrollTo(320) + expect(announcement().hasAttribute('inert')).toBe(false) + }) + + it('keeps the banner visible near the top and ignores overscroll bounce at both ends', () => { + scrollTo(400) + scrollTo(-30) + expect(announcement().hasAttribute('inert')).toBe(false) + scrollTo(10) + expect(announcement().hasAttribute('inert')).toBe(false) + + scrollTo(1200) + scrollTo(1250) + scrollTo(1200) + expect(announcement().hasAttribute('inert')).toBe(true) + scrollTo(1180) + expect(announcement().hasAttribute('inert')).toBe(false) + }) + + it('keeps the header stationary while a navigation menu is open', () => { + scrollTo(400) + click('Open mobile') + scrollTo(300) + expect(announcement().hasAttribute('inert')).toBe(true) + expect(host.style.scrollPaddingTop).toBe('104px') + + click('Close mobile') + scrollTo(280) + expect(announcement().hasAttribute('inert')).toBe(false) + }) + + it('does not hide a focused announcement link', () => { + host.querySelector('[data-test-announcement]')?.focus() + scrollTo(400) + expect(announcement().hasAttribute('inert')).toBe(false) + }) + + it('restores the full header if native focus scrolling reaches the top while a menu is open', () => { + scrollTo(400) + click('Open mobile') + scrollTo(0) + + expect(announcement().hasAttribute('inert')).toBe(false) + expect(host.style.scrollPaddingTop).toBe('104px') + expect(host.style.overflowY).toBe('hidden') + }) + + it('removes the scroll listener when the shell unmounts', () => { + const removeListener = vi.spyOn(host, 'removeEventListener') + unmount() + expect(removeListener).toHaveBeenCalledWith('scroll', expect.any(Function)) + }) +}) diff --git a/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx b/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx index dff811a914d..f3f4c4d94c2 100644 --- a/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx +++ b/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx @@ -22,6 +22,7 @@ interface NavbarFrostContextValue { } const NavbarFrostContext = createContext(null) +const SCROLL_DIRECTION_THRESHOLD = 8 /** Lets each nav surface report its open state so the shell can coordinate shared effects. */ export function useNavbarFrost(): NavbarFrostContextValue | null { @@ -29,6 +30,7 @@ export function useNavbarFrost(): NavbarFrostContextValue | null { } interface NavbarShellProps { + announcement?: ReactNode children: ReactNode } @@ -39,8 +41,7 @@ interface NavbarShellProps { * 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 + * scroll port is watched by an {@link IntersectionObserver}. 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. @@ -51,8 +52,12 @@ interface NavbarShellProps { * while the fill still fades, so the frost appears smoothly without the jitter. * * The measured header height anchors the desktop panel and bounds the mobile - * sheet, including changes to the announcement strip or text sizing. The same - * height offsets native page and hash scrolling inside the landing scroll port. + * sheet, including changes to the announcement strip or text sizing. Native + * page and hash scrolling reserve the full height so changing banner visibility + * does not move the scroll anchor and leaves room for the banner to return. + * Scrolling down slides the announcement above the viewport; scrolling up + * restores it. Moving the sticky inset preserves document flow and scroll + * position. Menu offsets use only the visible portion of the header. * * Both navigation surfaces report open state through {@link NavbarFrostContext}. * While either is open, the shell locks its actual scroll port, preserves the @@ -75,10 +80,12 @@ interface NavbarShellProps { * Only this shell hydrates; the nav content is server-rendered and passed through * as {@link children}, so the wordmark and links stay zero-hydration and crawlable. */ -export function NavbarShell({ children }: NavbarShellProps) { +export function NavbarShell({ announcement, children }: NavbarShellProps) { const sentinelRef = useRef(null) const headerRef = useRef(null) + const announcementRef = useRef(null) const [scrolled, setScrolled] = useState(false) + const [announcementHidden, setAnnouncementHidden] = useState(false) const [menuOpenBySource, setMenuOpenBySource] = useState({ desktop: false, mobile: false }) const menuOpen = menuOpenBySource.desktop || menuOpenBySource.mobile @@ -88,24 +95,50 @@ export function NavbarShell({ children }: NavbarShellProps) { if (!header || !scrollPort) return const previousScrollPaddingTop = scrollPort.style.scrollPaddingTop - let previousHeight = 0 const updateHeight = () => { const height = header.getBoundingClientRect().height - if (height === previousHeight) return - previousHeight = height - header.style.setProperty('--landing-header-height', `${height}px`) + const announcementHeight = announcementRef.current?.getBoundingClientRect().height ?? 0 + header.style.setProperty('--landing-announcement-height', `${announcementHeight}px`) + header.style.setProperty( + '--landing-header-height', + `calc(${height}px - var(--landing-announcement-offset, 0px))` + ) scrollPort.style.scrollPaddingTop = `${height}px` } updateHeight() const observer = new ResizeObserver(updateHeight) observer.observe(header) + if (announcementRef.current) observer.observe(announcementRef.current) return () => { observer.disconnect() scrollPort.style.scrollPaddingTop = previousScrollPaddingTop } }, []) + useEffect(() => { + const scrollPort = sentinelRef.current?.parentElement + const banner = announcementRef.current + if (!scrollPort || !banner) return + + const scrollPosition = () => + Math.max(0, Math.min(scrollPort.scrollTop, scrollPort.scrollHeight - scrollPort.clientHeight)) + let previousPosition = scrollPosition() + const onScroll = () => { + const position = scrollPosition() + const delta = position - previousPosition + const nearTop = position <= banner.offsetHeight + if (!nearTop && (menuOpen || Math.abs(delta) < SCROLL_DIRECTION_THRESHOLD)) return + + previousPosition = position + if (banner.contains(document.activeElement)) return + setAnnouncementHidden(!nearTop && delta > 0) + } + + scrollPort.addEventListener('scroll', onScroll, { passive: true }) + return () => scrollPort.removeEventListener('scroll', onScroll) + }, [menuOpen]) + useEffect(() => { const sentinel = sentinelRef.current if (!sentinel) return @@ -166,7 +199,13 @@ export function NavbarShell({ children }: NavbarShellProps) {
- + }>