diff --git a/.gitignore b/.gitignore index 9423b33c0..a4d9a09de 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ # VS Code settings .vscode/settings.json +.claude/settings.local.json + +*storybook.log +storybook-static diff --git a/.prettierignore b/.prettierignore index 5eb7286c5..d9344bc0c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,6 +4,7 @@ dist/ # Dependencies node_modules/ +pnpm-lock.yaml # Logs npm-debug.log* diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 000000000..0ba6cd49b --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,18 @@ +import type { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { + stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: [ + '@chromatic-com/storybook', + '@storybook/addon-docs', + '@storybook/addon-onboarding', + '@storybook/addon-a11y', + ], + framework: { + name: '@storybook/react-vite', + options: { + viteConfigPath: './.storybook/vite.config.ts', + }, + }, +}; +export default config; diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 000000000..fc20842d1 --- /dev/null +++ b/.storybook/preview.ts @@ -0,0 +1,87 @@ +import type { Preview } from '@storybook/react-vite'; +import '../app/app.css'; + +// Custom viewports for testing your app's breakpoints +const customViewports = { + xs: { + name: 'XS - Custom (480px)', + styles: { width: '480px', height: '800px' }, + }, + belowSm: { + name: 'Below SM (639px)', + styles: { width: '639px', height: '800px' }, + }, + small: { + name: 'Small Mobile (440px)', + styles: { width: '440px', height: '800px' }, + }, + tiny: { + name: 'Tiny (375px)', + styles: { width: '375px', height: '800px' }, + }, +}; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + a11y: { + // 'todo' - show a11y violations in the test UI only + // 'error' - fail CI on a11y violations + // 'off' - skip a11y checks entirely + test: 'todo', + }, + backgrounds: { + default: 'light', + values: [ + { + name: 'light', + value: '#ffffff', + }, + { + name: 'dark', + value: '#1a1a1a', + }, + ], + }, + viewport: { + viewports: customViewports, + }, + }, + globalTypes: { + theme: { + description: 'Global theme for components', + defaultValue: 'light', + toolbar: { + title: 'Theme', + icon: 'circlehollow', + items: ['light', 'dark'], + dynamicTitle: true, + }, + }, + }, + decorators: [ + (Story, context) => { + const theme = context.globals.theme || 'light'; + + // Apply theme to document for Tailwind dark mode + if (typeof document !== 'undefined') { + if (theme === 'dark') { + document.documentElement.classList.add('dark'); + document.documentElement.dataset.theme = 'dark'; + } else { + document.documentElement.classList.remove('dark'); + document.documentElement.dataset.theme = 'light'; + } + } + + return Story(); + }, + ], +}; + +export default preview; diff --git a/.storybook/vite.config.ts b/.storybook/vite.config.ts new file mode 100644 index 000000000..18df5398c --- /dev/null +++ b/.storybook/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite'; +import tailwindcss from '@tailwindcss/vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +// Separate Vite config for Storybook that excludes React Router +export default defineConfig({ + plugins: [tailwindcss(), tsconfigPaths()], + define: { + 'process.env.DISABLE_REACT_ROUTER': '"true"', + }, + json: { + stringify: true, + }, +}); diff --git a/CLAUDE.md b/CLAUDE.md index a35351fe1..84a52b198 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Common Commands +Run `pnpm check` to format, typecheck, and test, before asking for feedback on work. Never delete a test to pass without asking. + ### Development - `pnpm dev` - Start development server with Netlify functions (primary dev command) @@ -105,3 +107,11 @@ This is a React Router v7 SPA application for building AI-powered mini apps. Key - Vite plugins: Tailwind, TypeScript paths, devtools JSON - Test environment configured to disable React Router when needed - Coverage tracking for critical components and utilities + +# important-instruction-reminders + +Do what has been asked; nothing more, nothing less. +NEVER create files unless they're absolutely necessary for achieving your goal. +ALWAYS prefer editing an existing file to creating a new one. +NEVER proactively create documentation files (\*.md) or README files. Only create documentation files if explicitly requested by the User. +NEVER put test mode checks in production code - instead mock upstream dependencies in tests if you need deterministic behavior. diff --git a/README.md b/README.md index 834ee3f9a..c7d9fcf10 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,16 @@ Create beautiful, interactive mini apps with zero setup. Your creations are auto - Add your AI account key from [OpenRouter](https://openrouter.ai/settings/keys) - Run `pnpm dev` +## Developer previews on the main domain (no redirects) + +Opt into an experimental branch deploy on the primary site using Netlify Split Testing with a cookie. Use `?ab=` on the main domain, for example: + +``` +https://vibes.diy/?ab=feature-new-ui +``` + +Note: the underlying Netlify cookie is named `nf_ab` and is host‑scoped by default (not shared between `www` and apex). See [docs/split-testing.md](docs/split-testing.md) for details and scope options. + ## Your Work is Always Safe Every app you create is automatically saved, so you can: diff --git a/__mocks__/useAuth.ts b/__mocks__/useAuth.ts index 0f2b131a5..9b09567b5 100644 --- a/__mocks__/useAuth.ts +++ b/__mocks__/useAuth.ts @@ -40,8 +40,6 @@ export const mockUseAuth = vi.fn().mockImplementation(() => defaultAuthenticated export const setMockAuthState = (state: Partial) => { mockUseAuth.mockImplementation(() => ({ ...defaultAuthenticatedState, - needsLogin: false, - setNeedsLogin: vi.fn(), ...state, })); }; diff --git a/app/app.css b/app/app.css index f4ef8e5ca..d6b281cdc 100644 --- a/app/app.css +++ b/app/app.css @@ -1,7 +1,118 @@ @import 'tailwindcss'; +/* @import 'tw-animate-css'; */ + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(93.46% 0.0305 255.11); + --secondary-background: oklch(100% 0 0); + --foreground: oklch(0% 0 0); + --main-foreground: oklch(0% 0 0); + --main: oklch(67.47% 0.1726 259.49); + --border: oklch(0% 0 0); + --ring: oklch(0% 0 0); + --overlay: oklch(0% 0 0 / 0.8); + --shadow: 4px 4px 0px 0px var(--border); + --chart-1: #5294ff; + --chart-2: #ff4d50; + --chart-3: #facc00; + --chart-4: #05e17a; + --chart-5: #7a83ff; + --chart-active-dot: #000; +} + +.dark { + --background: oklch(29.23% 0.0626 270.49); + --secondary-background: oklch(23.93% 0 0); + --foreground: oklch(92.49% 0 0); + --main-foreground: oklch(0% 0 0); + --main: oklch(67.47% 0.1726 259.49); + --border: oklch(0% 0 0); + --ring: oklch(100% 0 0); + --shadow: 4px 4px 0px 0px var(--border); + --chart-1: #5294ff; + --chart-2: #ff6669; + --chart-3: #e0b700; + --chart-4: #04c86d; + --chart-5: #7a83ff; + --chart-active-dot: #fff; +} + +@theme inline { + --color-main: var(--main); + --color-background: var(--background); + --color-secondary-background: var(--secondary-background); + --color-foreground: var(--foreground); + --color-main-foreground: var(--main-foreground); + --color-border: var(--border); + --color-overlay: var(--overlay); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + /* Neobrutalism Color System */ + --color-red-100: #ffcccb; + --color-red-200: #ff9f9f; + --color-red-300: #fa7a7a; + --color-red-400: #f76363; + --color-red-500: #e53e3e; + + --color-orange-100: #ffe4cc; + --color-orange-200: #ffc29f; + --color-orange-300: #ff965b; + --color-orange-400: #fa8543; + --color-orange-500: #dd6b20; + + --color-yellow-100: #fff5cc; + --color-yellow-200: #fff066; + --color-yellow-300: #ffe500; + --color-yellow-400: #ffe500; + --color-yellow-500: #d69e2e; + + --color-lime-100: #e6ffcc; + --color-lime-200: #b8ff9f; + --color-lime-300: #9dfc7c; + --color-lime-400: #7df752; + --color-lime-500: #68d391; + + --color-cyan-100: #ccf7ff; + --color-cyan-200: #a6faff; + --color-cyan-300: #79f7ff; + --color-cyan-400: #53f2fc; + --color-cyan-500: #4dd5e6; + + --color-blue-100: #e6f3ff; + --color-blue-200: #cce7ff; + --color-blue-300: #5294ff; + --color-blue-400: oklch(67.47% 0.1726 259.49); + --color-blue-500: #2b77e6; + + --color-violet-100: #f0ccff; + --color-violet-200: #a8a6ff; + --color-violet-300: #918efa; + --color-violet-400: #807dfa; + --color-violet-500: #7a83ff; + + --color-pink-100: #ffccf9; + --color-pink-200: #ffa6f6; + --color-pink-300: #fa8cef; + --color-pink-400: #fa7fee; + --color-pink-500: #ed64a6; + + --spacing-boxShadowX: 4px; + --spacing-boxShadowY: 4px; + --spacing-reverseBoxShadowX: -4px; + --spacing-reverseBoxShadowY: -4px; + --radius-base: 5px; + --shadow-shadow: var(--shadow); + --font-weight-base: 500; + --font-weight-heading: 700; +} @theme { - /* Color definitions - these will be available as bg-midnight, text-tahiti, etc. */ --color-midnight: #333; --color-tahiti: #999; --color-bermuda: #ccc; @@ -43,6 +154,21 @@ 'Segoe UI Symbol', 'Noto Color Emoji'; } +@layer base { + body { + @apply text-foreground font-base bg-background; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + @apply font-heading; + } +} + html, body { @apply bg-light-background-00 dark:bg-dark-background-00 text-light-primary dark:text-dark-primary; @@ -189,6 +315,26 @@ a, @apply bg-accent-03-light dark:bg-accent-03-dark; } +/* Global scrollbar styling */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(156, 163, 175, 0.5) transparent; +} + +::-webkit-scrollbar { + width: 5px; + height: 40px; +} + +::-webkit-scrollbar-thumb { + background: hsl(var(--gray-300)); + border-radius: 5px; +} + +::-webkit-scrollbar-track { + background: 0 0; +} + .text-accent-00 { @apply text-accent-00-light dark:text-accent-00-dark; } diff --git a/app/components/ChatInput.tsx b/app/components/ChatInput.tsx index 2f52d8763..e5ffa21a0 100644 --- a/app/components/ChatInput.tsx +++ b/app/components/ChatInput.tsx @@ -1,84 +1,150 @@ import type { ChangeEvent, KeyboardEvent } from 'react'; -import { useEffect, memo, useCallback } from 'react'; +import { useEffect, useCallback, useRef, forwardRef, useImperativeHandle, useState } from 'react'; import type { ChatState } from '../types/chat'; -import VibesDIYLogo from './VibesDIYLogo'; +import ModelPicker, { type ModelOption } from './ModelPicker'; +import { preloadLlmsText } from '../prompts'; interface ChatInputProps { chatState: ChatState; onSend: () => void; + // Optional model picker props (for backward compatibility in tests/stories) + currentModel?: string; + onModelChange?: (modelId: string) => void | Promise; + models?: ModelOption[]; + globalModel?: string; + showModelPickerInChat?: boolean; } -function ChatInput({ chatState, onSend }: ChatInputProps) { - // Internal callback to handle sending messages - const handleSendMessage = useCallback(() => { - if (chatState.sendMessage && !chatState.isStreaming) { - chatState.sendMessage(chatState.input); - onSend(); // Call onSend for side effects only - } - }, [chatState, onSend]); - // Auto-resize textarea function - const autoResizeTextarea = useCallback(() => { - const textarea = chatState.inputRef.current; - if (textarea) { - textarea.style.height = 'auto'; - const maxHeight = 200; - const minHeight = 90; - textarea.style.height = `${Math.max(minHeight, Math.min(maxHeight, textarea.scrollHeight))}px`; - } - }, [chatState.inputRef]); +export interface ChatInputRef { + clickSubmit: () => void; +} - // Initial auto-resize - useEffect(() => { - autoResizeTextarea(); - }, [chatState.input, autoResizeTextarea]); +const ChatInput = forwardRef( + ( + { chatState, onSend, currentModel, onModelChange, models, globalModel, showModelPickerInChat }, + ref + ) => { + // Refs + const submitButtonRef = useRef(null); + const containerRef = useRef(null); - return ( -
-
-