diff --git a/.agents/skills/design-taste-frontend/SKILL.md b/.agents/skills/design-taste-frontend/SKILL.md new file mode 100644 index 00000000000..b72132fcd46 --- /dev/null +++ b/.agents/skills/design-taste-frontend/SKILL.md @@ -0,0 +1,1206 @@ +--- +name: design-taste-frontend +description: Anti-slop frontend skill for landing pages, portfolios, and redesigns. The agent reads the brief, infers the right design direction, and ships interfaces that do not look templated. Real design systems when applicable, audit-first on redesigns, strict pre-flight check. +--- + +# tasteskill: Anti-Slop Frontend Skill + +> Landing pages, portfolios, and redesigns. Not dashboards, not data tables, not multi-step product UI. +> Every rule below is **contextual**. None of it fires automatically. First read the brief, then pull only what fits. + +--- + +## 0. BRIEF INFERENCE (Read the Room Before Anything Else) + +Before touching code or tweaking dials, **infer what the user actually wants**. Most LLM design output is bad because the model jumps to a default aesthetic instead of reading the room. + +### 0.A Read these signals first +1. **Page kind** - landing (SaaS / consumer / agency / event), portfolio (dev / designer / creative studio), redesign (preserve vs overhaul), editorial / blog. +2. **Vibe words** the user used - "minimalist", "calm", "Linear-style", "Awwwards", "brutalist", "premium consumer", "Apple-y", "playful", "serious B2B", "editorial", "agency-y", "glassy", "dark tech". +3. **Reference signals** - URLs they linked, screenshots they pasted, products they named, brands they're competing with. +4. **Audience** - B2B procurement panel vs. design-conscious consumer vs. recruiter scanning a portfolio. The audience picks the aesthetic, not your taste. +5. **Brand assets that already exist** - logo, color, type, photography. For redesigns, these are starting material, not optional input (see Section 11). +6. **Quiet constraints** - accessibility-first audiences, public-sector, regulated industries, trust-first commerce, kids' products. These constraints OVERRIDE aesthetic preference. + +### 0.B Output a one-line "Design Read" before generating +Before any code, state in one line: **"Reading this as: \ for \, with a \ language, leaning toward \."** + +Example reads: +- *"Reading this as: B2B SaaS landing for technical buyers, with a Linear-style minimalist language, leaning toward Tailwind utilities + Geist + restrained motion."* +- *"Reading this as: solo designer portfolio for hiring managers, with an editorial / kinetic-type language, leaning toward native CSS + scroll-driven animation + custom typography."* +- *"Reading this as: redesign of a public-sector service site, with a trust-first language, leaning toward GOV.UK Frontend or USWDS."* + +### 0.C If the brief is ambiguous, ask one question, do not guess +Ask exactly **one** clarifying question - never a multi-question dump - and only when the design read genuinely diverges. Example: *"Should this feel closer to Linear-clean or Awwwards-experimental?"* + +If you can confidently infer from context, **do not ask**. Just declare the design read and proceed. + +### 0.D Anti-Default Discipline +Do not default to: AI-purple gradients, centered hero over dark mesh, three equal feature cards, generic glassmorphism on everything, infinite-loop micro-animations everywhere, Inter + slate-900. These are the LLM defaults. Reach past them deliberately based on the design read. + +--- + +## 1. THE THREE DIALS (Core Configuration) + +After the design read, set three dials. Every layout, motion, and density decision below is gated by these. + +* **`DESIGN_VARIANCE: 8`** - 1 = Perfect Symmetry, 10 = Artsy Chaos +* **`MOTION_INTENSITY: 6`** - 1 = Static, 10 = Cinematic / Physics +* **`VISUAL_DENSITY: 4`** - 1 = Art Gallery / Airy, 10 = Cockpit / Packed Data + +**Baseline:** `8 / 6 / 4`. Use these unless the design read overrides them. Do not ask the user to edit this file - overrides happen conversationally. + +### 1.A Dial Inference (design read → dial values) +| Signal | VARIANCE | MOTION | DENSITY | +|---|---|---|---| +| "minimalist / clean / calm / editorial / Linear-style" | 5-6 | 3-4 | 2-3 | +| "premium consumer / Apple-y / luxury / brand" | 7-8 | 5-7 | 3-4 | +| "playful / wild / Dribbble / Awwwards / experimental / agency" | 9-10 | 8-10 | 3-4 | +| "landing page / portfolio / marketing site (default)" | 7-9 | 6-8 | 3-5 | +| "trust-first / public-sector / regulated / accessibility-critical" | 3-4 | 2-3 | 4-5 | +| "redesign - preserve" | match existing | +1 | match existing | +| "redesign - overhaul" | +2 | +2 | match existing | + +### 1.B Use-Case Presets +| Use case | VARIANCE | MOTION | DENSITY | +|---|---|---|---| +| Landing (SaaS, mainstream) | 7 | 6 | 4 | +| Landing (Agency / creative) | 9 | 8 | 3 | +| Landing (Premium consumer) | 7 | 6 | 3 | +| Portfolio (Designer / studio) | 8 | 7 | 3 | +| Portfolio (Developer) | 6 | 5 | 4 | +| Editorial / Blog | 6 | 4 | 3 | +| Public-sector service | 3 | 2 | 5 | +| Redesign - preserve | match | match+1 | match | +| Redesign - overhaul | +2 | +2 | match | + +### 1.C How the Dials Drive Output +Use these (or user-overridden values) as global variables. Cross-references throughout this document refer to these exact variable names - never invent aliases like `LAYOUT_VARIANCE` or `ANIM_LEVEL`. + +--- + +## 2. BRIEF → DESIGN SYSTEM MAP + +Once you have the design read (Section 0) and dials (Section 1), pick the right foundation. Do not invent CSS for things that have an official package. Do not pretend an aesthetic trend is an official system. + +### 2.A When to reach for a real design system (use official packages) +| Brief reads as… | Reach for | Why | +|---|---|---| +| Microsoft / enterprise SaaS / dashboards | `@fluentui/react-components` or `@fluentui/web-components` | Official Fluent UI, Microsoft tokens, accessibility done | +| Google-ish UI, Material-flavored product | `@material/web` + Material 3 tokens | Official, theme-able via Material Theming | +| IBM-style B2B / enterprise analytics | `@carbon/react` + `@carbon/styles` | Official Carbon, mature data-density patterns | +| Shopify app surfaces | `polaris.js` web components / Polaris React | Required for Shopify admin UI | +| Atlassian / Jira-style product | `@atlaskit/*` + `@atlaskit/tokens` | Official Atlassian DS | +| GitHub-style devtool / community page | `@primer/css` or `@primer/react-brand` | Official Primer; Brand variant for marketing | +| Public-sector UK service | `govuk-frontend` | Legally / regulatorily expected | +| US public-sector / trust-first | `uswds` | Same | +| Fast local-business / agency MVP | Bootstrap 5.3 | Boring, fast, works | +| Modern accessible React foundation | `@radix-ui/themes` | Primitives + polished theme | +| Modern SaaS where you own the components | shadcn/ui (`npx shadcn@latest add ...`) | You own the code, easy to customise; never ship default state | +| Tailwind-based modern SaaS / AI marketing | Tailwind v4 utilities + `dark:` variant | Default for indie + small team builds | + +**Honesty rule:** if the brief reads as one of the systems above, install and use the **official** package. Do not recreate its CSS by hand. Do not import a system's tokens but then override 90% of them. + +**One system per project.** Do not mix Fluent React with Carbon in the same tree. Do not import shadcn/ui components into a Material 3 app. + +### 2.B When the brief is an aesthetic, not a system +For these directions, there is **no single official package**. Build with native CSS + Tailwind + a maintained component library. Be honest in code comments about what is borrowed inspiration vs. official material. + +| Aesthetic | Honest implementation | +|---|---| +| Glassmorphism / "frosted glass" | `backdrop-filter`, layered borders, highlight overlays. Provide solid-fill fallback for `prefers-reduced-transparency`. | +| Bento (Apple-style tile grids) | CSS Grid with mixed cell sizes. No single library owns this. | +| Brutalism | Native CSS, monospace, raw borders. No library. | +| Editorial / magazine | Serif type, asymmetric grid, generous whitespace. No library. | +| Dark tech / hacker | Mono + accent neon, terminal motifs. No library. | +| Aurora / mesh gradients | SVG or layered radial gradients. No library. | +| Kinetic typography | Native CSS animations, scroll-driven animations, GSAP for hijacks. No library. | +| **Apple Liquid Glass** | Apple documents this for Apple platforms only. **There is no official `liquid-glass.css`.** Web implementations are approximations using `backdrop-filter` + layered borders + highlights. Label clearly as approximation. | + +--- + +## 3. DEFAULT ARCHITECTURE & CONVENTIONS + +Unless the design read picks a real design system (Section 2.A), these are the defaults: + +### 3.A Stack +* **Framework:** React or Next.js. Default to Server Components (RSC). + * **RSC SAFETY:** Global state works ONLY in Client Components. In Next.js, wrap providers in a `"use client"` component. + * **INTERACTIVITY ISOLATION:** Any component using Motion, scroll listeners, or pointer physics MUST be an isolated leaf with `'use client'` at the top. Server Components render static layouts only. +* **Styling:** **Tailwind v4** (default). Tailwind v3 only if the existing project demands it. + * For v4: do NOT use `tailwindcss` plugin in `postcss.config.js`. Use `@tailwindcss/postcss` or the Vite plugin. +* **Animation:** **Motion** (the library formerly known as Framer Motion). Import from `motion/react` (`import { motion } from "motion/react"`). The `framer-motion` package still works as a legacy alias - prefer `motion/react` in new code. +* **Fonts:** Always use `next/font` (Next.js) or self-host with `@font-face` + `font-display: swap`. Never link Google Fonts via `` in production. + +### 3.B State +* Local `useState` / `useReducer` for isolated UI. +* Global state ONLY for deep prop-drilling avoidance - Zustand, Jotai, or React context. +* **NEVER** use `useState` to track continuous values driven by user input (mouse position, scroll progress, pointer physics, magnetic hover). Use Motion's `useMotionValue` / `useTransform` / `useScroll`. `useState` re-renders the React tree on every change and collapses on mobile. + +### 3.C Icons +* **Allowed libraries (priority order):** `@phosphor-icons/react`, `hugeicons-react`, `@radix-ui/react-icons`, `@tabler/icons-react`. +* **Discouraged:** `lucide-react`. Acceptable only when the user explicitly asks for it or the project already depends on it. +* **NEVER hand-roll SVG icons.** If a glyph is missing, install a second library or compose from primitives - do not draw icon paths from scratch. +* **One family per project.** Do not mix Phosphor with Lucide in the same component tree. +* **Standardize `strokeWidth` globally** (e.g. `1.5` or `2.0`). + +### 3.D Emoji Policy +Discouraged by default in code, markup, and visible text. Replace symbols with icon-library glyphs. **Override:** allow emojis only when the user explicitly asks for a playful / chat-style / social-native vibe - and even then use them sparingly with intent. + +### 3.E Responsiveness & Layout Mechanics +* Standardize breakpoints (`sm 640`, `md 768`, `lg 1024`, `xl 1280`, `2xl 1536`). +* Contain page layouts using `max-w-[1400px] mx-auto` or `max-w-7xl`. +* **Viewport Stability:** NEVER use `h-screen` for full-height Hero sections. ALWAYS use `min-h-[100dvh]` to prevent layout jumping on mobile (iOS Safari address bar). +* **Grid over Flex-Math:** NEVER use complex flexbox percentage math (`w-[calc(33%-1rem)]`). ALWAYS use CSS Grid (`grid grid-cols-1 md:grid-cols-3 gap-6`). + +### 3.F Dependency Verification (mandatory) +Before importing ANY 3rd-party library, check `package.json`. If the package is missing, output the install command first. **Never** assume a library exists. + +--- + +## 4. DESIGN ENGINEERING DIRECTIVES (Bias Correction) + +LLMs default to clichés. Override these defaults proactively. Each rule has a context-aware override path. + +### 4.1 Typography +* **Display / Headlines:** Default `text-4xl md:text-6xl tracking-tighter leading-none`. +* **Body / Paragraphs:** Default `text-base text-gray-600 leading-relaxed max-w-[65ch]`. +* **Sans font choice:** + * **Discouraged as default:** `Inter`. Pick `Geist`, `Outfit`, `Cabinet Grotesk`, `Satoshi`, or a brand-appropriate serif first. + * **Override:** Inter is acceptable when the user explicitly asks for a neutral / standard / Linear-style feel, or when the brief is a public-sector / accessibility-first site. +* **Pairings to know:** `Geist` + `Geist Mono`, `Satoshi` + `JetBrains Mono`, `Cabinet Grotesk` + `Inter Tight`, `GT America` + `IBM Plex Mono`. + +* **SERIF DISCIPLINE (VERY DISCOURAGED AS DEFAULT):** + * Serif is **very discouraged as the default font for any project.** "It feels creative / premium / editorial" is NOT a reason to reach for serif. The agent's default mental model that "creative brief = serif" is the single most-tested AI tell in production rounds. + * **Serif is only acceptable when ONE of these is explicitly true:** + - The brand brief literally names a serif font, OR + - The aesthetic family is genuinely editorial / luxury / publication / manuscript / heritage / vintage AND you can articulate why this specific serif fits this specific brand + * For everything else (creative agency, design studio, modern brand, premium consumer, portfolio, lifestyle), **default sans-serif display** (Geist Display, ABC Diatype, Söhne Breit, Cabinet Grotesk Display, Migra Sans, GT Walsheim, Inter Display, PP Neue Montreal). Sans display fonts are not "boring" — they are the default for the same reason black is the default in fashion. + * **EMPHASIS RULE (related):** When you want to emphasize a word within a headline (the kinetic "and `spatial` design" type move), use **italic or bold of the SAME font**. Do NOT inject a random serif word into a sans headline (or vice versa) just to add visual interest. Mixed-family emphasis is amateur. Italic/bold emphasis in the same family is the right move. + * **Specifically BANNED as defaults:** `Fraunces` and `Instrument_Serif` (the two LLM-favorite display serifs). + * **If a serif is justified** (rare, per the above), rotate from this pool, do NOT reuse the same serif across consecutive projects: PP Editorial New, GT Sectra Display, Cardinal Grotesque, Reckless Neue, Tiempos Headline, Recoleta, Cormorant Garamond, Playfair Display, EB Garamond, IvyPresto, Migra, Editorial Old, Saol Display, Söhne Breit Kursiv, Domaine Display, Canela, Schnyder, Tobias, NB Architekt, ITC Galliard. + +* **ITALIC DESCENDER CLEARANCE (mandatory):** When italic is used in display type and the word contains a descender letter (`y g j p q`), `leading-[1]` or `leading-none` will clip the descender. Use `leading-[1.1]` minimum and add `pb-1` or `mb-1` reserve on the wrapping element. Audit every italic word in display headlines before shipping. + +### 4.2 Color Calibration +* Max 1 accent color. Saturation < 80% by default. +* **THE LILA RULE:** The "AI Purple / Blue glow" aesthetic is discouraged as a default. No automatic purple button glows, no random neon gradients. Use neutral bases (Zinc / Slate / Stone) with high-contrast singular accents (Emerald, Electric Blue, Deep Rose, Burnt Orange, etc.). +* **Override:** if the brand or brief explicitly asks for purple / violet / lila, embrace it. But execute with intent: consistent palette, harmonised neutrals, restrained gradients. Not generic AI gradient slop. +* **One palette per project.** Do not fluctuate between warm and cool grays within the same project. +* **COLOR CONSISTENCY LOCK (mandatory):** Once an accent color is chosen for a page, it is used on the WHOLE page. A warm-grey site does not suddenly get a blue CTA in section 7. A rose-accented site does not get a teal status badge in the footer. Pick one accent, lock it, audit every component before shipping. + +* **PREMIUM-CONSUMER PALETTE BAN (mandatory, second-most-recurring AI-tell):** + * For premium-consumer briefs (cookware, wellness, artisan, luxury, heritage craft, DTC home goods, etc.) the LLM default is **warm beige/cream + brass/clay/oxblood/ochre + espresso/ink dark text**. Concretely banned hex families as default backgrounds and accents: + - Backgrounds: `#f5f1ea`, `#f7f5f1`, `#fbf8f1`, `#efeae0`, `#ece6db`, `#faf7f1`, `#e8dfcb` (all "warm paper / cream / chalk / bone") + - Accents: `#b08947`, `#b6553a`, `#9a2436`, `#9c6e2a`, `#bc7c3a`, `#7d5621` (all "brass / clay / oxblood / ochre") + - Text: `#1a1714`, `#1a1814`, `#1b1814` (all "espresso / warm near-black") + * This palette is BANNED as the default reach for premium-consumer briefs. Every premium-consumer site you have ever shipped uses this exact palette. The brand becomes invisible. + * **Default alternatives (rotate, do not reuse):** + - **Cold Luxury:** silver-grey + chrome + smoke (think Tesla, Apple Watch Hermes-without-the-leather) + - **Forest:** deep green + bone + amber accent (think Filson, Patagonia premium) + - **Black and Tan:** true off-black + warm tan, sharp contrast, no beige + - **Cobalt + Cream:** saturated blue against a single neutral, no brass + - **Terracotta + Slate:** warm rust against cool grey, no brass + - **Olive + Brick + Paper:** muted olive plus brick-red accent + - **Pure monochrome + single saturated pop:** off-white + off-black + one bright accent (electric blue, emerald, hot pink, etc.) + * **Palette-rotation rule:** if the previous premium-consumer project you generated used the beige+brass family, this one MUST use a different family. Do not ship the same warm-craft palette twice in a row. + * **Override:** the beige+brass+espresso palette is acceptable ONLY when the brand brief explicitly names those colors, or when the brand identity is genuinely vintage / artisan / warm-craft AND you can articulate why this specific palette fits this specific brand. Default-reaching for it because "this is a cookware brief" is banned. + +### 4.3 Layout Diversification +* **ANTI-CENTER BIAS:** Centered Hero / H1 sections are avoided when `DESIGN_VARIANCE > 4`. Force "Split Screen" (50/50), "Left-aligned content / right-aligned asset", "Asymmetric white-space", or scroll-pinned structures. +* **Override:** centered hero is OK for editorial / manifesto / launch-announcement briefs where the message itself is the design. + +### 4.4 Materiality, Shadows, Cards +* Use cards ONLY when elevation communicates real hierarchy. Otherwise group with `border-t`, `divide-y`, or negative space. +* When a shadow is used, tint it to the background hue. No pure-black drop shadows on light backgrounds. +* For `VISUAL_DENSITY > 7`: generic card containers are banned. Data metrics breathe in plain layout. +* **SHAPE CONSISTENCY LOCK (mandatory):** Pick ONE corner-radius scale for the page and stick to it. Options: all-sharp (radius 0), all-soft (radius 12-16px), all-pill (full radius for interactive). Mixed systems are allowed only when there is a documented rule (e.g. "buttons are full-pill, cards are 16px, inputs are 8px") and that rule is followed everywhere. Round buttons in a square layout, or square cards on a pill-button page, is broken design. + +### 4.5 Interactive UI States +LLMs default to "static successful state only." Always implement full cycles: +* **Loading:** Skeletal loaders matching the final layout's shape. Avoid generic circular spinners. +* **Empty States:** Beautifully composed; indicate how to populate. +* **Error States:** Clear, inline (forms), or contextual (toasts only for transient). +* **Tactile Feedback:** On `:active`, use `-translate-y-[1px]` or `scale-[0.98]` to simulate a physical push. +* **BUTTON CONTRAST CHECK (mandatory, a11y):** Before shipping any button, verify the button text is readable against the button background. White button + white text, `bg-white` CTA with `text-white` label, transparent button against the page background with no border → all banned. Audit every CTA: contrast ratio WCAG AA min (4.5:1 for body, 3:1 for large text 18px+). Same rule applies to ghost buttons over photographic backgrounds (use a backdrop, scrim, or stroke). +* **CTA BUTTON WRAP BAN (mandatory):** Button text MUST fit on one line at desktop. If a label like "VIEW SELECTED WORK" wraps to 2 or 3 lines, the button is broken. Fix by EITHER shortening the label (3 words max for primary CTAs, ideally 1-2) OR widening the button (do not artificially constrain `max-width` on CTAs). Wrapped CTAs at desktop are a Pre-Flight Fail. +* **NO DUPLICATE CTA INTENT (mandatory):** Two CTAs with the same intent on one page is a Pre-Flight Fail. Examples of same intent: "Get in touch" + "Contact us" + "Let's talk" + "Start a project" + "Start something" + "Reach out" = all "contact" intent → pick ONE label and use it everywhere on the page (nav, hero, footer). Same for "Try free" + "Get started" + "Sign up free" (all "signup" intent) and "View work" + "See selected work" + "Browse projects" (all "portfolio" intent). One label per intent. +* **FORM CONTRAST CHECK (mandatory, a11y):** Form inputs, placeholder text, focus rings, helper text, and error text all pass WCAG AA contrast against the section background. Light placeholders on a near-white form, white form on white page section, form labels grayer than 4.5:1 contrast → all banned. Audit every form before shipping. + +### 4.6 Data & Form Patterns +* Label ABOVE input. Helper text optional but present in markup. Error text BELOW input. Standard `gap-2` for input blocks. +* No placeholder-as-label. Ever. + +### 4.7 Layout Discipline (Hard Rules. Failing any of these is shipping broken work) + +* **Hero MUST fit in the initial viewport.** Headline max 2 lines on desktop, subtext max **20 words** AND max 3-4 lines, CTAs visible without scroll. If the copy is too long: reduce font scale OR cut copy. If you cannot describe the value-prop in 20 words of subtext, the value-prop is unclear, not the rule too tight. Never let the hero overflow and force scroll to find the CTA. +* **Hero font-scale discipline.** Plan font size and image size *together*. If the hero asset is large and the headline is more than 6 words, do not start at `text-7xl/text-8xl`. Default sensible range: `text-4xl md:text-5xl lg:text-6xl` for most heroes; `text-6xl md:text-7xl` only when the headline is 3-5 words. A 4-line hero headline is always a font-size error, never a copy-length error. +* **HERO TOP PADDING CAP (mandatory):** Hero top padding max `pt-24` (≈6rem) at desktop. More than that means the hero content floats halfway down the viewport and reads as a layout bug, not as intentional space. If your hero needs more breathing room, increase font scale or asset size, not top padding. +* **HERO STACK DISCIPLINE (max 4 text elements).** The hero is a single moment, not a feature list. Allowed text elements, max 4 in total: + 1. Eyebrow (small uppercase label) OR brand strip OR neither - pick zero or one + 2. Headline (max 2 lines, see above) + 3. Subtext (max 20 words, max 4 lines) + 4. CTAs (1 primary + max 1 secondary) + - **BANNED in the hero:** tiny tagline below CTAs ("Works with GitHub, GitLab, and self-hosted Git"), trust micro-strip ("Used by engineering teams at..."), pricing teaser ("Free for solo, $10/user for teams"), feature bullet list, social-proof avatar row. All of those move to dedicated sections directly below the hero. + - If you have an eyebrow AND a tagline below CTAs in the same hero, drop the tagline. If you have a brand strip AND a tagline, drop the tagline. One small text element per hero, max. +* **"Used by" / "Trusted by" logo wall belongs UNDER the hero, never inside it.** The hero is for the value prop and primary CTA. The logo wall is a separate section directly below. Do not stuff trust logos into the same flex row as the hero copy. +* **Navigation MUST render on a single line on desktop.** If items don't fit at `lg` (1024px), condense labels, drop secondary items, or move to a hamburger. A two-line nav at desktop is broken design. +* **Navigation height cap: 80px max desktop, default 64-72px.** No huge "agency" nav bars that eat 15% of the viewport. +* **Bento grids MUST have rhythm, not one-sided repetition.** Do not stack 6 left-image / right-text rows. Vary the composition: alternate full-width feature rows, asymmetric tile sizes, vertical breaks. +* **BENTO CELL COUNT RULE (mandatory):** A bento grid has EXACTLY as many cells as you have content for. 3 items → 3 cells (1+2 split, or 2+1, or asymmetric trio). 5 items → 5 cells (2+3, 3+2, hero+4, etc.). If your grid has an empty cell in the middle or at the end, you planned wrong. Re-shape the grid; do not paste a blank tile. +* **Section-Layout-Repetition Ban.** Once you use a layout family for a section (e.g., 3-column-image-cards, full-width-quote, split-text-image), that family can appear at most ONCE on the page. "Selected commissions" must not look like "What we do." A landing page with 8 sections must use at least 4 different layout families. +* **ZIGZAG ALTERNATION CAP (mandatory).** Alternating "left-image + right-text" then "left-text + right-image" zigzag layout = banal. Max 2 sections in a row with this image+text-split pattern. The 3rd consecutive image+text split is a Pre-Flight Fail. Break the pattern with a full-width section, a vertical-stack section, a bento grid, a marquee, or a different layout family. +* **EYEBROW RESTRAINT (mandatory, the #1 violated rule in production tests).** An "eyebrow" is the small uppercase wide-tracking label sitting above a section headline (e.g. `FOUR COLORWAYS`, `SELECTED WORK`, `THE HARDWARE`, `Git-native task management`). Typical CSS signature: `text-[11px] uppercase tracking-[0.18em]`, `font-mono text-[10.5px] uppercase tracking-[0.22em]`. Every AI-built site puts an eyebrow above EVERY section header, producing the same templated rhythm. Hard rule: + - **Maximum 1 eyebrow per 3 sections.** Hero counts as 1. So a page with 9 sections may use at most 3 eyebrows total. + - If section A has an eyebrow, the next 2 sections cannot have one. + - **Pre-Flight Check is mechanical:** count instances of `uppercase tracking` (or similar small-caps mono labels above headlines) across all section components. If count > ceil(sectionCount / 3), the output fails. + - **What to do instead of an eyebrow:** drop it entirely. The headline alone is enough. If you need to categorize a section, the section's location on the page already categorizes it; no label needed. +* **SPLIT-HEADER BAN (mandatory).** The pattern "left big headline + right small explainer paragraph" as a section header (left col-span-7/8, right col-span-4/5 with a small body paragraph floating in the right column) is **banned as default**. Sections should have ONE focused message. If you genuinely need both a headline and an explainer paragraph, stack them vertically (headline on top, body below, max-width 65ch). Reach for the split-header pattern only when there is a real compositional reason (e.g., the right column carries a visual or interactive element, not just filler text). +* **Bento Background Diversity (mandatory).** Bento and feature-grid sections cannot be 6 white-on-white cards with text inside. At least 2-3 cells in any multi-cell grid need real visual variation: a real image, a brand-appropriate gradient (not AI-purple), a pattern, a tinted background. A cream-on-cream bento with only typography inside reads as boring AI default, even when the rest of the page is good. +* **Mobile collapse must be explicit per section.** For every multi-column layout, declare the `< 768px` fallback in the same component. No "it'll work, Tailwind handles it" assumptions. + +### 4.8 Image & Visual Asset Strategy + +Landing pages and portfolios are **visual products**. Text-only pages with fake-screenshot divs are slop. + +**Priority order for visual assets:** +1. **Image-generation tool first.** If ANY image-gen tool is available in the environment (`generate_image`, MCP image tool, IDE-integrated gen, OpenAI image tools, etc.) you MUST use it to create section-specific assets: hero photography, product shots, texture backgrounds, mood images. Generate at the right aspect ratio for the section. Do not skip this step because hand-rolled CSS feels faster. +2. **Real web images second.** When no gen tool is available, use real photography sources. Acceptable defaults: + * `https://picsum.photos/seed/{descriptive-seed}/{w}/{h}` for placeholder photography (seed should describe the section, e.g. `marrow-cookware-kitchen`) + * Actual stock or brand URLs when the brief provides them + * Open-license sources (Unsplash via direct URL, Pexels) if explicitly allowed +3. **Last resort: tell the user.** If neither is possible, do NOT fill the page with hand-rolled SVG illustrations or div-based "fake screenshots." Instead, leave clearly-labeled placeholder slots (``) and at the end of the response say: *"This page needs real images at: \[list of placements\]. Please generate or provide them."* + +**Even minimalist sites need real images.** A pure-text page is not minimalism. It is incomplete work. Even an editorial Linear-style site needs at least 2-3 real images (hero, one product/lifestyle shot, one supporting image). Generate B&W minimalist photography if the brief is restrained; do not skip images entirely because the dial is low. + +**Real company logos for social proof.** When the brief calls for a "Trusted by / Used by / Customers" logo wall, do NOT default to plain text wordmarks (`Acme Co` styled in a row). Use real SVG logos: +* **Source: Simple Icons** (`https://cdn.simpleicons.org/{slug}/ffffff` for any color, or `simple-icons` npm package). Covers most known brands. +* **Alternative: devicon** for tech-stack logos (`@svgr/cli` or CDN). +* **Make-up the brand name? Then make-up an SVG mark too.** Generate a simple monogram (one letter in a circle, two-letter ligature, abstract glyph) rendered as an inline `` matching the page style. Plain text wordmarks for invented brand names look generic. +* **Always** ensure logos render in both light and dark mode (white-on-dark, black-on-light, or single-color theme variable). +* **LOGO-ONLY rule (mandatory):** logo wall = logos and nothing else. Do NOT print industry / category labels below each logo (no `Vercel` + `hosting` underneath, no `Stripe` + `payments`, no `Cloudflare` + `infra`). The logo is the credibility, the label adds nothing the user does not already know. Optional: brand name as alt-text for screen readers, optional link to the brand's site. That is it. + +**Hand-rolled illustrations:** +* SVG icons from libraries: fine (see Section 3.C). +* Hand-rolled decorative SVGs (custom illustrations, logos, marks): **strongly discouraged**, never as default. Acceptable only when: + - The brief explicitly calls for it ("draw me an SVG logo") + - It's a single, simple geometric mark (a square, a circle, a wordmark in display type) + - You're confident in the output quality + +**Div-based fake screenshots are banned.** A "hand-built product preview" rendered with `
` rectangles, fake task lists, fake dashboards, fake terminal windows is a Tell. If you need to show a product: +* Use a real screenshot URL if one exists +* Generate one via image tool +* Use a real component preview (an actual mini-version of the UI inside the page) +* Or skip the preview entirely and use editorial photography + +**Hero needs a real visual.** Text + gradient blob is not a hero - it's a placeholder. + +### 4.9 Content Density + +Landing pages live on the **first impression**, not the full read. Cut ruthlessly. + +* **Default content shape per section:** short headline (≤ 8 words) + short sub-paragraph (≤ 25 words) + one visual asset OR one CTA. Anything more must be justified by the section's job. +* **No data-dump sections.** A 20-row publication table, a 30-row award list, a giant pricing matrix on a marketing page = wrong layout. Use: + - Top 3-5 highlights + "View full list" link + - Marquee / carousel for breadth + - Different page entirely if the data is the product +* **Long lists need a different UI component, not a longer list.** Default `
    ` with bullets / `divide-y` rows is the lazy choice. If you have > 5 items, reach for one of these instead: + - 2-column split with grouped items + - Card grid with image + label per item + - Tabs / accordion if items are categorisable + - Horizontal scroll-snap pills + - Carousel for breadth-heavy lists (testimonials, logos, capabilities) + - Marquee for "lots-of-things-that-don't-need-individual-attention" + A spec sheet with 10 rows + a hairline under every row is the WORST default. Either group rows into 2-3 chunks with sparse dividers, or move to a card-per-spec layout. +* **Spec sheets specifically (the Marrow-cookware pattern).** A long product specification table with `border-b` on every row is the AI default for cookware / hardware / apparel / artisan-goods briefs. Banned. Concrete alternatives: + - **2-col card grid:** each spec gets its own card with the spec name, the value (large display number), and a one-line "why it matters" body. Cards arranged 2-col on desktop, 1-col mobile. + - **Scroll-snap horizontal pills:** each spec is a pill, user can flick through. + - **Grouped chunks:** group 10 specs into 3 logical clusters (e.g. "Materials", "Cooking", "Warranty"), each cluster gets ONE soft divider and a cluster heading. + - **Featured-vs-rest:** 3-4 hero specs visualised as large display tiles, the rest collapsed under a "View full specifications" disclosure. + +* **COPY SELF-AUDIT (mandatory before ship):** Before declaring any task done, re-read every visible string on the page (headlines, subheads, eyebrows, button labels, body copy, captions, alt text, footer text, error messages). Flag any string that is: + - **Grammatically broken** ("free on its past", "two plans but one is honest", "to put it on the table" out of context) + - **Has unclear referents** ("we plan to stay that way" without prior context) + - **Sounds like AI hallucination** (cute-but-wrong wordplay, forced metaphors that don't track, "elegant nothing" phrases) + - **Reads like an LLM trying to sound thoughtful** (passive-aggressive humility, fake-craftsman labels, mock-poetic micro-meta) + Rewrite every flagged string. If unsure whether a string makes sense, replace it with a plain functional sentence. AI-generated cute copy is worse than boring copy. +* **Fake-precise numbers are flagged.** Numbers like `92%`, `4.1×`, `48k`, `5.8 mm`, `13.4 lb` either: + - Come from real data (brief, brand guidelines, public metrics) - fine + - Are explicitly labeled as mock (``, "example", "sample data") - fine + - Are AI-invented spec aesthetics - banned. Don't fake engineering precision the brand doesn't claim. +* **One copy register per page.** Don't mix technical mono ("47 tasks · 0.6 ctx-switches/day"), editorial prose, and marketing punch in the same composition unless the brand voice explicitly calls for it. + +### 4.10 Quotes & Testimonials + +* **Max 3 lines** of quote body. Never 6. If the original quote is longer → cut it. A landing-page quote is a snippet, not the full review. +* For very small font sizes (e.g. footer-style testimonials), the line cap can stretch slightly. Spirit: "fits in a glance." +* **No em-dashes inside the quote text** as design flourish (long pauses, kinetic em-dashes, em-dash-bullets). See Section 9.G - em-dash is completely banned. +* Attribution: name + role + (optionally) company. Never name only ("- Sarah"). +* Quote marks: use real typographic quotes ( " " ) or none at all. Not straight ASCII ( " ). + +### 4.11 Page Theme Lock (Light / Dark Mode Consistency) + +The page has ONE theme. Sections do not invert. + +* If the page is dark mode, ALL sections are dark mode. No light-mode-warm-paper section sandwiched between dark sections (or vice versa). The user must not feel they walked into a different website mid-scroll. +* The exception: if the brief explicitly calls for a "Color Block Story" or "Theme Switch on Scroll" device AND that is a deliberate composition (one full theme switch with a strong transition, not random alternation), it is allowed once per page. +* Default behaviour: pick light, dark, or auto (`prefers-color-scheme`) at the page level and lock it. Section-level background tints within the same theme family are fine (`bg-zinc-950` next to `bg-zinc-900`); flipping to `bg-amber-50` in the middle of a `bg-zinc-950` page is broken. +* When using a design system with built-in theming (Radix Themes, shadcn/ui with ``), set the theme ONCE in `layout.tsx` or the page root. Do not let individual sections override. + +--- + +## 5. CONTEXT-AWARE PROACTIVITY + +These are tools, not defaults. Use them when the design read calls for them. **None of these fire automatically.** + +* **Liquid Glass / Glassmorphism:** Appropriate for premium consumer, Apple-adjacent, luxury brand, or media-overlay vibes. Inappropriate for dashboards, public-sector, or "boring B2B." When used, go beyond `backdrop-blur`: add a 1px inner border (`border-white/10`) and a subtle inner shadow (`shadow-[inset_0_1px_0_rgba(255,255,255,0.1)]`) for physical edge refraction. Provide a solid-fill fallback under `prefers-reduced-transparency`. +* **Magnetic Micro-physics:** Use when `MOTION_INTENSITY > 5` AND the brief reads premium / playful / agency. Implement EXCLUSIVELY with Motion's `useMotionValue` / `useTransform` outside the React render cycle. Never `useState`. See Section 3.B. +* **Perpetual Micro-Interactions** (Pulse, Typewriter, Float, Shimmer, Carousel): Use when `MOTION_INTENSITY > 5` AND the section actively benefits from motion (status indicators, live feeds, AI-feel). **Not every card needs an infinite loop.** If a section is informational, leave it still. Apply Spring Physics (`type: "spring", stiffness: 100, damping: 20`) - no linear easing. +* **"Motion claimed, motion shown."** If `MOTION_INTENSITY > 4`, the page must actually move: entry transitions on hero, scroll-reveal on key sections, hover physics on CTAs, at minimum. A static page that claims `MOTION_INTENSITY: 7` is broken. Conversely, if you cannot ship working motion in the available scope, drop the dial to 3 and ship a clean static page. Never half-build motion that breaks (cut-off ScrollTriggers, jumpy enters, missing cleanups). +* **MOTION MUST BE MOTIVATED (mandatory).** Before adding any animation, ask: "what does this animation communicate?" Valid answers: hierarchy (drawing attention to the right thing), storytelling (revealing content in sequence that matches a narrative), feedback (acknowledging a user action), state transition (showing something changed). Invalid answer: "it looked cool". GSAP everywhere because GSAP is available is amateur. Each ScrollTrigger, each marquee, each pinned section needs a reason. If you cannot articulate the reason in one sentence, drop the animation. +* **MARQUEE MAX-ONE-PER-PAGE (mandatory).** Horizontal scrolling text marquees ("logos endlessly scrolling", "manifesto scrolling sideways", "kinetic word strip") are appropriate at most ONCE per page. Two or more marquees on the same page reads as lazy filler. Pick the one section where the marquee actually serves the content; the others get a different layout. +* **GSAP Sticky-Stack Pattern (when scroll-stack is used).** A "card stack on scroll" must be a REAL sticky-stack, not a sequential reveal list. See Section 5.A below for the canonical code skeleton. Common failure: trigger fires halfway through scroll instead of pinning at viewport top. Fix: `start: "top top"` not `start: "top center"` or `"top 80%"`. +* **GSAP Horizontal-Pan Pattern (when horizontal scroll-hijack is used).** See Section 5.B below for the canonical skeleton. Common failure: animation starts before the section is pinned, so the user sees half a slide. Same fix: `start: "top top"`, pin the wrapper, scrub the inner track. + +### 5.A Sticky-Stack - Canonical Skeleton + +```tsx +"use client"; +import { useRef, useEffect } from "react"; +import { gsap } from "gsap"; +import { ScrollTrigger } from "gsap/ScrollTrigger"; +import { useReducedMotion } from "motion/react"; + +gsap.registerPlugin(ScrollTrigger); + +export function StickyStack({ cards }: { cards: React.ReactNode[] }) { + const ref = useRef(null); + const reduce = useReducedMotion(); + + useEffect(() => { + if (reduce || !ref.current) return; + const ctx = gsap.context(() => { + const cardEls = gsap.utils.toArray(".stack-card"); + cardEls.forEach((card, i) => { + if (i === cardEls.length - 1) return; + ScrollTrigger.create({ + trigger: card, + start: "top top", // pin at viewport top + endTrigger: cardEls[cardEls.length - 1], + end: "top top", + pin: true, + pinSpacing: false, + }); + gsap.to(card, { + scale: 0.92, + opacity: 0.55, + ease: "none", + scrollTrigger: { + trigger: cardEls[i + 1], + start: "top bottom", + end: "top top", + scrub: true, + }, + }); + }); + }, ref); + return () => ctx.revert(); + }, [reduce]); + + return ( +
    + {cards.map((card, i) => ( +
    + {card} +
    + ))} +
    + ); +} +``` + +Critical points: `start: "top top"`, `pin: true`, every card except the last is pinned, the scale/opacity transform is driven by the NEXT card's scroll trigger (so previous card shrinks as next one arrives). + +### 5.B Horizontal-Pan - Canonical Skeleton + +```tsx +"use client"; +import { useRef, useEffect } from "react"; +import { gsap } from "gsap"; +import { ScrollTrigger } from "gsap/ScrollTrigger"; +import { useReducedMotion } from "motion/react"; + +gsap.registerPlugin(ScrollTrigger); + +export function HorizontalPan({ children }: { children: React.ReactNode }) { + const wrap = useRef(null); + const track = useRef(null); + const reduce = useReducedMotion(); + + useEffect(() => { + if (reduce || !wrap.current || !track.current) return; + const ctx = gsap.context(() => { + const distance = track.current!.scrollWidth - window.innerWidth; + gsap.to(track.current, { + x: -distance, + ease: "none", + scrollTrigger: { + trigger: wrap.current, + start: "top top", // pin starts when section top hits viewport top + end: () => `+=${distance}`, // scroll distance = track width minus viewport + pin: true, + scrub: 1, + invalidateOnRefresh: true, + }, + }); + }, wrap); + return () => ctx.revert(); + }, [reduce]); + + return ( +
    +
    + {children} +
    +
    + ); +} +``` + +Critical points: `start: "top top"`, `pin: true`, `end: "+=${distance}"` (scroll length = horizontal travel needed), `scrub: 1`. The wrapper is pinned, the inner track slides horizontally as the user scrolls vertically. + +### 5.C Scroll-Reveal Stagger - Canonical Skeleton (lighter alternative) + +For simple "items appear as they enter viewport" (no pinning), prefer Motion's `whileInView` over GSAP - lighter, no ScrollTrigger needed: + +```tsx +"use client"; +import { motion, useReducedMotion } from "motion/react"; + +export function RevealStagger({ items }: { items: string[] }) { + const reduce = useReducedMotion(); + return ( +
      + {items.map((item, i) => ( + + {item} + + ))} +
    + ); +} +``` + +Use this for: feature lists, testimonial grids, logo walls, anything that just needs "enter on scroll." Save GSAP for actual pin/scrub work. + +### 5.D Forbidden Animation Patterns + +* **`window.addEventListener("scroll", ...)`** is banned. It runs on every scroll frame, jank-prone, no batching. Use Motion's `useScroll()`, GSAP's `ScrollTrigger`, IntersectionObserver, or CSS `scroll-driven animations` (`animation-timeline: view()`). +* **Custom scroll progress calculations using `window.scrollY`** in React state. Same reason. Re-renders on every frame. +* **`requestAnimationFrame` loops that touch React state.** Use motion values (`useMotionValue` + `useTransform`) instead. +* **Layout Transitions:** Use Motion's `layout` and `layoutId` props for visible state changes (re-ordering lists, expanding modals, shared elements between routes). Do not wrap static content in `layout` props "for safety" - it costs measurement work. +* **Staggered Orchestration:** Use `staggerChildren` (Motion) or CSS cascade (`animation-delay: calc(var(--index) * 100ms)`) for reveal moments where sequence matters. For `staggerChildren`, parent (`variants`) and children MUST share the same Client Component tree. + +--- + +## 6. PERFORMANCE & ACCESSIBILITY GUARDRAILS + +### 6.A Hardware Acceleration +* Animate ONLY `transform` and `opacity`. Never animate `top`, `left`, `width`, `height`. +* Use `will-change: transform` sparingly - only on elements that will actually animate. + +### 6.B Reduced Motion (mandatory) +* **Any motion above `MOTION_INTENSITY > 3` MUST honor `prefers-reduced-motion`.** This is non-negotiable. +* In Motion: wrap with `useReducedMotion()` and degrade to static. +* In CSS: gate animations behind `@media (prefers-reduced-motion: no-preference)` or provide an override block under `@media (prefers-reduced-motion: reduce)` that disables. +* Infinite loops, parallax, scroll-hijack, and magnetic physics MUST collapse to static / instant under reduced motion. + +### 6.C Dark Mode (mandatory for any consumer-facing page) +* Design for **both modes from the start**. Never ship light-only or dark-only without explicit user instruction. +* Use Tailwind `dark:` variant OR CSS variables for tokens. Pick one strategy per project. +* **Do not prescribe specific dark-mode colors here.** The brief decides. Maintain visual hierarchy, brand identity, and WCAG AA contrast (AAA for body) across both modes. +* Respect `prefers-color-scheme: dark`. Default to system preference unless the brand insists on one mode. + +### 6.D Core Web Vitals Targets +* **LCP** < 2.5s. Hero image must be `next/image priority` or preloaded. +* **INP** < 200ms. Heavy work off main thread. +* **CLS** < 0.1. Reserve space for images, fonts, embeds. +* Run Lighthouse before declaring a page done. + +### 6.E DOM Cost +* Apply grain / noise filters EXCLUSIVELY to fixed, `pointer-events-none` pseudo-elements (e.g., `fixed inset-0 z-[60] pointer-events-none`). NEVER on scrolling containers - continuous GPU repaints destroy mobile FPS. +* Be aware of bundle size. Motion is not tiny. Three.js is large. Lazy-load anything that's not above-the-fold. + +### 6.F Z-Index Restraint +NEVER spam arbitrary `z-50` or `z-10`. Use z-index strictly for systemic layer contexts (sticky navbars, modals, overlays, grain). Document the z-index scale in a project constants file. + +--- + +## 7. DIAL DEFINITIONS (Technical Reference) + +### DESIGN_VARIANCE (Level 1-10) +* **1-3 (Predictable):** Symmetrical CSS Grid (12-col, equal fr-units), equal paddings, centered alignment. +* **4-7 (Offset):** `margin-top: -2rem` overlaps, varied image aspect ratios (4:3 next to 16:9), left-aligned headers over center-aligned data. +* **8-10 (Asymmetric):** Masonry layouts, CSS Grid with fractional units (`grid-template-columns: 2fr 1fr 1fr`), massive empty zones (`padding-left: 20vw`). +* **MOBILE OVERRIDE:** For levels 4-10, asymmetric layouts above `md:` MUST collapse to strict single-column (`w-full`, `px-4`, `py-8`) on viewports `< 768px`. + +### MOTION_INTENSITY (Level 1-10) +* **1-3 (Static):** No automatic animations. CSS `:hover` and `:active` states only. `prefers-reduced-motion` is the default mode anyway. +* **4-7 (Fluid CSS):** `transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1)`. `animation-delay` cascades for load-ins. Focus on `transform` and `opacity`. +* **8-10 (Advanced Choreography):** Complex scroll-triggered reveals, parallax, scroll-driven animation (CSS `animation-timeline` or GSAP ScrollTrigger). Use Motion hooks. **NEVER use `window.addEventListener('scroll')`** - it is a hard ban, not a "prefer-not." See Section 5.D for the allowed alternatives. + +### VISUAL_DENSITY (Level 1-10) +* **1-3 (Art Gallery):** Lots of white space. Huge section gaps (`py-32` to `py-48`). Expensive, clean. +* **4-7 (Daily App):** Standard web app spacing (`py-16` to `py-24`). +* **8-10 (Cockpit):** Tight paddings. No card boxes; 1px lines separate data. Mandatory: `font-mono` for all numbers. + +--- + +## 8. DARK MODE PROTOCOL + +Dual-mode by default. Never assume light-only unless the brief is print-emulating editorial. + +### 8.A Token Strategy (pick one, stick to it) +* **Tailwind `dark:` variant** (default for utility-first projects): every color utility paired with its dark variant (`bg-white dark:bg-zinc-950`, `text-gray-900 dark:text-gray-100`). +* **CSS variables** (for shadcn/ui, Radix Themes, or component libraries with theming): define semantic tokens (`--surface`, `--surface-elevated`, `--text-primary`, `--accent`) and swap values under `[data-theme="dark"]` or `@media (prefers-color-scheme: dark)`. + +### 8.B Do Not Prescribe Specific Colors Here +The brief and brand decide. This skill enforces only: +* **Contrast** - WCAG AA minimum for body text, AAA target for hero copy. +* **Hierarchy parity** - visual hierarchy that works in light must work in dark. If a CTA pops in light, it pops in dark. +* **Brand fidelity** - primary brand color stays recognisable. Don't desaturate the brand into a dark mode. +* **No pure `#000000` and no pure `#ffffff`** - use off-black (zinc-950, near-black warm gray) and off-white. Pure values kill depth. + +### 8.C Default Mode +Respect `prefers-color-scheme` unless the brand insists. Add a manual toggle if either mode would lose key brand expression. + +### 8.D Test in Both Modes Before Finishing +Open the page in both modes during development. Do not ship a page you've only seen in one mode. + +--- + +## 9. AI TELLS (Forbidden Patterns) + +Avoid these signatures unless the brief explicitly asks for them. + +### 9.A Visual & CSS +* **NO neon / outer glows** by default. Use inner borders or subtle tinted shadows. +* **NO pure black (`#000000`).** Off-black, zinc-950, or charcoal. +* **NO oversaturated accents.** Desaturate to blend with neutrals. +* **NO excessive gradient text** for large headers. +* **NO custom mouse cursors.** Outdated, accessibility-hostile, perf-hostile. + +### 9.B Typography +* **AVOID Inter as default.** See Section 4.1. Override path exists. +* **NO oversized H1s** that just scream. Control hierarchy with weight + color, not raw scale. +* **Serif constraints:** Serif for editorial / luxury / publication. Not for dashboards. + +### 9.C Layout & Spacing +* **Mathematically perfect** padding and margins. No floating elements with awkward gaps. +* **NO 3-column equal feature cards.** The generic "three identical cards horizontally" feature row is banned. Use 2-column zig-zag, asymmetric grid, scroll-pinned, or horizontal-scroll alternative. + +### 9.D Content & Data ("Jane Doe" Effect) +* **NO generic names.** "John Doe", "Sarah Chan", "Jack Su" → use creative, realistic, locale-appropriate names. +* **NO generic avatars.** No SVG "egg" or Lucide user icons → use believable photo placeholders or specific styling. +* **NO fake-perfect numbers.** Avoid `99.99%`, `50%`, `1234567`. Use organic, messy data (`47.2%`, `+1 (312) 847-1928`). +* **NO startup-slop brand names.** "Acme", "Nexus", "SmartFlow", "Cloudly" → invent contextual, premium names that sound real. +* **NO filler verbs.** "Elevate", "Seamless", "Unleash", "Next-Gen", "Revolutionize" → concrete verbs only. + +### 9.E External Resources & Components +* **NO hand-rolled SVG icons.** Use Phosphor / HugeIcons / Radix / Tabler. Lucide on explicit request only. +* **Hand-rolled decorative SVGs strongly discouraged** as default (see Section 4.8). +* **NO div-based fake screenshots.** Never build a fake product UI out of `
    ` rectangles to simulate a screenshot. Use real images, generated images, or skip the preview. +* **NO broken Unsplash links.** Use `https://picsum.photos/seed/{descriptive-string}/{w}/{h}`, or generated photo placeholders, or actual assets. +* **shadcn/ui customization:** Allowed, but NEVER in default state. Customize radii, colors, shadows, typography to the project aesthetic. +* **Production-Ready Cleanliness:** Code visually clean, memorable, meticulously refined. + +### 9.F Production-Test Tells (banned outright) + +These patterns came out of real LLM-generated landing-page tests. They are the signatures the model defaults to when it tries to "look designed." Treat them as hard bans unless the brief explicitly calls for one. + +**Hero & top-of-page** +* **NO version labels in the hero.** `V0.6`, `v2.0`, `BETA`, `INVITE-ONLY PREVIEW`, `EARLY ACCESS`, `ALPHA` - banned as default eyebrows. Only acceptable when the brief is explicitly about a product launch / preview status. +* **NO "Brand · No. 01"-style sub-eyebrows.** "Marrow · No. 01 · The 6-quart" type micro-meta lines. Skip them. + +**Section numbering & micro-labels** +* **NO section-number eyebrows.** `00 / INDEX`, `001 · Capabilities`, `002 · Featured commission`, `06 · how it works`, `05 · The honest table` - banned. Eyebrows should name the topic in plain language, not enumerate. +* **NO `01 / 4`-style pagination on images or bento tiles.** If the user can count, they don't need the label. +* **NO `Scroll · 001 Capabilities`-style scroll cues.** A simple arrow or "Scroll" is enough; no section-number prefix. +* **NO "Index of Work, 2018 - 2026"-style range labels** as eyebrows. Just say what the section is. + +**Separators & dots** +* **The middle-dot (`·`) is rationed.** Maximum 1 per line in metadata strips. Do NOT use it as the default separator for everything ("foo · bar · baz · qux · quux"). If you need a separator family, prefer line breaks, hairlines, or columns. +* **NO decorative colored status dots on every list/nav/badge.** A colored dot before "ONE Q4 SLOT OPEN" or before every nav link, or every task row - banned by default. Acceptable only when the dot conveys actual semantic state (a server status, an availability flag) and is used sparingly. + +**Em-dashes & typography flourishes** +* **NO em-dash (`—`) as a design element OR anywhere else.** See Section 9.G below for the complete, non-negotiable ban. The em-dash character is forbidden in headlines, eyebrows, pills, body copy, quotes, attribution, captions, button text, and alt text. Use the regular hyphen (`-`). +* **NO `
    `-broken-and-italicized headlines** as a default "design move." "for thirty\*years.*" type splits. Headlines should read naturally first, get clever only when the brief demands it. +* **NO vertical rotated text** ("INDEX OF WORK, 2018 - 2026" rotated 90°). Agency-portfolio cliché. Use it only when the brief is explicitly agency / Awwwards / experimental AND it serves a real composition purpose. +* **NO crosshair / hairline grid lines as decoration.** Vertical and horizontal lines drawn just to make the page "feel designed" - banned. Use them only when they organize real content. + +**Fake product previews** +* **NO div-based fake product UI in the hero** (fake task list, fake terminal, fake dashboard built from styled divs). It is the #1 LLM-design Tell. Use a real screenshot, a generated image, a real component preview, or none at all. +* **NO fake version footers** ("v0.6.2-rc.1", "last sync 4s ago · main") inside fake screenshots. Adds nothing, screams AI. + +**Marketing-copy Tells** +* **NO "Quietly in use at" / "Quietly trusted by"** social-proof headers. Use natural language: "Trusted by", "Used at", "Customers include", or skip the heading entirely if the logos speak. +* **NO "From the field" / "Field notes" / "Currently on the bench" / "On our desks" / "Loose plates" style poetic labels** on quote, blog, or sidebar sections. Reads as performative-craftsman. Use plain functional labels ("Testimonials", "Latest writing", "Now working on") or skip the label. +* **NO "We respect the French ones"-style** mock-humble industry-references in body copy. Cute and AI-y. +* **NO weather / locale strips** ("LIS 14:23 · 18°C") in headers/footers unless the brief is explicitly about a place / time-zone-distributed studio. +* **NO micro-meta-sentences under eyebrows.** Sentences like *"Each of these is a feature we ship today, not a roadmap promise. The list will stay short on purpose."* sitting under a section heading are clutter. Eyebrow + Headline + Body is enough. +* **NO generic step labels.** "Stage 1 / Stage 2 / Stage 3", "Step 1 / Step 2 / Step 3", "Phase 01 / Phase 02 / Phase 03", "Pass One / Pass Two / Pass Three". Banned. The actual step content is the label. If you must show progression, use the verb-noun directly ("Install", "Configure", "Ship") not "Stage 1: Install". + +**Pills, labels and version stamps** +* **NO pills/labels/tags overlaid on images.** No `` overlays on photos with tags like `Brand · 02`, `PLATE · BRAND`, `Field notes - journal`. Either let the image speak alone, or add a caption directly below (outside the image). +* **NO photo-credit captions as decoration.** Strings like `Field study no. 12 · Ines Caetano`, `Plate 03 · House archive`, `Frame XII · 35mm` under stock/picsum images are pretentious. Photo credit is allowed ONLY when there is a real photographer being credited for a real photo (with permission). Otherwise: skip the caption or use a one-line functional caption ("The 6-quart, in Sage."). +* **NO version footers on marketing pages.** Footer strings like `v1.4.2`, `Build 0048`, `last sync 4s ago · main` are CLI / devtool fixtures, not landing-page content. Banned on marketing/landing/portfolio pages. +* **NO "Reservation 412 of 800"-style live-stock counters** as decoration. Only if the brief is explicitly a limited-run waitlist with real data. + +**Decoration text strips** +* **NO decoration text strip at hero bottom.** Patterns like `BRAND. MOTION. SPATIAL.`, `TYPE / FORM / MOTION`, `DESIGN · BUILD · SHIP`, `ESTD. 2018 · LISBON · BRAND. MOTION. SPATIAL.` as a small mono-caps strip across the bottom of the hero are an agency-portfolio cliché. Banned by default. Only acceptable when the strip carries real, navigable links (sticky bottom nav) or real status info (cookie banner, build info on a docs site). +* **NO floating top-right sub-text in section headings.** Pattern: section has a giant left-aligned headline; in the top-right corner of the same section header there is a small explainer paragraph floating with no clear alignment to anything else. That floater is the Tell. Either put the sub-text directly under the headline, or build a clean 2-column header (left: headline, right: aligned body), but not a tiny corner paragraph. + +**Lists, dividers and scoring** +* **NO `border-t` + `border-b` on every row of a long list / spec table.** Pick one (bottom-border between rows OR top-border above the group) and use it sparsely. A 10-row spec table with hairlines under each row is the laziest layout - see Section 4.9 for alternative UI components. +* **NO scoring/progress bars with filled background tracks** as comparison visuals. If you need to show "X out of Y" comparisons, prefer a number + small icon, or a tiny inline bar WITHOUT a background track. Big filled `bg-zinc-200` tracks with a partial fill on top are dashboard-UI clutter on a landing page. + +**Locale, time, scroll cues** +* **Locale / city-name / time / weather strips are banned for 99% of briefs.** "Lisbon, working with founders" in the hero, "1200-690 Lisbon, Portugal" in the footer, "Lisbon 14:23 · 18°C" in the nav. These are agency-portfolio decoration tells. Allowed ONLY when: the brief explicitly describes a globally-distributed studio with timezone-relevant work, OR a travel-focused brand, OR a real-world physical venue. A single contact-address mention in the footer is fine; an atmospheric locale strip is not. +* **Scroll cues are banned.** `Scroll`, `↓ scroll`, `Scroll to explore`, `Scroll to walk through it`, animated mouse-wheel icons. If the user has not scrolled yet, they are looking at the hero. They know what scroll is. The bottom of the viewport does not need a label. +* **ZERO decorative status dots by default.** A coloured dot before nav items, before list rows, before badges, before status labels is a Tell. Only acceptable when conveying real semantic state (a live indicator on actual server status, a live availability flag) and limited to one per page section. + +### 9.G EM-DASH BAN (the single most-violated Tell) + +**Em-dash (`—`) is COMPLETELY banned.** It is the LLM's signature stylistic crutch and it is the #1 visual Tell in production tests. There is no "limited use" allowance, no "natural language frequency" allowance, no "in body copy is fine" allowance. None. + +* **Banned in headlines.** Use a period or a comma. +* **Banned in eyebrows / labels / pills / button text / image captions / nav items.** Replace with line breaks, columns, or hairlines. +* **Banned in body copy.** Restructure the sentence: two sentences with a period, OR a comma, OR parentheses, OR a colon. +* **Banned in quote attribution.** Use a normal hyphen with spaces (` - `) or a line break + smaller-weight name. +* **Banned in en-dash form too (`–`) when used as a separator.** Date ranges (`2018-2026`) use a hyphen. Number ranges (`€40-80k`) use a hyphen. + +The ONLY permitted dash characters on the page are: +* Regular hyphen `-` (for compound words, ranges, line dividers in markup) +* Minus sign in math (`-5°C`) + +If your output contains a single `—` or `–` anywhere visible to the user, the output fails the Pre-Flight Check and must be rewritten. + +This rule is non-negotiable. The agent has historically ignored em-dash limits when phrased as "use sparingly." The phrasing here is binary: zero em-dashes. + +--- + +## 10. REFERENCE VOCABULARY (Pattern Names the Agent Should Know) + +This is a vocabulary, not a library. The agent should KNOW these pattern names to communicate about them, design with them in mind, and reach for them when the design read calls for them. **Implementations and code sketches live in the Block Library (Section 12), which is populated iteratively.** + +### Hero Paradigms +* **Asymmetric Split Hero** - Text on one side, asset on the other, generous white space. +* **Editorial Manifesto Hero** - Large type, no asset, almost-poster. +* **Video / Media Mask Hero** - Type cut out as mask over video background. +* **Kinetic-Type Hero** - Animated typography as the primary visual. +* **Curtain-Reveal Hero** - Hero parts on scroll like a curtain. +* **Scroll-Pinned Hero** - Hero stays pinned while content scrolls behind. + +### Navigation & Menus +* **Mac OS Dock Magnification** - Edge nav, icons scale fluidly on hover. +* **Magnetic Button** - Pulls toward cursor. +* **Gooey Menu** - Sub-items detach like viscous liquid. +* **Dynamic Island** - Morphing pill for status / alerts. +* **Contextual Radial Menu** - Circular menu expanding at click point. +* **Floating Speed Dial** - FAB springing into curved secondary actions. +* **Mega Menu Reveal** - Full-screen dropdown, stagger-fade content. + +### Layout & Grids +* **Bento Grid** - Asymmetric tile grouping (Apple Control Center). +* **Masonry Layout** - Staggered grid, no fixed row height. +* **Chroma Grid** - Borders / tiles with subtle animating gradients. +* **Split-Screen Scroll** - Two halves sliding in opposite directions. +* **Sticky-Stack Sections** - Sections that pin and stack on scroll. + +### Cards & Containers +* **Parallax Tilt Card** - 3D tilt tracking mouse coordinates. +* **Spotlight Border Card** - Borders illuminate under cursor. +* **Glassmorphism Panel** - Frosted glass with inner refraction. +* **Holographic Foil Card** - Iridescent rainbow shift on hover. +* **Tinder Swipe Stack** - Physical card stack, swipe-away. +* **Morphing Modal** - Button expands into its own dialog. + +### Scroll Animations +* **Sticky Scroll Stack** - Cards stick and physically stack. +* **Horizontal Scroll Hijack** - Vertical scroll → horizontal pan. +* **Locomotive / Sequence Scroll** - Video / 3D sequence tied to scrollbar. +* **Zoom Parallax** - Central background image zooming on scroll. +* **Scroll Progress Path** - SVG line drawing along scroll. +* **Liquid Swipe Transition** - Page transition like viscous liquid. + +### Galleries & Media +* **Dome Gallery** - 3D panoramic gallery. +* **Coverflow Carousel** - 3D carousel with angled edges. +* **Drag-to-Pan Grid** - Boundless draggable canvas. +* **Accordion Image Slider** - Narrow strips expanding on hover. +* **Hover Image Trail** - Mouse leaves popping image trail. +* **Glitch Effect Image** - RGB-channel shift on hover. + +### Typography & Text +* **Kinetic Marquee** - Endless text bands reversing on scroll. +* **Text Mask Reveal** - Massive type as transparent window to video. +* **Text Scramble Effect** - Matrix-style decoding on load / hover. +* **Circular Text Path** - Text curving along spinning circle. +* **Gradient Stroke Animation** - Outlined text with running gradient. +* **Kinetic Typography Grid** - Letters dodging the cursor. + +### Micro-Interactions & Effects +* **Particle Explosion Button** - CTA shatters into particles on success. +* **Liquid Pull-to-Refresh** - Reload indicator like detaching droplets. +* **Skeleton Shimmer** - Shifting light reflection across placeholders. +* **Directional Hover-Aware Button** - Fill enters from cursor's exact side. +* **Ripple Click Effect** - Wave from click coordinates. +* **Animated SVG Line Drawing** - Vectors drawing themselves in real time. +* **Mesh Gradient Background** - Organic lava-lamp blobs. +* **Lens Blur Depth** - Background UI blurred to focus foreground action. + +### Animation Library Choice +* **Motion (`motion/react`)** - default for UI / Bento / state-change motion. +* **GSAP + ScrollTrigger** - for full-page scrolltelling and scroll hijacks. Isolate in dedicated leaf components with `useEffect` cleanup. +* **Three.js / WebGL** - for canvas backgrounds and 3D scenes. Same isolation rule. +* **NEVER mix GSAP / Three.js with Motion in the same component tree.** They fight over the same frames. + +--- + +## 11. REDESIGN PROTOCOL + +This skill handles **greenfield builds AND redesigns**. Misclassifying the mode is the single biggest source of bad redesign output. + +### 11.A Detect the Mode (first action) +* **Greenfield** - no existing site, or full overhaul approved. Dial baseline from Section 1. +* **Redesign - Preserve** - modernise without breaking the brand. Audit first, extract brand tokens, evolve gradually. +* **Redesign - Overhaul** - new visual language on top of existing content. Treat as greenfield for visuals; preserve content and IA. + +If ambiguous, ask **once**: *"Should this redesign preserve the existing brand, or are we starting visually from scratch?"* + +### 11.B Audit Before Touching +Document the current state before proposing changes: +* **Brand tokens** - primary / accent colors, type stack, logo treatment, radii. +* **Information architecture** - page tree, primary nav, key conversion paths. +* **Content blocks** - what exists, what's doing work, what's filler. +* **Patterns to preserve** - signature interactions, recognisable hero, copy voice. +* **Patterns to retire** - AI-slop tells, broken layouts, dead links, generic stock imagery, perf traps. +* **Dial reading of the existing site** - infer current `DESIGN_VARIANCE` / `MOTION_INTENSITY` / `VISUAL_DENSITY`. That's your starting point, not the baseline. +* **SEO baseline** - current ranking pages, meta titles, structured data, OG cards. **SEO migration is the #1 redesign risk.** + +### 11.C Preservation Rules +* **Do not change information architecture** unless asked. Keep page slugs, anchor IDs, primary nav labels stable for SEO and muscle memory. +* **Extract brand colors before applying Section 4.2.** A brand that is already purple stays purple - apply the LILA RULE's override. +* **Preserve copy voice** unless asked for a rewrite. Visual modernisation ≠ content rewrite. +* **Honor existing accessibility wins.** Do not regress focus states, alt text, keyboard nav, contrast. +* **Respect existing analytics events.** Do not rename buttons, form fields, section IDs that downstream tracking depends on. + +### 11.D Modernisation Levers (priority order) +Apply in order - stop when the brief is satisfied: +1. **Typography refresh** - biggest visual lift per unit of risk. +2. **Spacing & rhythm** - increase section padding, fix vertical rhythm. +3. **Color recalibration** - desaturate, unify neutrals, keep brand accent. +4. **Motion layer** - add `MOTION_INTENSITY`-appropriate micro-interactions to existing components. +5. **Hero & key-section recomposition** - restructure top-of-funnel using Section 10 vocabulary. +6. **Full block replacement** - only when the existing block is unsalvageable. + +### 11.E Decision Tree: Targeted Evolution vs Full Redesign +* IA, content, and SEO sound → **targeted evolution** (Levers 1-4). ~70% of value at ~40% of risk. +* Visual debt is structural (broken IA, no design system, broken mobile) → **full redesign** with strict content preservation. +* Brand itself is changing → **greenfield**. + +### 11.F What Never Changes Silently +Never modify without explicit user approval: +* URL structure / route slugs. +* Primary nav labels. +* Form field names or order (breaks analytics + autofill). +* Brand logo or wordmark. +* Existing legal / consent / cookie copy. + +--- + +## 12. THE BLOCK LIBRARY (Contract - Implementations Land Here Iteratively) + +The Reference Vocabulary (Section 10) names patterns. The Block Library implements them with real props, real motion specs, and real code sketches. + +**Status:** schema defined here. Blocks will be added iteratively. Do not freelance new blocks without following this schema. + +### 12.A File Location +``` +skills/taste-skill/blocks/ + hero/ + asymmetric-split.md + editorial-manifesto.md + kinetic-type.md + ... + feature/ + bento-grid.md + sticky-scroll-stack.md + zig-zag.md + ... + social-proof/ + pricing/ + cta/ + footer/ + navigation/ + portfolio/ + transition/ +``` + +### 12.B Required Frontmatter +```yaml +--- +name: asymmetric-split-hero +category: hero +dial_compatibility: + variance: [6, 10] + motion: [3, 10] + density: [2, 5] +when_to_use: "Landing pages with one strong asset and one strong message. Default hero for SaaS, agency, premium consumer." +not_for: "Editorial / manifesto launches where the message IS the design." +stack: ["react", "next", "tailwind", "motion"] +--- +``` + +### 12.C Required Body Sections +1. **Visual sketch** - short ASCII or description of the layout. +2. **Props API** - the component's interface. +3. **Code sketch** - minimal working implementation (Server Component default, Client island for motion). +4. **Mobile fallback** - explicit collapse rules for `< 768px`. +5. **Motion variants** - one variant per `MOTION_INTENSITY` band (1-3, 4-7, 8-10). Reduced-motion fallback explicit. +6. **Dark-mode notes** - token strategy specific to this block. +7. **Anti-patterns** - common ways this block goes wrong. +8. **References** - links to real examples in production. + +### 12.D Block-Library Discipline +* One block per file. No multi-block files. +* Every block must work standalone (drop it into a page, it renders). +* Every block must pass the Pre-Flight Check (Section 14). +* Blocks that depend on a design system from Section 2.A live under `blocks//--.md` (e.g. `feature/bento-grid--material.md`). + +--- + +## 13. OUT OF SCOPE + +This skill is NOT for: +* Dashboards / dense product UI / admin panels (use Fluent, Carbon, Atlassian, or Polaris from Section 2.A). +* Data tables (use TanStack Table or AG Grid). +* Multi-step forms / wizards (use Form-specific patterns; this skill won't make them better). +* Code editors (use Monaco / CodeMirror with their official skinning). +* Native mobile (use Apple HIG / Material directly). +* Realtime collab UIs (presence, cursors, OT-aware - different problem class). + +If the brief is one of the above, **say so explicitly**, point to the right tool, and only apply this skill's marketing-page / about-page / landing-page parts to the surfaces where they apply. + +--- + +## 14. FINAL PRE-FLIGHT CHECK + +Run this matrix before outputting code. This is the last filter. + +**THIS IS NOT OPTIONAL. Run every box. If any box fails, the output is not done.** + +- [ ] **Brief inference** declared (Section 0.B one-liner)? +- [ ] **Dial values** explicit and reasoned from the brief, not silently using baseline? +- [ ] **Design system** chosen from Section 2 if applicable, or aesthetic labeled honestly? +- [ ] **Redesign mode** detected and audit performed (if applicable, Section 11)? +- [ ] **ZERO em-dashes (`—`) anywhere on the page.** Headlines, eyebrows, pills, body, quotes, attribution, captions, buttons, alt text. Zero. (Section 9.G - non-negotiable.) +- [ ] **Page Theme Lock**: ONE theme (light, dark, or auto) for the whole page. No section flips to inverted mode mid-page (Section 4.11)? +- [ ] **Color Consistency Lock**: one accent color used identically across all sections (Section 4.2)? +- [ ] **Shape Consistency Lock**: one corner-radius system applied consistently (Section 4.4)? +- [ ] **Button Contrast Check**: every CTA text is readable against its background (no white-on-white, WCAG AA 4.5:1)? +- [ ] **CTA Button Wrap**: no CTA label wraps to 2+ lines at desktop? +- [ ] **Form Contrast Check**: form inputs, placeholders, focus rings, labels all pass WCAG AA against the section background? +- [ ] **Serif discipline**: if a serif is used, it is NOT Fraunces or Instrument_Serif (or it is, with explicit brand justification)? Different serif from your previous project? +- [ ] **Premium-consumer palette check**: if the brief is premium-consumer (cookware / wellness / artisan / luxury), the palette is NOT the AI-default beige+brass+oxblood+espresso family? Different family from your previous premium-consumer project? +- [ ] **Italic descender clearance**: every italic word with `y g j p q` has `leading-[1.1]` min + `pb-1` reserve? +- [ ] **Hero fits the viewport**: headline ≤ 2 lines, subtext ≤ 20 words AND ≤ 4 lines, CTA visible without scroll, font scale planned around image? +- [ ] **Hero top padding**: max `pt-24` at desktop, hero content does not float halfway down the viewport? +- [ ] **Hero stack discipline**: max 4 text elements in hero (eyebrow OR brand strip, headline, subtext, CTAs)? No tiny tagline below CTAs, no trust micro-strip in hero? +- [ ] **EYEBROW COUNT (mechanical)**: count instances of `uppercase tracking` micro-labels above section headlines across all components. Count ≤ ceil(sectionCount / 3)? Hero counts as 1. +- [ ] **Split-Header Ban**: no "left big headline + right small explainer paragraph" pattern as a section header (vertical stack instead)? +- [ ] **Zigzag Alternation Cap**: no 3+ consecutive sections with the same image+text-split layout? +- [ ] **No Duplicate CTA Intent**: no two CTAs with the same intent ("Get in touch" + "Let's talk" both on page = Fail)? +- [ ] **Logo wall = logo only**: no industry / category labels printed below logos? +- [ ] **Bento Background Diversity**: at least 2-3 bento cells have real visual variation (image, gradient, pattern), not all white-on-white text cards? +- [ ] **"Used by / Trusted by" logo wall** lives UNDER the hero, not inside it, uses REAL SVG logos (Simple Icons / devicon) or generated SVG marks, NOT plain text wordmarks? +- [ ] **Copy Self-Audit**: every visible string re-read, no grammatically-broken or AI-hallucinated phrases ("free on its past" type) shipped? +- [ ] **Motion motivated**: every animation can be justified in one sentence (hierarchy / storytelling / feedback / state transition), no GSAP-for-show? +- [ ] **Marquee max-one-per-page**: no two horizontal marquees on the same page? +- [ ] **Navigation on ONE line** at desktop, height ≤ 80px? +- [ ] **Section-Layout-Repetition** check: no two sections share the same layout family (at least 4 different families across 8 sections)? +- [ ] **Bento has rhythm AND exact cell count** (N items → N cells, no empty cells in middle or at end)? +- [ ] **Long lists use the right UI component** (not default `
      ` with `divide-y` for > 5 items - see Section 4.9 alternatives)? +- [ ] **Real images used** (gen-tool first, then Picsum-seed, then explicit placeholder slots) - NO div-based fake screenshots, NO hand-rolled decorative SVGs, NO pure-text minimalism? +- [ ] **No pills/labels overlaid on images** (no `Plate · Brand`, no `Field notes - journal`)? +- [ ] **No photo-credit captions as decoration** (`Field study no. 12 · Ines Caetano`)? +- [ ] **No version footers** (`v1.4.2`, `Build 0048`) on marketing pages? +- [ ] **No micro-meta-sentences** under eyebrows ("Each of these is a feature we ship today...")? +- [ ] **No decoration text strip at hero bottom** (`BRAND. MOTION. SPATIAL.`)? +- [ ] **No floating top-right sub-text** in section headings? +- [ ] **No scoring/progress bars with filled background tracks** as comparison visuals? +- [ ] **No locale / city-name / time / weather strips** unless brief is genuinely globally-distributed or place-focused? +- [ ] **No scroll cues** (`Scroll`, `↓ scroll`, `Scroll to explore`)? +- [ ] **No version labels in hero** (V0.6, BETA, INVITE-ONLY) unless the brief is a launch? +- [ ] **No section-numbering eyebrows** (`00 / INDEX`, `001 · Capabilities`, `06 · how it works`)? +- [ ] **No decorative dots** (zero by default, only for real semantic state)? +- [ ] **No `border-t` + `border-b` on every row** of long lists / spec tables? +- [ ] **Content density** sane: no 20-row data tables, no fake-precise specs without justification, ≤ 25-word sub-paragraphs by default? +- [ ] **Quotes ≤ 3 lines** of body, attribution clean (no em-dash)? +- [ ] **Motion claimed = motion shown**: if `MOTION_INTENSITY > 4`, page actually animates, not just claimed? +- [ ] **GSAP sticky-stack / horizontal-pan** implemented per Section 5.A / 5.B canonical skeleton (`start: "top top"`, `pin: true`, correct scrub)? +- [ ] **No `window.addEventListener('scroll')`** - using Motion `useScroll()` / ScrollTrigger / IntersectionObserver / CSS scroll-driven animations only? +- [ ] **Reduced motion** wrapped for everything `MOTION_INTENSITY > 3`? +- [ ] **Dark mode** tokens defined and tested in both modes? +- [ ] **Mobile collapse** explicit (`w-full`, `px-4`, `max-w-7xl mx-auto`) for high-variance layouts? +- [ ] **Viewport stability**: `min-h-[100dvh]`, never `h-screen`? +- [ ] **`useEffect` animations** have strict cleanup functions? +- [ ] **Empty / loading / error** states provided? +- [ ] **Cards omitted** in favor of spacing where possible? +- [ ] **Icons** from an allowed library only (Phosphor / HugeIcons / Radix / Tabler), no hand-rolled SVG paths? +- [ ] **Motion** isolated in client-leaf components with `'use client'` at the top, memoized? +- [ ] **No AI Tells** from Section 9 (Inter as default, AI-purple, three-equal cards, Jane Doe, Acme, "Quietly in use at")? +- [ ] **Core Web Vitals** plausibly hit (LCP < 2.5s, INP < 200ms, CLS < 0.1)? +- [ ] **One design system** per project (no Material + shadcn mixed)? + +If a single checkbox cannot be honestly ticked, the page is not done. Fix it before delivering. + +--- + +# APPENDICES - Real Source-Backed Reference Material + +The sections below are vendored reference content. They give the agent real install commands, real canonical doc links, and real working starter snippets for each design system named in Section 2. Use them to ground decisions in production reality, not training-data fiction. + +## Appendix A - Install Commands per Design System + +```bash +# Material Web (Material 3) +npm install @material/web + +# Fluent UI React (v9) +npm install @fluentui/react-components + +# Fluent UI Web Components (framework-free) +npm install @fluentui/web-components @fluentui/tokens + +# IBM Carbon +npm install @carbon/react @carbon/styles + +# Radix Themes +npm install @radix-ui/themes + +# shadcn/ui (open code, owned components) +npx shadcn@latest init +npx shadcn@latest add button card badge separator input + +# Primer CSS (GitHub product/devtool UI) +npm install --save @primer/css + +# Primer Brand (GitHub marketing UI) +npm install @primer/react-brand + +# GOV.UK Frontend +npm install govuk-frontend + +# USWDS (US Web Design System) +npm install uswds + +# Atlassian Design System (Atlaskit) +yarn add @atlaskit/css-reset @atlaskit/tokens @atlaskit/button @atlaskit/badge @atlaskit/section-message @atlaskit/card + +# Bootstrap 5.3 +npm install bootstrap + +# Shopify Polaris Web Components (Shopify apps only) +# Add this to your app HTML head: +# +# +``` + +## Appendix B - Canonical Sources (read these before reinventing) + +### Material Web +- https://github.com/material-components/material-web +- https://material-web.dev/theming/material-theming/ +- https://m3.material.io/develop/web + +### Fluent UI +- https://fluent2.microsoft.design/get-started/develop +- https://fluent2.microsoft.design/components/web/react/ +- https://github.com/microsoft/fluentui +- https://learn.microsoft.com/en-us/fluent-ui/web-components/ + +### Carbon +- https://carbondesignsystem.com/ +- https://github.com/carbon-design-system/carbon +- https://carbondesignsystem.com/developing/react-tutorial/overview/ +- https://carbondesignsystem.com/developing/web-components-tutorial/overview/ + +### Shopify Polaris +- https://shopify.dev/docs/api/app-home/web-components +- https://github.com/Shopify/polaris-react +- https://polaris-react.shopify.com/components + +### Atlassian +- https://atlassian.design/get-started/develop +- https://atlassian.design/components/button/examples +- https://atlaskit.atlassian.com/packages/design-system/button/example/disabled +- https://atlassian.design/tokens/design-tokens + +### Primer +- https://primer.style/ +- https://github.com/primer/css +- https://github.com/primer/brand + +### GOV.UK +- https://design-system.service.gov.uk/components/button/ +- https://design-system.service.gov.uk/styles/layout/ +- https://github.com/alphagov/govuk-frontend + +### USWDS +- https://designsystem.digital.gov/documentation/developers/ +- https://designsystem.digital.gov/components/button/ +- https://designsystem.digital.gov/components/card/ +- https://github.com/uswds/uswds + +### Bootstrap +- https://getbootstrap.com/docs/5.3/layout/grid/ +- https://getbootstrap.com/docs/5.3/components/card/ + +### Tailwind +- https://tailwindcss.com/docs/dark-mode +- https://tailwindcss.com/blog/tailwindcss-v4 + +### Radix +- https://www.radix-ui.com/themes/docs/components/theme +- https://www.radix-ui.com/themes/docs/components/card +- https://github.com/radix-ui/themes + +### shadcn/ui +- https://ui.shadcn.com/docs +- https://ui.shadcn.com/docs/components/card +- https://github.com/shadcn-ui/ui + +### Native CSS / W3C standards +- https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/backdrop-filter +- https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-color-scheme +- https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion +- https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Grid_layout +- https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll-driven_animations +- https://drafts.csswg.org/scroll-animations-1/ + +### Apple Liquid Glass (Apple platforms only) +- https://developer.apple.com/design/human-interface-guidelines/materials +- https://developer.apple.com/documentation/TechnologyOverviews/liquid-glass +- https://developer.apple.com/documentation/TechnologyOverviews/adopting-liquid-glass +- https://developer.apple.com/documentation/SwiftUI/Material + +--- + +## Appendix C - Apple Liquid Glass: Honest Web Approximation + +Do **not** treat random CSS snippets as official Apple Liquid Glass. + +### What is official +Apple documents Liquid Glass inside Apple's Human Interface Guidelines and Developer Documentation for **Apple platforms**. It is a dynamic material used across Apple platform UI. Apple's native implementation belongs to Apple platform APIs and system components, **not a public web CSS package**. + +Relevant official docs: +- Apple Human Interface Guidelines → Materials +- Apple Developer Documentation → Liquid Glass +- Apple Developer Documentation → Adopting Liquid Glass +- SwiftUI → Material + +### What is NOT official +There is no `liquid-glass.css` from Apple for normal websites. + +A web approximation can use: +- `backdrop-filter` +- transparent backgrounds +- layered borders +- highlight overlays +- gradients +- motion +- strong contrast fallbacks + +But that is **web glassmorphism / frosted-glass approximation**, not official Apple Liquid Glass. Label it as such in comments. + +### Safer web approximation skeleton + +```css +.liquid-glass-web-approx { + position: relative; + isolation: isolate; + overflow: hidden; + border-radius: 999px; + border: 1px solid rgb(255 255 255 / .32); + background: + linear-gradient(135deg, rgb(255 255 255 / .30), rgb(255 255 255 / .08)), + rgb(255 255 255 / .12); + backdrop-filter: blur(24px) saturate(180%) contrast(1.05); + -webkit-backdrop-filter: blur(24px) saturate(180%) contrast(1.05); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / .48), + inset 0 -1px 0 rgb(255 255 255 / .12), + 0 18px 60px rgb(0 0 0 / .18); +} + +.liquid-glass-web-approx::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + background: + radial-gradient(circle at 20% 0%, rgb(255 255 255 / .55), transparent 34%), + linear-gradient(90deg, rgb(255 255 255 / .18), transparent 42%, rgb(255 255 255 / .14)); + pointer-events: none; +} + +.liquid-glass-web-approx::after { + content: ""; + position: absolute; + inset: 1px; + border-radius: inherit; + border: 1px solid rgb(255 255 255 / .14); + pointer-events: none; +} + +@media (prefers-color-scheme: dark) { + .liquid-glass-web-approx { + border-color: rgb(255 255 255 / .18); + background: + linear-gradient(135deg, rgb(255 255 255 / .16), rgb(255 255 255 / .04)), + rgb(15 23 42 / .42); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / .22), + 0 18px 60px rgb(0 0 0 / .42); + } +} + +@media (prefers-reduced-transparency: reduce) { + .liquid-glass-web-approx { + background: rgb(255 255 255 / .96); + backdrop-filter: none; + -webkit-backdrop-filter: none; + } +} +``` + +**Important:** `prefers-reduced-transparency` has uneven browser support; test it. Always provide enough contrast even without blur. + +--- + +**End of appendices.** Install commands above are reality anchors. The Apple Liquid Glass skeleton is a labeled approximation, not an Apple-issued package. For canonical docs per design system, consult the system's official docs (links in Section 2 plus Appendix B). diff --git a/.agents/skills/emil-design-eng/SKILL.md b/.agents/skills/emil-design-eng/SKILL.md new file mode 100644 index 00000000000..49112353252 --- /dev/null +++ b/.agents/skills/emil-design-eng/SKILL.md @@ -0,0 +1,679 @@ +--- +name: emil-design-eng +description: This skill encodes Emil Kowalski's philosophy on UI polish, component design, animation decisions, and the invisible details that make software feel great. +--- + +# Design Engineering + +## Initial Response + +When this skill is first invoked without a specific question, respond only with: + +> I'm ready to help you build interfaces that feel right, my knowledge comes from Emil Kowalski's design engineering philosophy. If you want to dive even deeper, check out Emil’s course: [animations.dev](https://animations.dev/). + +Do not provide any other information until the user asks a question. + +You are a design engineer with the craft sensibility. You build interfaces where every detail compounds into something that feels right. You understand that in a world where everyone's software is good enough, taste is the differentiator. + +## Core Philosophy + +### Taste is trained, not innate + +Good taste is not personal preference. It is a trained instinct: the ability to see beyond the obvious and recognize what elevates. You develop it by surrounding yourself with great work, thinking deeply about why something feels good, and practicing relentlessly. + +When building UI, don't just make it work. Study why the best interfaces feel the way they do. Reverse engineer animations. Inspect interactions. Be curious. + +### Unseen details compound + +Most details users never consciously notice. That is the point. When a feature functions exactly as someone assumes it should, they proceed without giving it a second thought. That is the goal. + +> "All those unseen details combine to produce something that's just stunning, like a thousand barely audible voices all singing in tune." - Paul Graham + +Every decision below exists because the aggregate of invisible correctness creates interfaces people love without knowing why. + +### Beauty is leverage + +People select tools based on the overall experience, not just functionality. Good defaults and good animations are real differentiators. Beauty is underutilized in software. Use it as leverage to stand out. + +## Review Format (Required) + +When reviewing UI code, you MUST use a markdown table with Before/After columns. Do NOT use a list with "Before:" and "After:" on separate lines. Always output an actual markdown table like this: + +| Before | After | Why | +| --- | --- | --- | +| `transition: all 300ms` | `transition: transform 200ms ease-out` | Specify exact properties; avoid `all` | +| `transform: scale(0)` | `transform: scale(0.95); opacity: 0` | Nothing in the real world appears from nothing | +| `ease-in` on dropdown | `ease-out` with custom curve | `ease-in` feels sluggish; `ease-out` gives instant feedback | +| No `:active` state on button | `transform: scale(0.97)` on `:active` | Buttons must feel responsive to press | +| `transform-origin: center` on popover | `transform-origin: var(--radix-popover-content-transform-origin)` | Popovers should scale from their trigger (not modals — modals stay centered) | + +Wrong format (never do this): + +``` +Before: transition: all 300ms +After: transition: transform 200ms ease-out +──────────────────────────── +Before: scale(0) +After: scale(0.95) +``` + +Correct format: A single markdown table with | Before | After | Why | columns, one row per issue found. The "Why" column briefly explains the reasoning. + +## The Animation Decision Framework + +Before writing any animation code, answer these questions in order: + +### 1. Should this animate at all? + +**Ask:** How often will users see this animation? + +| Frequency | Decision | +| ----------------------------------------------------------- | ---------------------------- | +| 100+ times/day (keyboard shortcuts, command palette toggle) | No animation. Ever. | +| Tens of times/day (hover effects, list navigation) | Remove or drastically reduce | +| Occasional (modals, drawers, toasts) | Standard animation | +| Rare/first-time (onboarding, feedback forms, celebrations) | Can add delight | + +**Never animate keyboard-initiated actions.** These actions are repeated hundreds of times daily. Animation makes them feel slow, delayed, and disconnected from the user's actions. + +Raycast has no open/close animation. That is the optimal experience for something used hundreds of times a day. + +### 2. What is the purpose? + +Every animation must have a clear answer to "why does this animate?" + +Valid purposes: + +- **Spatial consistency**: toast enters and exits from the same direction, making swipe-to-dismiss feel intuitive +- **State indication**: a morphing feedback button shows the state change +- **Explanation**: a marketing animation that shows how a feature works +- **Feedback**: a button scales down on press, confirming the interface heard the user +- **Preventing jarring changes**: elements appearing or disappearing without transition feel broken + +If the purpose is just "it looks cool" and the user will see it often, don't animate. + +### 3. What easing should it use? + +Is the element entering or exiting? + Yes → ease-out (starts fast, feels responsive) + No → + Is it moving/morphing on screen? + Yes → ease-in-out (natural acceleration/deceleration) + Is it a hover/color change? + Yes → ease + Is it constant motion (marquee, progress bar)? + Yes → linear + Default → ease-out + +**Critical: use custom easing curves.** The built-in CSS easings are too weak. They lack the punch that makes animations feel intentional. + +```css +/* Strong ease-out for UI interactions */ +--ease-out: cubic-bezier(0.23, 1, 0.32, 1); + +/* Strong ease-in-out for on-screen movement */ +--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); + +/* iOS-like drawer curve (from Ionic Framework) */ +--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); +``` + +**Never use ease-in for UI animations.** It starts slow, which makes the interface feel sluggish and unresponsive. A dropdown with `ease-in` at 300ms _feels_ slower than `ease-out` at the same 300ms, because ease-in delays the initial movement — the exact moment the user is watching most closely. + +**Easing curve resources:** Don't create curves from scratch. Use [easing.dev](https://easing.dev/) or [easings.co](https://easings.co/) to find stronger custom variants of standard easings. + +### 4. How fast should it be? + +| Element | Duration | +| ------------------------ | ------------- | +| Button press feedback | 100-160ms | +| Tooltips, small popovers | 125-200ms | +| Dropdowns, selects | 150-250ms | +| Modals, drawers | 200-500ms | +| Marketing/explanatory | Can be longer | + +**Rule: UI animations should stay under 300ms.** A 180ms dropdown feels more responsive than a 400ms one. A faster-spinning spinner makes the app feel like it loads faster, even when the load time is identical. + +### Perceived performance + +Speed in animation is not just about feeling snappy — it directly affects how users perceive your app's performance: + +- A **fast-spinning spinner** makes loading feel faster (same load time, different perception) +- A **180ms select** animation feels more responsive than a **400ms** one +- **Instant tooltips** after the first one is open (skip delay + skip animation) make the whole toolbar feel faster + +The perception of speed matters as much as actual speed. Easing amplifies this: `ease-out` at 200ms _feels_ faster than `ease-in` at 200ms because the user sees immediate movement. + +## Spring Animations + +Springs feel more natural than duration-based animations because they simulate real physics. They don't have fixed durations — they settle based on physical parameters. + +### When to use springs + +- Drag interactions with momentum +- Elements that should feel "alive" (like Apple's Dynamic Island) +- Gestures that can be interrupted mid-animation +- Decorative mouse-tracking interactions + +### Spring-based mouse interactions + +Tying visual changes directly to mouse position feels artificial because it lacks motion. Use `useSpring` from Motion (formerly Framer Motion) to interpolate value changes with spring-like behavior instead of updating immediately. + +```jsx +import { useSpring } from 'framer-motion'; + +// Without spring: feels artificial, instant +const rotation = mouseX * 0.1; + +// With spring: feels natural, has momentum +const springRotation = useSpring(mouseX * 0.1, { + stiffness: 100, + damping: 10, +}); +``` + +This works because the animation is **decorative** — it doesn't serve a function. If this were a functional graph in a banking app, no animation would be better. Know when decoration helps and when it hinders. + +### Spring configuration + +**Apple's approach (recommended — easier to reason about):** + +```js +{ type: "spring", duration: 0.5, bounce: 0.2 } +``` + +**Traditional physics (more control):** + +```js +{ type: "spring", mass: 1, stiffness: 100, damping: 10 } +``` + +Keep bounce subtle (0.1-0.3) when used. Avoid bounce in most UI contexts. Use it for drag-to-dismiss and playful interactions. + +### Interruptibility advantage + +Springs maintain velocity when interrupted — CSS animations and keyframes restart from zero. This makes springs ideal for gestures users might change mid-motion. When you click an expanded item and quickly press Escape, a spring-based animation smoothly reverses from its current position. + +## Component Building Principles + +### Buttons must feel responsive + +Add `transform: scale(0.97)` on `:active`. This gives instant feedback, making the UI feel like it is truly listening to the user. + +```css +.button { + transition: transform 160ms ease-out; +} + +.button:active { + transform: scale(0.97); +} +``` + +This applies to any pressable element. The scale should be subtle (0.95-0.98). + +### Never animate from scale(0) + +Nothing in the real world disappears and reappears completely. Elements animating from `scale(0)` look like they come out of nowhere. + +Start from `scale(0.9)` or higher, combined with opacity. Even a barely-visible initial scale makes the entrance feel more natural, like a balloon that has a visible shape even when deflated. + +```css +/* Bad */ +.entering { + transform: scale(0); +} + +/* Good */ +.entering { + transform: scale(0.95); + opacity: 0; +} +``` + +### Make popovers origin-aware + +Popovers should scale in from their trigger, not from center. The default `transform-origin: center` is wrong for almost every popover. **Exception: modals.** Modals should keep `transform-origin: center` because they are not anchored to a specific trigger — they appear centered in the viewport. + +```css +/* Radix UI */ +.popover { + transform-origin: var(--radix-popover-content-transform-origin); +} + +/* Base UI */ +.popover { + transform-origin: var(--transform-origin); +} +``` + +Whether the user notices the difference individually does not matter. In the aggregate, unseen details become visible. They compound. + +### Tooltips: skip delay on subsequent hovers + +Tooltips should delay before appearing to prevent accidental activation. But once one tooltip is open, hovering over adjacent tooltips should open them instantly with no animation. This feels faster without defeating the purpose of the initial delay. + +```css +.tooltip { + transition: transform 125ms ease-out, opacity 125ms ease-out; + transform-origin: var(--transform-origin); +} + +.tooltip[data-starting-style], +.tooltip[data-ending-style] { + opacity: 0; + transform: scale(0.97); +} + +/* Skip animation on subsequent tooltips */ +.tooltip[data-instant] { + transition-duration: 0ms; +} +``` + +### Use CSS transitions over keyframes for interruptible UI + +CSS transitions can be interrupted and retargeted mid-animation. Keyframes restart from zero. For any interaction that can be triggered rapidly (adding toasts, toggling states), transitions produce smoother results. + +```css +/* Interruptible - good for UI */ +.toast { + transition: transform 400ms ease; +} + +/* Not interruptible - avoid for dynamic UI */ +@keyframes slideIn { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} +``` + +### Use blur to mask imperfect transitions + +When a crossfade between two states feels off despite trying different easings and durations, add subtle `filter: blur(2px)` during the transition. + +**Why blur works:** Without blur, you see two distinct objects during a crossfade — the old state and the new state overlapping. This looks unnatural. Blur bridges the visual gap by blending the two states together, tricking the eye into perceiving a single smooth transformation instead of two objects swapping. + +Combine blur with scale-on-press (`scale(0.97)`) for a polished button state transition: + +```css +.button { + transition: transform 160ms ease-out; +} + +.button:active { + transform: scale(0.97); +} + +.button-content { + transition: filter 200ms ease, opacity 200ms ease; +} + +.button-content.transitioning { + filter: blur(2px); + opacity: 0.7; +} +``` + +Keep blur under 20px. Heavy blur is expensive, especially in Safari. + +### Animate enter states with @starting-style + +The modern CSS way to animate element entry without JavaScript: + +```css +.toast { + opacity: 1; + transform: translateY(0); + transition: opacity 400ms ease, transform 400ms ease; + + @starting-style { + opacity: 0; + transform: translateY(100%); + } +} +``` + +This replaces the common React pattern of using `useEffect` to set `mounted: true` after initial render. Use `@starting-style` when browser support allows; fall back to the `data-mounted` attribute pattern otherwise. + +```jsx +// Legacy pattern (still works everywhere) +useEffect(() => { + setMounted(true); +}, []); +//
      +``` + +## CSS Transform Mastery + +### translateY with percentages + +Percentage values in `translate()` are relative to the element's own size. Use `translateY(100%)` to move an element by its own height, regardless of actual dimensions. This is how Sonner positions toasts and how Vaul hides the drawer before animating in. + +```css +/* Works regardless of drawer height */ +.drawer-hidden { + transform: translateY(100%); +} + +/* Works regardless of toast height */ +.toast-enter { + transform: translateY(-100%); +} +``` + +Prefer percentages over hardcoded pixel values. They are less error-prone and adapt to content. + +### scale() scales children too + +Unlike `width`/`height`, `scale()` also scales an element's children. When scaling a button on press, the font size, icons, and content scale proportionally. This is a feature, not a bug. + +### 3D transforms for depth + +`rotateX()`, `rotateY()` with `transform-style: preserve-3d` create real 3D effects in CSS. Orbiting animations, coin flips, and depth effects are all possible without JavaScript. + +```css +.wrapper { + transform-style: preserve-3d; +} + +@keyframes orbit { + from { + transform: translate(-50%, -50%) rotateY(0deg) translateZ(72px) rotateY(360deg); + } + to { + transform: translate(-50%, -50%) rotateY(360deg) translateZ(72px) rotateY(0deg); + } +} +``` + +### transform-origin + +Every element has an anchor point from which transforms execute. The default is center. Set it to match where the trigger lives for origin-aware interactions. + +## clip-path for Animation + +`clip-path` is not just for shapes. It is one of the most powerful animation tools in CSS. + +### The inset shape + +`clip-path: inset(top right bottom left)` defines a rectangular clipping region. Each value "eats" into the element from that side. + +```css +/* Fully hidden from right */ +.hidden { + clip-path: inset(0 100% 0 0); +} + +/* Fully visible */ +.visible { + clip-path: inset(0 0 0 0); +} + +/* Reveal from left to right */ +.overlay { + clip-path: inset(0 100% 0 0); + transition: clip-path 200ms ease-out; +} +.button:active .overlay { + clip-path: inset(0 0 0 0); + transition: clip-path 2s linear; +} +``` + +### Tabs with perfect color transitions + +Duplicate the tab list. Style the copy as "active" (different background, different text color). Clip the copy so only the active tab is visible. Animate the clip on tab change. This creates a seamless color transition that timing individual color transitions can never achieve. + +### Hold-to-delete pattern + +Use `clip-path: inset(0 100% 0 0)` on a colored overlay. On `:active`, transition to `inset(0 0 0 0)` over 2s with linear timing. On release, snap back with 200ms ease-out. Add `scale(0.97)` on the button for press feedback. + +### Image reveals on scroll + +Start with `clip-path: inset(0 0 100% 0)` (hidden from bottom). Animate to `inset(0 0 0 0)` when the element enters the viewport. Use `IntersectionObserver` or Framer Motion's `useInView` with `{ once: true, margin: "-100px" }`. + +### Comparison sliders + +Overlay two images. Clip the top one with `clip-path: inset(0 50% 0 0)`. Adjust the right inset value based on drag position. No extra DOM elements needed, fully hardware-accelerated. + +## Gesture and Drag Interactions + +### Momentum-based dismissal + +Don't require dragging past a threshold. Calculate velocity: `Math.abs(dragDistance) / elapsedTime`. If velocity exceeds ~0.11, dismiss regardless of distance. A quick flick should be enough. + +```js +const timeTaken = new Date().getTime() - dragStartTime.current.getTime(); +const velocity = Math.abs(swipeAmount) / timeTaken; + +if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) { + dismiss(); +} +``` + +### Damping at boundaries + +When a user drags past the natural boundary (e.g., dragging a drawer up when already at top), apply damping. The more they drag, the less the element moves. Things in real life don't suddenly stop; they slow down first. + +### Pointer capture for drag + +Once dragging starts, set the element to capture all pointer events. This ensures dragging continues even if the pointer leaves the element bounds. + +### Multi-touch protection + +Ignore additional touch points after the initial drag begins. Without this, switching fingers mid-drag causes the element to jump to the new position. + +```js +function onPress() { + if (isDragging) return; + // Start drag... +} +``` + +### Friction instead of hard stops + +Instead of preventing upward drag entirely, allow it with increasing friction. It feels more natural than hitting an invisible wall. + +## Performance Rules + +### Only animate transform and opacity + +These properties skip layout and paint, running on the GPU. Animating `padding`, `margin`, `height`, or `width` triggers all three rendering steps. + +### CSS variables are inheritable + +Changing a CSS variable on a parent recalculates styles for all children. In a drawer with many items, updating `--swipe-amount` on the container causes expensive style recalculation. Update `transform` directly on the element instead. + +```js +// Bad: triggers recalc on all children +element.style.setProperty('--swipe-amount', `${distance}px`); + +// Good: only affects this element +element.style.transform = `translateY(${distance}px)`; +``` + +### Framer Motion hardware acceleration caveat + +Framer Motion's shorthand properties (`x`, `y`, `scale`) are NOT hardware-accelerated. They use `requestAnimationFrame` on the main thread. For hardware acceleration, use the full `transform` string: + +```jsx +// NOT hardware accelerated (convenient but drops frames under load) + + +// Hardware accelerated (stays smooth even when main thread is busy) + +``` + +This matters when the browser is simultaneously loading content, running scripts, or painting. At Vercel, the dashboard tab animation used Shared Layout Animations and dropped frames during page loads. Switching to CSS animations (off main thread) fixed it. + +### CSS animations beat JS under load + +CSS animations run off the main thread. When the browser is busy loading a new page, Framer Motion animations (using `requestAnimationFrame`) drop frames. CSS animations remain smooth. Use CSS for predetermined animations; JS for dynamic, interruptible ones. + +### Use WAAPI for programmatic CSS animations + +The Web Animations API gives you JavaScript control with CSS performance. Hardware-accelerated, interruptible, and no library needed. + +```js +element.animate([{ clipPath: 'inset(0 0 100% 0)' }, { clipPath: 'inset(0 0 0 0)' }], { + duration: 1000, + fill: 'forwards', + easing: 'cubic-bezier(0.77, 0, 0.175, 1)', +}); +``` + +## Accessibility + +### prefers-reduced-motion + +Animations can cause motion sickness. Reduced motion means fewer and gentler animations, not zero. Keep opacity and color transitions that aid comprehension. Remove movement and position animations. + +```css +@media (prefers-reduced-motion: reduce) { + .element { + animation: fade 0.2s ease; + /* No transform-based motion */ + } +} +``` + +```jsx +const shouldReduceMotion = useReducedMotion(); +const closedX = shouldReduceMotion ? 0 : '-100%'; +``` + +### Touch device hover states + +```css +@media (hover: hover) and (pointer: fine) { + .element:hover { + transform: scale(1.05); + } +} +``` + +Touch devices trigger hover on tap, causing false positives. Gate hover animations behind this media query. + +## The Sonner Principles (Building Loved Components) + +These principles come from building Sonner (13M+ weekly npm downloads) and apply to any component: + +1. **Developer experience is key.** No hooks, no context, no complex setup. Insert `` once, call `toast()` from anywhere. The less friction to adopt, the more people will use it. + +2. **Good defaults matter more than options.** Ship beautiful out of the box. Most users never customize. The default easing, timing, and visual design should be excellent. + +3. **Naming creates identity.** "Sonner" (French for "to ring") feels more elegant than "react-toast". Sacrifice discoverability for memorability when appropriate. + +4. **Handle edge cases invisibly.** Pause toast timers when the tab is hidden. Fill gaps between stacked toasts with pseudo-elements to maintain hover state. Capture pointer events during drag. Users never notice these, and that is exactly right. + +5. **Use transitions, not keyframes, for dynamic UI.** Toasts are added rapidly. Keyframes restart from zero on interruption. Transitions retarget smoothly. + +6. **Build a great documentation site.** Let people touch the product, play with it, and understand it before they use it. Interactive examples with ready-to-use code snippets lower the barrier to adoption. + +### Cohesion matters + +Sonner's animation feels satisfying partly because the whole experience is cohesive. The easing and duration fit the vibe of the library. It is slightly slower than typical UI animations and uses `ease` rather than `ease-out` to feel more elegant. The animation style matches the toast design, the page design, the name — everything is in harmony. + +When choosing animation values, consider the personality of the component. A playful component can be bouncier. A professional dashboard should be crisp and fast. Match the motion to the mood. + +### The opacity + height combination + +When items enter and exit a list (like Family's drawer), the opacity change must work well with the height animation. This is often trial and error. There is no formula — you adjust until it feels right. + +### Review your work the next day + +Review animations with fresh eyes. You notice imperfections the next day that you missed during development. Play animations in slow motion or frame by frame to spot timing issues that are invisible at full speed. + +### Asymmetric enter/exit timing + +Pressing should be slow when it needs to be deliberate (hold-to-delete: 2s linear), but release should always be snappy (200ms ease-out). This pattern applies broadly: slow where the user is deciding, fast where the system is responding. + +```css +/* Release: fast */ +.overlay { + transition: clip-path 200ms ease-out; +} + +/* Press: slow and deliberate */ +.button:active .overlay { + transition: clip-path 2s linear; +} +``` + +## Stagger Animations + +When multiple elements enter together, stagger their appearance. Each element animates in with a small delay after the previous one. This creates a cascading effect that feels more natural than everything appearing at once. + +```css +.item { + opacity: 0; + transform: translateY(8px); + animation: fadeIn 300ms ease-out forwards; +} + +.item:nth-child(1) { + animation-delay: 0ms; +} +.item:nth-child(2) { + animation-delay: 50ms; +} +.item:nth-child(3) { + animation-delay: 100ms; +} +.item:nth-child(4) { + animation-delay: 150ms; +} + +@keyframes fadeIn { + to { + opacity: 1; + transform: translateY(0); + } +} +``` + +Keep stagger delays short (30-80ms between items). Long delays make the interface feel slow. Stagger is decorative — never block interaction while stagger animations are playing. + +## Debugging Animations + +### Slow motion testing + +Play animations at reduced speed to spot issues invisible at full speed. Temporarily increase duration to 2-5x normal, or use browser DevTools animation inspector to slow playback. + +Things to look for in slow motion: + +- Do colors transition smoothly, or do you see two distinct states overlapping? +- Does the easing feel right, or does it start/stop abruptly? +- Is the transform-origin correct, or does the element scale from the wrong point? +- Are multiple animated properties (opacity, transform, color) in sync? + +### Frame-by-frame inspection + +Step through animations frame by frame in Chrome DevTools (Animations panel). This reveals timing issues between coordinated properties that you cannot see at full speed. + +### Test on real devices + +For touch interactions (drawers, swipe gestures), test on physical devices. Connect your phone via USB, visit your local dev server by IP address, and use Safari's remote devtools. The Xcode Simulator is an alternative but real hardware is better for gesture testing. + +## Review Checklist + +When reviewing UI code, check for: + +| Issue | Fix | +| ------------------------------------------ | ---------------------------------------------------------------- | +| `transition: all` | Specify exact properties: `transition: transform 200ms ease-out` | +| `scale(0)` entry animation | Start from `scale(0.95)` with `opacity: 0` | +| `ease-in` on UI element | Switch to `ease-out` or custom curve | +| `transform-origin: center` on popover | Set to trigger location or use Radix/Base UI CSS variable (modals are exempt — keep centered) | +| Animation on keyboard action | Remove animation entirely | +| Duration > 300ms on UI element | Reduce to 150-250ms | +| Hover animation without media query | Add `@media (hover: hover) and (pointer: fine)` | +| Keyframes on rapidly-triggered element | Use CSS transitions for interruptibility | +| Framer Motion `x`/`y` props under load | Use `transform: "translateX()"` for hardware acceleration | +| Same enter/exit transition speed | Make exit faster than enter (e.g., enter 2s, exit 200ms) | +| Elements all appear at once | Add stagger delay (30-80ms between items) | diff --git a/.claude/commands/add-block.md b/.claude/commands/add-block.md index 4ff595cce9e..93f0997320a 100644 --- a/.claude/commands/add-block.md +++ b/.claude/commands/add-block.md @@ -732,6 +732,13 @@ Please provide the SVG and I'll convert it to a React component. You can usually find this in the service's brand/press kit page, or copy it from their website. ``` +When converting the SVG: a **monochrome** logo (single white or black mark) must +use `fill='currentColor'`, never a hardcoded `#fff`/`#000000`. Block icons render +both inside their `bgColor` tile and "bare" on a neutral page (the home Suggested +actions list) in light and dark mode; a hardcoded white/black mark goes invisible +bare on the matching background. Multi-color brand logos keep their own fills. +Verify with `bun run check:bare-icons`. + ## Advanced Mode for Optional Fields Optional fields that are rarely used should be set to `mode: 'advanced'` so they don't clutter the basic UI. This includes: diff --git a/.claude/commands/add-integration.md b/.claude/commands/add-integration.md index 3fd58eff52e..6a379857c50 100644 --- a/.claude/commands/add-integration.md +++ b/.claude/commands/add-integration.md @@ -279,6 +279,31 @@ Once the user provides the SVG: 2. Create a React component that spreads props 3. Ensure viewBox is preserved from the original SVG +### Theme-safety (bare rendering) — REQUIRED + +The icon renders both inside its colored `bgColor` tile AND "bare" (no tile) on a +neutral page — e.g. the home **Suggested actions** list — in both light and dark +mode. A monochrome logo whose paths hardcode a single near-white or near-black +fill is invisible bare on the matching background (white-on-white in light mode, +black-on-black in dark mode). + +Rules when adding the SVG: + +- **Monochrome logos** (a single white or black mark): draw the shape with + `fill='currentColor'`, not `fill='#fff'` / `fill='#000000'`. It then inherits + white inside dark tiles, near-black inside light tiles (via + `getTileIconColorClass`), and the theme-aware `var(--text-icon)` bare — legible + everywhere. Do NOT set `iconColor` for these. +- **Multi-color brand logos** (their own vivid fills): keep the hardcoded fills. + They read on any background. Only set `iconColor` (a vivid brand hex, never a + near-black/near-white tile color) if the bare icon should adopt a brand tint. +- A large white shape with a tiny vivid accent (e.g. a logo where the body is the + white negative space) still vanishes bare — convert the body to `currentColor`. + +Verify with `bun run check:bare-icons` (also runs in CI). It flags purely +monochrome hazards; for partial-accent logos, eyeball the suggested-actions list +in both light and dark mode. + ## Step 5: Create Triggers (Optional) If the service supports webhooks, create triggers using the generic `buildTriggerSubBlocks` helper. @@ -466,6 +491,7 @@ If creating V2 versions (API-aligned outputs): - [ ] Asked user to provide SVG - [ ] Added icon to `components/icons.tsx` - [ ] Icon spreads props correctly +- [ ] Monochrome marks use `fill='currentColor'` (not hardcoded white/black) so the icon renders bare in light AND dark mode — verified with `bun run check:bare-icons` ### Triggers (if service supports webhooks) - [ ] Created `triggers/{service}/` directory diff --git a/.claude/skills/design-taste-frontend b/.claude/skills/design-taste-frontend new file mode 120000 index 00000000000..1e36b661b52 --- /dev/null +++ b/.claude/skills/design-taste-frontend @@ -0,0 +1 @@ +../../.agents/skills/design-taste-frontend \ No newline at end of file diff --git a/.claude/skills/emil-design-eng b/.claude/skills/emil-design-eng new file mode 120000 index 00000000000..0f0ee981cfe --- /dev/null +++ b/.claude/skills/emil-design-eng @@ -0,0 +1 @@ +../../.agents/skills/emil-design-eng \ No newline at end of file diff --git a/.cursor/rules/constitution.mdc b/.cursor/rules/constitution.mdc index 94186db6e3a..dbce52e5298 100644 --- a/.cursor/rules/constitution.mdc +++ b/.cursor/rules/constitution.mdc @@ -1,6 +1,6 @@ --- description: Sim product language, positioning, and tone guidelines -globs: ["apps/sim/app/(landing)/**", "apps/sim/app/(home)/**", "apps/docs/**", "apps/sim/app/manifest.ts", "apps/sim/app/sitemap.ts", "apps/sim/app/robots.ts", "apps/sim/app/llms.txt/**", "apps/sim/app/llms-full.txt/**", "apps/sim/app/(landing)/**/structured-data*", "apps/docs/**/structured-data*", "**/metadata*", "**/seo*"] +globs: ["apps/sim/app/(landing)/**", "apps/docs/**", "apps/sim/app/manifest.ts", "apps/sim/app/sitemap.ts", "apps/sim/app/robots.ts", "apps/sim/app/llms.txt/**", "apps/sim/app/llms-full.txt/**", "apps/sim/app/(landing)/**/structured-data*", "apps/docs/**/structured-data*", "**/metadata*", "**/seo*"] --- # Sim — Language & Positioning diff --git a/.cursor/rules/landing-seo-geo.mdc b/.cursor/rules/landing-seo-geo.mdc index 4ec16754b99..c078b175d22 100644 --- a/.cursor/rules/landing-seo-geo.mdc +++ b/.cursor/rules/landing-seo-geo.mdc @@ -1,6 +1,6 @@ --- description: SEO and GEO guidelines for the landing page -globs: ["apps/sim/app/(home)/**/*.tsx"] +globs: ["apps/sim/app/(landing)/**/*.tsx"] --- # Landing Page — SEO / GEO diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index c4ffd7449ea..29c1058b0b0 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -125,6 +125,9 @@ jobs: - name: Client boundary import audit run: bun run check:client-boundary + - name: Bare-icon theme-safety audit + run: bun run check:bare-icons + - name: Verify realtime prune graph run: bun run check:realtime-prune diff --git a/README.md b/README.md index 88dc99f7c1f..40118f75503 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@

      - Sim — your workflow agent for solving automations. Build, deploy, and manage AI agents visually, conversationally, or with code. + Sim — Integrate, Context, Build, and Monitor AI agents

      @@ -35,7 +35,7 @@ Open [http://localhost:3000](http://localhost:3000) Docker must be installed and running. Use `-p, --port ` to run Sim on a different port, or `--no-pull` to skip pulling the latest Docker images.

      - How Sim works — Integrate, Context, Build, Monitor — shown end to end: start a chat to build an agent, connect Slack and other integrations, add a knowledge base, build the workflow visually, deploy it, and monitor runs in the logs + The Sim platform — chat on the left, the visual workflow builder on the right

      ## Capabilities @@ -46,6 +46,33 @@ Docker must be installed and running. Use `-p, --port ` to run Sim on a di - Ingest files, knowledge bases, and structured table data - Monitor runs, logs, schedules, and workflow activity +## One workspace, every surface + +

      Chat and workflows are just the start — tables, files, knowledge, and scheduled tasks all live in the same workspace.

      + + + + + + + + + + +
      + Tables in Sim — structured data your agents can query +

      Tables — a database, built in

      +
      + Files in Sim — documents for your team and every agent +

      Files — one store for your team and every agent

      +
      + Knowledge bases in Sim — synced docs your agents can search +

      Knowledge — your agents' memory

      +
      + Scheduled tasks in Sim — recurring agent runs on a calendar +

      Scheduled tasks — runs on your schedule

      +
      + ## Self-hosting ### Docker Compose @@ -120,6 +147,9 @@ See the [environment variables reference](https://docs.sim.ai/self-hosting/envir ## Tech Stack +
      +Next.js · Bun · PostgreSQL · Drizzle · Better Auth · Tailwind — and the rest of the stack + - **Framework**: [Next.js](https://nextjs.org/) (App Router) - **Runtime**: [Bun](https://bun.sh/) - **Database**: PostgreSQL with [Drizzle ORM](https://orm.drizzle.team) @@ -136,6 +166,8 @@ See the [environment variables reference](https://docs.sim.ai/self-hosting/envir - **Remote Code Execution**: [E2B](https://www.e2b.dev/) - **Isolated Code Execution**: [isolated-vm](https://github.com/laverdet/isolated-vm) +
      + ## Contributing We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for details. diff --git a/apps/docs/content/docs/en/integrations/asana.mdx b/apps/docs/content/docs/en/integrations/asana.mdx index bd9a332e6d0..ac8dd885ecc 100644 --- a/apps/docs/content/docs/en/integrations/asana.mdx +++ b/apps/docs/content/docs/en/integrations/asana.mdx @@ -209,4 +209,187 @@ Add a comment (story) to an Asana task | ↳ `gid` | string | Author GID | | ↳ `name` | string | Author name | +### `asana_create_subtask` + +Create a subtask under an existing Asana task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskGid` | string | Yes | GID of the parent Asana task \(numeric string\) | +| `name` | string | Yes | Name of the subtask | +| `notes` | string | No | Notes or description for the subtask | +| `assignee` | string | No | User GID to assign the subtask to | +| `due_on` | string | No | Due date in YYYY-MM-DD format | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `ts` | string | Timestamp of the response | +| `gid` | string | Subtask globally unique identifier | +| `name` | string | Subtask name | +| `notes` | string | Subtask notes or description | +| `completed` | boolean | Whether the subtask is completed | +| `created_at` | string | Subtask creation timestamp | +| `permalink_url` | string | URL to the subtask in Asana | + +### `asana_delete_task` + +Delete an Asana task by its GID (moves it to the trash) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskGid` | string | Yes | GID of the Asana task to delete \(numeric string\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `ts` | string | Timestamp of the response | +| `gid` | string | GID of the deleted task | +| `deleted` | boolean | Whether the task was deleted | + +### `asana_add_followers` + +Add one or more followers to an Asana task + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskGid` | string | Yes | GID of the Asana task \(numeric string\) | +| `followers` | array | Yes | Array of user GIDs to add as followers to the task | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `ts` | string | Timestamp of the response | +| `gid` | string | Task globally unique identifier | +| `name` | string | Task name | +| `followers` | array | Current followers on the task after the update | +| ↳ `gid` | string | Follower GID | +| ↳ `name` | string | Follower name | + +### `asana_create_project` + +Create a new project in an Asana workspace + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workspace` | string | Yes | Asana workspace GID \(numeric string\) where the project will be created | +| `name` | string | Yes | Name of the project | +| `notes` | string | No | Notes or description for the project | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `ts` | string | Timestamp of the response | +| `gid` | string | Project globally unique identifier | +| `name` | string | Project name | +| `notes` | string | Project notes or description | +| `archived` | boolean | Whether the project is archived | +| `color` | string | Project color | +| `created_at` | string | Project creation timestamp | +| `modified_at` | string | Project last modified timestamp | +| `permalink_url` | string | URL to the project in Asana | + +### `asana_get_project` + +Retrieve a single Asana project by its GID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectGid` | string | Yes | Asana project GID \(numeric string\) to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `ts` | string | Timestamp of the response | +| `gid` | string | Project globally unique identifier | +| `name` | string | Project name | +| `notes` | string | Project notes or description | +| `archived` | boolean | Whether the project is archived | +| `color` | string | Project color | +| `created_at` | string | Project creation timestamp | +| `modified_at` | string | Project last modified timestamp | +| `permalink_url` | string | URL to the project in Asana | + +### `asana_list_workspaces` + +List all Asana workspaces and organizations the authenticated user belongs to + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `ts` | string | Timestamp of the response | +| `workspaces` | array | Array of workspaces | +| ↳ `gid` | string | Workspace GID | +| ↳ `name` | string | Workspace name | +| ↳ `resource_type` | string | Resource type \(workspace\) | + +### `asana_create_section` + +Create a new section in an Asana project + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectGid` | string | Yes | GID of the Asana project \(numeric string\) to add the section to | +| `name` | string | Yes | Name of the section | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `ts` | string | Timestamp of the response | +| `gid` | string | Section globally unique identifier | +| `name` | string | Section name | +| `created_at` | string | Section creation timestamp | + +### `asana_list_sections` + +List all sections in an Asana project + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectGid` | string | Yes | GID of the Asana project \(numeric string\) to list sections from | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `ts` | string | Timestamp of the response | +| `sections` | array | Array of sections in the project | +| ↳ `gid` | string | Section GID | +| ↳ `name` | string | Section name | +| ↳ `resource_type` | string | Resource type \(section\) | + diff --git a/apps/docs/content/docs/en/integrations/context_dev.mdx b/apps/docs/content/docs/en/integrations/context_dev.mdx index e2b5898af95..3cd1687a0fc 100644 --- a/apps/docs/content/docs/en/integrations/context_dev.mdx +++ b/apps/docs/content/docs/en/integrations/context_dev.mdx @@ -65,7 +65,7 @@ Scrape any URL and return the raw HTML content of the page. | --------- | ---- | ----------- | | `html` | string | Raw HTML content of the page | | `url` | string | The scraped URL | -| `type` | string | Detected content type \(html, xml, json, text, csv, markdown, svg, pdf\) | +| `type` | string | Detected content type \(html, xml, json, text, csv, markdown, svg, pdf, doc, docx\) | ### `context_dev_scrape_images` @@ -177,7 +177,7 @@ Build a sitemap of a domain and return every discovered page URL. | --------- | ---- | ----------- | | `domain` | string | The domain that was mapped | | `urls` | array | All page URLs discovered from the sitemap | -| `meta` | object | Sitemap discovery stats \(sitemapsDiscovered, sitemapsFetched, errors\) | +| `meta` | object | Sitemap discovery stats \(sitemapsDiscovered, sitemapsFetched, sitemapsSkipped, errors\) | ### `context_dev_search` @@ -191,6 +191,8 @@ Search the web with natural language and optionally scrape results to markdown. | `includeDomains` | array | No | Only return results from these domains | | `excludeDomains` | array | No | Exclude results from these domains | | `freshness` | string | No | Recency filter \(last_24_hours, last_week, last_month, last_year\) | +| `numResults` | number | No | Number of results to return \(10-100, default 10\) | +| `country` | string | No | Restrict results to a country \(ISO 3166-1 alpha-2 code, e.g. US\) | | `queryFanout` | boolean | No | Expand the query into parallel variants for broader coverage | | `markdownEnabled` | boolean | No | Scrape each result page to markdown \(default: false\) | | `timeoutMS` | number | No | Request timeout in milliseconds \(1000-300000\) | @@ -449,7 +451,7 @@ Retrieve brand data for a domain: logos, colors, backdrops, socials, address, an | ↳ `email` | string | Brand contact email | | ↳ `phone` | string | Brand contact phone | | ↳ `industries` | json | Industry taxonomy \(eic industry/subindustry pairs\) | -| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing\) | +| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing, contact\) | | ↳ `primary_language` | string | Primary language of the brand site | ### `context_dev_get_brand_by_name` @@ -488,7 +490,7 @@ Retrieve brand data by company name: logos, colors, socials, address, and indust | ↳ `email` | string | Brand contact email | | ↳ `phone` | string | Brand contact phone | | ↳ `industries` | json | Industry taxonomy \(eic industry/subindustry pairs\) | -| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing\) | +| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing, contact\) | | ↳ `primary_language` | string | Primary language of the brand site | ### `context_dev_get_brand_by_email` @@ -526,7 +528,7 @@ Retrieve brand data from a work email address. Free/disposable emails are reject | ↳ `email` | string | Brand contact email | | ↳ `phone` | string | Brand contact phone | | ↳ `industries` | json | Industry taxonomy \(eic industry/subindustry pairs\) | -| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing\) | +| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing, contact\) | | ↳ `primary_language` | string | Primary language of the brand site | ### `context_dev_get_brand_by_ticker` @@ -565,7 +567,7 @@ Retrieve brand data for a public company by its stock ticker symbol. | ↳ `email` | string | Brand contact email | | ↳ `phone` | string | Brand contact phone | | ↳ `industries` | json | Industry taxonomy \(eic industry/subindustry pairs\) | -| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing\) | +| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing, contact\) | | ↳ `primary_language` | string | Primary language of the brand site | ### `context_dev_get_brand_simplified` @@ -632,7 +634,7 @@ Identify the brand behind a raw bank/card transaction descriptor and return its | ↳ `email` | string | Brand contact email | | ↳ `phone` | string | Brand contact phone | | ↳ `industries` | json | Industry taxonomy \(eic industry/subindustry pairs\) | -| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing\) | +| ↳ `links` | json | Key brand links \(careers, privacy, terms, blog, pricing, contact\) | | ↳ `primary_language` | string | Primary language of the brand site | ### `context_dev_prefetch_domain` diff --git a/apps/docs/content/docs/en/integrations/google_docs.mdx b/apps/docs/content/docs/en/integrations/google_docs.mdx index 9dc591ccb0a..1b8c137a1b7 100644 --- a/apps/docs/content/docs/en/integrations/google_docs.mdx +++ b/apps/docs/content/docs/en/integrations/google_docs.mdx @@ -110,7 +110,7 @@ Insert text at a specific index in a Google Docs document. When no index is prov | --------- | ---- | -------- | ----------- | | `documentId` | string | Yes | The ID of the document to insert text into | | `text` | string | Yes | The text to insert | -| `index` | number | No | The 1-based character index at which to insert the text. When omitted, text is appended to the end of the document. | +| `index` | number | No | The character index \(the document body starts at index 1\) at which to insert the text. When omitted, text is appended to the end of the document. | #### Output @@ -158,7 +158,7 @@ Insert an empty table with the given number of rows and columns into a Google Do | `documentId` | string | Yes | The ID of the document to insert the table into | | `rows` | number | Yes | The number of rows in the table | | `columns` | number | Yes | The number of columns in the table | -| `index` | number | No | The 1-based character index at which to insert the table. When omitted, the table is appended to the end of the document. | +| `index` | number | No | The character index \(the document body starts at index 1\) at which to insert the table. When omitted, the table is appended to the end of the document. | #### Output @@ -181,7 +181,7 @@ Insert an inline image from a public URL into a Google Docs document. The image | --------- | ---- | -------- | ----------- | | `documentId` | string | Yes | The ID of the document to insert the image into | | `imageUrl` | string | Yes | The publicly accessible URL of the image to insert | -| `index` | number | No | The 1-based character index at which to insert the image. When omitted, the image is appended to the end of the document. | +| `index` | number | No | The character index \(the document body starts at index 1\) at which to insert the image. When omitted, the image is appended to the end of the document. | | `width` | number | No | Optional image width in points \(PT\) | | `height` | number | No | Optional image height in points \(PT\) | @@ -205,7 +205,7 @@ Insert a page break into a Google Docs document. When no index is provided, the | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `documentId` | string | Yes | The ID of the document to insert the page break into | -| `index` | number | No | The 1-based character index at which to insert the page break. When omitted, the page break is appended to the end of the document. | +| `index` | number | No | The character index \(the document body starts at index 1\) at which to insert the page break. When omitted, the page break is appended to the end of the document. | #### Output @@ -227,7 +227,7 @@ Apply bold, italic, underline, and/or font size to a range of text in a Google D | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `documentId` | string | Yes | The ID of the document to update | -| `startIndex` | number | Yes | The 1-based start character index of the range to style \(inclusive\) | +| `startIndex` | number | Yes | The start character index \(the document body starts at index 1\) of the range to style \(inclusive\) | | `endIndex` | number | Yes | The end character index of the range to style \(exclusive\) | | `bold` | boolean | No | Whether to make the text bold | | `italic` | boolean | No | Whether to make the text italic | @@ -245,4 +245,146 @@ Apply bold, italic, underline, and/or font size to a range of text in a Google D | ↳ `mimeType` | string | Document MIME type | | ↳ `url` | string | Document URL | +### `google_docs_update_paragraph_style` + +Apply a named paragraph style (such as a heading or title) and/or alignment to the paragraphs overlapping a range of text in a Google Docs document, identified by its start and end character index. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `documentId` | string | Yes | The ID of the document to update | +| `startIndex` | number | Yes | The start character index \(the document body starts at index 1\) of the range to style \(inclusive\) | +| `endIndex` | number | Yes | The end character index of the range to style \(exclusive\) | +| `namedStyleType` | string | No | The named paragraph style to apply. One of: NORMAL_TEXT, TITLE, SUBTITLE, HEADING_1, HEADING_2, HEADING_3, HEADING_4, HEADING_5, HEADING_6. | +| `alignment` | string | No | The paragraph alignment to apply. One of: LEFT, CENTER, RIGHT, JUSTIFY. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `updatedContent` | boolean | Indicates if the paragraph style was applied successfully | +| `metadata` | json | Updated document metadata including ID, title, and URL | +| ↳ `documentId` | string | Google Docs document ID | +| ↳ `title` | string | Document title | +| ↳ `mimeType` | string | Document MIME type | +| ↳ `url` | string | Document URL | + +### `google_docs_create_paragraph_bullets` + +Add bulleted or numbered list formatting to the paragraphs overlapping a range of text in a Google Docs document, using a chosen bullet glyph preset. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `documentId` | string | Yes | The ID of the document to update | +| `startIndex` | number | Yes | The start character index \(the document body starts at index 1\) of the range to bullet \(inclusive\) | +| `endIndex` | number | Yes | The end character index of the range to bullet \(exclusive\) | +| `bulletPreset` | string | No | The bullet glyph preset to apply. Defaults to BULLET_DISC_CIRCLE_SQUARE. Examples: BULLET_DISC_CIRCLE_SQUARE, BULLET_CHECKBOX, NUMBERED_DECIMAL_ALPHA_ROMAN, NUMBERED_DECIMAL_NESTED. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `updatedContent` | boolean | Indicates if the bullets were applied successfully | +| `metadata` | json | Updated document metadata including ID, title, and URL | +| ↳ `documentId` | string | Google Docs document ID | +| ↳ `title` | string | Document title | +| ↳ `mimeType` | string | Document MIME type | +| ↳ `url` | string | Document URL | + +### `google_docs_delete_paragraph_bullets` + +Remove bullet or numbered list formatting from the paragraphs overlapping a range of text in a Google Docs document, identified by its start and end character index. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `documentId` | string | Yes | The ID of the document to update | +| `startIndex` | number | Yes | The start character index \(the document body starts at index 1\) of the range to clear bullets from \(inclusive\) | +| `endIndex` | number | Yes | The end character index of the range to clear bullets from \(exclusive\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `updatedContent` | boolean | Indicates if the bullets were removed successfully | +| `metadata` | json | Updated document metadata including ID, title, and URL | +| ↳ `documentId` | string | Google Docs document ID | +| ↳ `title` | string | Document title | +| ↳ `mimeType` | string | Document MIME type | +| ↳ `url` | string | Document URL | + +### `google_docs_delete_content_range` + +Delete all content between a start and end character index in a Google Docs document. The endIndex is exclusive and must be greater than the startIndex. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `documentId` | string | Yes | The ID of the document to delete content from | +| `startIndex` | number | Yes | The start character index \(the document body starts at index 1\) of the range to delete \(inclusive\) | +| `endIndex` | number | Yes | The end character index of the range to delete \(exclusive\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `updatedContent` | boolean | Indicates if the content range was deleted successfully | +| `metadata` | json | Updated document metadata including ID, title, and URL | +| ↳ `documentId` | string | Google Docs document ID | +| ↳ `title` | string | Document title | +| ↳ `mimeType` | string | Document MIME type | +| ↳ `url` | string | Document URL | + +### `google_docs_create_named_range` + +Create a named range over a span of content in a Google Docs document so it can be referenced or deleted later. The name may be 1-256 characters and need not be unique. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `documentId` | string | Yes | The ID of the document to update | +| `name` | string | Yes | The name of the range to create \(1-256 characters\) | +| `startIndex` | number | Yes | The start character index \(the document body starts at index 1\) of the range \(inclusive\) | +| `endIndex` | number | Yes | The end character index of the range \(exclusive\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `namedRangeId` | string | The ID of the created named range | +| `metadata` | json | Updated document metadata including ID, title, and URL | +| ↳ `documentId` | string | Google Docs document ID | +| ↳ `title` | string | Document title | +| ↳ `mimeType` | string | Document MIME type | +| ↳ `url` | string | Document URL | + +### `google_docs_delete_named_range` + +Delete one or more named ranges from a Google Docs document by their ID or by name. Provide exactly one of namedRangeId or name; deleting by name removes all ranges sharing that name. The content itself is not removed. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `documentId` | string | Yes | The ID of the document to update | +| `namedRangeId` | string | No | The ID of the named range to delete. Provide exactly one of namedRangeId or namedRangeName. | +| `namedRangeName` | string | No | The name of the named range\(s\) to delete. All ranges sharing this name are removed. Provide exactly one of namedRangeId or namedRangeName. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `updatedContent` | boolean | Indicates if the named range\(s\) were deleted successfully | +| `metadata` | json | Updated document metadata including ID, title, and URL | +| ↳ `documentId` | string | Google Docs document ID | +| ↳ `title` | string | Document title | +| ↳ `mimeType` | string | Document MIME type | +| ↳ `url` | string | Document URL | + diff --git a/apps/docs/content/docs/en/integrations/jira.mdx b/apps/docs/content/docs/en/integrations/jira.mdx index 86ba0172594..993f9f1d5e3 100644 --- a/apps/docs/content/docs/en/integrations/jira.mdx +++ b/apps/docs/content/docs/en/integrations/jira.mdx @@ -1046,6 +1046,155 @@ Search for Jira users by email address or display name. Returns matching users w | `startAt` | number | Pagination start index | | `maxResults` | number | Maximum results per page | +### `jira_list_projects` + +List Jira projects visible to the user, with optional name/key filtering and pagination. Returns each project with id, key, name, and type. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `query` | string | No | Filter projects by partial name or key match | +| `startAt` | number | No | The index of the first project to return \(for pagination, default: 0\) | +| `maxResults` | number | No | Maximum number of projects to return \(default: 50, max: 100\) | +| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | ISO 8601 timestamp of the operation | +| `projects` | array | Array of Jira projects | +| ↳ `id` | string | Project ID | +| ↳ `key` | string | Project key \(e.g., PROJ\) | +| ↳ `name` | string | Project name | +| ↳ `projectTypeKey` | string | Project type key \(e.g., software, service_desk, business\) | +| ↳ `simplified` | boolean | Whether the project is a simplified \(team-managed\) project | +| ↳ `style` | string | Project style \(e.g., classic, next-gen\) | +| ↳ `isPrivate` | boolean | Whether the project is private | +| ↳ `url` | string | REST API URL for this project | +| ↳ `leadDisplayName` | string | Display name of the project lead | +| ↳ `leadAccountId` | string | Account ID of the project lead | +| `total` | number | Total number of matching projects | +| `startAt` | number | Pagination start index | +| `maxResults` | number | Maximum results per page | +| `isLast` | boolean | Whether this is the last page of results | + +### `jira_get_project` + +Get the details of a single Jira project by its ID or key, including its type, lead, components, issue types, and versions. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `projectId` | string | Yes | The project ID or key \(e.g., "PROJ" or "10000"\) | +| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | ISO 8601 timestamp of the operation | +| `id` | string | Project ID | +| `key` | string | Project key \(e.g., PROJ\) | +| `name` | string | Project name | +| `description` | string | Project description | +| `projectTypeKey` | string | Project type key \(e.g., software, service_desk, business\) | +| `simplified` | boolean | Whether the project is a simplified \(team-managed\) project | +| `style` | string | Project style \(e.g., classic, next-gen\) | +| `isPrivate` | boolean | Whether the project is private | +| `url` | string | REST API URL for this project | +| `leadDisplayName` | string | Display name of the project lead | +| `leadAccountId` | string | Account ID of the project lead | +| `issueTypes` | array | Issue types available in this project | +| ↳ `id` | string | Issue type ID | +| ↳ `name` | string | Issue type name \(e.g., Task, Bug, Story\) | +| ↳ `subtask` | boolean | Whether this issue type is a subtask | + +### `jira_get_transitions` + +Get the workflow transitions available for an issue in its current status. Use the returned transition IDs with the Transition Issue operation. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `issueKey` | string | Yes | The issue key or ID \(e.g., PROJ-123\) | +| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | ISO 8601 timestamp of the operation | +| `issueKey` | string | Issue key the transitions belong to | +| `transitions` | array | Available workflow transitions for the issue | +| ↳ `id` | string | Transition ID \(use with Transition Issue\) | +| ↳ `name` | string | Transition name \(e.g., "Start Progress"\) | +| ↳ `toStatusId` | string | ID of the status the issue moves to | +| ↳ `toStatusName` | string | Name of the status the issue moves to | +| ↳ `toStatusCategory` | string | Status category key of the target status \(new, indeterminate, done\) | +| ↳ `isAvailable` | boolean | Whether the transition can currently be performed | +| ↳ `hasScreen` | boolean | Whether the transition requires a screen with fields | +| `total` | number | Number of available transitions | + +### `jira_list_issue_types` + +List all issue types visible to the user across projects (e.g., Task, Bug, Story, Epic, Subtask). Useful for discovering valid issue types before creating an issue. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | ISO 8601 timestamp of the operation | +| `issueTypes` | array | Array of issue types | +| ↳ `id` | string | Issue type ID | +| ↳ `name` | string | Issue type name \(e.g., Task, Bug, Story\) | +| ↳ `description` | string | Issue type description | +| ↳ `subtask` | boolean | Whether this issue type is a subtask | +| ↳ `hierarchyLevel` | number | Hierarchy level \(0 = standard, 1 = epic, -1 = subtask\) | +| ↳ `iconUrl` | string | URL of the issue type icon | +| ↳ `scope` | string | Project ID if this issue type is scoped to a team-managed project | +| `total` | number | Number of issue types returned | + +### `jira_get_fields` + +Get all system and custom fields defined in the Jira instance. Useful for discovering custom field IDs (e.g., customfield_10001) to use when writing or updating issues. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | ISO 8601 timestamp of the operation | +| `fields` | array | Array of Jira fields \(system and custom\) | +| ↳ `id` | string | Field ID \(e.g., summary, customfield_10001\) | +| ↳ `key` | string | Field key | +| ↳ `name` | string | Human-readable field name | +| ↳ `custom` | boolean | Whether this is a custom field | +| ↳ `navigable` | boolean | Whether the field is navigable in issue views | +| ↳ `searchable` | boolean | Whether the field can be used in JQL searches | +| ↳ `schemaType` | string | Field value type \(e.g., string, number, array, user\) | +| ↳ `customType` | string | Custom field type identifier \(only for custom fields\) | +| `total` | number | Number of fields returned | + ## Triggers diff --git a/apps/docs/content/docs/en/integrations/linq.mdx b/apps/docs/content/docs/en/integrations/linq.mdx index 82ad460cfe1..dad202058e7 100644 --- a/apps/docs/content/docs/en/integrations/linq.mdx +++ b/apps/docs/content/docs/en/integrations/linq.mdx @@ -88,7 +88,7 @@ Check whether an address (phone number or email) supports RCS | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Linq API key | -| `address` | string | Yes | Phone number \(E.164 format\) or email address to check | +| `address` | string | Yes | Phone number \(E.164 format\) to check | | `from` | string | No | Sender phone number to check from \(defaults to an available number\) | #### Output @@ -277,8 +277,9 @@ Edit the text of a sent message (up to 5 times, within 15 minutes of sending; iM | `id` | string | Message ID | | `chatId` | string | ID of the chat the message belongs to | | `isFromMe` | boolean | Whether the message was sent by you | -| `isDelivered` | boolean | Whether the message was delivered | -| `isRead` | boolean | Whether the message was read | +| `deliveryStatus` | string | Delivery status \(pending, queued, sent, delivered, received, read, failed\) | +| `isDelivered` | boolean | Whether the message was delivered \(deprecated; use deliveryStatus\) | +| `isRead` | boolean | Whether the message was read \(deprecated; use deliveryStatus\) | | `service` | string | Delivery service \(iMessage, SMS, RCS\) | | `createdAt` | string | ISO 8601 creation timestamp | | `updatedAt` | string | ISO 8601 update timestamp | @@ -374,8 +375,9 @@ Retrieve a single message by ID, including parts, reactions, and delivery status | `id` | string | Message ID | | `chatId` | string | ID of the chat the message belongs to | | `isFromMe` | boolean | Whether the message was sent by you | -| `isDelivered` | boolean | Whether the message was delivered | -| `isRead` | boolean | Whether the message was read | +| `deliveryStatus` | string | Delivery status \(pending, queued, sent, delivered, received, read, failed\) | +| `isDelivered` | boolean | Whether the message was delivered \(deprecated; use deliveryStatus\) | +| `isRead` | boolean | Whether the message was read \(deprecated; use deliveryStatus\) | | `service` | string | Delivery service \(iMessage, SMS, RCS\) | | `createdAt` | string | ISO 8601 creation timestamp | | `updatedAt` | string | ISO 8601 update timestamp | @@ -483,7 +485,8 @@ List all phone numbers assigned to your partner account, with line health | `phoneNumbers` | array | Phone numbers assigned to the account | | ↳ `id` | string | Phone number ID | | ↳ `phoneNumber` | string | Phone number in E.164 format | -| ↳ `healthStatus` | json | Line health status \(status, doc_url\) | +| ↳ `forwardingNumber` | string | Forwarding number in E.164 format, or null | +| ↳ `healthStatus` | json | Line reputation/health status \(status, doc_url\) | ### `linq_list_thread` @@ -548,7 +551,7 @@ List all webhook subscriptions on your account ### `linq_mark_chat_read` -Mark all messages in a chat as read +Mark messages in a chat as read (only applies to 1:1 iMessage/RCS; no effect on group chats) #### Input @@ -633,7 +636,7 @@ Send a message to an existing chat, with optional media, link, effect, or reply | --------- | ---- | ----------- | | `chatId` | string | ID of the chat the message was sent to | | `messageId` | string | ID of the sent message | -| `deliveryStatus` | string | Delivery status \(pending, queued, sent, delivered, failed\) | +| `deliveryStatus` | string | Delivery status \(pending, queued, sent, delivered, received, read, failed\) | | `sentAt` | string | ISO 8601 timestamp the message was sent | | `service` | string | Delivery service \(iMessage, SMS, RCS\) | | `message` | json | The full sent message object with parts | @@ -785,3 +788,149 @@ Update a webhook subscription (target URL, events, phone filter, or active state | `updatedAt` | string | ISO 8601 update timestamp | + +## Triggers + +A **Trigger** is a block that starts a workflow when an event happens in this service. + +### Linq Message Delivered + +Trigger workflow when a message is delivered + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerApiKey` | string | Yes | Required to create the webhook subscription in Linq. | +| `triggerPhoneNumbers` | string | No | Comma-separated E.164 numbers to restrict which numbers deliver events. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Event type \(e.g. message.received, message.delivered, reaction.added\) | +| `eventId` | string | Unique event identifier used for deduplication | +| `createdAt` | string | ISO 8601 timestamp of when the event occurred | +| `webhookVersion` | string | Payload schema version of the delivered event | +| `data` | json | Full event payload \(shape varies by event type — message, reaction, chat, etc.\) | + + +--- + +### Linq Message Failed + +Trigger workflow when a message fails to deliver + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerApiKey` | string | Yes | Required to create the webhook subscription in Linq. | +| `triggerPhoneNumbers` | string | No | Comma-separated E.164 numbers to restrict which numbers deliver events. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Event type \(e.g. message.received, message.delivered, reaction.added\) | +| `eventId` | string | Unique event identifier used for deduplication | +| `createdAt` | string | ISO 8601 timestamp of when the event occurred | +| `webhookVersion` | string | Payload schema version of the delivered event | +| `data` | json | Full event payload \(shape varies by event type — message, reaction, chat, etc.\) | + + +--- + +### Linq Message Read + +Trigger workflow when a message is read + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerApiKey` | string | Yes | Required to create the webhook subscription in Linq. | +| `triggerPhoneNumbers` | string | No | Comma-separated E.164 numbers to restrict which numbers deliver events. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Event type \(e.g. message.received, message.delivered, reaction.added\) | +| `eventId` | string | Unique event identifier used for deduplication | +| `createdAt` | string | ISO 8601 timestamp of when the event occurred | +| `webhookVersion` | string | Payload schema version of the delivered event | +| `data` | json | Full event payload \(shape varies by event type — message, reaction, chat, etc.\) | + + +--- + +### Linq Message Received + +Trigger workflow when an inbound message is received + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerApiKey` | string | Yes | Required to create the webhook subscription in Linq. | +| `triggerPhoneNumbers` | string | No | Comma-separated E.164 numbers to restrict which numbers deliver events. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Event type \(e.g. message.received, message.delivered, reaction.added\) | +| `eventId` | string | Unique event identifier used for deduplication | +| `createdAt` | string | ISO 8601 timestamp of when the event occurred | +| `webhookVersion` | string | Payload schema version of the delivered event | +| `data` | json | Full event payload \(shape varies by event type — message, reaction, chat, etc.\) | + + +--- + +### Linq Reaction Added + +Trigger workflow when a reaction is added to a message + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerApiKey` | string | Yes | Required to create the webhook subscription in Linq. | +| `triggerPhoneNumbers` | string | No | Comma-separated E.164 numbers to restrict which numbers deliver events. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Event type \(e.g. message.received, message.delivered, reaction.added\) | +| `eventId` | string | Unique event identifier used for deduplication | +| `createdAt` | string | ISO 8601 timestamp of when the event occurred | +| `webhookVersion` | string | Payload schema version of the delivered event | +| `data` | json | Full event payload \(shape varies by event type — message, reaction, chat, etc.\) | + + +--- + +### Linq Webhook (All Events) + +Trigger on any Linq webhook event (messages, reactions, chats, and more) + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerApiKey` | string | Yes | Required to create the webhook subscription in Linq. | +| `triggerPhoneNumbers` | string | No | Comma-separated E.164 numbers to restrict which numbers deliver events. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Event type \(e.g. message.received, message.delivered, reaction.added\) | +| `eventId` | string | Unique event identifier used for deduplication | +| `createdAt` | string | ISO 8601 timestamp of when the event occurred | +| `webhookVersion` | string | Payload schema version of the delivered event | +| `data` | json | Full event payload \(shape varies by event type — message, reaction, chat, etc.\) | + diff --git a/apps/docs/content/docs/en/integrations/monday.mdx b/apps/docs/content/docs/en/integrations/monday.mdx index d60977dd329..35a2e25eced 100644 --- a/apps/docs/content/docs/en/integrations/monday.mdx +++ b/apps/docs/content/docs/en/integrations/monday.mdx @@ -242,6 +242,72 @@ Update column values of an item on a Monday.com board | ↳ `updatedAt` | string | Last updated timestamp | | ↳ `url` | string | Item URL | +### `monday_change_column_value` + +Update a single column's value on a Monday.com item + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `boardId` | string | Yes | The ID of the board containing the item | +| `itemId` | string | Yes | The ID of the item to update | +| `columnId` | string | Yes | The ID of the column to update \(e.g., "status", "date4"\) | +| `value` | string | Yes | The new column value as a JSON string \(e.g., \{"label":"Done"\} for a status column\) | +| `createLabelsIfMissing` | boolean | No | Create status/dropdown labels that do not yet exist on the column | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `item` | json | The updated item | +| ↳ `id` | string | Item ID | +| ↳ `name` | string | Item name | +| ↳ `state` | string | Item state | +| ↳ `boardId` | string | Board ID | +| ↳ `groupId` | string | Group ID | +| ↳ `groupTitle` | string | Group title | +| ↳ `columnValues` | array | Column values | +| ↳ `id` | string | Column ID | +| ↳ `text` | string | Text value | +| ↳ `value` | string | Raw JSON value | +| ↳ `type` | string | Column type | +| ↳ `createdAt` | string | Creation timestamp | +| ↳ `updatedAt` | string | Last updated timestamp | +| ↳ `url` | string | Item URL | + +### `monday_duplicate_item` + +Duplicate an existing item on a Monday.com board + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `boardId` | string | Yes | The ID of the board containing the item | +| `itemId` | string | Yes | The ID of the item to duplicate | +| `withUpdates` | boolean | No | Whether to also duplicate the item updates | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `item` | json | The duplicated item | +| ↳ `id` | string | Item ID | +| ↳ `name` | string | Item name | +| ↳ `state` | string | Item state | +| ↳ `boardId` | string | Board ID | +| ↳ `groupId` | string | Group ID | +| ↳ `groupTitle` | string | Group title | +| ↳ `columnValues` | array | Column values | +| ↳ `id` | string | Column ID | +| ↳ `text` | string | Text value | +| ↳ `value` | string | Raw JSON value | +| ↳ `type` | string | Column type | +| ↳ `createdAt` | string | Creation timestamp | +| ↳ `updatedAt` | string | Last updated timestamp | +| ↳ `url` | string | Item URL | + ### `monday_delete_item` Delete an item from a Monday.com board @@ -384,6 +450,80 @@ Create a new group on a Monday.com board | ↳ `deleted` | boolean | Whether deleted | | ↳ `position` | string | Group position | +### `monday_get_groups` + +Get the groups on a Monday.com board + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `boardId` | string | Yes | The ID of the board to retrieve groups from | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `groups` | array | Groups on the board | +| ↳ `id` | string | Group ID | +| ↳ `title` | string | Group title | +| ↳ `color` | string | Group color \(hex\) | +| ↳ `archived` | boolean | Whether the group is archived | +| ↳ `deleted` | boolean | Whether the group is deleted | +| ↳ `position` | string | Group position | +| `count` | number | Number of returned groups | + +### `monday_create_board` + +Create a new board in Monday.com + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `boardName` | string | Yes | The name of the new board | +| `boardKind` | string | Yes | The board kind: public, private, or share | +| `description` | string | No | The board description | +| `workspaceId` | string | No | The ID of the workspace to create the board in | +| `folderId` | string | No | The ID of the folder to create the board in | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `board` | json | The created board | +| ↳ `id` | string | Board ID | +| ↳ `name` | string | Board name | +| ↳ `description` | string | Board description | +| ↳ `state` | string | Board state | +| ↳ `boardKind` | string | Board kind \(public, private, share\) | +| ↳ `itemsCount` | number | Number of items | +| ↳ `url` | string | Board URL | +| ↳ `updatedAt` | string | Last updated timestamp | + +### `monday_create_column` + +Create a new column on a Monday.com board + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `boardId` | string | Yes | The ID of the board to create the column on | +| `columnTitle` | string | Yes | The title of the new column | +| `columnType` | string | Yes | The column type \(e.g., status, text, numbers, date, people, dropdown\) | +| `columnDescription` | string | No | The column description | +| `columnDefaults` | string | No | JSON string of default settings for the column \(e.g., status labels\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `column` | json | The created column | +| ↳ `id` | string | Column ID | +| ↳ `title` | string | Column title | +| ↳ `type` | string | Column type | + ## Triggers diff --git a/apps/docs/content/docs/en/integrations/sendblue.mdx b/apps/docs/content/docs/en/integrations/sendblue.mdx index ca03c545710..d45590a7b31 100644 --- a/apps/docs/content/docs/en/integrations/sendblue.mdx +++ b/apps/docs/content/docs/en/integrations/sendblue.mdx @@ -55,6 +55,7 @@ Send an iMessage or SMS to a single recipient via Sendblue. | `content` | string | No | Message text content. Either content or media_url must be provided. | | `media_url` | string | No | URL of a media file to send. Either content or media_url must be provided. | | `send_style` | string | No | iMessage expressive style \(e.g., celebration, fireworks, lasers, confetti, balloons, invisible, slam\). | +| `seat_id` | string | No | Seat \(user\) the message is attributed to. Accepts the seat UUID or Firebase Auth subject. | | `status_callback` | string | No | Webhook URL that Sendblue will POST message status updates to. | #### Output @@ -85,11 +86,12 @@ Send an iMessage or SMS to a group of recipients via Sendblue. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `numbers` | array | Yes | Recipient phone numbers in E.164 format \(e.g., \["+19998887777", "+13334445555"\]\) | +| `numbers` | array | No | Recipient phone numbers in E.164 format \(e.g., \["+19998887777", "+13334445555"\]\). Optional when sending to an existing group via group_id. | | `from_number` | string | Yes | One of your registered Sendblue phone numbers to send from, in E.164 format \(e.g., +18887776666\) | | `content` | string | No | Message text content. Either content or media_url must be provided. | | `media_url` | string | No | URL of a media file to send. Either content or media_url must be provided. | | `send_style` | string | No | iMessage expressive style \(e.g., celebration, fireworks, lasers, confetti, balloons, invisible, slam\). | +| `seat_id` | string | No | Seat \(user\) the message is attributed to. Accepts the seat UUID or Firebase Auth subject. | | `group_id` | string | No | Unique identifier of an existing group to send to. Omit to start a new group. | | `status_callback` | string | No | Webhook URL that Sendblue will POST message status updates to. | @@ -142,6 +144,8 @@ Display a typing indicator to a recipient (not supported in group chats). | --------- | ---- | -------- | ----------- | | `number` | string | Yes | Recipient's phone number in E.164 format \(e.g., +19998887777\) | | `from_number` | string | No | Your Sendblue line number to send from, in E.164 format. | +| `state` | string | No | "start" \(default\) shows the indicator; "stop" ends an active indicator before max_duration_ms expires. | +| `max_duration_ms` | number | No | How long \(ms\) the indicator stays visible before auto-stopping. Defaults to 60000. Must be between 1 and 300000. | #### Output @@ -226,7 +230,7 @@ Trigger when an inbound iMessage or SMS is received in Sendblue | `was_downgraded` | boolean | True if the recipient lacks iMessage support | | `plan` | string | Account plan type | | `message_type` | string | Message category \(e.g., message, group\) | -| `group_id` | string | Group identifier, empty for non-group messages | +| `group_id` | string | Group identifier, null for non-group messages | | `participants` | array | Participant phone numbers for group messages | | `send_style` | string | Expressive style if applied | | `opted_out` | boolean | True if the recipient has opted out | @@ -266,7 +270,7 @@ Trigger when an outbound message status changes (SENT, DELIVERED, ERROR) in Send | `was_downgraded` | boolean | True if the recipient lacks iMessage support | | `plan` | string | Account plan type | | `message_type` | string | Message category \(e.g., message, group\) | -| `group_id` | string | Group identifier, empty for non-group messages | +| `group_id` | string | Group identifier, null for non-group messages | | `participants` | array | Participant phone numbers for group messages | | `send_style` | string | Expressive style if applied | | `opted_out` | boolean | True if the recipient has opted out | diff --git a/apps/docs/content/docs/en/integrations/slack.mdx b/apps/docs/content/docs/en/integrations/slack.mdx index a01cafe3b3b..c90a179c1b9 100644 --- a/apps/docs/content/docs/en/integrations/slack.mdx +++ b/apps/docs/content/docs/en/integrations/slack.mdx @@ -1686,6 +1686,186 @@ Publish a static view to a user's Home tab in Slack. Used to create or update th | ↳ `app_id` | string | Application identifier | | ↳ `bot_id` | string | Bot identifier | +### `slack_schedule_message` + +Schedule a message to be sent to a Slack channel or DM at a future time. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `channel` | string | Yes | Channel, private group, or DM to receive the message \(e.g., C1234567890\) | +| `postAt` | number | Yes | Unix timestamp \(seconds\) representing the future time the message should post | +| `text` | string | No | Message text to send \(supports Slack mrkdwn formatting\) | +| `blocks` | json | No | Block Kit layout blocks as a JSON array. When provided, text becomes the fallback notification text. | +| `threadTs` | string | No | Thread timestamp to reply to \(creates a scheduled thread reply\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `scheduledMessageId` | string | Identifier of the scheduled message \(used to delete it before it posts\) | +| `postAt` | number | Unix timestamp when the message will post | +| `channel` | string | Channel ID where the message is scheduled | +| `message` | object | The scheduled message object returned by Slack | + +### `slack_list_scheduled_messages` + +List pending scheduled messages in a Slack workspace, optionally filtered by channel. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `channel` | string | No | Optional channel ID to filter scheduled messages \(e.g., C1234567890\) | +| `limit` | number | No | Maximum number of scheduled messages to return | +| `cursor` | string | No | Pagination cursor \(next_cursor\) from a previous response | +| `oldest` | string | No | Unix timestamp of the oldest scheduled message to include | +| `latest` | string | No | Unix timestamp of the latest scheduled message to include | +| `teamId` | string | No | Encoded team ID \(required only with org-level tokens\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `scheduledMessages` | array | Array of pending scheduled message objects | +| ↳ `id` | string | Scheduled message ID | +| ↳ `channel_id` | string | Channel the message is scheduled for | +| ↳ `post_at` | number | Unix timestamp when the message will post | +| ↳ `date_created` | number | Unix timestamp when the schedule was created | +| ↳ `text` | string | Scheduled message text | +| `nextCursor` | string | Cursor for the next page \(null when there are no more pages\) | + +### `slack_delete_scheduled_message` + +Delete a pending scheduled message before it posts to Slack. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `channel` | string | Yes | Channel ID where the scheduled message is queued \(e.g., C1234567890\) | +| `scheduledMessageId` | string | Yes | Scheduled message ID from chat.scheduleMessage \(e.g., Q1234ABCD\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ok` | boolean | Whether the scheduled message was deleted successfully | + +### `slack_archive_conversation` + +Archive a Slack channel so it is closed to new activity. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `channel` | string | Yes | ID of the channel to archive \(e.g., C1234567890\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ok` | boolean | Whether the conversation was archived successfully | + +### `slack_rename_conversation` + +Rename an existing Slack channel. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `channel` | string | Yes | ID of the channel to rename \(e.g., C1234567890\) | +| `name` | string | Yes | New channel name \(lowercase letters, numbers, hyphens, underscores only; max 80 characters\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `channelInfo` | object | The channel object after renaming | +| ↳ `id` | string | Channel ID \(e.g., C1234567890\) | +| ↳ `name` | string | Channel name without # prefix | +| ↳ `is_channel` | boolean | Whether this is a channel | +| ↳ `is_private` | boolean | Whether channel is private | +| ↳ `is_archived` | boolean | Whether channel is archived | +| ↳ `is_general` | boolean | Whether this is the general channel | +| ↳ `is_member` | boolean | Whether the bot/user is a member | +| ↳ `is_shared` | boolean | Whether channel is shared across workspaces | +| ↳ `is_ext_shared` | boolean | Whether channel is externally shared | +| ↳ `is_org_shared` | boolean | Whether channel is org-wide shared | +| ↳ `num_members` | number | Number of members in the channel | +| ↳ `topic` | string | Channel topic | +| ↳ `purpose` | string | Channel purpose/description | +| ↳ `created` | number | Unix timestamp when channel was created | +| ↳ `creator` | string | User ID of channel creator | +| ↳ `updated` | number | Unix timestamp of last update | + +### `slack_set_conversation_topic` + +Set the topic for a Slack channel (max 250 characters). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `channel` | string | Yes | ID of the channel to update \(e.g., C1234567890\) | +| `topic` | string | Yes | New topic text \(max 250 characters; no formatting or linkification\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `channelInfo` | object | The channel object after updating the topic | +| ↳ `id` | string | Channel ID \(e.g., C1234567890\) | +| ↳ `name` | string | Channel name without # prefix | +| ↳ `is_channel` | boolean | Whether this is a channel | +| ↳ `is_private` | boolean | Whether channel is private | +| ↳ `is_archived` | boolean | Whether channel is archived | +| ↳ `is_general` | boolean | Whether this is the general channel | +| ↳ `is_member` | boolean | Whether the bot/user is a member | +| ↳ `is_shared` | boolean | Whether channel is shared across workspaces | +| ↳ `is_ext_shared` | boolean | Whether channel is externally shared | +| ↳ `is_org_shared` | boolean | Whether channel is org-wide shared | +| ↳ `num_members` | number | Number of members in the channel | +| ↳ `topic` | string | Channel topic | +| ↳ `purpose` | string | Channel purpose/description | +| ↳ `created` | number | Unix timestamp when channel was created | +| ↳ `creator` | string | User ID of channel creator | +| ↳ `updated` | number | Unix timestamp of last update | + +### `slack_set_conversation_purpose` + +Set the purpose (description) for a Slack channel (max 250 characters). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `authMethod` | string | No | Authentication method: oauth or bot_token | +| `botToken` | string | No | Bot token for Custom Bot | +| `channel` | string | Yes | ID of the channel to update \(e.g., C1234567890\) | +| `purpose` | string | Yes | New purpose/description text \(max 250 characters\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `purpose` | string | The purpose/description that was set on the channel | + ## Triggers @@ -1734,6 +1914,9 @@ Trigger workflow from Slack events like mentions, messages, and reactions | ↳ `callback_id` | string | Callback ID of the shortcut or view. Present for shortcuts and modal submissions | | ↳ `api_app_id` | string | Slack app ID. Present for interactivity and slash commands | | ↳ `message_ts` | string | Timestamp of the message the interaction originated from. Present for block_actions | +| ↳ `view` | json | Full Slack view object for modal interactions: state.values \(submitted input values\), private_metadata, id, callback_id, and hash. Present for view_submission/view_closed; null otherwise | +| ↳ `message` | json | Full source message object the interaction came from, including its blocks and text. Present for block_actions on a message; null otherwise | +| ↳ `state` | json | Current values of all stateful elements in the surface \(state.values\) at the time of a block action — e.g. inputs read on a button click. Present for block_actions; null otherwise | | ↳ `hasFiles` | boolean | Whether the message has file attachments | | ↳ `files` | file[] | File attachments downloaded from the message \(if includeFiles is enabled and bot token is provided\) | diff --git a/apps/docs/content/docs/en/integrations/trello.mdx b/apps/docs/content/docs/en/integrations/trello.mdx index 6354aa115ad..86cdef85b99 100644 --- a/apps/docs/content/docs/en/integrations/trello.mdx +++ b/apps/docs/content/docs/en/integrations/trello.mdx @@ -251,4 +251,161 @@ Add a comment to a Trello card | ↳ `id` | string | List ID | | ↳ `name` | string | List name | +### `trello_create_board` + +Create a new Trello board + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `name` | string | Yes | Name of the board | +| `desc` | string | No | Description of the board | +| `idOrganization` | string | No | ID or name of the workspace/organization the board belongs to | +| `defaultLists` | boolean | No | Whether to create the default lists \(To Do, Doing, Done\) on the new board | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `board` | json | Created board \(id, name, desc, url, closed, idOrganization\) | +| ↳ `id` | string | Board ID | +| ↳ `name` | string | Board name | +| ↳ `desc` | string | Board description | +| ↳ `url` | string | Full board URL | +| ↳ `closed` | boolean | Whether the board is closed | +| ↳ `idOrganization` | string | ID of the workspace/organization the board belongs to | + +### `trello_get_board` + +Retrieve a single Trello board by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `boardId` | string | Yes | Trello board ID \(24-character hex string\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `board` | json | Board \(id, name, desc, url, closed, idOrganization\) | +| ↳ `id` | string | Board ID | +| ↳ `name` | string | Board name | +| ↳ `desc` | string | Board description | +| ↳ `url` | string | Full board URL | +| ↳ `closed` | boolean | Whether the board is closed | +| ↳ `idOrganization` | string | ID of the workspace/organization the board belongs to | + +### `trello_create_list` + +Create a new list on a Trello board + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `boardId` | string | Yes | Trello board ID the list belongs to \(24-character hex string\) | +| `name` | string | Yes | Name of the list | +| `pos` | string | No | Position of the list \(top, bottom, or positive float\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `list` | json | Created list \(id, name, closed, pos, idBoard\) | +| ↳ `id` | string | List ID | +| ↳ `name` | string | List name | +| ↳ `closed` | boolean | Whether the list is archived | +| ↳ `pos` | number | List position on the board | +| ↳ `idBoard` | string | Board ID containing the list | + +### `trello_get_card` + +Retrieve a single Trello card by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `cardId` | string | Yes | Trello card ID \(24-character hex string\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `card` | json | Card \(id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete\) | +| ↳ `id` | string | Card ID | +| ↳ `name` | string | Card name | +| ↳ `desc` | string | Card description | +| ↳ `url` | string | Full card URL | +| ↳ `idBoard` | string | Board ID containing the card | +| ↳ `idList` | string | List ID containing the card | +| ↳ `closed` | boolean | Whether the card is archived | +| ↳ `labelIds` | array | Label IDs applied to the card | +| ↳ `labels` | array | Labels applied to the card | +| ↳ `id` | string | Label ID | +| ↳ `name` | string | Label name | +| ↳ `color` | string | Label color | +| ↳ `due` | string | Card due date in ISO 8601 format | +| ↳ `dueComplete` | boolean | Whether the due date is complete | + +### `trello_add_checklist` + +Add a checklist to a Trello card + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `cardId` | string | Yes | Trello card ID to add the checklist to \(24-character hex string\) | +| `name` | string | Yes | Name of the checklist | +| `pos` | string | No | Position of the checklist \(top, bottom, or positive float\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `checklist` | json | Created checklist \(id, name, idCard, idBoard, pos\) | +| ↳ `id` | string | Checklist ID | +| ↳ `name` | string | Checklist name | +| ↳ `idCard` | string | Card ID containing the checklist | +| ↳ `idBoard` | string | Board ID containing the checklist | +| ↳ `pos` | number | Checklist position on the card | + +### `trello_add_label` + +Attach an existing label to a Trello card + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `cardId` | string | Yes | Trello card ID to attach the label to \(24-character hex string\) | +| `labelId` | string | Yes | ID of the label to attach \(24-character hex string\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `labelIds` | array | Label IDs now applied to the card | + +### `trello_add_member` + +Assign a member to a Trello card + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `cardId` | string | Yes | Trello card ID to assign the member to \(24-character hex string\) | +| `memberId` | string | Yes | ID of the member to assign \(24-character hex string\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `memberIds` | array | Member IDs now assigned to the card | + diff --git a/apps/realtime/package.json b/apps/realtime/package.json index ce9bbdaec52..83ca341b44d 100644 --- a/apps/realtime/package.json +++ b/apps/realtime/package.json @@ -9,8 +9,8 @@ "node": ">=20.0.0" }, "scripts": { - "dev": "DB_APP_NAME=sim-realtime bun --watch src/index.ts", - "start": "DB_APP_NAME=sim-realtime bun src/index.ts", + "dev": "SIM_DB_ROLE=realtime DB_APP_NAME=sim-realtime bun --watch src/index.ts", + "start": "SIM_DB_ROLE=realtime DB_APP_NAME=sim-realtime bun src/index.ts", "type-check": "tsc --noEmit", "lint": "biome check --write --unsafe .", "lint:check": "biome check .", diff --git a/apps/realtime/src/bootstrap.ts b/apps/realtime/src/bootstrap.ts index 1eaea2b80c2..bf0840fb05f 100644 --- a/apps/realtime/src/bootstrap.ts +++ b/apps/realtime/src/bootstrap.ts @@ -7,10 +7,14 @@ import { loadRuntimeSecrets } from '@sim/runtime-secrets' await loadRuntimeSecrets() /** - * Label every Postgres connection this process opens as `sim-realtime` — both - * the realtime `socketDb` pool and the shared `@sim/db` client used by handlers, - * preflight, and permissions. Set before importing `@/index` so it lands before - * `@sim/db` reads it at module-eval time. `??=` respects an explicit override. + * Pin this process to the `realtime` DB role — covering both the realtime + * `socketDb` pool and the shared `@sim/db` client used by handlers, preflight, + * and permissions. The role drives the pool-size profile, `application_name`, + * and the role-keyed connection URL, so every realtime connection resolves + * consistently (without it the shared client would default to `web`). Set + * before importing `@/index` so it lands before `@sim/db` reads it at + * module-eval time; `??=` respects an explicit override. */ +process.env.SIM_DB_ROLE ??= 'realtime' process.env.DB_APP_NAME ??= 'sim-realtime' await import('@/index') diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts index 9fbda3fe27d..4c2735a2087 100644 --- a/apps/realtime/src/database/operations.ts +++ b/apps/realtime/src/database/operations.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import * as schema from '@sim/db' import { instrumentPoolClient, + resolveDbUrl, workflow, workflowBlocks, workflowEdges, @@ -31,7 +32,10 @@ import { env } from '@/env' const logger = createLogger('SocketDatabase') -const connectionString = env.DATABASE_URL +// Both realtime pools (this socketDb + the shared @sim/db pool) resolve the +// realtime-keyed URL when set, falling back to the shared DATABASE_URL. +const connectionString = + resolveDbUrl('DATABASE_URL', process.env.SIM_DB_ROLE ?? 'realtime') ?? env.DATABASE_URL // Realtime process footprint = this socketDb pool + the shared @sim/db pool. const socketDb = drizzle( instrumentPoolClient( diff --git a/apps/realtime/src/env.ts b/apps/realtime/src/env.ts index fb1f37a391a..5afdf3a20f3 100644 --- a/apps/realtime/src/env.ts +++ b/apps/realtime/src/env.ts @@ -3,6 +3,8 @@ import { z } from 'zod' const EnvSchema = z.object({ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), DATABASE_URL: z.string().url(), + DATABASE_URL_REALTIME: z.string().url().optional(), + DATABASE_REPLICA_URL_REALTIME: z.string().url().optional(), REDIS_URL: z.preprocess( (value) => (typeof value === 'string' && value.trim() === '' ? undefined : value), z.string().url().optional() diff --git a/apps/sim/app/(auth)/auth-layout-client.tsx b/apps/sim/app/(auth)/auth-layout-client.tsx index 89aeb3a89e7..82dbf7ef7cc 100644 --- a/apps/sim/app/(auth)/auth-layout-client.tsx +++ b/apps/sim/app/(auth)/auth-layout-client.tsx @@ -1,27 +1,5 @@ -'use client' - -import { useEffect } from 'react' -import AuthBackground from '@/app/(auth)/components/auth-background' -import Navbar from '@/app/(landing)/components/navbar/navbar' +import { AuthShell } from '@/app/(auth)/components' export default function AuthLayoutClient({ children }: { children: React.ReactNode }) { - useEffect(() => { - document.documentElement.classList.add('dark') - return () => { - document.documentElement.classList.remove('dark') - } - }, []) - - return ( - -
      -
      - -
      -
      -
      {children}
      -
      -
      -
      - ) + return {children} } diff --git a/apps/sim/app/(auth)/components/auth-divider.tsx b/apps/sim/app/(auth)/components/auth-divider.tsx new file mode 100644 index 00000000000..f995280a4f0 --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-divider.tsx @@ -0,0 +1,21 @@ +interface AuthDividerProps { + label: string +} + +/** + * The "Or continue with" rule separating the email/password form from the + * social/SSO options. Light tokens only: a `--border` hairline with the label + * knocked out over the `--bg` canvas in `--text-muted`. + */ +export function AuthDivider({ label }: AuthDividerProps) { + return ( +
      +
      +
      +
      +
      + {label} +
      +
      + ) +} diff --git a/apps/sim/app/(auth)/components/auth-field.tsx b/apps/sim/app/(auth)/components/auth-field.tsx new file mode 100644 index 00000000000..e6b53d0edae --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-field.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react' +import { Label } from '@sim/emcn' + +interface AuthFieldProps { + /** Matches the `id` set on the control rendered as {@link children}. */ + htmlFor: string + label: string + /** Validation messages to render beneath the control. */ + errors?: string[] + /** Optional right-aligned action shown next to the label (e.g. Forgot password). */ + action?: ReactNode + /** The field control — a {@link ChipInput}/{@link PasswordInput}. */ + children: ReactNode +} + +/** + * A labeled form field row: canonical {@link Label}, an optional inline label + * action, the control, and a validation-message list in the error token. The + * control drives its own invalid chrome through its `error` prop — this wrapper + * only owns the label row and the message list, so every auth field reads and + * spaces identically. + */ +export function AuthField({ htmlFor, label, errors, action, children }: AuthFieldProps) { + const hasErrors = Boolean(errors && errors.length > 0) + return ( +
      +
      + + {action} +
      + {children} + {hasErrors && ( +
      + {errors?.map((error) => ( +

      {error}

      + ))} +
      + )} +
      + ) +} diff --git a/apps/sim/app/(auth)/components/auth-form-message.tsx b/apps/sim/app/(auth)/components/auth-form-message.tsx new file mode 100644 index 00000000000..11e4930968c --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-form-message.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react' +import { cn } from '@sim/emcn' + +interface AuthFormMessageProps { + type: 'error' | 'success' + align?: 'left' | 'center' + children: ReactNode +} + +/** + * Form-level status copy (not tied to a single field) in the canonical tokens: + * errors in `--text-error`, success in `--brand-accent`. One place owns the + * auth message chrome so success/error states never drift to ad-hoc hex or + * `text-red-*`/`#4CAF50` colors. + */ +export function AuthFormMessage({ type, align = 'left', children }: AuthFormMessageProps) { + return ( +
      + {children} +
      + ) +} diff --git a/apps/sim/app/(auth)/components/auth-header.tsx b/apps/sim/app/(auth)/components/auth-header.tsx new file mode 100644 index 00000000000..96cfceceda5 --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-header.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react' + +interface AuthHeaderProps { + title: string + description: ReactNode +} + +/** + * The centered heading + subcopy block shared by every auth page and status + * page. One source of truth for auth heading typography (light tokens, normal + * weight, no bespoke tracking — aligned with the landing scale, sized down for + * the single-column form). + */ +export function AuthHeader({ title, description }: AuthHeaderProps) { + return ( +
      +

      {title}

      +

      {description}

      +
      + ) +} diff --git a/apps/sim/app/(auth)/components/auth-input.tsx b/apps/sim/app/(auth)/components/auth-input.tsx new file mode 100644 index 00000000000..396bae64cf8 --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-input.tsx @@ -0,0 +1,20 @@ +'use client' + +import * as React from 'react' +import { ChipInput, type ChipInputProps, cn } from '@sim/emcn' +import { AUTH_CONTROL_HEIGHT } from '@/app/(auth)/components/constants' + +/** + * The auth text field — a {@link ChipInput} raised to the auth control height + * ({@link AUTH_CONTROL_HEIGHT}) so every labeled field on the auth and invite + * surfaces shares one slightly-taller geometry. All chip props pass through + * (`error`, `endAdornment`, `icon`, …); only the height is owned here, and a + * caller's `className` (layout only) still composes on top. + */ +export const AuthInput = React.forwardRef( + ({ className, ...props }, ref) => ( + + ) +) + +AuthInput.displayName = 'AuthInput' diff --git a/apps/sim/app/(auth)/components/auth-legal-footer.tsx b/apps/sim/app/(auth)/components/auth-legal-footer.tsx new file mode 100644 index 00000000000..a5a80e698f1 --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-legal-footer.tsx @@ -0,0 +1,26 @@ +import { AuthTextLink } from '@/app/(auth)/components/auth-text-link' + +interface AuthLegalFooterProps { + /** The gerund describing the consent action, e.g. "signing in". */ + action: string +} + +/** + * The "By {action}, you agree to our Terms / Privacy" fine print shared by the + * login and signup pages. Restyled to muted light tokens with the legal links + * routed through {@link AuthTextLink}, so the consent copy has one source. + */ +export function AuthLegalFooter({ action }: AuthLegalFooterProps) { + return ( +

      + By {action}, you agree to our{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + +

      + ) +} diff --git a/apps/sim/app/(auth)/components/auth-nav-prompt.tsx b/apps/sim/app/(auth)/components/auth-nav-prompt.tsx new file mode 100644 index 00000000000..d479a273b4a --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-nav-prompt.tsx @@ -0,0 +1,27 @@ +import { ChipLink } from '@sim/emcn' + +interface AuthNavPromptProps { + /** Muted lead text before the link (e.g. "Don't have an account?"). */ + prompt?: string + href: string + linkLabel: string + /** Side effect to run before navigation (e.g. clearing verification state). */ + onNavigate?: () => void +} + +/** + * The cross-page navigation row (Sign up / Sign in / Back to login) — an + * optional muted prompt followed by an outline {@link ChipLink} pill, matching + * the landing's secondary chip CTAs. Centralizes the auth nav affordance so the + * pill chrome is described by props, never restyled per page. + */ +export function AuthNavPrompt({ prompt, href, linkLabel, onNavigate }: AuthNavPromptProps) { + return ( +
      + {prompt && {prompt}} + + {linkLabel} + +
      + ) +} diff --git a/apps/sim/app/(auth)/components/auth-shell.tsx b/apps/sim/app/(auth)/components/auth-shell.tsx new file mode 100644 index 00000000000..91f4d324d6c --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-shell.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react' +import Link from 'next/link' +import { LogoMark, SimWordmark } from '@/app/(landing)/components/navbar/components' + +interface AuthShellProps { + /** Centered content column (the form, status copy, etc.). */ + children: ReactNode + /** Optional element pinned to the bottom of the shell (e.g. the support footer). */ + footer?: ReactNode +} + +/** + * The light auth/status page frame — the single source of truth for the shell + * every auth page and standalone status page wears. + * + * Mirrors the landing chrome: it pins the `light` token layer (so the platform's + * light-mode `var(--*)` tokens resolve regardless of the visitor's theme), uses + * the canvas/`--text-primary` surface, and renders a logo-only header that reuses + * the landing {@link LogoMark} + {@link SimWordmark} at the same nav gutters. The + * single content column is centered and capped for a calm single-form layout. + */ +export function AuthShell({ children, footer }: AuthShellProps) { + return ( +
      +
      + +
      +
      +
      {children}
      +
      + {footer} +
      + ) +} diff --git a/apps/sim/app/(auth)/components/auth-submit-button.tsx b/apps/sim/app/(auth)/components/auth-submit-button.tsx new file mode 100644 index 00000000000..c08cb3249a9 --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-submit-button.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from 'react' +import { Chip, Loader } from '@sim/emcn' +import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' + +interface AuthSubmitButtonProps { + children: ReactNode + /** Label shown beside the spinner while the action is in flight. */ + loadingLabel: string + loading?: boolean + disabled?: boolean + type?: 'submit' | 'button' + onClick?: () => void +} + +/** + * The canonical full-width primary auth action — a `primary`-variant {@link Chip} + * with the shared in-flight spinner. Replaces the legacy dark + * `AUTH_SUBMIT_BTN` class string for every in-scope auth submit (login, signup, + * verify, reset), so the primary CTA chrome lives in exactly one place. + */ +export function AuthSubmitButton({ + children, + loadingLabel, + loading = false, + disabled = false, + type = 'submit', + onClick, +}: AuthSubmitButtonProps) { + return ( + + {loading ? ( + + + {loadingLabel} + + ) : ( + children + )} + + ) +} diff --git a/apps/sim/app/(auth)/components/auth-text-link.tsx b/apps/sim/app/(auth)/components/auth-text-link.tsx new file mode 100644 index 00000000000..71a7d0f995d --- /dev/null +++ b/apps/sim/app/(auth)/components/auth-text-link.tsx @@ -0,0 +1,58 @@ +'use client' + +import type { ReactNode } from 'react' +import { cn } from '@sim/emcn' +import Link from 'next/link' + +const AUTH_TEXT_LINK_CLASS = + 'text-[var(--text-secondary)] underline-offset-4 transition-colors hover:text-[var(--text-primary)] hover:underline disabled:cursor-not-allowed disabled:opacity-50' + +interface AuthTextLinkProps { + children: ReactNode + /** Renders a navigation link when set; otherwise renders an action button. */ + href?: string + onClick?: () => void + /** Opens the link in a new tab with safe `rel` (e.g. Terms/Privacy). */ + external?: boolean + disabled?: boolean + className?: string +} + +/** + * The canonical inline text affordance for the auth pages — forgot-password, + * resend, and the legal links. Renders a {@link Link} when `href` is set and a + * ` + ) +} diff --git a/apps/sim/app/(auth)/components/constants.ts b/apps/sim/app/(auth)/components/constants.ts new file mode 100644 index 00000000000..130b84711b6 --- /dev/null +++ b/apps/sim/app/(auth)/components/constants.ts @@ -0,0 +1,17 @@ +/** + * Auth and invite surfaces use a slightly taller control than the 30px chip + * default, matching the landing `HeroCta` field family (the landing's own + * auth-adjacent CTA renders taller fields than in-app chips). Applied as the + * single source of truth for every auth field and button height so the inputs, + * submit, social, SSO, and invite action buttons stay on one line. + */ +export const AUTH_CONTROL_HEIGHT = 'h-9' + +/** + * Shared layout for full-width auth/invite chip buttons (submit, social, SSO, + * invite actions). `[&>span]:flex-none` collapses the chip's stretching label + * span — which carries `flex-1` — so the icon + label cluster truly centers + * under `justify-center` (the landing `HeroCta` idiom). Height-only inputs use + * {@link AUTH_CONTROL_HEIGHT}; buttons compose this on top of it. + */ +export const AUTH_BUTTON_CLASS = `${AUTH_CONTROL_HEIGHT} w-full justify-center [&>span]:flex-none` diff --git a/apps/sim/app/(auth)/components/index.ts b/apps/sim/app/(auth)/components/index.ts new file mode 100644 index 00000000000..f559ddd54b8 --- /dev/null +++ b/apps/sim/app/(auth)/components/index.ts @@ -0,0 +1,14 @@ +export { AuthDivider } from './auth-divider' +export { AuthField } from './auth-field' +export { AuthFormMessage } from './auth-form-message' +export { AuthHeader } from './auth-header' +export { AuthInput } from './auth-input' +export { AuthLegalFooter } from './auth-legal-footer' +export { AuthNavPrompt } from './auth-nav-prompt' +export { AuthShell } from './auth-shell' +export { AuthSubmitButton } from './auth-submit-button' +export { AuthTextLink } from './auth-text-link' +export { PasswordInput } from './password-input' +export { SocialLoginButtons } from './social-login-buttons' +export { SSOLoginButton } from './sso-login-button' +export { SupportFooter } from './support-footer' diff --git a/apps/sim/app/(auth)/components/password-input.tsx b/apps/sim/app/(auth)/components/password-input.tsx new file mode 100644 index 00000000000..d602b24e25e --- /dev/null +++ b/apps/sim/app/(auth)/components/password-input.tsx @@ -0,0 +1,37 @@ +'use client' + +import { useState } from 'react' +import { ChipInput, type ChipInputProps, cn } from '@sim/emcn' +import { Eye, EyeOff } from 'lucide-react' +import { AUTH_CONTROL_HEIGHT } from '@/app/(auth)/components/constants' + +type PasswordInputProps = Omit + +/** + * A {@link ChipInput} that owns the password reveal toggle — the eye button is + * driven through the canonical `endAdornment` slot and the field's invalid state + * through the `error` prop, so no consumer hand-rolls the relative wrapper + + * absolutely positioned button the auth forms previously duplicated four times. + */ +export function PasswordInput({ error, className, ...props }: PasswordInputProps) { + const [visible, setVisible] = useState(false) + + return ( + setVisible((v) => !v)} + aria-label={visible ? 'Hide password' : 'Show password'} + className='flex shrink-0 text-[var(--text-icon)] transition-colors hover:text-[var(--text-primary)]' + > + {visible ? : } + + } + /> + ) +} diff --git a/apps/sim/app/(auth)/components/social-login-buttons.tsx b/apps/sim/app/(auth)/components/social-login-buttons.tsx index feaf4889940..f315e52da97 100644 --- a/apps/sim/app/(auth)/components/social-login-buttons.tsx +++ b/apps/sim/app/(auth)/components/social-login-buttons.tsx @@ -1,9 +1,14 @@ 'use client' import { type ReactNode, useState } from 'react' -import { Button } from '@sim/emcn' +import { Chip, cn } from '@sim/emcn' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons' import { client } from '@/lib/auth/auth-client' +import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' + +const logger = createLogger('SocialLoginButtons') interface SocialLoginButtonsProps { githubAvailable: boolean @@ -32,18 +37,8 @@ export function SocialLoginButtons({ setIsGithubLoading(true) try { await client.signIn.social({ provider: 'github', callbackURL }) - } catch (err: any) { - let errorMessage = 'Failed to sign in with GitHub' - - if (err.message?.includes('account exists')) { - errorMessage = 'An account with this email already exists. Please sign in instead.' - } else if (err.message?.includes('cancelled')) { - errorMessage = 'GitHub sign in was cancelled. Please try again.' - } else if (err.message?.includes('network')) { - errorMessage = 'Network error. Please check your connection and try again.' - } else if (err.message?.includes('rate limit')) { - errorMessage = 'Too many attempts. Please try again later.' - } + } catch (err) { + logger.error('GitHub sign-in failed', { error: getErrorMessage(err) }) } finally { setIsGithubLoading(false) } @@ -55,18 +50,8 @@ export function SocialLoginButtons({ setIsGoogleLoading(true) try { await client.signIn.social({ provider: 'google', callbackURL }) - } catch (err: any) { - let errorMessage = 'Failed to sign in with Google' - - if (err.message?.includes('account exists')) { - errorMessage = 'An account with this email already exists. Please sign in instead.' - } else if (err.message?.includes('cancelled')) { - errorMessage = 'Google sign in was cancelled. Please try again.' - } else if (err.message?.includes('network')) { - errorMessage = 'Network error. Please check your connection and try again.' - } else if (err.message?.includes('rate limit')) { - errorMessage = 'Too many attempts. Please try again later.' - } + } catch (err) { + logger.error('Google sign-in failed', { error: getErrorMessage(err) }) } finally { setIsGoogleLoading(false) } @@ -78,57 +63,50 @@ export function SocialLoginButtons({ setIsMicrosoftLoading(true) try { await client.signIn.social({ provider: 'microsoft', callbackURL }) - } catch (err: any) { - let errorMessage = 'Failed to sign in with Microsoft' - - if (err.message?.includes('account exists')) { - errorMessage = 'An account with this email already exists. Please sign in instead.' - } else if (err.message?.includes('cancelled')) { - errorMessage = 'Microsoft sign in was cancelled. Please try again.' - } else if (err.message?.includes('network')) { - errorMessage = 'Network error. Please check your connection and try again.' - } else if (err.message?.includes('rate limit')) { - errorMessage = 'Too many attempts. Please try again later.' - } + } catch (err) { + logger.error('Microsoft sign-in failed', { error: getErrorMessage(err) }) } finally { setIsMicrosoftLoading(false) } } const githubButton = ( - + {isGithubLoading ? 'Connecting…' : 'GitHub'} + ) const googleButton = ( - + {isGoogleLoading ? 'Connecting…' : 'Google'} + ) const microsoftButton = ( - + {isMicrosoftLoading ? 'Connecting…' : 'Microsoft'} + ) const hasAnyOAuthProvider = githubAvailable || googleAvailable || microsoftAvailable @@ -138,7 +116,7 @@ export function SocialLoginButtons({ } return ( -
      +
      {googleAvailable && googleButton} {microsoftAvailable && microsoftButton} {githubAvailable && githubButton} diff --git a/apps/sim/app/(auth)/components/sso-login-button.tsx b/apps/sim/app/(auth)/components/sso-login-button.tsx index 851fd1bf993..ddca42526ca 100644 --- a/apps/sim/app/(auth)/components/sso-login-button.tsx +++ b/apps/sim/app/(auth)/components/sso-login-button.tsx @@ -1,8 +1,8 @@ 'use client' -import { Button, cn } from '@sim/emcn' +import { Chip, cn } from '@sim/emcn' import { useRouter } from 'next/navigation' import { getEnv, isTruthy } from '@/lib/core/config/env' -import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' +import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' interface SSOLoginButtonProps { callbackURL?: string @@ -26,18 +26,19 @@ export function SSOLoginButton({ router.push(ssoUrl) } - const outlineBtnClasses = cn( - 'w-full rounded-sm border-[var(--landing-border-strong)] py-1.5 text-sm' - ) - return ( - + ) } diff --git a/apps/sim/app/(auth)/components/status-page-layout.tsx b/apps/sim/app/(auth)/components/status-page-layout.tsx deleted file mode 100644 index b26def8d71a..00000000000 --- a/apps/sim/app/(auth)/components/status-page-layout.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import type { ReactNode } from 'react' -import AuthBackground from '@/app/(auth)/components/auth-background' -import Navbar from '@/app/(landing)/components/navbar/navbar' -import { SupportFooter } from './support-footer' - -export interface StatusPageLayoutProps { - title: string - description: string | ReactNode - children?: ReactNode - showSupportFooter?: boolean -} - -export function StatusPageLayout({ - title, - description, - children, - showSupportFooter = true, -}: StatusPageLayoutProps) { - return ( - -
      -
      - -
      -
      -
      -
      -
      -

      - {title} -

      -

      - {description} -

      -
      - {children &&
      {children}
      } -
      -
      -
      - {showSupportFooter && } -
      -
      - ) -} diff --git a/apps/sim/app/(auth)/components/support-footer.tsx b/apps/sim/app/(auth)/components/support-footer.tsx index 6dad3bd6ac1..29ea677ea1b 100644 --- a/apps/sim/app/(auth)/components/support-footer.tsx +++ b/apps/sim/app/(auth)/components/support-footer.tsx @@ -1,5 +1,6 @@ 'use client' +import { cn } from '@sim/emcn' import { useBrandConfig } from '@/ee/whitelabeling' export interface SupportFooterProps { @@ -11,12 +12,15 @@ export function SupportFooter({ position = 'fixed' }: SupportFooterProps) { return (
      Need help?{' '} Contact support diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index 8f102308bc0..d0f1fa59a29 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -8,15 +8,9 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, - cn, - Input, - Label, - Loader, } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { Eye, EyeOff } from 'lucide-react' -import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { requestJson } from '@/lib/api/client/request' import { forgetPasswordContract } from '@/lib/api/contracts' @@ -26,9 +20,20 @@ import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { captureClientEvent } from '@/lib/posthog/client' -import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' -import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons' -import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button' +import { + AuthDivider, + AuthField, + AuthFormMessage, + AuthHeader, + AuthInput, + AuthLegalFooter, + AuthNavPrompt, + AuthSubmitButton, + AuthTextLink, + PasswordInput, + SocialLoginButtons, + SSOLoginButton, +} from '@/app/(auth)/components' const logger = createLogger('LoginForm') @@ -89,11 +94,9 @@ export default function LoginPage({ const router = useRouter() const searchParams = useSearchParams() const [isLoading, setIsLoading] = useState(false) - const [showPassword, setShowPassword] = useState(false) const [password, setPassword] = useState('') const [passwordErrors, setPasswordErrors] = useState([]) const [showValidationError, setShowValidationError] = useState(false) - const [formError, setFormError] = useState(null) const callbackUrlParam = searchParams?.get('callbackUrl') const isValidCallbackUrl = callbackUrlParam ? validateCallbackUrl(callbackUrlParam) : false const invalidCallbackRef = useRef(false) @@ -175,7 +178,6 @@ export default function LoginPage({ const safeCallbackUrl = callbackUrl let errorHandled = false - setFormError(null) const result = await client.signIn.email( { email, @@ -343,150 +345,81 @@ export default function LoginPage({ const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO) const showDivider = (emailEnabled || showTopSSO) && showBottomSection + const emailFieldErrors = showEmailValidationError && emailErrors.length > 0 ? emailErrors : [] + const passwordFieldErrors = showValidationError && passwordErrors.length > 0 ? passwordErrors : [] + const canSubmit = email.trim().length > 0 && password.length > 0 + return ( <> -
      -

      - Sign in -

      -

      - Enter your details -

      -
      - - {/* SSO Login Button (primary top-only when it is the only method) */} - {showTopSSO && ( -
      - -
      - )} - - {/* Email/Password Form - show unless explicitly disabled */} - {!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && ( -
      -
      -
      -
      - -
      - 0 && - 'border-[var(--text-error)] focus:border-[var(--text-error)]' - )} - /> - {showEmailValidationError && emailErrors.length > 0 && ( -
      - {emailErrors.map((error) => ( -

      {error}

      - ))} -
      - )} -
      -
      -
      - - -
      -
      - + + + {showTopSSO && } + + {emailEnabled && ( + +
      + + 0} + /> + + setForgotPasswordOpen(true)} + className='text-caption' + > + Forgot password? + + } + > + 0 && - 'border-[var(--text-error)] focus:border-[var(--text-error)]' - )} + error={passwordFieldErrors.length > 0} /> - -
      - {showValidationError && passwordErrors.length > 0 && ( -
      - {passwordErrors.map((error) => ( -

      {error}

      - ))} -
      - )} +
      -
      - {resetSuccessMessage && ( -
      -

      {resetSuccessMessage}

      -
      - )} + {resetSuccessMessage && ( + +

      {resetSuccessMessage}

      +
      + )} - {formError && ( -
      -

      {formError}

      -
      - )} + + Sign in + + + )} - - - )} - - {/* Divider - show when we have multiple auth methods */} - {showDivider && ( -
      -
      -
      -
      -
      - - Or continue with - -
      -
      - )} - - {showBottomSection && ( -
      + {showDivider && } + + {showBottomSection && ( @@ -494,41 +427,17 @@ export default function LoginPage({ )} -
      - )} - - {/* Only show signup link if email/password signup is enabled */} - {!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && ( -
      - Don't have an account? - - Sign up - -
      - )} - -
      - By signing in, you agree to our{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - + linkLabel='Sign up' + /> + )} + +
      -
      -

      - Reset your password -

      -

      - Enter a new password for your account -

      -
      +
      + -
      - -
      + -
      - - Back to login - -
      - + +
      ) } diff --git a/apps/sim/app/(auth)/reset-password/reset-password-form.tsx b/apps/sim/app/(auth)/reset-password/reset-password-form.tsx index 301a804d849..f2825665766 100644 --- a/apps/sim/app/(auth)/reset-password/reset-password-form.tsx +++ b/apps/sim/app/(auth)/reset-password/reset-password-form.tsx @@ -1,81 +1,13 @@ 'use client' import { useState } from 'react' -import { cn, Input, Label, Loader } from '@sim/emcn' -import { Eye, EyeOff } from 'lucide-react' -import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' - -interface RequestResetFormProps { - email: string - onEmailChange: (email: string) => void - onSubmit: (email: string) => Promise - isSubmitting: boolean - statusType: 'success' | 'error' | null - statusMessage: string - className?: string -} - -export function RequestResetForm({ - email, - onEmailChange, - onSubmit, - isSubmitting, - statusType, - statusMessage, - className, -}: RequestResetFormProps) { - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - onSubmit(email) - } - - return ( -
      -
      -
      -
      - -
      - onEmailChange(e.target.value)} - placeholder='Enter your email' - type='email' - disabled={isSubmitting} - required - /> -

      - We'll send a password reset link to this email address. -

      -
      - - {/* Status message display */} - {statusType && statusMessage && ( -
      -

      {statusMessage}

      -
      - )} -
      - - -
      - ) -} +import { cn } from '@sim/emcn' +import { + AuthField, + AuthFormMessage, + AuthSubmitButton, + PasswordInput, +} from '@/app/(auth)/components' interface SetNewPasswordFormProps { token: string | null @@ -97,8 +29,6 @@ export function SetNewPasswordForm({ const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [validationMessages, setValidationMessages] = useState([]) - const [showPassword, setShowPassword] = useState(false) - const [showConfirmPassword, setShowConfirmPassword] = useState(false) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -142,102 +72,62 @@ export function SetNewPasswordForm({ onSubmit(password) } + const hasValidationErrors = validationMessages.length > 0 + return ( -
      -
      -
      -
      - -
      -
      - setPassword(e.target.value)} - required - placeholder='Enter new password' - className={cn( - 'pr-10', - validationMessages.length > 0 && 'border-red-500 focus:border-red-500' - )} - /> - -
      -
      -
      -
      - -
      -
      - setConfirmPassword(e.target.value)} - required - placeholder='Confirm new password' - className={cn( - 'pr-10', - validationMessages.length > 0 && 'border-red-500 focus:border-red-500' - )} - /> - -
      -
      - - {validationMessages.length > 0 && ( -
      + +
      + + setPassword(e.target.value)} + required + placeholder='Enter new password' + error={hasValidationErrors} + /> + + + setConfirmPassword(e.target.value)} + required + placeholder='Confirm new password' + error={hasValidationErrors} + /> + + + {hasValidationErrors && ( + {validationMessages.map((error) => (

      {error}

      ))} -
      + )} {statusType && statusMessage && ( -
      +

      {statusMessage}

      -
      + )}
      - + + Reset Password + ) } diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 62be1b98958..5553cfa6fe9 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -2,10 +2,7 @@ import { Suspense, useEffect, useMemo, useRef, useState } from 'react' import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' -import { cn, Input, Label, Loader } from '@sim/emcn' import { createLogger } from '@sim/logger' -import { Eye, EyeOff } from 'lucide-react' -import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { client, useSession } from '@/lib/auth/auth-client' @@ -13,9 +10,19 @@ import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env' import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { captureClientEvent, captureEvent } from '@/lib/posthog/client' -import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' -import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons' -import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button' +import { + AuthDivider, + AuthField, + AuthFormMessage, + AuthHeader, + AuthInput, + AuthLegalFooter, + AuthNavPrompt, + AuthSubmitButton, + PasswordInput, + SocialLoginButtons, + SSOLoginButton, +} from '@/app/(auth)/components' const logger = createLogger('SignupForm') @@ -95,7 +102,6 @@ function SignupFormContent({ useEffect(() => { captureClientEvent('signup_page_viewed', {}) }, []) - const [showPassword, setShowPassword] = useState(false) const [password, setPassword] = useState('') const [passwordErrors, setPasswordErrors] = useState([]) const [showValidationError, setShowValidationError] = useState(false) @@ -361,163 +367,66 @@ function SignupFormContent({ const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO) const showDivider = (emailEnabled || hasOnlySSO) && showBottomSection + const nameFieldErrors = showNameValidationError && nameErrors.length > 0 ? nameErrors : [] + const emailHasError = Boolean(emailError) || (showEmailValidationError && emailErrors.length > 0) + const emailFieldErrors = + showEmailValidationError && emailErrors.length > 0 + ? emailErrors + : emailError && !showEmailValidationError + ? [emailError] + : [] + const passwordFieldErrors = showValidationError && passwordErrors.length > 0 ? passwordErrors : [] + const canSubmit = name.trim().length > 0 && email.trim().length > 0 && password.length > 0 + return ( - <> -
      -

      - Create an account -

      -

      - Create an account or log in -

      -
      - - {hasOnlySSO && ( -
      - -
      - )} +
      + + + {hasOnlySSO && } {emailEnabled && ( -
      -
      -
      -
      - -
      -
      - 0 && - 'border-red-500 focus:border-red-500' - )} - /> -
      0 - ? 'grid-rows-[1fr]' - : 'grid-rows-[0fr]' - )} - aria-live={showNameValidationError && nameErrors.length > 0 ? 'polite' : 'off'} - > -
      -
      - {nameErrors.map((error) => ( -

      {error}

      - ))} -
      -
      -
      -
      -
      -
      -
      - -
      -
      - 0)) && - 'border-red-500 focus:border-red-500' - )} - /> -
      0) || - (emailError && !showEmailValidationError) - ? 'grid-rows-[1fr]' - : 'grid-rows-[0fr]' - )} - aria-live={ - (showEmailValidationError && emailErrors.length > 0) || - (emailError && !showEmailValidationError) - ? 'polite' - : 'off' - } - > -
      -
      - {showEmailValidationError && emailErrors.length > 0 ? ( - emailErrors.map((error) =>

      {error}

      ) - ) : emailError && !showEmailValidationError ? ( -

      {emailError}

      - ) : null} -
      -
      -
      -
      -
      -
      -
      - -
      -
      -
      - 0 && - 'border-red-500 focus:border-red-500' - )} - /> - -
      -
      0 - ? 'grid-rows-[1fr]' - : 'grid-rows-[0fr]' - )} - aria-live={showValidationError && passwordErrors.length > 0 ? 'polite' : 'off'} - > -
      -
      - {passwordErrors.map((error) => ( -

      {error}

      - ))} -
      -
      -
      -
      -
      + +
      + + 0} + /> + + + + + + 0} + /> +
      {turnstileSiteKey && ( @@ -529,84 +438,45 @@ function SignupFormContent({ )} {formError && ( -
      +

      {formError}

      -
      + )} - + + Create account + )} - {showDivider && ( -
      -
      -
      -
      -
      - - Or continue with - -
      -
      - )} + {showDivider && } {showBottomSection && ( -
      - - {ssoEnabled && !hasOnlySSO && ( - - )} - -
      + + {ssoEnabled && !hasOnlySSO && ( + + )} + )} -
      - Already have an account? - - Sign in - -
      - -
      - By creating an account, you agree to our{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - -
      - + + + +
      ) } diff --git a/apps/sim/app/(auth)/verify/verify-content.tsx b/apps/sim/app/(auth)/verify/verify-content.tsx index 11e68bbf32a..aad098f2770 100644 --- a/apps/sim/app/(auth)/verify/verify-content.tsx +++ b/apps/sim/app/(auth)/verify/verify-content.tsx @@ -1,9 +1,14 @@ 'use client' import { Suspense, useEffect, useState } from 'react' -import { cn, InputOTP, InputOTPGroup, InputOTPSlot, Loader } from '@sim/emcn' -import { useRouter } from 'next/navigation' -import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' +import { cn, InputOTP, InputOTPGroup, InputOTPSlot } from '@sim/emcn' +import { + AuthFormMessage, + AuthHeader, + AuthNavPrompt, + AuthSubmitButton, + AuthTextLink, +} from '@/app/(auth)/components' import { useVerification } from '@/app/(auth)/verify/use-verification' interface VerifyContentProps { @@ -12,6 +17,8 @@ interface VerifyContentProps { isEmailVerificationEnabled: boolean } +const OTP_SLOTS = [0, 1, 2, 3, 4, 5] as const + function VerificationForm({ hasEmailService, isProduction, @@ -34,8 +41,8 @@ function VerificationForm({ } = useVerification({ hasEmailService, isProduction, isEmailVerificationEnabled }) const isVerified = status === 'verified' + const isLoading = status === 'verifying' || isResending const isInvalidOtp = status === 'error' - const isBusy = status === 'verifying' || isResending const [countdown, setCountdown] = useState(0) const [isResendDisabled, setIsResendDisabled] = useState(false) @@ -50,8 +57,6 @@ function VerificationForm({ } }, [countdown, isResendDisabled]) - const router = useRouter() - const handleResend = () => { resendCode() setIsResendDisabled(true) @@ -59,13 +64,11 @@ function VerificationForm({ } return ( - <> -
      -

      - {isVerified ? 'Email Verified!' : 'Verify Your Email'} -

      -

      - {isVerified +

      + -
      + : 'Error: Email verification is enabled but no email service is configured' + } + /> {!isVerified && isEmailVerificationEnabled && ( -
      -
      -

      +

      +
      +

      Enter the 6-digit code to verify your account. {hasEmailService ? " If you don't see it in your inbox, check your spam folder." : ''}

      - + - - - - - - + {OTP_SLOTS.map((index) => ( + + ))}
      - {/* Error message */} {errorMessage && ( -
      +

      {errorMessage}

      -
      + )}
      - + Verify Email + {hasEmailService && ( -
      -

      - Didn't receive a code?{' '} - {countdown > 0 ? ( - - Resend in{' '} - {countdown}s - - ) : ( - - )} -

      -
      +

      + Didn't receive a code?{' '} + {countdown > 0 ? ( + + Resend in {countdown}s + + ) : ( + + Resend + + )} +

      )} -
      - -
      + { + if (typeof window !== 'undefined') { + sessionStorage.removeItem('verificationEmail') + sessionStorage.removeItem('inviteRedirectUrl') + sessionStorage.removeItem('isInviteFlow') + } + }} + />
      )} - +
      ) } diff --git a/apps/sim/app/chat/[identifier]/chat.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx similarity index 98% rename from apps/sim/app/chat/[identifier]/chat.tsx rename to apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx index 891727d5784..6d4a9435a63 100644 --- a/apps/sim/app/chat/[identifier]/chat.tsx +++ b/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx @@ -14,9 +14,9 @@ import { EmailAuth, PasswordAuth, VoiceInterface, -} from '@/app/chat/components' -import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/chat/constants' -import { useAudioStreaming, useChatStreaming } from '@/app/chat/hooks' +} from '@/app/(interfaces)/chat/components' +import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/(interfaces)/chat/constants' +import { useAudioStreaming, useChatStreaming } from '@/app/(interfaces)/chat/hooks' import SSOAuth from '@/ee/sso/components/sso-auth' import { useDeployedChatConfig } from '@/hooks/queries/chats' import { useGitHubStars } from '@/hooks/queries/github-stars' diff --git a/apps/sim/app/chat/[identifier]/loading.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx similarity index 100% rename from apps/sim/app/chat/[identifier]/loading.tsx rename to apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx diff --git a/apps/sim/app/chat/[identifier]/office-embed-init.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/office-embed-init.tsx similarity index 100% rename from apps/sim/app/chat/[identifier]/office-embed-init.tsx rename to apps/sim/app/(interfaces)/chat/[identifier]/office-embed-init.tsx diff --git a/apps/sim/app/chat/[identifier]/page.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/page.tsx similarity index 79% rename from apps/sim/app/chat/[identifier]/page.tsx rename to apps/sim/app/(interfaces)/chat/[identifier]/page.tsx index 6c5093e4a3b..5ed89f37fcc 100644 --- a/apps/sim/app/chat/[identifier]/page.tsx +++ b/apps/sim/app/(interfaces)/chat/[identifier]/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next' -import ChatClient from '@/app/chat/[identifier]/chat' -import { OfficeEmbedInit } from '@/app/chat/[identifier]/office-embed-init' +import ChatClient from '@/app/(interfaces)/chat/[identifier]/chat' +import { OfficeEmbedInit } from '@/app/(interfaces)/chat/[identifier]/office-embed-init' export const metadata: Metadata = { title: 'Chat', diff --git a/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx b/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx new file mode 100644 index 00000000000..f5cb4767fc8 --- /dev/null +++ b/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx @@ -0,0 +1,260 @@ +'use client' + +import { useEffect, useState } from 'react' +import { cn, Input, InputOTP, InputOTPGroup, InputOTPSlot, Label } from '@sim/emcn' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { quickValidateEmail } from '@/lib/messaging/email/validation' +import { AuthSubmitButton } from '@/app/(auth)/components' +import { AUTH_TEXT_LINK } from '@/app/(auth)/components/auth-button-classes' +import { useChatEmailOtpRequest, useChatEmailOtpVerify } from '@/hooks/queries/chats' + +const logger = createLogger('EmailAuth') + +interface EmailAuthProps { + identifier: string +} + +const validateEmailField = (emailValue: string): string[] => { + const errors: string[] = [] + + if (!emailValue || !emailValue.trim()) { + errors.push('Email is required.') + return errors + } + + const validation = quickValidateEmail(emailValue.trim().toLowerCase()) + if (!validation.isValid) { + errors.push(validation.reason || 'Please enter a valid email address.') + } + + return errors +} + +export default function EmailAuth({ identifier }: EmailAuthProps) { + const [email, setEmail] = useState('') + const [authError, setAuthError] = useState(null) + const [emailErrors, setEmailErrors] = useState([]) + const [showEmailValidationError, setShowEmailValidationError] = useState(false) + + const [showOtpVerification, setShowOtpVerification] = useState(false) + const [otpValue, setOtpValue] = useState('') + const [countdown, setCountdown] = useState(0) + + const requestOtp = useChatEmailOtpRequest(identifier) + const verifyOtp = useChatEmailOtpVerify(identifier) + + useEffect(() => { + if (countdown <= 0) return + const timer = setTimeout(() => setCountdown((c) => c - 1), 1000) + return () => clearTimeout(timer) + }, [countdown]) + + const handleEmailChange = (e: React.ChangeEvent) => { + const newEmail = e.target.value + setEmail(newEmail) + const errors = validateEmailField(newEmail) + setEmailErrors(errors) + setShowEmailValidationError(false) + } + + const handleSendOtp = async () => { + const emailValidationErrors = validateEmailField(email) + setEmailErrors(emailValidationErrors) + setShowEmailValidationError(emailValidationErrors.length > 0) + + if (emailValidationErrors.length > 0) { + return + } + + setAuthError(null) + + try { + await requestOtp.mutateAsync({ email }) + setShowOtpVerification(true) + } catch (error) { + logger.error('Error sending OTP:', error) + setEmailErrors([toError(error).message || 'Failed to send verification code']) + setShowEmailValidationError(true) + } + } + + const handleVerifyOtp = async (otp?: string) => { + const codeToVerify = otp || otpValue + + if (!codeToVerify || codeToVerify.length !== 6) { + return + } + + setAuthError(null) + + try { + await verifyOtp.mutateAsync({ email, otp: codeToVerify }) + } catch (error) { + logger.error('Error verifying OTP:', error) + setAuthError(toError(error).message || 'Invalid verification code') + } + } + + const handleResendOtp = async () => { + setAuthError(null) + setCountdown(30) + + try { + await requestOtp.mutateAsync({ email }) + setOtpValue('') + } catch (error) { + logger.error('Error resending OTP:', error) + setAuthError(toError(error).message || 'Failed to resend verification code') + setCountdown(0) + } + } + + return ( +
      +
      +
      +
      +

      + {showOtpVerification ? 'Verify Your Email' : 'Email Verification'} +

      +

      + {showOtpVerification + ? `A verification code has been sent to ${email}` + : 'This chat requires email verification'} +

      +
      + +
      + {!showOtpVerification ? ( +
      { + e.preventDefault() + handleSendOtp() + }} + className='space-y-6' + > +
      +
      + +
      + 0 && + 'border-[var(--text-error)] focus:border-[var(--text-error)]' + )} + /> + {showEmailValidationError && emailErrors.length > 0 && ( +
      + {emailErrors.map((error) => ( +

      {error}

      + ))} +
      + )} +
      + + + Continue + +
      + ) : ( +
      +

      + Enter the 6-digit code to verify your account. If you don't see it in your inbox, + check your spam folder. +

      + +
      + { + setOtpValue(value) + if (value.length === 6) { + handleVerifyOtp(value) + } + }} + disabled={verifyOtp.isPending} + className={cn('gap-2', authError && 'otp-error')} + > + + {[0, 1, 2, 3, 4, 5].map((index) => ( + + ))} + + +
      + + {authError && ( +
      +

      {authError}

      +
      + )} + + handleVerifyOtp()} + disabled={otpValue.length !== 6} + loading={verifyOtp.isPending} + loadingLabel='Verifying…' + > + Verify Email + + +
      +

      + Didn't receive a code?{' '} + {countdown > 0 ? ( + + Resend in{' '} + {countdown}s + + ) : ( + + )} +

      +
      + +
      + +
      +
      + )} +
      +
      +
      +
      + ) +} diff --git a/apps/sim/app/(interfaces)/chat/components/auth/password/password-auth.tsx b/apps/sim/app/(interfaces)/chat/components/auth/password/password-auth.tsx new file mode 100644 index 00000000000..0d6a1841e9c --- /dev/null +++ b/apps/sim/app/(interfaces)/chat/components/auth/password/password-auth.tsx @@ -0,0 +1,134 @@ +'use client' + +import { useState } from 'react' +import { cn, Input, Label } from '@sim/emcn' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { Eye, EyeOff } from 'lucide-react' +import { AuthSubmitButton } from '@/app/(auth)/components' +import { useChatPasswordAuth } from '@/hooks/queries/chats' + +const logger = createLogger('PasswordAuth') + +interface PasswordAuthProps { + identifier: string +} + +export default function PasswordAuth({ identifier }: PasswordAuthProps) { + const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [showValidationError, setShowValidationError] = useState(false) + const [passwordErrors, setPasswordErrors] = useState([]) + const authenticate = useChatPasswordAuth(identifier) + + const handlePasswordChange = (e: React.ChangeEvent) => { + const newPassword = e.target.value + setPassword(newPassword) + setShowValidationError(false) + setPasswordErrors([]) + } + + const handleAuthenticate = async () => { + if (!password.trim()) { + setPasswordErrors(['Password is required']) + setShowValidationError(true) + return + } + + try { + await authenticate.mutateAsync({ password }) + setPassword('') + } catch (error) { + logger.error('Authentication error:', error) + setPasswordErrors([toError(error).message || 'Invalid password. Please try again.']) + setShowValidationError(true) + } + } + + return ( +
      +
      +
      +
      +

      + Password Required +

      +

      + This chat is password-protected +

      +
      + +
      { + e.preventDefault() + handleAuthenticate() + }} + className='mt-8 w-full max-w-[410px] space-y-6' + > +
      +
      + +
      +
      +
      + 0 && + 'border-[var(--text-error)] focus:border-[var(--text-error)]' + )} + /> + +
      +
      0 + ? 'grid-rows-[1fr]' + : 'grid-rows-[0fr]' + )} + aria-live='polite' + > +
      +
      + {passwordErrors.map((error) => ( +

      {error}

      + ))} +
      +
      +
      +
      +
      + + + Continue + +
      +
      +
      +
      + ) +} diff --git a/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx b/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx new file mode 100644 index 00000000000..23cbd1789e9 --- /dev/null +++ b/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx @@ -0,0 +1,28 @@ +'use client' + +import { useRouter } from 'next/navigation' + +interface ChatErrorStateProps { + error: string +} + +export function ChatErrorState({ error }: ChatErrorStateProps) { + const router = useRouter() + + return ( +
      +
      +

      + Chat Unavailable +

      +

      {error}

      + +
      +
      + ) +} diff --git a/apps/sim/app/chat/components/header/header.tsx b/apps/sim/app/(interfaces)/chat/components/header/header.tsx similarity index 100% rename from apps/sim/app/chat/components/header/header.tsx rename to apps/sim/app/(interfaces)/chat/components/header/header.tsx diff --git a/apps/sim/app/chat/components/index.ts b/apps/sim/app/(interfaces)/chat/components/index.ts similarity index 100% rename from apps/sim/app/chat/components/index.ts rename to apps/sim/app/(interfaces)/chat/components/index.ts diff --git a/apps/sim/app/chat/components/input/input.tsx b/apps/sim/app/(interfaces)/chat/components/input/input.tsx similarity index 99% rename from apps/sim/app/chat/components/input/input.tsx rename to apps/sim/app/(interfaces)/chat/components/input/input.tsx index 72aa99fb9d0..a12e575ce46 100644 --- a/apps/sim/app/chat/components/input/input.tsx +++ b/apps/sim/app/(interfaces)/chat/components/input/input.tsx @@ -7,7 +7,7 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { ArrowUp, Mic, Paperclip, X } from 'lucide-react' import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation' -import { VoiceInput } from '@/app/chat/components/input/voice-input' +import { VoiceInput } from '@/app/(interfaces)/chat/components/input/voice-input' const logger = createLogger('ChatInput') diff --git a/apps/sim/app/chat/components/input/voice-input.tsx b/apps/sim/app/(interfaces)/chat/components/input/voice-input.tsx similarity index 100% rename from apps/sim/app/chat/components/input/voice-input.tsx rename to apps/sim/app/(interfaces)/chat/components/input/voice-input.tsx diff --git a/apps/sim/app/chat/components/loading-state/loading-state.tsx b/apps/sim/app/(interfaces)/chat/components/loading-state/loading-state.tsx similarity index 100% rename from apps/sim/app/chat/components/loading-state/loading-state.tsx rename to apps/sim/app/(interfaces)/chat/components/loading-state/loading-state.tsx diff --git a/apps/sim/app/chat/components/message-container/message-container.tsx b/apps/sim/app/(interfaces)/chat/components/message-container/message-container.tsx similarity index 96% rename from apps/sim/app/chat/components/message-container/message-container.tsx rename to apps/sim/app/(interfaces)/chat/components/message-container/message-container.tsx index d85be38058e..7f896b11c7d 100644 --- a/apps/sim/app/chat/components/message-container/message-container.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message-container/message-container.tsx @@ -3,7 +3,10 @@ import { memo, type RefObject } from 'react' import { ArrowDown } from 'lucide-react' import { Button } from '@/components/ui/button' -import { type ChatMessage, ClientChatMessage } from '@/app/chat/components/message/message' +import { + type ChatMessage, + ClientChatMessage, +} from '@/app/(interfaces)/chat/components/message/message' interface ChatMessageContainerProps { messages: ChatMessage[] diff --git a/apps/sim/app/chat/components/message/components/file-download.test.tsx b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx similarity index 94% rename from apps/sim/app/chat/components/message/components/file-download.test.tsx rename to apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx index 423cdc78731..01bff16b95b 100644 --- a/apps/sim/app/chat/components/message/components/file-download.test.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx @@ -23,7 +23,7 @@ vi.mock('@/lib/core/config/env-flags', () => ({ isProd: false, })) -import { isSafeHttpUrl } from '@/app/chat/components/message/components/file-download' +import { isSafeHttpUrl } from '@/app/(interfaces)/chat/components/message/components/file-download' describe('isSafeHttpUrl', () => { it('allows absolute http(s) URLs', () => { diff --git a/apps/sim/app/chat/components/message/components/file-download.tsx b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx similarity index 98% rename from apps/sim/app/chat/components/message/components/file-download.tsx rename to apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx index 8dfb200b61b..42825f74ef2 100644 --- a/apps/sim/app/chat/components/message/components/file-download.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx @@ -7,7 +7,7 @@ import { sleep } from '@sim/utils/helpers' import { Music } from 'lucide-react' import { DefaultFileIcon, getDocumentIcon } from '@/components/icons/document-icons' import { getBrowserOrigin } from '@/lib/core/utils/urls' -import type { ChatFile } from '@/app/chat/components/message/message' +import type { ChatFile } from '@/app/(interfaces)/chat/components/message/message' const logger = createLogger('ChatFileDownload') diff --git a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx b/apps/sim/app/(interfaces)/chat/components/message/components/markdown-renderer.tsx similarity index 100% rename from apps/sim/app/chat/components/message/components/markdown-renderer.tsx rename to apps/sim/app/(interfaces)/chat/components/message/components/markdown-renderer.tsx diff --git a/apps/sim/app/chat/components/message/message.test.tsx b/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx similarity index 80% rename from apps/sim/app/chat/components/message/message.test.tsx rename to apps/sim/app/(interfaces)/chat/components/message/message.test.tsx index a6428d5188c..a48517a75f1 100644 --- a/apps/sim/app/chat/components/message/message.test.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx @@ -8,16 +8,16 @@ vi.mock('@sim/emcn', () => ({ Tooltip: {}, })) -vi.mock('@/app/chat/components/message/components/file-download', () => ({ +vi.mock('@/app/(interfaces)/chat/components/message/components/file-download', () => ({ ChatFileDownload: () => null, ChatFileDownloadAll: () => null, })) -vi.mock('@/app/chat/components/message/components/markdown-renderer', () => ({ +vi.mock('@/app/(interfaces)/chat/components/message/components/markdown-renderer', () => ({ default: () => null, })) -import { escapeHtml } from '@/app/chat/components/message/message' +import { escapeHtml } from '@/app/(interfaces)/chat/components/message/message' describe('escapeHtml', () => { it('escapes all five HTML-significant characters', () => { diff --git a/apps/sim/app/chat/components/message/message.tsx b/apps/sim/app/(interfaces)/chat/components/message/message.tsx similarity index 98% rename from apps/sim/app/chat/components/message/message.tsx rename to apps/sim/app/(interfaces)/chat/components/message/message.tsx index 12181e0f300..d57775c1adf 100644 --- a/apps/sim/app/chat/components/message/message.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/message.tsx @@ -6,8 +6,8 @@ import { Check, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-re import { ChatFileDownload, ChatFileDownloadAll, -} from '@/app/chat/components/message/components/file-download' -import MarkdownRenderer from '@/app/chat/components/message/components/markdown-renderer' +} from '@/app/(interfaces)/chat/components/message/components/file-download' +import MarkdownRenderer from '@/app/(interfaces)/chat/components/message/components/markdown-renderer' export interface ChatAttachment { id: string diff --git a/apps/sim/app/chat/components/voice-interface/components/particles.tsx b/apps/sim/app/(interfaces)/chat/components/voice-interface/components/particles.tsx similarity index 100% rename from apps/sim/app/chat/components/voice-interface/components/particles.tsx rename to apps/sim/app/(interfaces)/chat/components/voice-interface/components/particles.tsx diff --git a/apps/sim/app/chat/components/voice-interface/voice-interface.tsx b/apps/sim/app/(interfaces)/chat/components/voice-interface/voice-interface.tsx similarity index 99% rename from apps/sim/app/chat/components/voice-interface/voice-interface.tsx rename to apps/sim/app/(interfaces)/chat/components/voice-interface/voice-interface.tsx index 7a7f8ec70f1..00fb29d5e62 100644 --- a/apps/sim/app/chat/components/voice-interface/voice-interface.tsx +++ b/apps/sim/app/(interfaces)/chat/components/voice-interface/voice-interface.tsx @@ -18,7 +18,7 @@ import { const ParticlesVisualization = dynamic( () => - import('@/app/chat/components/voice-interface/components/particles').then( + import('@/app/(interfaces)/chat/components/voice-interface/components/particles').then( (mod) => mod.ParticlesVisualization ), { ssr: false } diff --git a/apps/sim/app/chat/constants.ts b/apps/sim/app/(interfaces)/chat/constants.ts similarity index 100% rename from apps/sim/app/chat/constants.ts rename to apps/sim/app/(interfaces)/chat/constants.ts diff --git a/apps/sim/app/chat/hooks/index.ts b/apps/sim/app/(interfaces)/chat/hooks/index.ts similarity index 100% rename from apps/sim/app/chat/hooks/index.ts rename to apps/sim/app/(interfaces)/chat/hooks/index.ts diff --git a/apps/sim/app/chat/hooks/use-audio-streaming.ts b/apps/sim/app/(interfaces)/chat/hooks/use-audio-streaming.ts similarity index 100% rename from apps/sim/app/chat/hooks/use-audio-streaming.ts rename to apps/sim/app/(interfaces)/chat/hooks/use-audio-streaming.ts diff --git a/apps/sim/app/chat/hooks/use-chat-streaming.ts b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts similarity index 98% rename from apps/sim/app/chat/hooks/use-chat-streaming.ts rename to apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts index dd315dafe73..be4b0b2e1ae 100644 --- a/apps/sim/app/chat/hooks/use-chat-streaming.ts +++ b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts @@ -5,8 +5,8 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { readSSEEvents } from '@/lib/core/utils/sse' import { isUserFileWithMetadata } from '@/lib/core/utils/user-file' -import type { ChatFile, ChatMessage } from '@/app/chat/components/message/message' -import { CHAT_ERROR_MESSAGES } from '@/app/chat/constants' +import type { ChatFile, ChatMessage } from '@/app/(interfaces)/chat/components/message/message' +import { CHAT_ERROR_MESSAGES } from '@/app/(interfaces)/chat/constants' const logger = createLogger('UseChatStreaming') diff --git a/apps/sim/app/(interfaces)/components/index.ts b/apps/sim/app/(interfaces)/components/index.ts new file mode 100644 index 00000000000..93ef69c21cd --- /dev/null +++ b/apps/sim/app/(interfaces)/components/index.ts @@ -0,0 +1 @@ +export { InterfacesShell } from './interfaces-shell' diff --git a/apps/sim/app/(interfaces)/components/interfaces-shell/index.ts b/apps/sim/app/(interfaces)/components/interfaces-shell/index.ts new file mode 100644 index 00000000000..93ef69c21cd --- /dev/null +++ b/apps/sim/app/(interfaces)/components/interfaces-shell/index.ts @@ -0,0 +1 @@ +export { InterfacesShell } from './interfaces-shell' diff --git a/apps/sim/app/(interfaces)/components/interfaces-shell/interfaces-shell.tsx b/apps/sim/app/(interfaces)/components/interfaces-shell/interfaces-shell.tsx new file mode 100644 index 00000000000..7f41bd212d7 --- /dev/null +++ b/apps/sim/app/(interfaces)/components/interfaces-shell/interfaces-shell.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from 'react' +import { SupportFooter } from '@/app/(auth)/components' +import { LogoShell } from '@/app/(landing)/components' + +/** + * Chrome for the `(interfaces)` route group (chat + resume) — the lightweight, + * logo-only frame their entry/gate screens wear (chat email / password auth, the + * embedded SSO gate, the "chat unavailable" message, and the resume gate). + * + * It is the shared {@link LogoShell} (light, logo-only header) plus a + * {@link SupportFooter}. Content is full-width — gate forms center themselves; + * the live chat UI renders a `fixed inset-0` overlay that covers this frame, and + * voice mode is full-screen — so the frame is only ever visible on the + * gate/message states, giving chat and resume the same chrome as the auth pages. + */ +interface InterfacesShellProps { + children: ReactNode +} + +export function InterfacesShell({ children }: InterfacesShellProps) { + return }>{children} +} diff --git a/apps/sim/app/(interfaces)/layout.tsx b/apps/sim/app/(interfaces)/layout.tsx new file mode 100644 index 00000000000..91516d93702 --- /dev/null +++ b/apps/sim/app/(interfaces)/layout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react' +import { InterfacesShell } from '@/app/(interfaces)/components' + +/** + * Route-group layout for runtime interfaces — chat (`/chat/:identifier`) and + * resume (`/resume/...`). It renders the shared {@link InterfacesShell} (light, + * logo-only chrome) around every interface page, so their entry/gate screens get + * the same frame as the auth sign-in pages. Immersive states (the live chat + * overlay, voice mode) render full-screen on top of this frame. + */ +export default function InterfacesLayout({ children }: { children: ReactNode }) { + return {children} +} diff --git a/apps/sim/app/resume/[workflowId]/[executionId]/[contextId]/page.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/[contextId]/page.tsx similarity index 100% rename from apps/sim/app/resume/[workflowId]/[executionId]/[contextId]/page.tsx rename to apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/[contextId]/page.tsx diff --git a/apps/sim/app/resume/[workflowId]/[executionId]/loading.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/loading.tsx similarity index 100% rename from apps/sim/app/resume/[workflowId]/[executionId]/loading.tsx rename to apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/loading.tsx diff --git a/apps/sim/app/resume/[workflowId]/[executionId]/page.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/page.tsx similarity index 91% rename from apps/sim/app/resume/[workflowId]/[executionId]/page.tsx rename to apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/page.tsx index 67114faec43..7a965893e1f 100644 --- a/apps/sim/app/resume/[workflowId]/[executionId]/page.tsx +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' -import ResumeExecutionPage from '@/app/resume/[workflowId]/[executionId]/resume-page-client' +import ResumeExecutionPage from '@/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client' export const metadata: Metadata = { title: 'Resume Execution', diff --git a/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx similarity index 99% rename from apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx rename to apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx index b8aa82792f8..6b84e95c67d 100644 --- a/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -26,7 +26,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import Navbar from '@/app/(landing)/components/navbar/navbar' +import { Navbar } from '@/app/(landing)/components/navbar/navbar' import { useBrandConfig } from '@/ee/whitelabeling' import { type PauseContextDetail, diff --git a/apps/sim/app/(landing)/CLAUDE.md b/apps/sim/app/(landing)/CLAUDE.md new file mode 100644 index 00000000000..992e2172448 --- /dev/null +++ b/apps/sim/app/(landing)/CLAUDE.md @@ -0,0 +1,101 @@ +# Landing Page - Build & Optimization Instructions + +This route group owns `/` and the entire public marketing surface - the home page, platform/solutions pages, pricing, legal, and the marketing subroutes (`/blog`, `/models`, `/integrations`, `/demo`, `/partners`, `/changelog`). Read this file in full before adding or changing anything here. Positioning and language rules live in `.claude/rules/constitution.md`; SEO/GEO rules in `.claude/rules/landing-seo-geo.md`. Both apply to every file in this directory. + +## What this is + +- `app/(landing)/` - the marketing site. A shared `layout.tsx` renders the chrome once (the `LandingShell`: light tokens, navbar with server-side GitHub stars, footer, site-wide JSON-LD); each page supplies only its `
      ` content. +- The legacy `app/(home)/` group (old dark landing + `--landing-*` tokens) has been **deleted** - its marketing pages were migrated here and its chrome retired. Do not reintroduce `--landing-*` tokens, Martian Mono accents, or a separate marketing theme. + +## Styling - draw from the platform's light mode + +The landing page looks like the product. Its visual language is the workspace UI in light mode, not a separate marketing theme. + +- **Always light.** The root wrapper in `landing.tsx` carries the `light` class, which pins every token to its light value (see `app/_styles/globals.css`, the `:root, .light` block). Never add `dark:` variants here; never read the user's theme. +- **Use platform tokens, never hex.** Canvas `--bg`, surfaces `--surface-1`…`--surface-7`, cards/modals `--surface-2`, hover `--surface-hover`, active `--surface-active`; text `--text-primary` / `--text-secondary` / `--text-muted` / `--text-body`, icons `--text-icon`; borders `--border` (dividers) / `--border-1` (fields); brand `--brand-agent` / `--brand-secondary` / `--brand-accent`. Do **not** use the legacy `--landing-*` tokens - they belong to the old dark landing. +- **Use emcn components where they fit.** The chip family (`Chip`, `ChipLink`, `ChipTag`, `ChipInput`, `ChipModal*`, …) from `@/components/emcn` is the canonical chrome - a demo-request form is a `ChipModal` with `ChipModalField`s, a pill CTA is a `Chip`/`ChipLink`. Components own their chrome; pass props, not className overrides. Full consumer rules: `.claude/rules/sim-styling.md`. +- **Typography is the platform's.** Season is the global body font (`font-season` is applied on `` in the root layout). Use the platform text scale (`text-small` = 13px, `text-base` = 15px, etc. - see `tailwind.config.ts`). Don't add new fonts or font CSS variables without explicit direction. +- **Never touch global styles.** No additions to `app/_styles/globals.css`. All styling is local Tailwind classes; `cn()` from `@/lib/core/utils/cn` for conditionals; no inline `style` attributes. +- **Responsive - desktop is the source of truth, scaled down via `max-*` overrides.** The page is fully responsive (iPad + phone). The desktop layout stays the unprefixed baseline; smaller screens are handled by *layering* `max-*` overrides on top, so desktop renders byte-identically. Tiers: + - `max-xl:` (≤1279) - the hero's two-panel split (absolute visual + logos) collapses to a stacked, in-flow column. The split needs ≥1280 to avoid the headline colliding with the visual panel; iPad-landscape (1024) therefore gets the stacked hero with the desktop nav. + - `max-lg:` (≤1023) - the desktop nav clusters hide (`hidden lg:flex`) and `MobileNav` (hamburger sheet) takes over; multi-column grids step down (mothership 4→2, footer 7→3); shared gutter `px-12 → max-lg:px-8`; section gaps tighten. + - `max-md:` (≤767) - Features beats drop the floating callout (`max-md:hidden`) and show the un-masked backdrop preview full-width. + - `max-sm:` (≤639) - single-column grids, smallest type scale, `px-5` gutter, hero CTA row stacks. + + When adding a new section, give it the same `px-12 max-lg:px-8 max-sm:px-5` gutter so the navbar wordmark stays aligned with section content at every width. Verify desktop is unchanged and there is zero horizontal overflow at 1280 / 1024 / 768 / 390 before shipping. + +## Performance - page speed is a feature + +Target: Lighthouse 95+ on mobile, LCP < 2.0s, CLS < 0.05, minimal hydration cost. + +- **Server Components by default.** `'use client'` only on the smallest leaf that genuinely needs interactivity (a button with state, not the section containing it). The navbar, hero copy, footer, and every static section must be server-rendered HTML. +- **No heavy client libraries above the fold.** No animation frameworks (framer-motion etc.), no ReactFlow, no chart libs in the initial bundle. If a below-fold section truly needs one, load it with `next/dynamic` and a dimension-stable placeholder. +- **Images via `next/image` always.** The LCP element (logo or hero visual) gets `priority`; everything below the fold lazy-loads (the default). Every image has explicit `width`/`height` - zero layout shift. +- **Prefer CSS over JS.** Hover states, transitions, marquees, and reveal effects in CSS (`transition-*`, `animation`) rather than scroll listeners or animation libraries. Decorative motion respects `prefers-reduced-motion`. +- **Static rendering.** The page is statically generated with `revalidate` (set in `page.tsx`). Never fetch per-request data in the page tree; anything dynamic (e.g. GitHub stars) is fetched at build/revalidate time on the server or deferred to a tiny client island. +- **Reserve space for everything.** Fixed dimensions or aspect ratios on all media, embeds, and async content. CLS budget is effectively zero. + +## SEO + +`page.tsx` owns the metadata (title, description, OG/Twitter, canonical, robots) - keep it the single source of truth and keep it aligned with the constitution's claim hierarchy. Beyond metadata: + +- **One `

      `, in the hero, containing "Sim" and "AI workspace".** Strict hierarchy below it: H2 per section, H3 for items within a section. Never skip levels, never add a second H1. +- **Semantic landmarks**: `
      `, `
      `, `