From d62fc7d11e2cba945c9278b802d387d4381cee5c Mon Sep 17 00:00:00 2001 From: badarouzia Date: Fri, 22 May 2026 13:10:11 +0200 Subject: [PATCH 01/17] feat(chatbot): add remaining atom components ChatInput, ChatAvatar, and TypingIndicator --- .../components/atoms/Chatbot/ChatBubble.tsx | 54 +++++++++++++++++++ .../components/atoms/Chatbot/ChatInput.tsx | 20 +++++++ .../atoms/Chatbot/TypingIndicator.tsx | 40 ++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 web/src/components/atoms/Chatbot/ChatBubble.tsx create mode 100644 web/src/components/atoms/Chatbot/ChatInput.tsx create mode 100644 web/src/components/atoms/Chatbot/TypingIndicator.tsx diff --git a/web/src/components/atoms/Chatbot/ChatBubble.tsx b/web/src/components/atoms/Chatbot/ChatBubble.tsx new file mode 100644 index 00000000..6ab0172c --- /dev/null +++ b/web/src/components/atoms/Chatbot/ChatBubble.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +interface ChatBubbleProps { + /** Le contenu textuel du message */ + message: string; + /** L'expéditeur du message pour adapter le style */ + sender: 'user' | 'ai'; +} + +/** + * ChatBubble Component + * Gère l'affichage visuel d'une bulle de message unique avec le style + * approprié selon l'émetteur (Utilisateur ou IA). + */ +export const ChatBubble = ({ message, sender }: ChatBubbleProps) => { + const isAi = sender === 'ai'; + + return ( +
+
+

{message}

+
+
+ ); +}; + +// ── Styles en ligne (CSS-in-JS) ── +const bubbleContainer: React.CSSProperties = { + display: 'flex', + width: '100%', + margin: '4px 0', +}; + +const bubbleStyle: React.CSSProperties = { + maxWidth: '80%', + padding: '12px 16px', + boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)', + wordBreak: 'break-word', +}; + +const textStyle: React.CSSProperties = { + margin: 0, + fontSize: '14px', + lineHeight: '1.5', + fontFamily: 'inherit', +}; \ No newline at end of file diff --git a/web/src/components/atoms/Chatbot/ChatInput.tsx b/web/src/components/atoms/Chatbot/ChatInput.tsx new file mode 100644 index 00000000..23a46596 --- /dev/null +++ b/web/src/components/atoms/Chatbot/ChatInput.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +interface ChatInputProps extends React.InputHTMLAttributes { + // Hérite de tous les attributs classiques d'un input HTML +} + +export const ChatInput = (props: ChatInputProps) => { + return ; +}; + +const inputStyle: React.CSSProperties = { + flex: 1, + border: 'none', + outline: 'none', + backgroundColor: 'transparent', + color: '#FFFFFF', + fontSize: '15px', + fontFamily: 'inherit', + padding: '12px 14px', +}; \ No newline at end of file diff --git a/web/src/components/atoms/Chatbot/TypingIndicator.tsx b/web/src/components/atoms/Chatbot/TypingIndicator.tsx new file mode 100644 index 00000000..2b319d5d --- /dev/null +++ b/web/src/components/atoms/Chatbot/TypingIndicator.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +export const TypingIndicator = () => { + return ( +
+
+
+
+ + {/* Petit hack CSS inline injecté pour l'animation des points */} + +
+ ); +}; + +const containerStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: '4px', + padding: '14px 18px', + backgroundColor: '#FFFFFF', + border: '1px solid #E2E8F0', + borderRadius: '16px 16px 16px 4px', + width: 'fit-content', +}; + +const dotStyle: React.CSSProperties = { + width: '6px', + height: '6px', + backgroundColor: '#64748B', + borderRadius: '50%', +}; \ No newline at end of file From a599e80d9f3e37f05f727390df68399448462bed Mon Sep 17 00:00:00 2001 From: badarouzia Date: Sat, 23 May 2026 05:58:04 +0200 Subject: [PATCH 02/17] feat: inject ChatWidget globally in root layout --- web/DESIGN.md | 434 +++++++++--------- .../components/atoms/Chatbot/ChatAvatar.tsx | 35 ++ .../components/atoms/Chatbot/ChatBubble.tsx | 91 ++-- .../components/atoms/Chatbot/ChatInput.tsx | 78 +++- .../atoms/Chatbot/TypingIndicator.tsx | 69 ++- .../molecules/chatbot/ChatInputBar.tsx | 77 ++++ .../molecules/chatbot/ChatMessage.tsx | 63 +++ .../organisms/chatbot/ChatWidget.tsx | 237 ++++++++++ .../organisms/chatbot/ChatWindow.tsx | 118 +++++ web/src/routes/ai-chat.tsx | 58 +-- 10 files changed, 889 insertions(+), 371 deletions(-) create mode 100644 web/src/components/atoms/Chatbot/ChatAvatar.tsx create mode 100644 web/src/components/molecules/chatbot/ChatInputBar.tsx create mode 100644 web/src/components/molecules/chatbot/ChatMessage.tsx create mode 100644 web/src/components/organisms/chatbot/ChatWidget.tsx create mode 100644 web/src/components/organisms/chatbot/ChatWindow.tsx diff --git a/web/DESIGN.md b/web/DESIGN.md index c480f99b..0e218031 100644 --- a/web/DESIGN.md +++ b/web/DESIGN.md @@ -1,217 +1,217 @@ -# TalkUp.AI — Web design system - -This document describes how the **TalkUp** product should look and feel in the browser. It is aligned with the **implemented tokens** in `src/styles/tailwind.css` (Tailwind v4 `@theme`). When in doubt, **the CSS variables win** — update this file if tokens change. - -TalkUp is a career and interview preparation product (simulations, agenda, notes, profile). The UI should feel **clear, professional, and calm**: plenty of whitespace, readable type, and a **cool blue / indigo** accent — not a warm “marketing brochure” palette. - ---- - -## 1. Visual theme and atmosphere - -- **Canvas**: White (`--color-background`) with **subtle cool-tinted surfaces** (`--color-surface` `#f8f9ff`, `--color-surface-raised` `#ecedf6`) so sections and sidebars feel layered without heavy chrome. -- **Accent**: Interactive blue `--color-accent` `#2b70c9` (links, focus, primary actions). Deeper navy **`--color-primary` `#29457a`** supports brand-weight elements where used in the app. -- **Contrast**: Body text is near-black violet-gray (`--color-text` `#24242d`), not pure black — slightly softer for long sessions (interviews, calendar, editors). -- **Depth**: Prefer **borders** (`--color-border` `#d9dbeb`) and light surface steps over dramatic shadows. Elevation is modest; the product is tool-like, not a landing-page spectacle. -- **Dark mode**: The `.dark` class on the document flips the full palette (see `tailwind.css`). New UI must remain usable in both schemes. - -**Key characteristics** - -- Cool lavender-gray surfaces, blue accent, high legibility. -- Structured layouts (dashboards, tables, forms) over full-bleed gradient marketing blocks. -- French copy is common in settings flows; layout should tolerate longer labels. - ---- - -## 2. Color palette and roles - -Use **semantic CSS variables** in components (Tailwind: `bg-background`, `text-text`, `border-border`, `bg-accent`, etc., per your class conventions). Hex values below are the **light theme** defaults from `@theme`. - -### Brand and interactive - -| Role | Variable | Light default | Usage | -|------|-----------|---------------|--------| -| Accent | `--color-accent` | `#2b70c9` | Links, primary buttons, focus rings, key highlights | -| Accent hover / active | `--color-accent-hover`, `--color-accent-active` | `#205497`, `#163865` | Pressed / hover states | -| Primary (brand weight) | `--color-primary` | `#29457a` | Strong brand moments (varies by screen) | -| Text link | `--color-text-link` | `#2b70c9` | Inline links | - -### Surfaces - -| Role | Variable | Light default | -|------|-----------|---------------| -| Page background | `--color-background` | `#fff` | -| Soft panels | `--color-surface` | `#f8f9ff` | -| Raised / cards / sidebar | `--color-surface-raised`, `--color-surface-sidebar` | `#ecedf6` | -| Note cards | `--color-note-card` | `#ecedf6` | - -### Text - -| Role | Variable | Light default | -|------|-----------|---------------| -| Primary | `--color-text` | `#24242d` | -| Secondary | `--color-text-weak` … `--color-text-weakest` | `#383850` → `#a4a4b2` | -| Muted UI | `--color-text-idle` | `#57585e` | - -### Borders and icons - -| Role | Variable | Light default | -|------|-----------|---------------| -| Default border | `--color-border` | `#d9dbeb` | -| Strong border | `--color-border-strong` | `#c8c8d4` | -| Icons | `--color-icon` | `#5f5f77` | - -### Status (use full scale: base + weak/weaker + hover/active) - -- **Success**: `--color-success` (e.g. confirmations, positive states) -- **Warning**: `--color-warning` -- **Error / danger**: `--color-error`, `--color-danger` (aliases) - -### User-chosen accents (profile / appearance) - -Some flows (e.g. profile appearance) allow a **user accent** such as `#2B70C9`, `#1D9E75`, etc. Treat these as **overrides on top of** the system accent for that user’s preview only; core chrome should still respect theme tokens where possible. - ---- - -## 3. Typography - -### Font families - -| Role | Variable / stack | Usage | -|------|------------------|--------| -| Display | `--font-display`: **Saira**, `sans-serif` | Headings (`text-h1` … `text-h6`), button text utilities | -| Body / UI | `--font-body`: **Inter**, `sans-serif` | Body copy, labels, dense UI | - -Google Fonts are loaded from `index.html` (Inter + Saira, variable weights). - -### Scale (implemented utilities) - -Sizes are defined as `@theme` variables and applied via classes in `@layer utilities` in `tailwind.css`: - -| Utility | Font | Size | Weight (typical) | -|---------|------|------|-------------------| -| `text-h1` … `text-h4` | Saira | 40px … 24px | 700 | -| `text-h5` | Saira | 20px | 500 | -| `text-h6` | Saira | 18px | 400 | -| `text-body-xl-strong` | Inter | 18px | 700 | -| `text-body-l`, `text-body-l-strong` | Inter | 16px | 400 / 700 | -| `text-body-m`, `text-label-*` | Inter | 14px | 400 | -| `text-body-s`, `text-body-s-strong` | Inter | 12px | 400 / 700 | -| `text-button-m`, `text-button-s` | Saira | 14px / 12px | 400 | - -### Principles - -- **Headings**: Saira, bold display treatment — confident and product-like (not ultra-light display cut). -- **Body**: Inter, comfortable line-height (~1.5) for forms, notes, and interview UI. -- **Buttons**: Saira via `text-button-*` utilities keeps actions visually consistent with the brand. -- Do **not** introduce ad hoc font families for new screens unless there is a strong reason; extend the existing scale. - ---- - -## 4. Component styling (product conventions) - -### Buttons - -- **Primary**: Filled with accent / primary tokens, white or high-contrast label text, clear hover/active (see existing button atoms and Tailwind classes). -- **Secondary / ghost**: Transparent or surface background, border from `--color-border`, text from secondary text tokens. -- **Destructive**: Use `--color-danger` and its hover/active variants. - -Avoid ElevenLabs-style **full pill (9999px) everywhere** unless a specific screen already uses that pattern; match existing app buttons. - -### Cards and panels - -- Background: `--color-background` or `--color-surface` / `--color-surface-raised` depending on hierarchy. -- Border: `1px` or token border with `--color-border`; radius typically **8px–12px** in newer profile-style UI, consistent with surrounding components. -- Prefer spacing and alignment over heavy shadow stacks. - -### Inputs and forms - -- Borders and focus: use **accent-colored focus** (e.g. ring or border + soft glow) consistent with `--color-accent`. -- Labels: secondary text color, small/medium body scale. -- Always label controls for accessibility (`htmlFor` / `id`). - -### Navigation and shell - -- Sidebars often use `--color-surface-sidebar` and border separation. -- Sticky top bars: light background, bottom border; actions right-aligned where the app already does so. - -### Atomic design - -Shared UI lives under `src/components/` in **atoms → molecules → organisms**. Prefer extending existing pieces before adding parallel patterns. - ---- - -## 5. Layout and spacing - -- **Base unit**: 4px / 8px mental grid (Tailwind spacing scale is the practical reference). -- **Density**: Interview and calendar views may be denser; marketing-style “huge vertical gaps” are not the default. -- **Max width**: Centered content where it matches existing routes; dashboards are often full-width with internal max-width for readability. - ---- - -## 6. Depth and elevation - -TalkUp does **not** rely on a multi-layer, warm-tinted shadow language. Prefer: - -- **Flat** surfaces with borders. -- **Optional** light shadow for modals / dropdowns only where already used in the codebase. - -Focus rings should meet accessibility contrast; use accent or browser defaults wired through Tailwind focus utilities. - ---- - -## 7. Do’s and don’ts - -### Do - -- Use **`tailwind.css` tokens** for colors, fonts, and semantic states (including dark mode). -- Use **Saira + Inter** via the provided utilities. -- Keep **accent blue** (`#2b70c9` light) as the default interactive color unless the screen is explicitly user-themed. -- Test **dark mode** when adding persistent UI. - -### Don’t - -- Don’t replace the palette with **achromatic warm stone** or black-only CTAs from third-party reference docs. -- Don’t add **Waldenburg / Geist** or other fonts not in the project without a deliberate design decision and `index.html` update. -- Don’t hardcode hex colors when a **CSS variable or Tailwind semantic class** exists. -- Don’t ship UI that only works in light mode. - ---- - -## 8. Responsive behavior - -Follow patterns already used in the app (TanStack Router layouts, existing breakpoints in components). Typical expectations: - -- **Mobile**: stack sidebars / filters; preserve touch-friendly targets (min ~44px where possible). -- **Desktop**: multi-column dashboards, calendar grids, split editors. - -There is no single “hamburger at 1024px” rule in this doc — match each route’s existing layout. - ---- - -## 9. Agent prompt guide (TalkUp-aligned) - -### Quick token reference - -- Page: `var(--color-background)`; soft sections: `var(--color-surface)`. -- Text: `var(--color-text)` primary, `var(--color-text-weaker)` secondary. -- Actions: `var(--color-accent)`; borders: `var(--color-border)`. -- Headings: Saira; body: Inter; sizes from `text-h*` / `text-body-*`. - -### Example prompts - -- *“Build a settings card: background `var(--color-surface-raised)`, 10px radius, border `var(--color-border)`. Section title Saira 13px semibold, body Inter 13px `var(--color-text-weaker)`. Primary button filled `var(--color-accent)` white text.”* -- *“Create a form row: label Inter 12px `var(--color-text-idle)`, input border `var(--color-border)`, focus ring using `var(--color-accent)` at ~22% opacity.”* -- *“Hero for TalkUp: headline Saira `text-h1`, subcopy Inter `text-body-l`, CTA primary accent — cool white/lavender surfaces, not warm beige.”* - -### Iteration checklist - -1. Check `tailwind.css` for the latest variable values. -2. Use semantic tokens, not one-off hex, unless user-specific accent. -3. Verify **dark** class behavior for new surfaces and borders. -4. Reuse **atoms/molecules/organisms** before inventing new folders. - ---- - -## 10. Relation to external inspiration - -Earlier drafts referenced **ElevenLabs**-style marketing aesthetics (warm stone, Waldenburg, extreme pill buttons, layered shadows). **That is not the TalkUp baseline.** You may still borrow *ideas* (clarity, whitespace, strong hierarchy) as long as **colors, fonts, and tokens** stay consistent with this file and `src/styles/tailwind.css`. +# TalkUp.AI — Web design system + +This document describes how the **TalkUp** product should look and feel in the browser. It is aligned with the **implemented tokens** in `src/styles/tailwind.css` (Tailwind v4 `@theme`). When in doubt, **the CSS variables win** — update this file if tokens change. + +TalkUp is a career and interview preparation product (simulations, agenda, notes, profile). The UI should feel **clear, professional, and calm**: plenty of whitespace, readable type, and a **cool blue / indigo** accent — not a warm “marketing brochure” palette. + +--- + +## 1. Visual theme and atmosphere + +- **Canvas**: White (`--color-background`) with **subtle cool-tinted surfaces** (`--color-surface` `#f8f9ff`, `--color-surface-raised` `#ecedf6`) so sections and sidebars feel layered without heavy chrome. +- **Accent**: Interactive blue `--color-accent` `#2b70c9` (links, focus, primary actions). Deeper navy **`--color-primary` `#29457a`** supports brand-weight elements where used in the app. +- **Contrast**: Body text is near-black violet-gray (`--color-text` `#24242d`), not pure black — slightly softer for long sessions (interviews, calendar, editors). +- **Depth**: Prefer **borders** (`--color-border` `#d9dbeb`) and light surface steps over dramatic shadows. Elevation is modest; the product is tool-like, not a landing-page spectacle. +- **Dark mode**: The `.dark` class on the document flips the full palette (see `tailwind.css`). New UI must remain usable in both schemes. + +**Key characteristics** + +- Cool lavender-gray surfaces, blue accent, high legibility. +- Structured layouts (dashboards, tables, forms) over full-bleed gradient marketing blocks. +- French copy is common in settings flows; layout should tolerate longer labels. + +--- + +## 2. Color palette and roles + +Use **semantic CSS variables** in components (Tailwind: `bg-background`, `text-text`, `border-border`, `bg-accent`, etc., per your class conventions). Hex values below are the **light theme** defaults from `@theme`. + +### Brand and interactive + +| Role | Variable | Light default | Usage | +| ---------------------- | ----------------------------------------------- | -------------------- | --------------------------------------------------- | +| Accent | `--color-accent` | `#2b70c9` | Links, primary buttons, focus rings, key highlights | +| Accent hover / active | `--color-accent-hover`, `--color-accent-active` | `#205497`, `#163865` | Pressed / hover states | +| Primary (brand weight) | `--color-primary` | `#29457a` | Strong brand moments (varies by screen) | +| Text link | `--color-text-link` | `#2b70c9` | Inline links | + +### Surfaces + +| Role | Variable | Light default | +| ------------------------ | --------------------------------------------------- | ------------- | +| Page background | `--color-background` | `#fff` | +| Soft panels | `--color-surface` | `#f8f9ff` | +| Raised / cards / sidebar | `--color-surface-raised`, `--color-surface-sidebar` | `#ecedf6` | +| Note cards | `--color-note-card` | `#ecedf6` | + +### Text + +| Role | Variable | Light default | +| --------- | -------------------------------------------- | --------------------- | +| Primary | `--color-text` | `#24242d` | +| Secondary | `--color-text-weak` … `--color-text-weakest` | `#383850` → `#a4a4b2` | +| Muted UI | `--color-text-idle` | `#57585e` | + +### Borders and icons + +| Role | Variable | Light default | +| -------------- | ----------------------- | ------------- | +| Default border | `--color-border` | `#d9dbeb` | +| Strong border | `--color-border-strong` | `#c8c8d4` | +| Icons | `--color-icon` | `#5f5f77` | + +### Status (use full scale: base + weak/weaker + hover/active) + +- **Success**: `--color-success` (e.g. confirmations, positive states) +- **Warning**: `--color-warning` +- **Error / danger**: `--color-error`, `--color-danger` (aliases) + +### User-chosen accents (profile / appearance) + +Some flows (e.g. profile appearance) allow a **user accent** such as `#2B70C9`, `#1D9E75`, etc. Treat these as **overrides on top of** the system accent for that user’s preview only; core chrome should still respect theme tokens where possible. + +--- + +## 3. Typography + +### Font families + +| Role | Variable / stack | Usage | +| --------- | ----------------------------------------- | ------------------------------------------------------- | +| Display | `--font-display`: **Saira**, `sans-serif` | Headings (`text-h1` … `text-h6`), button text utilities | +| Body / UI | `--font-body`: **Inter**, `sans-serif` | Body copy, labels, dense UI | + +Google Fonts are loaded from `index.html` (Inter + Saira, variable weights). + +### Scale (implemented utilities) + +Sizes are defined as `@theme` variables and applied via classes in `@layer utilities` in `tailwind.css`: + +| Utility | Font | Size | Weight (typical) | +| ----------------------------------- | ----- | ----------- | ---------------- | +| `text-h1` … `text-h4` | Saira | 40px … 24px | 700 | +| `text-h5` | Saira | 20px | 500 | +| `text-h6` | Saira | 18px | 400 | +| `text-body-xl-strong` | Inter | 18px | 700 | +| `text-body-l`, `text-body-l-strong` | Inter | 16px | 400 / 700 | +| `text-body-m`, `text-label-*` | Inter | 14px | 400 | +| `text-body-s`, `text-body-s-strong` | Inter | 12px | 400 / 700 | +| `text-button-m`, `text-button-s` | Saira | 14px / 12px | 400 | + +### Principles + +- **Headings**: Saira, bold display treatment — confident and product-like (not ultra-light display cut). +- **Body**: Inter, comfortable line-height (~1.5) for forms, notes, and interview UI. +- **Buttons**: Saira via `text-button-*` utilities keeps actions visually consistent with the brand. +- Do **not** introduce ad hoc font families for new screens unless there is a strong reason; extend the existing scale. + +--- + +## 4. Component styling (product conventions) + +### Buttons + +- **Primary**: Filled with accent / primary tokens, white or high-contrast label text, clear hover/active (see existing button atoms and Tailwind classes). +- **Secondary / ghost**: Transparent or surface background, border from `--color-border`, text from secondary text tokens. +- **Destructive**: Use `--color-danger` and its hover/active variants. + +Avoid ElevenLabs-style **full pill (9999px) everywhere** unless a specific screen already uses that pattern; match existing app buttons. + +### Cards and panels + +- Background: `--color-background` or `--color-surface` / `--color-surface-raised` depending on hierarchy. +- Border: `1px` or token border with `--color-border`; radius typically **8px–12px** in newer profile-style UI, consistent with surrounding components. +- Prefer spacing and alignment over heavy shadow stacks. + +### Inputs and forms + +- Borders and focus: use **accent-colored focus** (e.g. ring or border + soft glow) consistent with `--color-accent`. +- Labels: secondary text color, small/medium body scale. +- Always label controls for accessibility (`htmlFor` / `id`). + +### Navigation and shell + +- Sidebars often use `--color-surface-sidebar` and border separation. +- Sticky top bars: light background, bottom border; actions right-aligned where the app already does so. + +### Atomic design + +Shared UI lives under `src/components/` in **atoms → molecules → organisms**. Prefer extending existing pieces before adding parallel patterns. + +--- + +## 5. Layout and spacing + +- **Base unit**: 4px / 8px mental grid (Tailwind spacing scale is the practical reference). +- **Density**: Interview and calendar views may be denser; marketing-style “huge vertical gaps” are not the default. +- **Max width**: Centered content where it matches existing routes; dashboards are often full-width with internal max-width for readability. + +--- + +## 6. Depth and elevation + +TalkUp does **not** rely on a multi-layer, warm-tinted shadow language. Prefer: + +- **Flat** surfaces with borders. +- **Optional** light shadow for modals / dropdowns only where already used in the codebase. + +Focus rings should meet accessibility contrast; use accent or browser defaults wired through Tailwind focus utilities. + +--- + +## 7. Do’s and don’ts + +### Do + +- Use **`tailwind.css` tokens** for colors, fonts, and semantic states (including dark mode). +- Use **Saira + Inter** via the provided utilities. +- Keep **accent blue** (`#2b70c9` light) as the default interactive color unless the screen is explicitly user-themed. +- Test **dark mode** when adding persistent UI. + +### Don’t + +- Don’t replace the palette with **achromatic warm stone** or black-only CTAs from third-party reference docs. +- Don’t add **Waldenburg / Geist** or other fonts not in the project without a deliberate design decision and `index.html` update. +- Don’t hardcode hex colors when a **CSS variable or Tailwind semantic class** exists. +- Don’t ship UI that only works in light mode. + +--- + +## 8. Responsive behavior + +Follow patterns already used in the app (TanStack Router layouts, existing breakpoints in components). Typical expectations: + +- **Mobile**: stack sidebars / filters; preserve touch-friendly targets (min ~44px where possible). +- **Desktop**: multi-column dashboards, calendar grids, split editors. + +There is no single “hamburger at 1024px” rule in this doc — match each route’s existing layout. + +--- + +## 9. Agent prompt guide (TalkUp-aligned) + +### Quick token reference + +- Page: `var(--color-background)`; soft sections: `var(--color-surface)`. +- Text: `var(--color-text)` primary, `var(--color-text-weaker)` secondary. +- Actions: `var(--color-accent)`; borders: `var(--color-border)`. +- Headings: Saira; body: Inter; sizes from `text-h*` / `text-body-*`. + +### Example prompts + +- _“Build a settings card: background `var(--color-surface-raised)`, 10px radius, border `var(--color-border)`. Section title Saira 13px semibold, body Inter 13px `var(--color-text-weaker)`. Primary button filled `var(--color-accent)` white text.”_ +- _“Create a form row: label Inter 12px `var(--color-text-idle)`, input border `var(--color-border)`, focus ring using `var(--color-accent)` at ~22% opacity.”_ +- _“Hero for TalkUp: headline Saira `text-h1`, subcopy Inter `text-body-l`, CTA primary accent — cool white/lavender surfaces, not warm beige.”_ + +### Iteration checklist + +1. Check `tailwind.css` for the latest variable values. +2. Use semantic tokens, not one-off hex, unless user-specific accent. +3. Verify **dark** class behavior for new surfaces and borders. +4. Reuse **atoms/molecules/organisms** before inventing new folders. + +--- + +## 10. Relation to external inspiration + +Earlier drafts referenced **ElevenLabs**-style marketing aesthetics (warm stone, Waldenburg, extreme pill buttons, layered shadows). **That is not the TalkUp baseline.** You may still borrow _ideas_ (clarity, whitespace, strong hierarchy) as long as **colors, fonts, and tokens** stay consistent with this file and `src/styles/tailwind.css`. diff --git a/web/src/components/atoms/Chatbot/ChatAvatar.tsx b/web/src/components/atoms/Chatbot/ChatAvatar.tsx new file mode 100644 index 00000000..b8d485fe --- /dev/null +++ b/web/src/components/atoms/Chatbot/ChatAvatar.tsx @@ -0,0 +1,35 @@ +/** + * ChatAvatar + * + * Displays a small circular avatar inside the chatbot interface. + * The 'ai' variant uses the TalkUp gradient with a "T" initial. + * The 'user' variant uses a neutral slate background with a "U" initial. + * + * @param props - ChatAvatarProps + * @returns A circular avatar badge as a React functional component. + * + * @example + * + * + */ + +interface ChatAvatarProps { + /** 'ai' for the TalkUp assistant avatar, 'user' for the current user */ + variant: 'ai' | 'user'; +} + +export const ChatAvatar = ({ variant }: ChatAvatarProps) => { + const isAi = variant === 'ai'; + + return ( +
+ {isAi ? 'T' : 'U'} +
+ ); +}; diff --git a/web/src/components/atoms/Chatbot/ChatBubble.tsx b/web/src/components/atoms/Chatbot/ChatBubble.tsx index 6ab0172c..be791922 100644 --- a/web/src/components/atoms/Chatbot/ChatBubble.tsx +++ b/web/src/components/atoms/Chatbot/ChatBubble.tsx @@ -1,54 +1,37 @@ -import React from 'react'; - -interface ChatBubbleProps { - /** Le contenu textuel du message */ - message: string; - /** L'expéditeur du message pour adapter le style */ - sender: 'user' | 'ai'; -} - -/** - * ChatBubble Component - * Gère l'affichage visuel d'une bulle de message unique avec le style - * approprié selon l'émetteur (Utilisateur ou IA). - */ -export const ChatBubble = ({ message, sender }: ChatBubbleProps) => { - const isAi = sender === 'ai'; - - return ( -
-
-

{message}

-
-
- ); -}; - -// ── Styles en ligne (CSS-in-JS) ── -const bubbleContainer: React.CSSProperties = { - display: 'flex', - width: '100%', - margin: '4px 0', -}; - -const bubbleStyle: React.CSSProperties = { - maxWidth: '80%', - padding: '12px 16px', - boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)', - wordBreak: 'break-word', -}; - -const textStyle: React.CSSProperties = { - margin: 0, - fontSize: '14px', - lineHeight: '1.5', - fontFamily: 'inherit', -}; \ No newline at end of file +/** + * ChatBubble + * + * Renders a single message bubble inside the chatbot widget. + * Supports two variants: 'ai' (left-aligned, white background) and + * 'user' (right-aligned, TalkUp gradient background). + * + * @param props - ChatBubbleProps + * @returns A styled message bubble as a React functional component. + * + * @example + * + * + */ + +interface ChatBubbleProps { + /** The text content of the message */ + message: string; + /** Visual variant: 'ai' for assistant messages, 'user' for user messages */ + variant: 'ai' | 'user'; +} + +export const ChatBubble = ({ message, variant }: ChatBubbleProps) => { + const isAi = variant === 'ai'; + + return ( +
+ {message} +
+ ); +}; diff --git a/web/src/components/atoms/Chatbot/ChatInput.tsx b/web/src/components/atoms/Chatbot/ChatInput.tsx index 23a46596..78030598 100644 --- a/web/src/components/atoms/Chatbot/ChatInput.tsx +++ b/web/src/components/atoms/Chatbot/ChatInput.tsx @@ -1,20 +1,58 @@ -import React from 'react'; - -interface ChatInputProps extends React.InputHTMLAttributes { - // Hérite de tous les attributs classiques d'un input HTML -} - -export const ChatInput = (props: ChatInputProps) => { - return ; -}; - -const inputStyle: React.CSSProperties = { - flex: 1, - border: 'none', - outline: 'none', - backgroundColor: 'transparent', - color: '#FFFFFF', - fontSize: '15px', - fontFamily: 'inherit', - padding: '12px 14px', -}; \ No newline at end of file +/** + * ChatInput + * + * A controlled single-line text input for the chatbot message bar. + * Triggers onSend when the user presses Enter (without Shift). + * + * @param props - ChatInputProps + * @returns A styled text input as a React functional component. + * + * @example + * + */ + +interface ChatInputProps { + /** Current value of the input */ + value: string; + /** Callback fired on each keystroke */ + onChange: (value: string) => void; + /** Callback fired when the user presses Enter */ + onSend: () => void; + /** Placeholder text */ + placeholder?: string; + /** Whether the input is disabled */ + disabled?: boolean; +} + +export const ChatInput = ({ + value, + onChange, + onSend, + placeholder = 'Ask a question...', + disabled = false, +}: ChatInputProps) => { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + onSend(); + } + }; + + return ( + onChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={disabled} + aria-label="Chat message input" + className="flex-1 px-3 py-2 text-sm bg-slate-50 border border-slate-200 rounded-xl outline-none text-slate-800 placeholder:text-slate-400 focus:border-[#2B70C9] focus:bg-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + /> + ); +}; diff --git a/web/src/components/atoms/Chatbot/TypingIndicator.tsx b/web/src/components/atoms/Chatbot/TypingIndicator.tsx index 2b319d5d..f6b5687b 100644 --- a/web/src/components/atoms/Chatbot/TypingIndicator.tsx +++ b/web/src/components/atoms/Chatbot/TypingIndicator.tsx @@ -1,40 +1,29 @@ -import React from 'react'; - -export const TypingIndicator = () => { - return ( -
-
-
-
- - {/* Petit hack CSS inline injecté pour l'animation des points */} - -
- ); -}; - -const containerStyle: React.CSSProperties = { - display: 'flex', - alignItems: 'center', - gap: '4px', - padding: '14px 18px', - backgroundColor: '#FFFFFF', - border: '1px solid #E2E8F0', - borderRadius: '16px 16px 16px 4px', - width: 'fit-content', -}; - -const dotStyle: React.CSSProperties = { - width: '6px', - height: '6px', - backgroundColor: '#64748B', - borderRadius: '50%', -}; \ No newline at end of file +/** + * TypingIndicator + * + * Displays an animated three-dot indicator to signal that the TalkUp AI + * is currently generating a response. Uses CSS animations via Tailwind. + * + * @returns An animated typing indicator as a React functional component. + * + * @example + * {isTyping && } + */ + +export const TypingIndicator = () => { + return ( +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ ); +}; diff --git a/web/src/components/molecules/chatbot/ChatInputBar.tsx b/web/src/components/molecules/chatbot/ChatInputBar.tsx new file mode 100644 index 00000000..40ff9290 --- /dev/null +++ b/web/src/components/molecules/chatbot/ChatInputBar.tsx @@ -0,0 +1,77 @@ +import { ChatInput } from '@/components/atoms/Chatbot/ChatInput'; + +/** + * ChatInputBar + * + * Combines ChatInput + a send button into a single input bar. + * The send button is disabled when the input is empty or when isLoading is true. + * Triggers onSend both via the button click and via the ChatInput Enter key handler. + * + * @param props - ChatInputBarProps + * @returns A full input bar row as a React functional component. + * + * @example + * + */ + +interface ChatInputBarProps { + /** Current value of the text input */ + value: string; + /** Callback fired on each keystroke */ + onChange: (value: string) => void; + /** Callback fired when the user sends a message */ + onSend: () => void; + /** When true, disables input and send button while AI is responding */ + isLoading?: boolean; +} + +export const ChatInputBar = ({ + value, + onChange, + onSend, + isLoading = false, +}: ChatInputBarProps) => { + const canSend = value.trim().length > 0 && !isLoading; + + return ( +
+ + + +
+ ); +}; diff --git a/web/src/components/molecules/chatbot/ChatMessage.tsx b/web/src/components/molecules/chatbot/ChatMessage.tsx new file mode 100644 index 00000000..270a59d5 --- /dev/null +++ b/web/src/components/molecules/chatbot/ChatMessage.tsx @@ -0,0 +1,63 @@ +import { ChatAvatar } from '@/components/atoms/Chatbot/ChatAvatar'; +import { ChatBubble } from '@/components/atoms/Chatbot/ChatBubble'; +import { TypingIndicator } from '@/components/atoms/Chatbot/TypingIndicator'; + +/** + * ChatMessage + * + * Combines ChatAvatar + ChatBubble + timestamp into a single message row. + * Handles both 'ai' and 'user' layout (mirrored for user messages). + * Optionally renders a TypingIndicator instead of a bubble when isTyping is true. + * + * @param props - ChatMessageProps + * @returns A full message row as a React functional component. + * + * @example + * + * + * + */ + +interface ChatMessageProps { + /** The text content of the message */ + message?: string; + /** 'ai' for assistant, 'user' for current user */ + variant: 'ai' | 'user'; + /** Formatted timestamp string (e.g. "10:30") */ + timestamp?: string; + /** When true, renders a TypingIndicator instead of a bubble */ + isTyping?: boolean; +} + +export const ChatMessage = ({ + message, + variant, + timestamp, + isTyping = false, +}: ChatMessageProps) => { + const isAi = variant === 'ai'; + + return ( +
+ + +
+ {isTyping ? ( +
+ +
+ ) : ( + message && + )} + + {timestamp && !isTyping && ( + {timestamp} + )} +
+
+ ); +}; diff --git a/web/src/components/organisms/chatbot/ChatWidget.tsx b/web/src/components/organisms/chatbot/ChatWidget.tsx new file mode 100644 index 00000000..202cf3d0 --- /dev/null +++ b/web/src/components/organisms/chatbot/ChatWidget.tsx @@ -0,0 +1,237 @@ +import { ChatWindow, Message } from '@/components/organisms/chatbot/ChatWindow'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** + * ChatWidget + * + * The root chatbot organism. Renders a draggable floating action button (FAB) + * that toggles the ChatWindow open/closed. + * + * Features: + * - Draggable FAB that can be repositioned anywhere on screen + * - Smooth open/close animation on the chat window + * - Local message state management + * - Simulated AI typing indicator before response + * - Auto-scroll to latest message + * + * Place this component once at the root layout level so it persists + * across all routes. + * + * @returns The floating chatbot widget as a React functional component. + * + * @example + * // In __root.tsx or a layout component + * + */ + +const INITIAL_MESSAGES: Message[] = [ + { + id: 'welcome', + text: "Hello! I'm TalkUp AI. Ask me anything to prepare for your interview 🎯", + variant: 'ai', + timestamp: new Date().toLocaleTimeString('fr-FR', { + hour: '2-digit', + minute: '2-digit', + }), + }, +]; + +const AI_REPLIES = [ + 'For a Product Manager interview, focus on prioritization frameworks like RICE or MoSCoW.', + 'Practice the STAR method: Situation, Task, Action, Result. It structures your answers clearly.', + 'Research the company’s products and be ready to discuss how you would improve them.', +]; + +export const ChatWidget = () => { + const [isOpen, setIsOpen] = useState(false); + const [messages, setMessages] = useState(INITIAL_MESSAGES); + const [inputValue, setInputValue] = useState(''); + const [isTyping, setIsTyping] = useState(false); + const [position, setPosition] = useState({ x: 0, y: 0 }); + + const fabRef = useRef(null); + const isDragging = useRef(false); + const dragStart = useRef({ x: 0, y: 0 }); + const replyIndex = useRef(0); + + const getTimestamp = () => + new Date().toLocaleTimeString('fr-FR', { + hour: '2-digit', + minute: '2-digit', + }); + + const handleSend = useCallback(() => { + const text = inputValue.trim(); + if (!text || isTyping) return; + + const userMsg: Message = { + id: `user-${Date.now()}`, + text, + variant: 'user', + timestamp: getTimestamp(), + }; + + setMessages((prev) => [...prev, userMsg]); + setInputValue(''); + setIsTyping(true); + + setTimeout(() => { + const aiMsg: Message = { + id: `ai-${Date.now()}`, + text: AI_REPLIES[replyIndex.current % AI_REPLIES.length], + variant: 'ai', + timestamp: getTimestamp(), + }; + replyIndex.current += 1; + setMessages((prev) => [...prev, aiMsg]); + setIsTyping(false); + }, 1200); + }, [inputValue, isTyping]); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + isDragging.current = false; + dragStart.current = { + x: e.clientX - position.x, + y: e.clientY - position.y, + }; + + const onMouseMove = (ev: MouseEvent) => { + isDragging.current = true; + setPosition({ + x: ev.clientX - dragStart.current.x, + y: ev.clientY - dragStart.current.y, + }); + }; + + const onMouseUp = () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }, + [position], + ); + + const handleFabClick = () => { + if (isDragging.current) return; + setIsOpen((prev) => !prev); + }; + + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + const touch = e.touches[0]; + dragStart.current = { + x: touch.clientX - position.x, + y: touch.clientY - position.y, + }; + + const onTouchMove = (ev: TouchEvent) => { + const t = ev.touches[0]; + isDragging.current = true; + setPosition({ + x: t.clientX - dragStart.current.x, + y: t.clientY - dragStart.current.y, + }); + }; + + const onTouchEnd = () => { + document.removeEventListener('touchmove', onTouchMove); + document.removeEventListener('touchend', onTouchEnd); + setTimeout(() => { + isDragging.current = false; + }, 10); + }; + + document.addEventListener('touchmove', onTouchMove, { passive: false }); + document.addEventListener('touchend', onTouchEnd); + }, + [position], + ); + + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen) setIsOpen(false); + }; + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + }, [isOpen]); + + return ( +
+ {/* ── Chat window ── */} +
+ +
+ + {/* ── FAB ── */} + +
+ ); +}; diff --git a/web/src/components/organisms/chatbot/ChatWindow.tsx b/web/src/components/organisms/chatbot/ChatWindow.tsx new file mode 100644 index 00000000..a763dd02 --- /dev/null +++ b/web/src/components/organisms/chatbot/ChatWindow.tsx @@ -0,0 +1,118 @@ +import { ChatInputBar } from '@/components/molecules/chatbot/ChatInputBar'; +import { ChatMessage } from '@/components/molecules/chatbot/ChatMessage'; +import { useEffect, useRef } from 'react'; + +/** + * ChatWindow + * + * The main conversation panel of the TalkUp chatbot widget. + * Renders the header with AI status, the scrollable message list, + * the input bar, and a branded footer. + * + * Auto-scrolls to the latest message whenever messages change. + * + * @param props - ChatWindowProps + * @returns The full chat panel as a React functional component. + * + * @example + * + */ + +export interface Message { + /** Unique identifier for the message */ + id: string; + /** Text content */ + text: string; + /** 'ai' for assistant messages, 'user' for user messages */ + variant: 'ai' | 'user'; + /** Formatted time string */ + timestamp: string; +} + +interface ChatWindowProps { + /** List of messages to display */ + messages: Message[]; + /** Current value of the input */ + inputValue: string; + /** Callback fired on input change */ + onInputChange: (value: string) => void; + /** Callback fired when the user sends a message */ + onSend: () => void; + /** When true, shows a typing indicator at the bottom of the list */ + isTyping?: boolean; +} + +export const ChatWindow = ({ + messages, + inputValue, + onInputChange, + onSend, + isTyping = false, +}: ChatWindowProps) => { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, isTyping]); + + return ( +
+ {/* ── Header ── */} +
+
+ T +
+
+

+ TalkUp AI +

+

+ + Online — ready to help +

+
+
+ + {/* ── Messages ── */} +
+ {messages.map((msg) => ( + + ))} + + {isTyping && } + +
+
+ + {/* ── Input ── */} + + + {/* ── Footer ── */} +
+ Powered by TalkUp AI +
+
+ ); +}; diff --git a/web/src/routes/ai-chat.tsx b/web/src/routes/ai-chat.tsx index b140a928..08aba4d4 100644 --- a/web/src/routes/ai-chat.tsx +++ b/web/src/routes/ai-chat.tsx @@ -1,50 +1,28 @@ -import { Button } from '@/components/atoms/button'; -import { createAuthGuard } from '@/utils/auth.guards'; +import { ChatWidget } from '@/components/organisms/chatbot/ChatWidget'; import { createFileRoute } from '@tanstack/react-router'; -import toast from 'react-hot-toast'; +/** + * @route /ai-chat + * @description Entry point for the TalkUp AI chatbot session. + * Renders the floating ChatWidget that persists across the page. + */ export const Route = createFileRoute('/ai-chat')({ - beforeLoad: createAuthGuard('/ai-chat'), - component: AIChat, + component: AiChatPage, }); -function AIChat() { +function AiChatPage() { return ( -
-

AI Chat

-

Chat IA

-
- - - +
+
+

+ TalkUp AI Session +

+

+ Click the button in the bottom-right corner to start chatting. +

+ +
); } From 9d3dd349ffd33cc568a26b66054208dd04df6fa6 Mon Sep 17 00:00:00 2001 From: badarouzia Date: Thu, 4 Jun 2026 04:12:51 +0200 Subject: [PATCH 03/17] fix file format --- web/src/components/organisms/chatbot/ChatWidget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/organisms/chatbot/ChatWidget.tsx b/web/src/components/organisms/chatbot/ChatWidget.tsx index 202cf3d0..b013e9dd 100644 --- a/web/src/components/organisms/chatbot/ChatWidget.tsx +++ b/web/src/components/organisms/chatbot/ChatWidget.tsx @@ -234,4 +234,4 @@ export const ChatWidget = () => {
); -}; +}; \ No newline at end of file From bd8a6add70da6f6a467f8c67220718762fed302e Mon Sep 17 00:00:00 2001 From: badarouzia Date: Thu, 4 Jun 2026 04:38:25 +0200 Subject: [PATCH 04/17] fix: force prettier format on ChatWidget --- web/src/components/organisms/chatbot/ChatWidget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/organisms/chatbot/ChatWidget.tsx b/web/src/components/organisms/chatbot/ChatWidget.tsx index b013e9dd..202cf3d0 100644 --- a/web/src/components/organisms/chatbot/ChatWidget.tsx +++ b/web/src/components/organisms/chatbot/ChatWidget.tsx @@ -234,4 +234,4 @@ export const ChatWidget = () => {
); -}; \ No newline at end of file +}; From da14eb386df1a1b87c10486ff98f3ab5776f549a Mon Sep 17 00:00:00 2001 From: badarouzia Date: Thu, 4 Jun 2026 05:16:42 +0200 Subject: [PATCH 05/17] fix: cleanup timeout, unmount ChatWindow when closed, prevent scroll on touch drag --- .../organisms/chatbot/ChatWidget.tsx | 68 +++++++------------ web/src/routes/ai-chat.tsx | 1 + 2 files changed, 25 insertions(+), 44 deletions(-) diff --git a/web/src/components/organisms/chatbot/ChatWidget.tsx b/web/src/components/organisms/chatbot/ChatWidget.tsx index 202cf3d0..12f7a93a 100644 --- a/web/src/components/organisms/chatbot/ChatWidget.tsx +++ b/web/src/components/organisms/chatbot/ChatWidget.tsx @@ -1,29 +1,6 @@ import { ChatWindow, Message } from '@/components/organisms/chatbot/ChatWindow'; import { useCallback, useEffect, useRef, useState } from 'react'; -/** - * ChatWidget - * - * The root chatbot organism. Renders a draggable floating action button (FAB) - * that toggles the ChatWindow open/closed. - * - * Features: - * - Draggable FAB that can be repositioned anywhere on screen - * - Smooth open/close animation on the chat window - * - Local message state management - * - Simulated AI typing indicator before response - * - Auto-scroll to latest message - * - * Place this component once at the root layout level so it persists - * across all routes. - * - * @returns The floating chatbot widget as a React functional component. - * - * @example - * // In __root.tsx or a layout component - * - */ - const INITIAL_MESSAGES: Message[] = [ { id: 'welcome', @@ -39,7 +16,7 @@ const INITIAL_MESSAGES: Message[] = [ const AI_REPLIES = [ 'For a Product Manager interview, focus on prioritization frameworks like RICE or MoSCoW.', 'Practice the STAR method: Situation, Task, Action, Result. It structures your answers clearly.', - 'Research the company’s products and be ready to discuss how you would improve them.', + 'Research the company', ]; export const ChatWidget = () => { @@ -53,6 +30,8 @@ export const ChatWidget = () => { const isDragging = useRef(false); const dragStart = useRef({ x: 0, y: 0 }); const replyIndex = useRef(0); + // ✅ Fix 1: cleanup timeout ref + const timeoutRef = useRef>(); const getTimestamp = () => new Date().toLocaleTimeString('fr-FR', { @@ -75,7 +54,8 @@ export const ChatWidget = () => { setInputValue(''); setIsTyping(true); - setTimeout(() => { + // ✅ Fix 1: store timeout id for cleanup + timeoutRef.current = setTimeout(() => { const aiMsg: Message = { id: `ai-${Date.now()}`, text: AI_REPLIES[replyIndex.current % AI_REPLIES.length], @@ -88,6 +68,11 @@ export const ChatWidget = () => { }, 1200); }, [inputValue, isTyping]); + // ✅ Fix 1: clear timeout on unmount + useEffect(() => { + return () => clearTimeout(timeoutRef.current); + }, []); + const handleMouseDown = useCallback( (e: React.MouseEvent) => { isDragging.current = false; @@ -129,6 +114,8 @@ export const ChatWidget = () => { }; const onTouchMove = (ev: TouchEvent) => { + // ✅ Fix 2: prevent page scroll while dragging + ev.preventDefault(); const t = ev.touches[0]; isDragging.current = true; setPosition({ @@ -167,23 +154,18 @@ export const ChatWidget = () => { right: `${24 - position.x}px`, }} > - {/* ── Chat window ── */} -
- -
+ {/* ✅ Fix 3: unmount ChatWindow when closed */} + {isOpen && ( +
+ +
+ )} {/* ── FAB ── */} + +
); }; diff --git a/web/src/components/molecules/chatbot/ChatMessage.tsx b/web/src/components/molecules/chatbot/ChatMessage.tsx index 270a59d5..bb456ae1 100644 --- a/web/src/components/molecules/chatbot/ChatMessage.tsx +++ b/web/src/components/molecules/chatbot/ChatMessage.tsx @@ -1,6 +1,6 @@ -import { ChatAvatar } from '@/components/atoms/Chatbot/ChatAvatar'; -import { ChatBubble } from '@/components/atoms/Chatbot/ChatBubble'; -import { TypingIndicator } from '@/components/atoms/Chatbot/TypingIndicator'; +import { ChatAvatar } from '@/components/atoms/chatbot/ChatAvatar'; +import { ChatBubble } from '@/components/atoms/chatbot/ChatBubble'; +import { TypingIndicator } from '@/components/atoms/chatbot/TypingIndicator'; /** * ChatMessage @@ -47,7 +47,7 @@ export const ChatMessage = ({ className={`flex flex-col gap-1 ${isAi ? 'items-start' : 'items-end'}`} > {isTyping ? ( -
+
) : ( @@ -55,7 +55,7 @@ export const ChatMessage = ({ )} {timestamp && !isTyping && ( - {timestamp} + {timestamp} )}
diff --git a/web/src/components/organisms/chatbot/ChatWidget.tsx b/web/src/components/organisms/chatbot/ChatWidget.tsx index 9eeef791..8de50853 100644 --- a/web/src/components/organisms/chatbot/ChatWidget.tsx +++ b/web/src/components/organisms/chatbot/ChatWidget.tsx @@ -1,4 +1,7 @@ +import { Button } from '@/components/atoms/button'; +import { Icon } from '@/components/atoms/icon'; import { ChatWindow, Message } from '@/components/organisms/chatbot/ChatWindow'; +import { useDragFAB } from '@/hooks/ui/useDragFAB'; import { useCallback, useEffect, useRef, useState } from 'react'; const INITIAL_MESSAGES: Message[] = [ @@ -24,16 +27,15 @@ export const ChatWidget = () => { const [messages, setMessages] = useState(INITIAL_MESSAGES); const [inputValue, setInputValue] = useState(''); const [isTyping, setIsTyping] = useState(false); - const [position, setPosition] = useState({ x: 0, y: 0 }); const fabRef = useRef(null); - const isDragging = useRef(false); - const dragStart = useRef({ x: 0, y: 0 }); const replyIndex = useRef(0); const timeoutRef = useRef | undefined>( undefined, ); + const { position, onMouseDown, onTouchStart, isDragging } = useDragFAB(); + const getTimestamp = () => new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', @@ -72,77 +74,23 @@ export const ChatWidget = () => { return () => clearTimeout(timeoutRef.current); }, []); - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - isDragging.current = false; - dragStart.current = { - x: e.clientX - position.x, - y: e.clientY - position.y, - }; - - const onMouseMove = (ev: MouseEvent) => { - isDragging.current = true; - setPosition({ - x: ev.clientX - dragStart.current.x, - y: ev.clientY - dragStart.current.y, - }); - }; - - const onMouseUp = () => { - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - }; - - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - }, - [position], - ); - const handleFabClick = () => { if (isDragging.current) return; setIsOpen((prev) => !prev); }; - const handleTouchStart = useCallback( - (e: React.TouchEvent) => { - const touch = e.touches[0]; - dragStart.current = { - x: touch.clientX - position.x, - y: touch.clientY - position.y, - }; - - const onTouchMove = (ev: TouchEvent) => { - ev.preventDefault(); - const t = ev.touches[0]; - isDragging.current = true; - setPosition({ - x: t.clientX - dragStart.current.x, - y: t.clientY - dragStart.current.y, - }); - }; - - const onTouchEnd = () => { - document.removeEventListener('touchmove', onTouchMove); - document.removeEventListener('touchend', onTouchEnd); - setTimeout(() => { - isDragging.current = false; - }, 10); - }; - - document.addEventListener('touchmove', onTouchMove, { passive: false }); - document.addEventListener('touchend', onTouchEnd); - }, - [position], - ); + const closeChat = useCallback(() => { + setIsOpen(false); + fabRef.current?.focus(); + }, []); useEffect(() => { const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape' && isOpen) setIsOpen(false); + if (e.key === 'Escape' && isOpen) closeChat(); }; document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey); - }, [isOpen]); + }, [isOpen, closeChat]); return (
{ )} {/* ── FAB ── */} - + +
); }; diff --git a/web/src/components/organisms/chatbot/ChatWindow.tsx b/web/src/components/organisms/chatbot/ChatWindow.tsx index ed49bdcb..00ecd8fe 100644 --- a/web/src/components/organisms/chatbot/ChatWindow.tsx +++ b/web/src/components/organisms/chatbot/ChatWindow.tsx @@ -1,3 +1,4 @@ +import { ChatAvatar } from '@/components/atoms/chatbot/ChatAvatar'; import { ChatInputBar } from '@/components/molecules/chatbot/ChatInputBar'; import { ChatMessage } from '@/components/molecules/chatbot/ChatMessage'; import { useEffect, useRef } from 'react'; @@ -62,18 +63,21 @@ export const ChatWindow = ({ }, [messages, isTyping]); return ( -
+
{/* ── Header ── */} -
-
- T -
+
+
-

+

TalkUp AI

-

- +

+ Online — ready to help

@@ -81,7 +85,7 @@ export const ChatWindow = ({ {/* ── Messages ── */}
{/* ── Footer ── */} -
+
Powered by TalkUp AI
diff --git a/web/src/hooks/ui/useDragFAB.ts b/web/src/hooks/ui/useDragFAB.ts new file mode 100644 index 00000000..8b5f945a --- /dev/null +++ b/web/src/hooks/ui/useDragFAB.ts @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** + * useDragFAB + * + * Encapsulates the drag behaviour of a floating action button. + * Handles both mouse and touch input through a single code path, + * tracks an offset position, and exposes an `isDragging` ref so a + * click handler can distinguish a drag from a tap. + * + * Document-level listeners are registered only while a drag is active + * and are always removed — both on drag end and on unmount — so they + * never leak if the component unmounts mid-drag. + * + * @returns position (offset from the anchored corner), the press + * handlers to spread onto the FAB, and the isDragging ref. + * + * @example + * const { position, onMouseDown, onTouchStart, isDragging } = useDragFAB(); + */ +export const useDragFAB = () => { + const [position, setPosition] = useState({ x: 0, y: 0 }); + + const isDragging = useRef(false); + const dragStart = useRef({ x: 0, y: 0 }); + const cleanupRef = useRef<(() => void) | undefined>(undefined); + + const beginDrag = useCallback( + (clientX: number, clientY: number) => { + isDragging.current = false; + dragStart.current = { + x: clientX - position.x, + y: clientY - position.y, + }; + }, + [position], + ); + + const moveTo = useCallback((clientX: number, clientY: number) => { + isDragging.current = true; + setPosition({ + x: clientX - dragStart.current.x, + y: clientY - dragStart.current.y, + }); + }, []); + + const onMouseDown = useCallback( + (e: React.MouseEvent) => { + beginDrag(e.clientX, e.clientY); + + const onMove = (ev: MouseEvent) => moveTo(ev.clientX, ev.clientY); + const onUp = () => cleanupRef.current?.(); + + cleanupRef.current = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + cleanupRef.current = undefined; + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }, + [beginDrag, moveTo], + ); + + const onTouchStart = useCallback( + (e: React.TouchEvent) => { + const touch = e.touches[0]; + beginDrag(touch.clientX, touch.clientY); + + const onMove = (ev: TouchEvent) => { + ev.preventDefault(); + const t = ev.touches[0]; + moveTo(t.clientX, t.clientY); + }; + const onEnd = () => { + cleanupRef.current?.(); + setTimeout(() => { + isDragging.current = false; + }, 10); + }; + + cleanupRef.current = () => { + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onEnd); + cleanupRef.current = undefined; + }; + + document.addEventListener('touchmove', onMove, { passive: false }); + document.addEventListener('touchend', onEnd); + }, + [beginDrag, moveTo], + ); + + useEffect(() => { + return () => cleanupRef.current?.(); + }, []); + + return { position, onMouseDown, onTouchStart, isDragging }; +}; diff --git a/web/src/routes/ai-chat.tsx b/web/src/routes/ai-chat.tsx index f6043136..71b5f1f5 100644 --- a/web/src/routes/ai-chat.tsx +++ b/web/src/routes/ai-chat.tsx @@ -5,7 +5,7 @@ import { createFileRoute } from '@tanstack/react-router'; /** * @route /ai-chat * @description Entry point for the TalkUp AI chatbot session. - * Renders the floating ChatWidget that persists across the page. + * Renders the floating ChatWidget scoped to this route. */ export const Route = createFileRoute('/ai-chat')({ beforeLoad: createAuthGuard('/ai-chat'), From ba797f56ffba80fd2b26121e8a2094dd1cf58abc Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 24 Jun 2026 19:30:07 +0800 Subject: [PATCH 15/17] fix: clamp FAB drag, fix listener leaks, focus dialog, brand-gradient utility, chatbot tests --- web/src/components/atoms/base-input/index.tsx | 85 +++++----- .../components/atoms/chatbot/ChatAvatar.tsx | 2 +- .../components/atoms/chatbot/ChatBubble.tsx | 2 +- .../components/atoms/chatbot/ChatInput.tsx | 65 +++++--- .../molecules/chatbot/ChatInputBar.tsx | 13 +- .../organisms/chatbot/ChatWidget.spec.tsx | 92 +++++++++++ .../organisms/chatbot/ChatWidget.tsx | 47 +++--- .../organisms/chatbot/ChatWindow.tsx | 6 +- web/src/hooks/ui/useDragFAB.spec.ts | 154 ++++++++++++++++++ web/src/hooks/ui/useDragFAB.ts | 60 +++++-- web/src/styles/tailwind.css | 10 ++ web/src/tests/setupTests.ts | 6 + 12 files changed, 441 insertions(+), 101 deletions(-) create mode 100644 web/src/components/organisms/chatbot/ChatWidget.spec.tsx create mode 100644 web/src/hooks/ui/useDragFAB.spec.ts diff --git a/web/src/components/atoms/base-input/index.tsx b/web/src/components/atoms/base-input/index.tsx index af89537f..2157343f 100644 --- a/web/src/components/atoms/base-input/index.tsx +++ b/web/src/components/atoms/base-input/index.tsx @@ -23,44 +23,49 @@ export type { BaseInputProps }; * @param {string} [props.className] - Additional CSS classes to apply. * @returns {JSX.Element} The rendered input component. */ -export const BaseInput: React.FC = (props) => { - const { - id, - name = 'input', - value = '', - type = 'text', - placeholder = 'Enter text', - disabled = false, - readOnly = false, - required = false, - onChange = () => {}, - className, - ...rest - } = props as BaseInputProps; - const generatedId = useId(); - const inputId = id || generatedId; +export const BaseInput = React.forwardRef( + (props, ref) => { + const { + id, + name = 'input', + value = '', + type = 'text', + placeholder = 'Enter text', + disabled = false, + readOnly = false, + required = false, + onChange = () => {}, + className, + ...rest + } = props as BaseInputProps; + const generatedId = useId(); + const inputId = id || generatedId; - return ( - - ); -}; + return ( + + ); + }, +); + +BaseInput.displayName = 'BaseInput'; diff --git a/web/src/components/atoms/chatbot/ChatAvatar.tsx b/web/src/components/atoms/chatbot/ChatAvatar.tsx index 3c1091c6..fc4d5b90 100644 --- a/web/src/components/atoms/chatbot/ChatAvatar.tsx +++ b/web/src/components/atoms/chatbot/ChatAvatar.tsx @@ -32,7 +32,7 @@ export const ChatAvatar = ({ variant, size = 'sm' }: ChatAvatarProps) => {
diff --git a/web/src/components/atoms/chatbot/ChatBubble.tsx b/web/src/components/atoms/chatbot/ChatBubble.tsx index a66341dd..036093b9 100644 --- a/web/src/components/atoms/chatbot/ChatBubble.tsx +++ b/web/src/components/atoms/chatbot/ChatBubble.tsx @@ -28,7 +28,7 @@ export const ChatBubble = ({ message, variant }: ChatBubbleProps) => { className={`max-w-[220px] px-3 py-2 rounded-2xl text-body-s leading-relaxed ${ isAi ? 'bg-surface border border-border text-text rounded-bl-sm' - : 'bg-gradient-to-br from-[var(--color-accent)] to-[var(--color-success)] text-white rounded-br-sm' + : 'bg-brand-gradient text-white rounded-br-sm' }`} > {message} diff --git a/web/src/components/atoms/chatbot/ChatInput.tsx b/web/src/components/atoms/chatbot/ChatInput.tsx index 5411e279..4d79163d 100644 --- a/web/src/components/atoms/chatbot/ChatInput.tsx +++ b/web/src/components/atoms/chatbot/ChatInput.tsx @@ -1,7 +1,12 @@ +import { BaseInput } from '@/components/atoms/base-input'; +import React from 'react'; + /** * ChatInput * * A controlled single-line text input for the chatbot message bar. + * Thin wrapper around the shared BaseInput atom so focus-ring, disabled and + * accessibility behaviour stay consistent with the rest of the design system. * Triggers onSend when the user presses Enter (without Shift). * * @param props - ChatInputProps @@ -29,30 +34,38 @@ interface ChatInputProps { disabled?: boolean; } -export const ChatInput = ({ - value, - onChange, - onSend, - placeholder = 'Ask a question...', - disabled = false, -}: ChatInputProps) => { - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - onSend(); - } - }; +export const ChatInput = React.forwardRef( + ( + { + value, + onChange, + onSend, + placeholder = 'Ask a question...', + disabled = false, + }, + ref, + ) => { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + onSend(); + } + }; + + return ( + onChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={disabled} + className="flex-1 rounded-xl bg-background focus:bg-surface" + /> + ); + }, +); - return ( - onChange(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={placeholder} - disabled={disabled} - aria-label="Chat message input" - className="flex-1 px-3 py-2 text-body-s bg-background border border-border rounded-xl outline-none text-text placeholder:text-text-weaker focus:border-accent focus:bg-surface transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - /> - ); -}; +ChatInput.displayName = 'ChatInput'; diff --git a/web/src/components/molecules/chatbot/ChatInputBar.tsx b/web/src/components/molecules/chatbot/ChatInputBar.tsx index 64b46e59..2cd5d523 100644 --- a/web/src/components/molecules/chatbot/ChatInputBar.tsx +++ b/web/src/components/molecules/chatbot/ChatInputBar.tsx @@ -30,6 +30,8 @@ interface ChatInputBarProps { onSend: () => void; /** When true, disables input and send button while AI is responding */ isLoading?: boolean; + /** Ref forwarded to the underlying text input */ + inputRef?: React.Ref; } export const ChatInputBar = ({ @@ -37,12 +39,14 @@ export const ChatInputBar = ({ onChange, onSend, isLoading = false, + inputRef, }: ChatInputBarProps) => { const canSend = value.trim().length > 0 && !isLoading; return (
diff --git a/web/src/components/organisms/chatbot/ChatWidget.spec.tsx b/web/src/components/organisms/chatbot/ChatWidget.spec.tsx new file mode 100644 index 00000000..56487e16 --- /dev/null +++ b/web/src/components/organisms/chatbot/ChatWidget.spec.tsx @@ -0,0 +1,92 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ChatWidget } from './ChatWidget'; + +describe('ChatWidget', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + const openChat = () => { + fireEvent.click(screen.getByRole('button', { name: 'Open TalkUp chat' })); + }; + + it('renders the FAB closed by default', () => { + render(); + expect( + screen.getByRole('button', { name: 'Open TalkUp chat' }), + ).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('the FAB is type="button" so it never submits a surrounding form', () => { + const onSubmit = vi.fn((e: React.FormEvent) => e.preventDefault()); + render( +
+ + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Open TalkUp chat' })); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('opens the dialog and moves focus to the input', () => { + render(); + openChat(); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByLabelText('Chat message input')).toHaveFocus(); + }); + + it('closes on Escape and returns focus to the FAB', () => { + render(); + openChat(); + + act(() => { + fireEvent.keyDown(document, { key: 'Escape' }); + }); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Open TalkUp chat' }), + ).toHaveFocus(); + }); + + it('renders a welcome message with a timestamp computed at mount', () => { + render(); + openChat(); + expect(screen.getByText(/Ask me anything/)).toBeInTheDocument(); + // A HH:MM timestamp is rendered alongside the welcome bubble. + expect(screen.getByText(/^\d{2}:\d{2}$/)).toBeInTheDocument(); + }); + + it('sends a user message and shows an AI reply after the delay', () => { + render(); + openChat(); + + const input = screen.getByLabelText('Chat message input'); + fireEvent.change(input, { target: { value: 'Hello there' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send message' })); + + expect(screen.getByText('Hello there')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(1200); + }); + + expect(screen.getByText(/prioritization frameworks/)).toBeInTheDocument(); + }); + + it('does not send when the input is empty', () => { + render(); + openChat(); + const send = screen.getByRole('button', { name: 'Send message' }); + expect(send).toBeDisabled(); + }); +}); diff --git a/web/src/components/organisms/chatbot/ChatWidget.tsx b/web/src/components/organisms/chatbot/ChatWidget.tsx index 8de50853..cc4ce914 100644 --- a/web/src/components/organisms/chatbot/ChatWidget.tsx +++ b/web/src/components/organisms/chatbot/ChatWidget.tsx @@ -4,17 +4,8 @@ import { ChatWindow, Message } from '@/components/organisms/chatbot/ChatWindow'; import { useDragFAB } from '@/hooks/ui/useDragFAB'; import { useCallback, useEffect, useRef, useState } from 'react'; -const INITIAL_MESSAGES: Message[] = [ - { - id: 'welcome', - text: "Hello! I'm TalkUp AI. Ask me anything to prepare for your interview 🎯", - variant: 'ai', - timestamp: new Date().toLocaleTimeString('fr-FR', { - hour: '2-digit', - minute: '2-digit', - }), - }, -]; +const WELCOME_TEXT = + "Hello! I'm TalkUp AI. Ask me anything to prepare for your interview 🎯"; const AI_REPLIES = [ 'For a Product Manager interview, focus on prioritization frameworks like RICE or MoSCoW.', @@ -22,13 +13,29 @@ const AI_REPLIES = [ 'Research the company', ]; +const getTimestamp = () => + new Date().toLocaleTimeString('fr-FR', { + hour: '2-digit', + minute: '2-digit', + }); + export const ChatWidget = () => { const [isOpen, setIsOpen] = useState(false); - const [messages, setMessages] = useState(INITIAL_MESSAGES); + // Computed on mount so the welcome timestamp reflects when the chat is first + // rendered, not when the module was loaded. + const [messages, setMessages] = useState(() => [ + { + id: 'welcome', + text: WELCOME_TEXT, + variant: 'ai', + timestamp: getTimestamp(), + }, + ]); const [inputValue, setInputValue] = useState(''); const [isTyping, setIsTyping] = useState(false); const fabRef = useRef(null); + const inputRef = useRef(null); const replyIndex = useRef(0); const timeoutRef = useRef | undefined>( undefined, @@ -36,12 +43,6 @@ export const ChatWidget = () => { const { position, onMouseDown, onTouchStart, isDragging } = useDragFAB(); - const getTimestamp = () => - new Date().toLocaleTimeString('fr-FR', { - hour: '2-digit', - minute: '2-digit', - }); - const handleSend = useCallback(() => { const text = inputValue.trim(); if (!text || isTyping) return; @@ -92,6 +93,12 @@ export const ChatWidget = () => { return () => document.removeEventListener('keydown', handleKey); }, [isOpen, closeChat]); + // Move focus into the panel when it opens so keyboard and screen-reader users + // land inside the dialog rather than tabbing through the page behind it. + useEffect(() => { + if (isOpen) inputRef.current?.focus(); + }, [isOpen]); + return (
{ onInputChange={setInputValue} onSend={handleSend} isTyping={isTyping} + inputRef={inputRef} />
)} @@ -115,13 +123,14 @@ export const ChatWidget = () => { {/* ── FAB ── */}