diff --git a/cypress/e2e/auth.cy.js b/cypress/e2e/auth.cy.js index 48750ba..9f58366 100644 --- a/cypress/e2e/auth.cy.js +++ b/cypress/e2e/auth.cy.js @@ -87,7 +87,8 @@ describe("Authentication", () => { it("A-11: Sign out clears state and returns to API key screen", () => { cy.goToMainApp(); - cy.get(".app-signout-btn").click(); + // Sign out button is now in the sidebar + cy.contains("Sign out").click(); // With mock user, signOut clears apiKey but user persists (no real Firebase listener). // App shows API key setup screen since user exists but apiKey is empty. cy.get(".setup-card").should("exist"); diff --git a/cypress/e2e/main-app.cy.js b/cypress/e2e/main-app.cy.js index 282a7bf..76ecf0f 100644 --- a/cypress/e2e/main-app.cy.js +++ b/cypress/e2e/main-app.cy.js @@ -45,8 +45,9 @@ describe("Main App - Output Box", () => { cy.goToMainApp(); }); - it("O-1: shows placeholder text when empty", () => { - cy.contains("Your formatted text will appear here").should("be.visible"); + it("O-1: shows empty state when no output", () => { + cy.get(".chat-empty").should("be.visible"); + cy.contains("Tap the mic to start recording").should("be.visible"); }); it("O-2: displays formatted text after recording", () => { @@ -76,54 +77,51 @@ describe("Main App - Output Box", () => { cy.get(".export-modal").should("be.visible"); }); - it("O-5: action buttons disabled when no output", () => { - cy.get('.output-icon-btn[title="Copy text"]').should("be.disabled"); - cy.get('.output-icon-btn[title="Read aloud"]').should("be.disabled"); - cy.get('.output-icon-btn[title="Export"]').should("be.disabled"); + it("O-5: action buttons not visible when no output (empty state shown)", () => { + // When there's no output, the empty state is shown instead of OutputBox + cy.get(".chat-empty").should("be.visible"); + cy.get(".output-icon-btn").should("not.exist"); }); }); -describe("Main App - History", () => { +describe("Main App - Conversations", () => { beforeEach(() => { cy.goToMainApp(); }); - it("H-1: history section hidden when no items exist", () => { - cy.get(".history-list").should("not.exist"); + it("H-1: no conversations in sidebar when none exist", () => { + cy.get(".sidebar-convo-btn").should("not.exist"); + cy.contains("No conversations yet").should("exist"); }); - it("H-2: history appears after first recording with timestamp", () => { + it("H-2: conversation appears in sidebar after first recording", () => { cy.get(".mic-btn").click(); cy.get(".mic-btn").click(); cy.get(".output-text", { timeout: 5000 }).should("be.visible"); - cy.get(".history-list").should("be.visible"); - cy.get(".history-item").should("have.length", 1); - cy.get(".history-item-time").should("be.visible"); + cy.get(".sidebar-convo-btn").should("have.length", 1); }); - it("H-3: clicking a history item restores its text to OutputBox", () => { + it("H-3: clicking a conversation restores its text to OutputBox", () => { // Create a recording first cy.get(".mic-btn").click(); cy.get(".mic-btn").click(); cy.get(".output-text", { timeout: 5000 }).should("be.visible"); - // Get the text content - cy.get(".output-text").invoke("text").then((originalText) => { - // Click the history item - cy.get(".history-item").first().click(); - cy.get(".output-text").should("contain.text", originalText.substring(0, 50)); - }); + // Start a new conversation (clears output) + cy.get(".sidebar-new-btn").click(); + cy.get(".chat-empty").should("be.visible"); + + // Click the sidebar conversation to restore + cy.get(".sidebar-convo-btn").first().click(); + cy.get(".output-text").should("be.visible"); }); - it("H-4: stores up to 5 items, oldest dropped on overflow", () => { - // Create 6 recordings - for (let i = 0; i < 6; i++) { - cy.get(".mic-btn").click(); - cy.get(".mic-btn").click(); - cy.get(".output-text", { timeout: 5000 }).should("be.visible"); - // Wait for processing to finish before next recording - cy.contains("Tap to speak", { timeout: 5000 }).should("be.visible"); - } - cy.get(".history-item").should("have.length", 5); + it("H-4: new chat button clears output", () => { + cy.get(".mic-btn").click(); + cy.get(".mic-btn").click(); + cy.get(".output-text", { timeout: 5000 }).should("be.visible"); + + cy.get(".sidebar-new-btn").click(); + cy.get(".chat-empty").should("be.visible"); }); }); diff --git a/cypress/e2e/settings.cy.js b/cypress/e2e/settings.cy.js index 5c3509f..6325ace 100644 --- a/cypress/e2e/settings.cy.js +++ b/cypress/e2e/settings.cy.js @@ -1,19 +1,24 @@ +// Helper to open settings from sidebar +const openSettings = () => { + cy.get(".sidebar-footer-btn").first().click(); +}; + describe("Settings Modal", () => { beforeEach(() => { cy.goToMainApp(); }); - it("S-1: gear icon only visible on main app screen", () => { - cy.get('.header-icon-btn[aria-label="Settings"]').should("be.visible"); + it("S-1: settings button visible in sidebar on main app screen", () => { + cy.contains("Settings").should("exist"); }); - it("S-2: opens when gear icon is clicked", () => { - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + it("S-2: opens when settings button is clicked", () => { + openSettings(); cy.get(".settings-modal").should("be.visible"); }); it("S-3: three sidebar tabs render (Account, Formats, About)", () => { - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + openSettings(); cy.get(".settings-tab-btn").should("have.length", 3); cy.contains("Account").should("be.visible"); cy.contains("Formats").should("be.visible"); @@ -21,21 +26,21 @@ describe("Settings Modal", () => { }); it("S-4: closes on overlay click", () => { - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + openSettings(); cy.get(".settings-modal").should("be.visible"); cy.get(".settings-overlay").click({ force: true }); cy.get(".settings-modal").should("not.exist"); }); it("S-5: closes on Escape key", () => { - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + openSettings(); cy.get(".settings-modal").should("be.visible"); cy.get("body").type("{esc}"); cy.get(".settings-modal").should("not.exist"); }); it("S-6: closes on X button", () => { - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + openSettings(); cy.get(".settings-modal").should("be.visible"); cy.get(".settings-close-btn").click(); cy.get(".settings-modal").should("not.exist"); @@ -45,7 +50,7 @@ describe("Settings Modal", () => { describe("Settings - Account Tab", () => { beforeEach(() => { cy.goToMainApp(); - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + openSettings(); }); it("SA-1: displays user name and email", () => { @@ -58,7 +63,8 @@ describe("Settings - Account Tab", () => { }); it("SA-3: password reset button hidden for Google users", () => { - // Re-visit with a Google provider user + // Close current modal and re-visit with a Google provider user + cy.get(".settings-close-btn").click(); cy.visit("/", { onBeforeLoad(win) { win.__CYPRESS_MOCK_USER__ = { @@ -72,7 +78,7 @@ describe("Settings - Account Tab", () => { win.localStorage.setItem("sidekick_apikey_google-user-456", "sk-test-key-1234567890"); }, }); - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + openSettings(); cy.contains("Reset password").should("not.exist"); }); @@ -114,7 +120,7 @@ describe("Settings - Account Tab", () => { describe("Settings - Formats Tab", () => { beforeEach(() => { cy.goToMainApp(); - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + openSettings(); cy.contains("Formats").click(); }); @@ -150,7 +156,7 @@ describe("Settings - Formats Tab", () => { // Close and reopen settings cy.get(".settings-close-btn").click(); - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + openSettings(); cy.contains("Formats").click(); // Should still be toggled off @@ -161,7 +167,7 @@ describe("Settings - Formats Tab", () => { describe("Settings - About Tab", () => { beforeEach(() => { cy.goToMainApp(); - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + openSettings(); cy.contains("About").click(); }); diff --git a/cypress/e2e/theme-header.cy.js b/cypress/e2e/theme-header.cy.js index 5a1ca71..cce1e7e 100644 --- a/cypress/e2e/theme-header.cy.js +++ b/cypress/e2e/theme-header.cy.js @@ -19,9 +19,9 @@ describe("Theme", () => { }); it("T-3: theme persists across page refresh", () => { - // Go to main app and switch to light mode + // Go to main app and switch to light mode via sidebar settings cy.goToMainApp(); - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + cy.get(".sidebar-footer-btn").first().click(); cy.get(".settings-toggle").last().click(); cy.get("body").should("not.have.class", "dark"); @@ -34,9 +34,9 @@ describe("Theme", () => { }); }); -describe("Header", () => { - it("HD-1: logo renders on all screens", () => { - // Welcome screen +describe("Header & Sidebar", () => { + it("HD-1: logo/branding renders on all screens", () => { + // Welcome screen - header logo cy.visit("/", { onBeforeLoad(win) { win.localStorage.removeItem("sidekick_seen_welcome"); @@ -45,12 +45,12 @@ describe("Header", () => { cy.get(".header-logo").should("be.visible"); cy.get(".header-logo-name").should("contain.text", "Sidekick"); - // Auth screen (skip welcome, no user) + // Auth screen - header logo cy.skipWelcome(); cy.visit("/"); cy.get(".header-logo").should("be.visible"); - // API key screen (has user, no key) + // API key screen - header logo cy.visit("/", { onBeforeLoad(win) { win.localStorage.setItem("sidekick_seen_welcome", "1"); @@ -65,29 +65,29 @@ describe("Header", () => { }); cy.get(".header-logo").should("be.visible"); - // Main app screen + // Main app screen - sidebar exists cy.goToMainApp(); - cy.get(".header-logo").should("be.visible"); + cy.get(".sidebar").should("exist"); }); - it("HD-2: gear icon hidden on welcome screen", () => { + it("HD-2: sidebar hidden on welcome screen", () => { cy.visit("/", { onBeforeLoad(win) { win.localStorage.removeItem("sidekick_seen_welcome"); }, }); cy.get(".welcome-card").should("be.visible"); - cy.get('.header-icon-btn[aria-label="Settings"]').should("not.exist"); + cy.get(".sidebar").should("not.exist"); }); - it("HD-3: gear icon hidden on auth screen", () => { + it("HD-3: sidebar hidden on auth screen", () => { cy.skipWelcome(); cy.visit("/"); cy.get(".auth-card").should("be.visible"); - cy.get('.header-icon-btn[aria-label="Settings"]').should("not.exist"); + cy.get(".sidebar").should("not.exist"); }); - it("HD-4: gear icon hidden on API key screen", () => { + it("HD-4: sidebar hidden on API key screen", () => { cy.visit("/", { onBeforeLoad(win) { win.localStorage.setItem("sidekick_seen_welcome", "1"); @@ -101,11 +101,12 @@ describe("Header", () => { }, }); cy.get(".setup-card").should("be.visible"); - cy.get('.header-icon-btn[aria-label="Settings"]').should("not.exist"); + cy.get(".sidebar").should("not.exist"); }); - it("HD-5: gear icon visible on main app screen", () => { + it("HD-5: sidebar with settings visible on main app screen", () => { cy.goToMainApp(); - cy.get('.header-icon-btn[aria-label="Settings"]').should("be.visible"); + cy.get(".sidebar").should("exist"); + cy.contains("Settings").should("exist"); }); }); diff --git a/src/App.css b/src/App.css index 279091f..06a3b83 100644 --- a/src/App.css +++ b/src/App.css @@ -7,75 +7,164 @@ flex-direction: column; } -.app-hero { - padding: 48px 0 36px; - text-align: center; +/* ── Chat layout (main app with sidebar) ── */ +.app-wrap--chat { + max-width: none; + margin: 0; + padding: 0; + flex-direction: row; + height: 100vh; + overflow: hidden; } -.app-hero-title { - font-family: 'Instrument Serif', serif; - font-size: clamp(36px, 9vw, 48px); - font-weight: 400; - line-height: 1.1; - letter-spacing: -0.02em; - margin-bottom: 10px; - color: var(--text); +.chat-main { + flex: 1; + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; + min-width: 0; + background: var(--bg); } -.app-hero-title em { - font-style: italic; +/* ── Top bar (hamburger + title) ── */ +.chat-topbar { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + flex-shrink: 0; +} + +.chat-topbar-toggle { + width: 36px; + height: 36px; + border-radius: 8px; + border: none; + background: transparent; color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s, color 0.2s; } -.app-hero-sub { +.chat-topbar-toggle:hover { + background: var(--surface2); + color: var(--text); +} + +.chat-topbar-toggle.hidden { + opacity: 0; + pointer-events: none; +} + +.chat-topbar-title { font-size: 13px; + font-weight: 500; color: var(--text-muted); - letter-spacing: 0.01em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── Scrollable content area ── */ +.chat-content { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + padding: 0 24px; } -.app-screen { +.chat-content-inner { + max-width: 720px; + width: 100%; + margin: 0 auto; + flex: 1; display: flex; flex-direction: column; - gap: 16px; - padding-top: 24px; - animation: fadeUp 0.35s ease both; } -.app-footer { +/* ── Bottom input bar ── */ +.chat-inputbar { + padding: 16px 24px 24px; + flex-shrink: 0; display: flex; + justify-content: center; + background: var(--bg); +} + +.chat-inputbar-inner { + max-width: 720px; + width: 100%; +} + +/* ── Empty state in chat ── */ +.chat-empty { + flex: 1; + display: flex; + flex-direction: column; align-items: center; - justify-content: space-between; - padding-top: 8px; + justify-content: center; + gap: 12px; + opacity: 0.5; + padding: 48px 24px; } -.app-footer-key { +.chat-empty-logo { + width: 48px; + height: 48px; + background: var(--text); + color: var(--surface); + border-radius: 14px; display: flex; align-items: center; - gap: 5px; - font-size: 11px; + justify-content: center; + transition: background 0.3s, color 0.3s; +} + +.chat-empty-text { + font-size: 15px; color: var(--text-muted); + text-align: center; } -.app-footer-key-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: #2d9e6b; +/* ── Hero (Welcome, Auth, API key screens) ── */ +.app-hero { + padding: 48px 0 36px; + text-align: center; } -.app-signout-btn { - font-size: 11px; +.app-hero-title { + font-family: 'Instrument Serif', serif; + font-size: clamp(36px, 9vw, 48px); + font-weight: 400; + line-height: 1.1; + letter-spacing: -0.02em; + margin-bottom: 10px; + color: var(--text); +} + +.app-hero-title em { + font-style: italic; color: var(--text-muted); - background: none; - border: none; - cursor: pointer; - font-family: 'Syne', sans-serif; - text-decoration: underline; - text-underline-offset: 2px; - transition: color 0.2s; - padding: 4px 0; } -.app-signout-btn:hover { - color: var(--text); -} \ No newline at end of file +.app-hero-sub { + font-size: 13px; + color: var(--text-muted); + letter-spacing: 0.01em; +} + +/* ── Mobile ── */ +@media (max-width: 768px) { + .chat-content { + padding: 0 16px; + } + + .chat-inputbar { + padding: 12px 16px 20px; + } +} diff --git a/src/App.jsx b/src/App.jsx index d16a0d5..8c05826 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -6,10 +6,11 @@ import AuthScreen from "./components/AuthScreen.jsx"; import ApiKeySetup from "./components/ApiKeySetup.jsx"; import MicButton from "./components/MicButton.jsx"; import OutputBox from "./components/OutputBox.jsx"; -import History from "./components/History.jsx"; import Header from "./components/Header.jsx"; +import Sidebar from "./components/Sidebar.jsx"; import ExportModal from "./components/ExportModal.jsx"; import SettingsModal from "./components/SettingsModal.jsx"; +import { HamburgerIcon } from "./components/Icons.jsx"; import "./App.css"; export default function App() { @@ -18,26 +19,29 @@ export default function App() { ); const [user, setUser] = useState(undefined); const [authLoading, setAuthLoading] = useState(true); - const [dark, setDark] = useState( - () => { - const saved = localStorage.getItem("sidekick_dark_mode"); - return saved === null ? true : saved === "1"; - } - ); + const [dark, setDark] = useState(() => { + const saved = localStorage.getItem("sidekick_dark_mode"); + return saved === null ? true : saved === "1"; + }); const [apiKey, setApiKey] = useState(""); const [output, setOutput] = useState(""); const [processing, setProcessing] = useState(false); - const [history, setHistory] = useState([]); + const [conversations, setConversations] = useState([]); + const [activeConversationId, setActiveConversationId] = useState(null); const [showExportModal, setShowExportModal] = useState(false); const [showSettingsModal, setShowSettingsModal] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState( + () => window.innerWidth < 768 + ); + // Dark mode useEffect(() => { document.body.classList.toggle("dark", dark); localStorage.setItem("sidekick_dark_mode", dark ? "1" : "0"); }, [dark]); + // Auth listener useEffect(() => { - // Allow Cypress to inject a mock user for E2E testing const mockUser = window.__CYPRESS_MOCK_USER__; if (mockUser) { setUser(mockUser); @@ -50,12 +54,9 @@ export default function App() { const unsubscribe = onAuthStateChanged(auth, (firebaseUser) => { setUser(firebaseUser); setAuthLoading(false); - if (firebaseUser) { const savedKey = localStorage.getItem(`sidekick_apikey_${firebaseUser.uid}`); - if (savedKey) { - setApiKey(savedKey); - } + if (savedKey) setApiKey(savedKey); } else { setApiKey(""); } @@ -63,6 +64,30 @@ export default function App() { return () => unsubscribe(); }, []); + // Load conversations when user authenticates + useEffect(() => { + if (user && user.uid) { + const saved = localStorage.getItem(`sidekick_conversations_${user.uid}`); + if (saved) { + try { + setConversations(JSON.parse(saved)); + } catch { + setConversations([]); + } + } + } + }, [user]); + + // Persist conversations + useEffect(() => { + if (user && user.uid && conversations.length > 0) { + localStorage.setItem( + `sidekick_conversations_${user.uid}`, + JSON.stringify(conversations) + ); + } + }, [conversations, user]); + const handleWelcomeContinue = () => { localStorage.setItem("sidekick_seen_welcome", "1"); setShowWelcome(false); @@ -79,92 +104,174 @@ export default function App() { await signOut(auth); setApiKey(""); setOutput(""); - setHistory([]); + setConversations([]); + setActiveConversationId(null); }; const handleResult = (text) => { setOutput(text); - setHistory((prev) => [ - { - text, - time: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), - }, - ...prev.slice(0, 4), - ]); + const title = + text.substring(0, 60).replace(/\n/g, " ").trim() || "Untitled"; + const now = new Date().toISOString(); + + if (activeConversationId) { + setConversations((prev) => + prev.map((c) => + c.id === activeConversationId + ? { ...c, text, title, updatedAt: now } + : c + ) + ); + } else { + const newId = crypto.randomUUID(); + setConversations((prev) => [ + { id: newId, title, text, createdAt: now, updatedAt: now }, + ...prev, + ]); + setActiveConversationId(newId); + } + }; + + const handleNewConversation = () => { + setActiveConversationId(null); + setOutput(""); + }; + + const handleSelectConversation = (id) => { + const convo = conversations.find((c) => c.id === id); + if (convo) { + setActiveConversationId(id); + setOutput(convo.text); + } + if (window.innerWidth < 768) { + setSidebarCollapsed(true); + } }; + const isMainApp = !authLoading && !showWelcome && !!user && !!apiKey; + const hero = (

- Talk.
+ Talk. +
We'll take notes.

Voice-to-text, formatted for you

); - const renderContent = () => { - if (authLoading) return null; - - if (showWelcome) { - return ( - <> - {hero} - - - ); - } - - if (!user) { - return ( - <> - {hero} - - - ); - } - - if (!apiKey) { - return ( - <> - {hero} - - - ); - } + // ── Non-main screens ── + if (!isMainApp) { + const renderScreen = () => { + if (authLoading) return null; + if (showWelcome) + return ( + <> + {hero} + + + ); + if (!user) + return ( + <> + {hero} + + + ); + if (!apiKey) + return ( + <> + {hero} + + + ); + return null; + }; return ( -
- - setShowExportModal(true)} /> - {history.length > 0 && ( - setOutput(text)} /> - )} -
-
-
- API key connected -
- -
+
+
+ {renderScreen()}
); - }; - - const isMainApp = !authLoading && !showWelcome && !!user && !!apiKey; + } + // ── Main app: sidebar + chat ── return ( -
-
setShowSettingsModal(true)} showSettings={isMainApp} /> +
+ setShowSettingsModal(true)} + onSignOut={handleSignOut} + collapsed={sidebarCollapsed} + onToggleCollapse={() => setSidebarCollapsed((c) => !c)} + /> - {renderContent()} +
+
+ + + {activeConversationId + ? conversations.find((c) => c.id === activeConversationId) + ?.title || "" + : "New conversation"} + +
+ +
+
+ {!output && !processing ? ( +
+
+ + + + +
+

+ Tap the mic to start recording +

+
+ ) : ( + setShowExportModal(true)} + /> + )} +
+
+ +
+
+ +
+
+
{showExportModal && ( )} - {showSettingsModal && isMainApp && ( + {showSettingsModal && ( setShowSettingsModal(false)} user={user} diff --git a/src/components/Header.css b/src/components/Header.css index e339693..d9259d3 100644 --- a/src/components/Header.css +++ b/src/components/Header.css @@ -1,7 +1,6 @@ .header { display: flex; align-items: center; - justify-content: space-between; padding-top: 24px; } @@ -29,29 +28,3 @@ letter-spacing: -0.01em; color: var(--text); } - -.header-actions { - display: flex; - align-items: center; - gap: 8px; -} - -.header-icon-btn { - width: 36px; - height: 36px; - border-radius: 50%; - border: 1px solid var(--border); - background: var(--surface); - color: var(--text-muted); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - box-shadow: var(--shadow); - transition: all 0.2s; -} - -.header-icon-btn:hover { - color: var(--text); - border-color: var(--text-muted); -} \ No newline at end of file diff --git a/src/components/Header.jsx b/src/components/Header.jsx index 246a1e4..77f7fcc 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -1,13 +1,6 @@ import "./Header.css"; -const GearIcon = () => ( - - - - -); - -export default function Header({ onOpenSettings, showSettings }) { +export default function Header() { return (
@@ -19,14 +12,6 @@ export default function Header({ onOpenSettings, showSettings }) {
Sidekick
- - {showSettings && ( -
- -
- )}
); } diff --git a/src/components/Icons.jsx b/src/components/Icons.jsx index 5c48285..c3b1bd2 100644 --- a/src/components/Icons.jsx +++ b/src/components/Icons.jsx @@ -18,6 +18,21 @@ export const CloseIcon = () => ( ); +export const HamburgerIcon = () => ( + + + + + +); + +export const PlusIcon = () => ( + + + + +); + export const GoogleIcon = () => ( diff --git a/src/components/MicButton.css b/src/components/MicButton.css index af7241f..3aa2a80 100644 --- a/src/components/MicButton.css +++ b/src/components/MicButton.css @@ -1,18 +1,14 @@ -.mic-card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 36px 24px; +.mic-wrap { display: flex; flex-direction: column; align-items: center; - gap: 20px; - box-shadow: var(--shadow); + gap: 10px; + padding: 8px 0; } .mic-btn { - width: 88px; - height: 88px; + width: 56px; + height: 56px; border-radius: 50%; border: 2px solid var(--mic-idle); background: transparent; @@ -23,6 +19,7 @@ position: relative; color: var(--mic-idle); transition: border-color 0.25s, background 0.25s, transform 0.15s; + flex-shrink: 0; } .mic-btn:hover:not(:disabled) { @@ -83,7 +80,7 @@ display: flex; align-items: center; gap: 3px; - height: 24px; + height: 20px; opacity: 0; transition: opacity 0.3s; } @@ -109,4 +106,4 @@ .mic-wave-bar:nth-child(4) { animation-delay: 0.3s; } .mic-wave-bar:nth-child(5) { animation-delay: 0.2s; } .mic-wave-bar:nth-child(6) { animation-delay: 0.1s; } -.mic-wave-bar:nth-child(7) { animation-delay: 0s; } \ No newline at end of file +.mic-wave-bar:nth-child(7) { animation-delay: 0s; } diff --git a/src/components/MicButton.jsx b/src/components/MicButton.jsx index 481da09..b8b4b2d 100644 --- a/src/components/MicButton.jsx +++ b/src/components/MicButton.jsx @@ -3,13 +3,13 @@ import { MOCK_ESSAY } from "../utils/mockData"; import "./MicButton.css"; const StopIcon = () => ( - + ); const MicIcon = () => ( - + @@ -54,11 +54,11 @@ export default function MicButton({ apiKey, processing, setProcessing, onResult const statusText = active ? "Listening — tap to stop" : processing - ? "Processing…" + ? "Processing..." : "Tap to speak"; return ( -
+
- - - {statusText} +
); -} \ No newline at end of file +} diff --git a/src/components/OutputBox.css b/src/components/OutputBox.css index 09f058b..426a38d 100644 --- a/src/components/OutputBox.css +++ b/src/components/OutputBox.css @@ -1,22 +1,15 @@ .output-card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - overflow: hidden; - box-shadow: var(--shadow); - transition: border-color 0.3s; -} - -.output-card.has-content { - border-color: var(--text-muted); + flex: 1; + display: flex; + flex-direction: column; } .output-header { display: flex; align-items: center; justify-content: space-between; - padding: 14px 18px 12px; - border-bottom: 1px solid var(--border); + padding: 8px 0 12px; + flex-shrink: 0; } .output-label { @@ -37,7 +30,7 @@ height: 28px; border-radius: 7px; border: 1px solid var(--border); - background: var(--surface2); + background: var(--surface); color: var(--text-muted); cursor: pointer; display: flex; @@ -63,10 +56,7 @@ } .output-body { - padding: 32px 40px; - min-height: 100px; - max-height: 70vh; - overflow-y: auto; + flex: 1; display: flex; align-items: flex-start; } @@ -93,6 +83,7 @@ display: flex; gap: 5px; align-items: center; + padding: 8px 0; } .output-dot { @@ -104,4 +95,4 @@ } .output-dot:nth-child(2) { animation-delay: 0.2s; } -.output-dot:nth-child(3) { animation-delay: 0.4s; } \ No newline at end of file +.output-dot:nth-child(3) { animation-delay: 0.4s; } diff --git a/src/components/Sidebar.css b/src/components/Sidebar.css new file mode 100644 index 0000000..e551702 --- /dev/null +++ b/src/components/Sidebar.css @@ -0,0 +1,197 @@ +/* ── Sidebar ── */ +.sidebar { + width: 260px; + height: 100vh; + background: var(--surface); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + flex-shrink: 0; + transition: width 0.25s ease, opacity 0.25s ease; + overflow: hidden; + position: relative; + z-index: 100; +} + +.sidebar.collapsed { + width: 0; + border-right: none; +} + +/* ── Mobile backdrop ── */ +.sidebar-backdrop { + display: none; +} + +/* ── Header ── */ +.sidebar-header { + padding: 16px 12px 8px; + display: flex; + flex-direction: column; + gap: 8px; + flex-shrink: 0; +} + +.sidebar-toggle { + width: 36px; + height: 36px; + border-radius: 8px; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s, color 0.2s; +} + +.sidebar-toggle:hover { + background: var(--surface2); + color: var(--text); +} + +.sidebar-new-btn { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--surface2); + color: var(--text); + font-family: 'Syne', sans-serif; + font-size: 13px; + font-weight: 500; + cursor: pointer; + width: 100%; + text-align: left; + transition: background 0.2s, border-color 0.2s; + white-space: nowrap; +} + +.sidebar-new-btn:hover { + background: var(--border); + border-color: var(--text-muted); +} + +/* ── Conversation list ── */ +.sidebar-conversations { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.sidebar-empty { + font-size: 12px; + color: var(--text-muted); + padding: 12px 12px; + opacity: 0.6; + white-space: nowrap; +} + +.sidebar-convo-btn { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--text-muted); + font-family: 'Syne', sans-serif; + font-size: 13px; + font-weight: 400; + cursor: pointer; + width: 100%; + text-align: left; + transition: background 0.2s, color 0.2s; + white-space: nowrap; + min-width: 0; +} + +.sidebar-convo-btn:hover { + background: var(--surface2); + color: var(--text); +} + +.sidebar-convo-btn.active { + background: var(--surface2); + color: var(--text); + font-weight: 500; +} + +.sidebar-convo-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +/* ── Footer ── */ +.sidebar-footer { + padding: 8px; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 2px; + flex-shrink: 0; +} + +.sidebar-footer-btn { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--text-muted); + font-family: 'Syne', sans-serif; + font-size: 13px; + font-weight: 500; + cursor: pointer; + width: 100%; + text-align: left; + transition: background 0.2s, color 0.2s; + white-space: nowrap; +} + +.sidebar-footer-btn:hover { + background: var(--surface2); + color: var(--text); +} + +/* ── Mobile ── */ +@media (max-width: 768px) { + .sidebar { + position: fixed; + top: 0; + left: 0; + z-index: 1000; + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15); + } + + .sidebar.collapsed { + width: 0; + box-shadow: none; + } + + .sidebar-backdrop { + display: block; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + z-index: 999; + animation: sidebarFadeIn 0.2s ease; + } +} + +@keyframes sidebarFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx new file mode 100644 index 0000000..c20f575 --- /dev/null +++ b/src/components/Sidebar.jsx @@ -0,0 +1,91 @@ +import { PlusIcon, HamburgerIcon } from "./Icons"; +import "./Sidebar.css"; + +const GearIcon = () => ( + + + + +); + +const SignOutIcon = () => ( + + + + + +); + +const ChatIcon = () => ( + + + +); + +export default function Sidebar({ + conversations, + activeConversationId, + onSelectConversation, + onNewConversation, + onOpenSettings, + onSignOut, + collapsed, + onToggleCollapse, +}) { + return ( + <> + {/* Mobile backdrop */} + {!collapsed && ( +
+ )} + + + + ); +}