{
"skill": "frontend-design:frontend-design",
"args": "Do a design pass on the ReviewLens AI frontend. It's a review intelligence portal with two pages: a landing/upload page (index.html) and a chat interface (chat.html). The stack is Jinja2 templates + vanilla CSS + vanilla JS. The current CSS is in static/style.css, JS in static/app.js. Make the design distinctive and polished — dark theme, professional but not generic. Focus on the chat experience since that's where users spend most of their time."
}Base directory for this skill: /Users/shaynelarocque/.claude/plugins/cache/claude-plugins-official/frontend-design/61c0597779bd/skills/frontend-design
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
Before coding, understand the context and commit to a BOLD aesthetic direction: - Purpose: What problem does this interface solve? Who uses it? - Tone: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction. - Constraints: Technical requirements (framework, performance, accessibility). - Differentiation: What makes this UNFORGETTABLE? What's the one thing someone will remember?
CRITICAL: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is: - Production-grade and functional - Visually striking and memorable - Cohesive with a clear aesthetic point-of-view - Meticulously refined in every detail
Focus on: - Typography: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font. - Color & Theme: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. - Motion: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise. - Spatial Composition: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density. - Backgrounds & Visual Details: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
IMPORTANT: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
ARGUMENTS: Do a design pass on the ReviewLens AI frontend. It's a review intelligence portal with two pages: a landing/upload page (index.html) and a chat interface (chat.html). The stack is Jinja2 templates + vanilla CSS + vanilla JS. The current CSS is in static/style.css, JS in static/app.js. Make the design distinctive and polished — dark theme, professional but not generic. Focus on the chat experience since that's where users spend most of their time.
{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/templates/index.html"
} 1→<!DOCTYPE html>
2→<html lang="en">
3→<head>
4→ <meta charset="UTF-8">
5→ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6→ <title>ReviewLens AI</title>
7→ <link rel="stylesheet" href="/static/style.css">
8→ <script src="https://unpkg.com/htmx.org@2.0.4"></script>
9→</head>
10→<body>
11→ <div class="landing">
12→ <header class="landing-header">
13→ <h1>ReviewLens <span class="accent">AI</span></h1>
14→ <p class="tagline">Upload customer reviews. Ask questions. Get insights.</p>
15→ </header>
16→
17→ <main class="upload-section">
18→ <!-- CSV Upload (primary) -->
19→ <div class="upload-card primary">
20→ <h2>Upload Reviews</h2>
21→ <p class="upload-desc">Upload a CSV file with review data. We'll detect columns automatically.</p>
22→
23→ <form id="csv-form"
24→ hx-post="/upload"
25→ hx-encoding="multipart/form-data"
26→ hx-target="#upload-result"
27→ hx-indicator="#upload-spinner">
28→
29→ <div class="form-group">
30→ <label for="product_name">Product / Entity Name</label>
31→ <input type="text" id="product_name" name="product_name"
32→ placeholder="e.g., AcmeCo Widget Pro" required>
33→ </div>
34→
35→ <div class="form-group">
36→ <label for="platform">Platform</label>
37→ <select id="platform" name="platform">
38→ <option value="">Auto-detect</option>
39→ <option value="Amazon">Amazon</option>
40→ <option value="Google Maps">Google Maps</option>
41→ <option value="G2">G2</option>
42→ <option value="Capterra">Capterra</option>
43→ <option value="Trustpilot">Trustpilot</option>
44→ <option value="Yelp">Yelp</option>
45→ <option value="App Store">App Store</option>
46→ <option value="Other">Other</option>
47→ </select>
48→ </div>
49→
50→ <div class="file-drop" id="file-drop">
51→ <input type="file" id="file" name="file" accept=".csv" required>
52→ <div class="file-drop-content">
53→ <div class="file-icon">📄</div>
54→ <p>Drop a CSV file here or <span class="browse-link">browse</span></p>
55→ <p class="file-hint">Expects columns like: text/review, rating, date, author</p>
56→ </div>
57→ <div class="file-selected" style="display:none">
58→ <span class="file-name"></span>
59→ <button type="button" class="file-clear" onclick="clearFile()">✕</button>
60→ </div>
61→ </div>
62→
63→ <button type="submit" class="btn btn-primary" id="upload-btn">
64→ <span class="btn-text">Analyse Reviews</span>
65→ <span class="btn-spinner" id="upload-spinner" style="display:none">
66→ <span class="spinner"></span> Processing...
67→ </span>
68→ </button>
69→ </form>
70→
71→ <div id="upload-result"></div>
72→ </div>
73→
74→ <!-- URL Scrape (secondary) -->
75→ <div class="upload-card secondary">
76→ <h2>Scrape from URL <span class="badge">Best-effort</span></h2>
77→ <p class="upload-desc">Paste a review page URL. Works best with static pages — anti-bot measures may block some platforms.</p>
78→
79→ <form id="scrape-form"
80→ hx-post="/scrape"
81→ hx-target="#scrape-result"
82→ hx-indicator="#scrape-spinner">
83→
84→ <div class="form-group">
85→ <label for="scrape-product">Product / Entity Name</label>
86→ <input type="text" id="scrape-product" name="product_name"
87→ placeholder="e.g., AcmeCo Widget Pro">
88→ </div>
89→
90→ <div class="form-group">
91→ <label for="scrape-platform">Platform</label>
92→ <select id="scrape-platform" name="platform">
93→ <option value="">Auto-detect</option>
94→ <option value="Amazon">Amazon</option>
95→ <option value="Google Maps">Google Maps</option>
96→ <option value="G2">G2</option>
97→ <option value="Capterra">Capterra</option>
98→ <option value="Trustpilot">Trustpilot</option>
99→ <option value="Yelp">Yelp</option>
100→ </select>
101→ </div>
102→
103→ <div class="form-group">
104→ <label for="url">Review Page URL</label>
105→ <input type="url" id="url" name="url"
106→ placeholder="https://..." required>
107→ </div>
108→
109→ <button type="submit" class="btn btn-secondary">
110→ <span class="btn-text">Scrape & Analyse</span>
111→ <span class="btn-spinner" id="scrape-spinner" style="display:none">
112→ <span class="spinner"></span> Scraping...
113→ </span>
114→ </button>
115→ </form>
116→
117→ <div id="scrape-result"></div>
118→ </div>
119→ </main>
120→
121→ <footer class="landing-footer">
122→ <p>ReviewLens AI analyses only the reviews you provide. No external data. No hallucination.</p>
123→ </footer>
124→ </div>
125→
126→ <script src="/static/app.js"></script>
127→</body>
128→</html>
129→{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/templates/chat.html"
} 1→<!DOCTYPE html>
2→<html lang="en">
3→<head>
4→ <meta charset="UTF-8">
5→ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6→ <title>ReviewLens AI — {{ summary.product_name }}</title>
7→ <link rel="stylesheet" href="/static/style.css">
8→ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
9→ <script src="https://unpkg.com/htmx.org@2.0.4"></script>
10→</head>
11→<body>
12→ <div class="chat-layout">
13→ <!-- Sidebar: Ingestion Summary -->
14→ <aside class="sidebar">
15→ <div class="sidebar-header">
16→ <a href="/" class="logo-link">
17→ <h1 class="logo">ReviewLens <span class="accent">AI</span></h1>
18→ </a>
19→ </div>
20→
21→ <div class="summary-card">
22→ <h3>{{ summary.product_name }}</h3>
23→ <div class="summary-meta">
24→ <div class="meta-item">
25→ <span class="meta-label">Platform</span>
26→ <span class="meta-value">{{ summary.platform }}</span>
27→ </div>
28→ <div class="meta-item">
29→ <span class="meta-label">Reviews</span>
30→ <span class="meta-value">{{ summary.total_reviews }}</span>
31→ </div>
32→ {% if summary.average_rating %}
33→ <div class="meta-item">
34→ <span class="meta-label">Avg Rating</span>
35→ <span class="meta-value">{{ "%.1f"|format(summary.average_rating) }} ★</span>
36→ </div>
37→ {% endif %}
38→ {% if summary.date_range %}
39→ <div class="meta-item">
40→ <span class="meta-label">Date Range</span>
41→ <span class="meta-value">{{ summary.date_range }}</span>
42→ </div>
43→ {% endif %}
44→ <div class="meta-item">
45→ <span class="meta-label">Source</span>
46→ <span class="meta-value">{{ summary.source_type | upper }}</span>
47→ </div>
48→ </div>
49→
50→ {% if summary.rating_distribution %}
51→ <div class="rating-dist">
52→ <h4>Rating Distribution</h4>
53→ {% for star in ['5','4','3','2','1'] %}
54→ {% set count = summary.rating_distribution.get(star, 0) %}
55→ {% set pct = (count / summary.total_reviews * 100) if summary.total_reviews else 0 %}
56→ <div class="dist-row">
57→ <span class="dist-label">{{ star }}★</span>
58→ <div class="dist-bar-track">
59→ <div class="dist-bar-fill" style="width: {{ pct }}%"></div>
60→ </div>
61→ <span class="dist-count">{{ count }}</span>
62→ </div>
63→ {% endfor %}
64→ </div>
65→ {% endif %}
66→ </div>
67→
68→ <div class="sidebar-footer">
69→ <a href="/" class="btn btn-small">New Analysis</a>
70→ </div>
71→ </aside>
72→
73→ <!-- Main Chat Area -->
74→ <main class="chat-main">
75→ <div class="chat-messages" id="chat-messages">
76→ <!-- Existing messages -->
77→ {% for msg in messages %}
78→ {% if msg.role == 'assistant' %}
79→ <div class="message assistant-message">
80→ <div class="message-content">{{ msg.content }}</div>
81→ </div>
82→ {% else %}
83→ <div class="message user-message">
84→ <div class="message-content"><p>{{ msg.content }}</p></div>
85→ </div>
86→ {% endif %}
87→ {% endfor %}
88→
89→ <!-- Welcome message if no messages yet -->
90→ {% if not messages %}
91→ <div class="message assistant-message welcome">
92→ <div class="message-content">
93→ <p>Hello! I see you want to analyse reviews for <strong>{{ summary.product_name }}</strong>.
94→ I have <strong>{{ summary.total_reviews }}</strong> reviews loaded from <strong>{{ summary.platform }}</strong>{% if summary.date_range %}, spanning {{ summary.date_range }}{% endif %}.</p>
95→ <p>I can only answer questions about this specific dataset — no external data, no guesswork. What would you like to know?</p>
96→ </div>
97→ <div class="follow-ups">
98→ <button class="follow-up-btn" onclick="sendFollowUp(this)" data-question="What's the overall sentiment breakdown?">What's the overall sentiment breakdown?</button>
99→ <button class="follow-up-btn" onclick="sendFollowUp(this)" data-question="What are the most common complaints?">What are the most common complaints?</button>
100→ <button class="follow-up-btn" onclick="sendFollowUp(this)" data-question="Show me the rating distribution over time">Show me the rating distribution over time</button>
101→ <button class="follow-up-btn" onclick="sendFollowUp(this)" data-question="What do people praise the most?">What do people praise the most?</button>
102→ </div>
103→ </div>
104→ {% endif %}
105→ </div>
106→
107→ <!-- Chat Input -->
108→ <div class="chat-input-area">
109→ <form id="chat-form" onsubmit="sendMessage(event)">
110→ <div class="input-row">
111→ <textarea id="chat-input"
112→ name="message"
113→ placeholder="Ask about the reviews..."
114→ rows="1"
115→ autofocus></textarea>
116→ <button type="submit" class="btn btn-send" id="send-btn">
117→ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
118→ <path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>
119→ </svg>
120→ </button>
121→ </div>
122→ </form>
123→ <p class="input-hint">ReviewLens only analyses the uploaded reviews. It cannot access external data.</p>
124→ </div>
125→ </main>
126→ </div>
127→
128→ <script>
129→ var SESSION_ID = "{{ session.session_id }}";
130→ </script>
131→ <script src="/static/app.js"></script>
132→</body>
133→</html>
134→{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/static/style.css"
}{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/static/app.js"
} 1→/* ReviewLens AI — Stylesheet */
2→
3→/* ── Reset & Base ──────────────────────────────────────────────── */
4→
5→*, *::before, *::after {
6→ box-sizing: border-box;
7→ margin: 0;
8→ padding: 0;
9→}
10→
11→:root {
12→ --bg: #0f172a;
13→ --bg-card: #1e293b;
14→ --bg-input: #334155;
15→ --bg-hover: #475569;
16→ --text: #e2e8f0;
17→ --text-muted: #94a3b8;
18→ --text-dim: #64748b;
19→ --accent: #6366f1;
20→ --accent-hover: #818cf8;
21→ --accent-dim: rgba(99, 102, 241, 0.15);
22→ --success: #10b981;
23→ --warning: #f59e0b;
24→ --error: #ef4444;
25→ --border: #334155;
26→ --radius: 8px;
27→ --radius-lg: 12px;
28→ --shadow: 0 4px 6px -1px rgba(0,0,0,0.3);
29→ --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
30→ --mono: 'JetBrains Mono', 'Fira Code', monospace;
31→}
32→
33→html, body {
34→ height: 100%;
35→ font-family: var(--font);
36→ background: var(--bg);
37→ color: var(--text);
38→ line-height: 1.6;
39→ -webkit-font-smoothing: antialiased;
40→}
41→
42→a { color: var(--accent); text-decoration: none; }
43→a:hover { color: var(--accent-hover); }
44→
45→/* ── Landing Page ──────────────────────────────────────────────── */
46→
47→.landing {
48→ min-height: 100vh;
49→ display: flex;
50→ flex-direction: column;
51→ align-items: center;
52→ padding: 3rem 1.5rem;
53→}
54→
55→.landing-header {
56→ text-align: center;
57→ margin-bottom: 3rem;
58→}
59→
60→.landing-header h1 {
61→ font-size: 2.5rem;
62→ font-weight: 700;
63→ letter-spacing: -0.02em;
64→}
65→
66→.accent { color: var(--accent); }
67→
68→.tagline {
69→ color: var(--text-muted);
70→ font-size: 1.1rem;
71→ margin-top: 0.5rem;
72→}
73→
74→.upload-section {
75→ display: grid;
76→ grid-template-columns: 1fr 1fr;
77→ gap: 2rem;
78→ max-width: 900px;
79→ width: 100%;
80→}
81→
82→@media (max-width: 768px) {
83→ .upload-section { grid-template-columns: 1fr; }
84→}
85→
86→.upload-card {
87→ background: var(--bg-card);
88→ border: 1px solid var(--border);
89→ border-radius: var(--radius-lg);
90→ padding: 2rem;
91→}
92→
93→.upload-card.primary { border-color: var(--accent); }
94→
95→.upload-card h2 {
96→ font-size: 1.25rem;
97→ margin-bottom: 0.5rem;
98→}
99→
100→.upload-desc {
101→ color: var(--text-muted);
102→ font-size: 0.9rem;
103→ margin-bottom: 1.5rem;
104→}
105→
106→.badge {
107→ font-size: 0.7rem;
108→ background: var(--warning);
109→ color: #000;
110→ padding: 2px 8px;
111→ border-radius: 999px;
112→ font-weight: 600;
113→ vertical-align: middle;
114→}
115→
116→/* ── Forms ──────────────────────────────────────────────────────── */
117→
118→.form-group {
119→ margin-bottom: 1rem;
120→}
121→
122→.form-group label {
123→ display: block;
124→ font-size: 0.85rem;
125→ font-weight: 500;
126→ color: var(--text-muted);
127→ margin-bottom: 0.35rem;
128→}
129→
130→.form-group input,
131→.form-group select,
132→.form-group textarea {
133→ width: 100%;
134→ padding: 0.6rem 0.75rem;
135→ background: var(--bg-input);
136→ border: 1px solid var(--border);
137→ border-radius: var(--radius);
138→ color: var(--text);
139→ font-size: 0.9rem;
140→ font-family: var(--font);
141→ transition: border-color 0.15s;
142→}
143→
144→.form-group input:focus,
145→.form-group select:focus,
146→.form-group textarea:focus {
147→ outline: none;
148→ border-color: var(--accent);
149→}
150→
151→.form-group select {
152→ cursor: pointer;
153→}
154→
155→/* ── File Drop Zone ────────────────────────────────────────────── */
156→
157→.file-drop {
158→ border: 2px dashed var(--border);
159→ border-radius: var(--radius);
160→ padding: 2rem;
161→ text-align: center;
162→ cursor: pointer;
163→ transition: border-color 0.15s, background 0.15s;
164→ margin-bottom: 1.5rem;
165→ position: relative;
166→}
167→
168→.file-drop:hover,
169→.file-drop.drag-over {
170→ border-color: var(--accent);
171→ background: var(--accent-dim);
172→}
173→
174→.file-drop input[type="file"] {
175→ position: absolute;
176→ inset: 0;
177→ opacity: 0;
178→ cursor: pointer;
179→}
180→
181→.file-icon { font-size: 2rem; margin-bottom: 0.5rem; }
182→
183→.file-drop p {
184→ color: var(--text-muted);
185→ font-size: 0.9rem;
186→}
187→
188→.browse-link {
189→ color: var(--accent);
190→ text-decoration: underline;
191→}
192→
193→.file-hint {
194→ font-size: 0.8rem;
195→ color: var(--text-dim);
196→ margin-top: 0.25rem;
197→}
198→
199→.file-selected {
200→ display: flex;
201→ align-items: center;
202→ justify-content: center;
203→ gap: 0.75rem;
204→}
205→
206→.file-name {
207→ color: var(--text);
208→ font-weight: 500;
209→}
210→
211→.file-clear {
212→ background: none;
213→ border: none;
214→ color: var(--text-muted);
215→ cursor: pointer;
216→ font-size: 1.1rem;
217→ padding: 0.25rem;
218→}
219→
220→/* ── Buttons ───────────────────────────────────────────────────── */
221→
222→.btn {
223→ display: inline-flex;
224→ align-items: center;
225→ justify-content: center;
226→ gap: 0.5rem;
227→ padding: 0.7rem 1.5rem;
228→ border: none;
229→ border-radius: var(--radius);
230→ font-size: 0.9rem;
231→ font-weight: 600;
232→ font-family: var(--font);
233→ cursor: pointer;
234→ transition: background 0.15s, transform 0.1s;
235→ width: 100%;
236→}
237→
238→.btn:active { transform: scale(0.98); }
239→.btn:disabled { opacity: 0.6; cursor: not-allowed; }
240→
241→.btn-primary {
242→ background: var(--accent);
243→ color: #fff;
244→}
245→.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
246→
247→.btn-secondary {
248→ background: var(--bg-input);
249→ color: var(--text);
250→ border: 1px solid var(--border);
251→}
252→.btn-secondary:hover:not(:disabled) { background: var(--bg-hover); }
253→
254→.btn-small {
255→ padding: 0.4rem 1rem;
256→ font-size: 0.8rem;
257→ width: auto;
258→}
259→
260→.btn-spinner {
261→ display: inline-flex;
262→ align-items: center;
263→ gap: 0.5rem;
264→}
265→
266→.spinner {
267→ width: 16px;
268→ height: 16px;
269→ border: 2px solid rgba(255,255,255,0.3);
270→ border-top-color: #fff;
271→ border-radius: 50%;
272→ animation: spin 0.6s linear infinite;
273→}
274→
275→@keyframes spin { to { transform: rotate(360deg); } }
276→
277→/* ── Error Banner ──────────────────────────────────────────────── */
278→
279→.error-banner {
280→ background: rgba(239, 68, 68, 0.15);
281→ border: 1px solid var(--error);
282→ color: var(--error);
283→ padding: 0.75rem 1rem;
284→ border-radius: var(--radius);
285→ font-size: 0.9rem;
286→ margin-top: 1rem;
287→}
288→
289→/* ── Landing Footer ────────────────────────────────────────────── */
290→
291→.landing-footer {
292→ margin-top: 3rem;
293→ text-align: center;
294→ color: var(--text-dim);
295→ font-size: 0.85rem;
296→}
297→
298→/* ── Chat Layout ───────────────────────────────────────────────── */
299→
300→.chat-layout {
301→ display: grid;
302→ grid-template-columns: 280px 1fr;
303→ height: 100vh;
304→}
305→
306→@media (max-width: 768px) {
307→ .chat-layout { grid-template-columns: 1fr; }
308→ .sidebar { display: none; }
309→}
310→
311→/* ── Sidebar ───────────────────────────────────────────────────── */
312→
313→.sidebar {
314→ background: var(--bg-card);
315→ border-right: 1px solid var(--border);
316→ display: flex;
317→ flex-direction: column;
318→ overflow-y: auto;
319→}
320→
321→.sidebar-header {
322→ padding: 1.25rem 1.5rem;
323→ border-bottom: 1px solid var(--border);
324→}
325→
326→.logo {
327→ font-size: 1.1rem;
328→ font-weight: 700;
329→}
330→
331→.logo-link { color: var(--text); }
332→.logo-link:hover { color: var(--text); }
333→
334→.summary-card {
335→ padding: 1.5rem;
336→ flex: 1;
337→}
338→
339→.summary-card h3 {
340→ font-size: 1rem;
341→ margin-bottom: 1rem;
342→ word-break: break-word;
343→}
344→
345→.summary-meta {
346→ display: flex;
347→ flex-direction: column;
348→ gap: 0.6rem;
349→}
350→
351→.meta-item {
352→ display: flex;
353→ justify-content: space-between;
354→ font-size: 0.85rem;
355→}
356→
357→.meta-label { color: var(--text-muted); }
358→.meta-value { font-weight: 500; }
359→
360→/* ── Rating Distribution ───────────────────────────────────────── */
361→
362→.rating-dist {
363→ margin-top: 1.5rem;
364→}
365→
366→.rating-dist h4 {
367→ font-size: 0.8rem;
368→ color: var(--text-muted);
369→ text-transform: uppercase;
370→ letter-spacing: 0.05em;
371→ margin-bottom: 0.75rem;
372→}
373→
374→.dist-row {
375→ display: flex;
376→ align-items: center;
377→ gap: 0.5rem;
378→ margin-bottom: 0.35rem;
379→ font-size: 0.8rem;
380→}
381→
382→.dist-label {
383→ width: 2rem;
384→ text-align: right;
385→ color: var(--text-muted);
386→ flex-shrink: 0;
387→}
388→
389→.dist-bar-track {
390→ flex: 1;
391→ height: 6px;
392→ background: var(--bg-input);
393→ border-radius: 3px;
394→ overflow: hidden;
395→}
396→
397→.dist-bar-fill {
398→ height: 100%;
399→ background: var(--accent);
400→ border-radius: 3px;
401→ transition: width 0.3s ease;
402→}
403→
404→.dist-count {
405→ width: 2.5rem;
406→ text-align: right;
407→ color: var(--text-muted);
408→ flex-shrink: 0;
409→}
410→
411→.sidebar-footer {
412→ padding: 1rem 1.5rem;
413→ border-top: 1px solid var(--border);
414→}
415→
416→/* ── Chat Main ─────────────────────────────────────────────────── */
417→
418→.chat-main {
419→ display: flex;
420→ flex-direction: column;
421→ height: 100vh;
422→ overflow: hidden;
423→}
424→
425→.chat-messages {
426→ flex: 1;
427→ overflow-y: auto;
428→ padding: 2rem;
429→ display: flex;
430→ flex-direction: column;
431→ gap: 1rem;
432→}
433→
434→/* ── Messages ──────────────────────────────────────────────────── */
435→
436→.message {
437→ max-width: 720px;
438→ animation: fadeIn 0.2s ease;
439→}
440→
441→@keyframes fadeIn {
442→ from { opacity: 0; transform: translateY(4px); }
443→ to { opacity: 1; transform: translateY(0); }
444→}
445→
446→.user-message {
447→ align-self: flex-end;
448→}
449→
450→.user-message .message-content {
451→ background: var(--accent);
452→ color: #fff;
453→ padding: 0.75rem 1rem;
454→ border-radius: var(--radius-lg) var(--radius-lg) 4px var(--radius-lg);
455→}
456→
457→.user-message .message-content p { margin: 0; }
458→
459→.assistant-message {
460→ align-self: flex-start;
461→}
462→
463→.assistant-message .message-content {
464→ background: var(--bg-card);
465→ border: 1px solid var(--border);
466→ padding: 1rem 1.25rem;
467→ border-radius: 4px var(--radius-lg) var(--radius-lg) var(--radius-lg);
468→ line-height: 1.7;
469→}
470→
471→.assistant-message .message-content p { margin-bottom: 0.5rem; }
472→.assistant-message .message-content p:last-child { margin-bottom: 0; }
473→
474→.assistant-message .message-content h1,
475→.assistant-message .message-content h2,
476→.assistant-message .message-content h3 {
477→ margin: 1rem 0 0.5rem;
478→ font-size: 1rem;
479→ font-weight: 600;
480→}
481→
482→.assistant-message .message-content ul,
483→.assistant-message .message-content ol {
484→ padding-left: 1.5rem;
485→ margin-bottom: 0.5rem;
486→}
487→
488→.assistant-message .message-content li {
489→ margin-bottom: 0.25rem;
490→}
491→
492→.assistant-message .message-content strong {
493→ color: #fff;
494→}
495→
496→.assistant-message .message-content code {
497→ background: var(--bg-input);
498→ padding: 0.15rem 0.4rem;
499→ border-radius: 4px;
500→ font-size: 0.85em;
501→ font-family: var(--mono);
502→}
503→
504→.assistant-message .message-content blockquote {
505→ border-left: 3px solid var(--accent);
506→ padding-left: 1rem;
507→ color: var(--text-muted);
508→ margin: 0.5rem 0;
509→}
510→
511→.assistant-message .message-content table {
512→ width: 100%;
513→ border-collapse: collapse;
514→ margin: 0.75rem 0;
515→ font-size: 0.85rem;
516→}
517→
518→.assistant-message .message-content th,
519→.assistant-message .message-content td {
520→ padding: 0.5rem 0.75rem;
521→ border: 1px solid var(--border);
522→ text-align: left;
523→}
524→
525→.assistant-message .message-content th {
526→ background: var(--bg-input);
527→ font-weight: 600;
528→}
529→
530→/* ── Thinking Indicator ────────────────────────────────────────── */
531→
532→.thinking .message-content {
533→ display: flex;
534→ flex-direction: column;
535→ gap: 0.5rem;
536→}
537→
538→.thinking-dots {
539→ display: flex;
540→ gap: 4px;
541→ padding: 0.25rem 0;
542→}
543→
544→.thinking-dots span {
545→ width: 8px;
546→ height: 8px;
547→ background: var(--text-muted);
548→ border-radius: 50%;
549→ animation: bounce 1.4s ease-in-out infinite;
550→}
551→
552→.thinking-dots span:nth-child(2) { animation-delay: 0.16s; }
553→.thinking-dots span:nth-child(3) { animation-delay: 0.32s; }
554→
555→@keyframes bounce {
556→ 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
557→ 40% { transform: scale(1); opacity: 1; }
558→}
559→
560→.tool-activity {
561→ font-size: 0.8rem;
562→ color: var(--text-dim);
563→ font-family: var(--mono);
564→}
565→
566→/* ── Charts ────────────────────────────────────────────────────── */
567→
568→.chart-container {
569→ margin: 1rem 0;
570→ padding: 1rem;
571→ background: var(--bg);
572→ border-radius: var(--radius);
573→ border: 1px solid var(--border);
574→ max-width: 500px;
575→}
576→
577→.chart-container canvas {
578→ max-height: 300px;
579→}
580→
581→/* ── Follow-up Buttons ─────────────────────────────────────────── */
582→
583→.follow-ups {
584→ display: flex;
585→ flex-wrap: wrap;
586→ gap: 0.5rem;
587→ margin-top: 0.75rem;
588→}
589→
590→.follow-up-btn {
591→ background: var(--accent-dim);
592→ color: var(--accent-hover);
593→ border: 1px solid rgba(99, 102, 241, 0.3);
594→ border-radius: 999px;
595→ padding: 0.4rem 1rem;
596→ font-size: 0.8rem;
597→ font-family: var(--font);
598→ cursor: pointer;
599→ transition: background 0.15s, border-color 0.15s;
600→ white-space: nowrap;
601→}
602→
603→.follow-up-btn:hover {
604→ background: rgba(99, 102, 241, 0.25);
605→ border-color: var(--accent);
606→}
607→
608→/* ── Chat Input ────────────────────────────────────────────────── */
609→
610→.chat-input-area {
611→ padding: 1rem 2rem 1.5rem;
612→ border-top: 1px solid var(--border);
613→ background: var(--bg);
614→}
615→
616→.input-row {
617→ display: flex;
618→ gap: 0.75rem;
619→ align-items: flex-end;
620→}
621→
622→.input-row textarea {
623→ flex: 1;
624→ resize: none;
625→ padding: 0.75rem 1rem;
626→ background: var(--bg-card);
627→ border: 1px solid var(--border);
628→ border-radius: var(--radius-lg);
629→ color: var(--text);
630→ font-size: 0.9rem;
631→ font-family: var(--font);
632→ line-height: 1.5;
633→ max-height: 150px;
634→ transition: border-color 0.15s;
635→}
636→
637→.input-row textarea:focus {
638→ outline: none;
639→ border-color: var(--accent);
640→}
641→
642→.input-row textarea:disabled {
643→ opacity: 0.5;
644→}
645→
646→.btn-send {
647→ width: 44px;
648→ height: 44px;
649→ padding: 0;
650→ border-radius: 50%;
651→ flex-shrink: 0;
652→ background: var(--accent);
653→ color: #fff;
654→ border: none;
655→ cursor: pointer;
656→ display: flex;
657→ align-items: center;
658→ justify-content: center;
659→ transition: background 0.15s, transform 0.1s;
660→}
661→
662→.btn-send:hover:not(:disabled) { background: var(--accent-hover); }
663→.btn-send:active { transform: scale(0.95); }
664→.btn-send:disabled { opacity: 0.5; cursor: not-allowed; }
665→
666→.input-hint {
667→ text-align: center;
668→ font-size: 0.75rem;
669→ color: var(--text-dim);
670→ margin-top: 0.5rem;
671→}
672→
673→/* ── Scrollbar ─────────────────────────────────────────────────── */
674→
675→::-webkit-scrollbar {
676→ width: 6px;
677→}
678→
679→::-webkit-scrollbar-track {
680→ background: transparent;
681→}
682→
683→::-webkit-scrollbar-thumb {
684→ background: var(--bg-hover);
685→ border-radius: 3px;
686→}
687→
688→::-webkit-scrollbar-thumb:hover {
689→ background: var(--text-dim);
690→}
691→ 1→/* ReviewLens AI — Client-side logic */
2→
3→// ── File upload drag & drop ────────────────────────────────────────
4→
5→(function () {
6→ var dropZone = document.getElementById("file-drop");
7→ var fileInput = document.getElementById("file");
8→ if (!dropZone || !fileInput) return;
9→
10→ dropZone.addEventListener("click", function () {
11→ fileInput.click();
12→ });
13→
14→ dropZone.addEventListener("dragover", function (e) {
15→ e.preventDefault();
16→ dropZone.classList.add("drag-over");
17→ });
18→
19→ dropZone.addEventListener("dragleave", function () {
20→ dropZone.classList.remove("drag-over");
21→ });
22→
23→ dropZone.addEventListener("drop", function (e) {
24→ e.preventDefault();
25→ dropZone.classList.remove("drag-over");
26→ if (e.dataTransfer.files.length) {
27→ fileInput.files = e.dataTransfer.files;
28→ showFileName(e.dataTransfer.files[0].name);
29→ }
30→ });
31→
32→ fileInput.addEventListener("change", function () {
33→ if (fileInput.files.length) {
34→ showFileName(fileInput.files[0].name);
35→ }
36→ });
37→
38→ function showFileName(name) {
39→ var content = dropZone.querySelector(".file-drop-content");
40→ var selected = dropZone.querySelector(".file-selected");
41→ var nameEl = dropZone.querySelector(".file-name");
42→ content.style.display = "none";
43→ selected.style.display = "flex";
44→ nameEl.textContent = name;
45→ }
46→})();
47→
48→function clearFile() {
49→ var dropZone = document.getElementById("file-drop");
50→ var fileInput = document.getElementById("file");
51→ var content = dropZone.querySelector(".file-drop-content");
52→ var selected = dropZone.querySelector(".file-selected");
53→ content.style.display = "";
54→ selected.style.display = "none";
55→ fileInput.value = "";
56→}
57→
58→// ── HTMX loading states ────────────────────────────────────────────
59→
60→document.addEventListener("htmx:beforeRequest", function (e) {
61→ var form = e.detail.elt;
62→ var btn = form.querySelector("button[type=submit]");
63→ if (btn) {
64→ btn.disabled = true;
65→ var text = btn.querySelector(".btn-text");
66→ var spinner = btn.querySelector(".btn-spinner");
67→ if (text) text.style.display = "none";
68→ if (spinner) spinner.style.display = "inline-flex";
69→ }
70→});
71→
72→document.addEventListener("htmx:afterRequest", function (e) {
73→ var form = e.detail.elt;
74→ var btn = form.querySelector("button[type=submit]");
75→ if (btn) {
76→ btn.disabled = false;
77→ var text = btn.querySelector(".btn-text");
78→ var spinner = btn.querySelector(".btn-spinner");
79→ if (text) text.style.display = "";
80→ if (spinner) spinner.style.display = "none";
81→ }
82→});
83→
84→// ── Chat functionality ─────────────────────────────────────────────
85→
86→var chatMessages = document.getElementById("chat-messages");
87→var chatInput = document.getElementById("chat-input");
88→var chatForm = document.getElementById("chat-form");
89→var sendBtn = document.getElementById("send-btn");
90→var eventSource = null;
91→
92→function scrollToBottom() {
93→ if (chatMessages) {
94→ chatMessages.scrollTop = chatMessages.scrollHeight;
95→ }
96→}
97→
98→// Auto-resize textarea
99→if (chatInput) {
100→ chatInput.addEventListener("input", function () {
101→ this.style.height = "auto";
102→ this.style.height = Math.min(this.scrollHeight, 150) + "px";
103→ });
104→
105→ chatInput.addEventListener("keydown", function (e) {
106→ if (e.key === "Enter" && !e.shiftKey) {
107→ e.preventDefault();
108→ chatForm.dispatchEvent(new Event("submit"));
109→ }
110→ });
111→}
112→
113→function sendMessage(e) {
114→ e.preventDefault();
115→ var message = chatInput.value.trim();
116→ if (!message) return;
117→
118→ // Disable input while processing
119→ chatInput.disabled = true;
120→ sendBtn.disabled = true;
121→
122→ // Remove existing follow-up buttons
123→ var existingFollowUps = chatMessages.querySelectorAll(".follow-ups");
124→ existingFollowUps.forEach(function (el) {
125→ el.remove();
126→ });
127→
128→ // Add user message to chat
129→ var userDiv = document.createElement("div");
130→ userDiv.className = "message user-message";
131→ userDiv.innerHTML =
132→ '<div class="message-content"><p>' + escapeHtml(message) + "</p></div>";
133→ chatMessages.appendChild(userDiv);
134→
135→ // Add thinking indicator
136→ var thinkingDiv = document.createElement("div");
137→ thinkingDiv.id = "thinking-indicator";
138→ thinkingDiv.className = "message assistant-message thinking";
139→ thinkingDiv.innerHTML =
140→ '<div class="message-content">' +
141→ '<div class="thinking-dots"><span></span><span></span><span></span></div>' +
142→ "</div>";
143→ chatMessages.appendChild(thinkingDiv);
144→ scrollToBottom();
145→
146→ // Send via fetch and listen for SSE response
147→ var formData = new FormData();
148→ formData.append("message", message);
149→
150→ fetch("/chat/" + SESSION_ID + "/send", {
151→ method: "POST",
152→ body: formData,
153→ })
154→ .then(function () {
155→ // Start listening for SSE events
156→ listenForResponse();
157→ })
158→ .catch(function (err) {
159→ removeThinking();
160→ appendError("Failed to send message: " + err.message);
161→ chatInput.disabled = false;
162→ sendBtn.disabled = false;
163→ });
164→
165→ chatInput.value = "";
166→ chatInput.style.height = "auto";
167→}
168→
169→function listenForResponse() {
170→ if (eventSource) {
171→ eventSource.close();
172→ }
173→
174→ eventSource = new EventSource("/chat/" + SESSION_ID + "/stream");
175→
176→ eventSource.addEventListener("tool", function (e) {
177→ // Show tool activity in a subtle indicator
178→ var thinking = document.getElementById("thinking-indicator");
179→ if (thinking) {
180→ var content = thinking.querySelector(".message-content");
181→ content.innerHTML =
182→ '<div class="thinking-dots"><span></span><span></span><span></span></div>' +
183→ '<div class="tool-activity">' +
184→ escapeHtml(e.data) +
185→ "</div>";
186→ scrollToBottom();
187→ }
188→ });
189→
190→ eventSource.addEventListener("message", function (e) {
191→ removeThinking();
192→ // Insert the rendered HTML from the server
193→ var temp = document.createElement("div");
194→ temp.innerHTML = e.data;
195→ while (temp.firstChild) {
196→ chatMessages.appendChild(temp.firstChild);
197→ }
198→ // Execute any chart scripts
199→ runChartScripts();
200→ scrollToBottom();
201→ chatInput.disabled = false;
202→ sendBtn.disabled = false;
203→ chatInput.focus();
204→ });
205→
206→ eventSource.addEventListener("done", function () {
207→ eventSource.close();
208→ eventSource = null;
209→ removeThinking();
210→ chatInput.disabled = false;
211→ sendBtn.disabled = false;
212→ });
213→
214→ eventSource.onerror = function () {
215→ eventSource.close();
216→ eventSource = null;
217→ removeThinking();
218→ chatInput.disabled = false;
219→ sendBtn.disabled = false;
220→ };
221→}
222→
223→function removeThinking() {
224→ var el = document.getElementById("thinking-indicator");
225→ if (el) el.remove();
226→}
227→
228→function appendError(msg) {
229→ var div = document.createElement("div");
230→ div.className = "message assistant-message error";
231→ div.innerHTML = '<div class="message-content"><p>' + escapeHtml(msg) + "</p></div>";
232→ chatMessages.appendChild(div);
233→ scrollToBottom();
234→}
235→
236→function sendFollowUp(btn) {
237→ var question = btn.getAttribute("data-question");
238→ chatInput.value = question;
239→ chatForm.dispatchEvent(new Event("submit"));
240→}
241→
242→// ── Chart rendering ────────────────────────────────────────────────
243→
244→// Color palette
245→var CHART_COLORS = [
246→ "rgba(99, 102, 241, 0.8)", // indigo
247→ "rgba(16, 185, 129, 0.8)", // emerald
248→ "rgba(245, 158, 11, 0.8)", // amber
249→ "rgba(239, 68, 68, 0.8)", // red
250→ "rgba(139, 92, 246, 0.8)", // violet
251→ "rgba(6, 182, 212, 0.8)", // cyan
252→ "rgba(236, 72, 153, 0.8)", // pink
253→];
254→
255→var CHART_BORDERS = [
256→ "rgba(99, 102, 241, 1)",
257→ "rgba(16, 185, 129, 1)",
258→ "rgba(245, 158, 11, 1)",
259→ "rgba(239, 68, 68, 1)",
260→ "rgba(139, 92, 246, 1)",
261→ "rgba(6, 182, 212, 1)",
262→ "rgba(236, 72, 153, 1)",
263→];
264→
265→function renderChart(canvasId, config) {
266→ var canvas = document.getElementById(canvasId);
267→ if (!canvas) return;
268→
269→ var datasets = config.data.datasets.map(function (ds, i) {
270→ var colors =
271→ config.type === "pie" || config.type === "doughnut"
272→ ? CHART_COLORS.slice(0, ds.data.length)
273→ : [CHART_COLORS[i % CHART_COLORS.length]];
274→
275→ var borders =
276→ config.type === "pie" || config.type === "doughnut"
277→ ? CHART_BORDERS.slice(0, ds.data.length)
278→ : [CHART_BORDERS[i % CHART_BORDERS.length]];
279→
280→ return {
281→ label: ds.label,
282→ data: ds.data,
283→ backgroundColor: colors.length === 1 ? colors[0] : colors,
284→ borderColor: borders.length === 1 ? borders[0] : borders,
285→ borderWidth: config.type === "line" ? 2 : 1,
286→ tension: 0.3,
287→ fill: config.type === "line",
288→ };
289→ });
290→
291→ new Chart(canvas, {
292→ type: config.type,
293→ data: {
294→ labels: config.data.labels,
295→ datasets: datasets,
296→ },
297→ options: {
298→ responsive: true,
299→ maintainAspectRatio: true,
300→ plugins: {
301→ title: {
302→ display: true,
303→ text: config.title,
304→ font: { size: 14, weight: "600" },
305→ color: "#e2e8f0",
306→ },
307→ legend: {
308→ labels: { color: "#94a3b8" },
309→ },
310→ },
311→ scales:
312→ config.type !== "pie" && config.type !== "doughnut"
313→ ? {
314→ x: {
315→ ticks: { color: "#94a3b8" },
316→ grid: { color: "rgba(148, 163, 184, 0.1)" },
317→ },
318→ y: {
319→ ticks: { color: "#94a3b8" },
320→ grid: { color: "rgba(148, 163, 184, 0.1)" },
321→ beginAtZero: true,
322→ },
323→ }
324→ : undefined,
325→ },
326→ });
327→}
328→
329→function runChartScripts() {
330→ // Find and execute any chart script tags just added
331→ var scripts = chatMessages.querySelectorAll("script");
332→ scripts.forEach(function (script) {
333→ if (script.textContent.indexOf("renderChart") !== -1 && !script.dataset.executed) {
334→ script.dataset.executed = "true";
335→ eval(script.textContent);
336→ }
337→ });
338→}
339→
340→// ── Utilities ──────────────────────────────────────────────────────
341→
342→function escapeHtml(str) {
343→ var div = document.createElement("div");
344→ div.appendChild(document.createTextNode(str));
345→ return div.innerHTML;
346→}
347→
348→// Auto-scroll on new content
349→if (chatMessages) {
350→ var observer = new MutationObserver(function () {
351→ scrollToBottom();
352→ });
353→ observer.observe(chatMessages, { childList: true, subtree: true });
354→}
355→
356→// Initial scroll
357→scrollToBottom();
358→Now I have the full picture. Let me design this.
Direction: "Observatory" — a precision data workstation. Deep atmospheric darks, teal primary for data/clarity, warm amber for human interaction moments. Instrument Serif for the brand (editorial, unexpected), DM Sans for body (geometric, legible), IBM Plex Mono for data. Subtle grain texture, glass-morphism on cards, staggered reveals, a distinctive pulsing analysis bar instead of generic bouncing dots.
I'll rewrite all four frontend files.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ReviewLens AI</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=Instrument+Serif:ital@0;1&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<div class="grain"></div>
<div class="landing">
<header class="landing-header">
<div class="logo-mark">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<circle cx="16" cy="16" r="14" stroke="var(--teal)" stroke-width="1.5" opacity="0.4"/>
<circle cx="16" cy="16" r="8" stroke="var(--teal)" stroke-width="1.5"/>
<circle cx="16" cy="16" r="2.5" fill="var(--teal)"/>
</svg>
</div>
<h1 class="landing-title">Review<span class="serif-accent">Lens</span></h1>
<p class="tagline">Upload reviews. Interrogate the data. Surface what matters.</p>
</header>
<main class="upload-section">
<div class="upload-card primary" style="--delay: 0.1s">
<div class="card-label">Primary</div>
<h2>CSV Upload</h2>
<p class="upload-desc">Drop a CSV with review data. Column names are auto-detected.</p>
<form id="csv-form"
hx-post="/upload"
hx-encoding="multipart/form-data"
hx-target="#upload-result"
hx-indicator="#upload-spinner">
<div class="form-group">
<label for="product_name">Product / Entity</label>
<input type="text" id="product_name" name="product_name"
placeholder="e.g., AcmeCo Widget Pro" required>
</div>
<div class="form-group">
<label for="platform">Platform</label>
<select id="platform" name="platform">
<option value="">Auto-detect</option>
<option value="Amazon">Amazon</option>
<option value="Google Maps">Google Maps</option>
<option value="G2">G2</option>
<option value="Capterra">Capterra</option>
<option value="Trustpilot">Trustpilot</option>
<option value="Yelp">Yelp</option>
<option value="App Store">App Store</option>
<option value="Other">Other</option>
</select>
</div>
<div class="file-drop" id="file-drop">
<input type="file" id="file" name="file" accept=".csv" required>
<div class="file-drop-content">
<svg class="file-drop-icon" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="12" y1="18" x2="12" y2="12"/>
<polyline points="9 15 12 12 15 15"/>
</svg>
<p>Drop CSV here or <span class="browse-link">browse</span></p>
<p class="file-hint">Columns: text/review, rating, date, author</p>
</div>
<div class="file-selected" style="display:none">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--teal)" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<span class="file-name"></span>
<button type="button" class="file-clear" onclick="clearFile()">×</button>
</div>
</div>
<button type="submit" class="btn btn-primary" id="upload-btn">
<span class="btn-text">Analyse Reviews</span>
<span class="btn-spinner" id="upload-spinner" style="display:none">
<span class="spinner"></span> Indexing...
</span>
</button>
</form>
<div id="upload-result"></div>
</div>
<div class="upload-card secondary" style="--delay: 0.2s">
<div class="card-label">Secondary</div>
<h2>URL Scrape <span class="badge">Best-effort</span></h2>
<p class="upload-desc">Paste a review page URL. Anti-bot measures may limit results.</p>
<form id="scrape-form"
hx-post="/scrape"
hx-target="#scrape-result"
hx-indicator="#scrape-spinner">
<div class="form-group">
<label for="scrape-product">Product / Entity</label>
<input type="text" id="scrape-product" name="product_name"
placeholder="e.g., AcmeCo Widget Pro">
</div>
<div class="form-group">
<label for="scrape-platform">Platform</label>
<select id="scrape-platform" name="platform">
<option value="">Auto-detect</option>
<option value="Amazon">Amazon</option>
<option value="Google Maps">Google Maps</option>
<option value="G2">G2</option>
<option value="Capterra">Capterra</option>
<option value="Trustpilot">Trustpilot</option>
<option value="Yelp">Yelp</option>
</select>
</div>
<div class="form-group">
<label for="url">Review Page URL</label>
<input type="url" id="url" name="url"
placeholder="https://..." required>
</div>
<button type="submit" class="btn btn-secondary">
<span class="btn-text">Scrape & Analyse</span>
<span class="btn-spinner" id="scrape-spinner" style="display:none">
<span class="spinner"></span> Scraping...
</span>
</button>
</form>
<div id="scrape-result"></div>
</div>
</main>
<footer class="landing-footer">
<div class="footer-rule"></div>
<p>ReviewLens analyses only the reviews you provide. No external data. No hallucination.</p>
</footer>
</div>
<script src="/static/app.js"></script>
</body>
</html>
The file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/templates/index.html has been updated successfully.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ReviewLens AI — {{ summary.product_name }}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=Instrument+Serif:ital@0;1&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body class="chat-body">
<div class="grain"></div>
<div class="chat-layout">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<a href="/" class="logo-link">
<svg width="22" height="22" viewBox="0 0 32 32" fill="none">
<circle cx="16" cy="16" r="14" stroke="var(--teal)" stroke-width="1.5" opacity="0.4"/>
<circle cx="16" cy="16" r="8" stroke="var(--teal)" stroke-width="1.5"/>
<circle cx="16" cy="16" r="2.5" fill="var(--teal)"/>
</svg>
<span class="logo-text">Review<span class="serif-accent">Lens</span></span>
</a>
</div>
<div class="summary-panel">
<div class="summary-product">
<h3>{{ summary.product_name }}</h3>
<span class="summary-platform-badge">{{ summary.platform }}</span>
</div>
<div class="summary-stats-grid">
<div class="stat-cell">
<span class="stat-value">{{ summary.total_reviews }}</span>
<span class="stat-label">Reviews</span>
</div>
{% if summary.average_rating %}
<div class="stat-cell">
<span class="stat-value">{{ "%.1f"|format(summary.average_rating) }}</span>
<span class="stat-label">Avg Rating</span>
</div>
{% endif %}
<div class="stat-cell">
<span class="stat-value mono">{{ summary.source_type | upper }}</span>
<span class="stat-label">Source</span>
</div>
</div>
{% if summary.date_range %}
<div class="summary-date-range">
<span class="stat-label">Date Range</span>
<span class="mono">{{ summary.date_range }}</span>
</div>
{% endif %}
{% if summary.rating_distribution %}
<div class="rating-dist">
<span class="stat-label">Rating Distribution</span>
{% for star in ['5','4','3','2','1'] %}
{% set count = summary.rating_distribution.get(star, 0) %}
{% set pct = (count / summary.total_reviews * 100) if summary.total_reviews else 0 %}
<div class="dist-row" style="--delay: {{ loop.index * 0.06 }}s">
<span class="dist-star">{{ star }}</span>
<div class="dist-bar-track">
<div class="dist-bar-fill" style="--target-width: {{ pct }}%"></div>
</div>
<span class="dist-count mono">{{ count }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="sidebar-footer">
<a href="/" class="sidebar-new-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
New Analysis
</a>
</div>
</aside>
<!-- Chat -->
<main class="chat-main">
<div class="chat-messages" id="chat-messages">
{% for msg in messages %}
{% if msg.role == 'assistant' %}
<div class="message assistant-message">
<div class="message-content">{{ msg.content }}</div>
</div>
{% else %}
<div class="message user-message">
<div class="message-content"><p>{{ msg.content }}</p></div>
</div>
{% endif %}
{% endfor %}
{% if not messages %}
<div class="message assistant-message welcome" style="--delay: 0.15s">
<div class="message-content">
<p>I have <strong>{{ summary.total_reviews }} reviews</strong> loaded for <strong>{{ summary.product_name }}</strong> from <strong>{{ summary.platform }}</strong>{% if summary.date_range %}, spanning <strong>{{ summary.date_range }}</strong>{% endif %}.</p>
<p>I can only answer questions grounded in this dataset. What would you like to explore?</p>
</div>
<div class="follow-ups" style="--delay: 0.35s">
<button class="follow-up-btn" onclick="sendFollowUp(this)" data-question="What's the overall sentiment breakdown?">Sentiment breakdown</button>
<button class="follow-up-btn" onclick="sendFollowUp(this)" data-question="What are the most common complaints?">Top complaints</button>
<button class="follow-up-btn" onclick="sendFollowUp(this)" data-question="Show me the rating distribution over time">Ratings over time</button>
<button class="follow-up-btn" onclick="sendFollowUp(this)" data-question="What do people praise the most?">Most praised aspects</button>
</div>
</div>
{% endif %}
</div>
<div class="chat-input-area">
<form id="chat-form" onsubmit="sendMessage(event)">
<div class="input-row">
<textarea id="chat-input"
name="message"
placeholder="Ask about the reviews..."
rows="1"
autofocus></textarea>
<button type="submit" class="btn-send" id="send-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>
</svg>
</button>
</div>
</form>
<p class="input-hint">Analyses only the uploaded dataset. No external knowledge.</p>
</div>
</main>
</div>
<script>
var SESSION_ID = "{{ session.session_id }}";
</script>
<script src="/static/app.js"></script>
</body>
</html>
The file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/templates/chat.html has been updated successfully.
/* ═══════════════════════════════════════════════════════════════════
ReviewLens AI — Observatory
Deep atmospheric dark, teal data clarity, warm amber interaction.
═══════════════════════════════════════════════════════════════════ */
/* ── Reset ─────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── Tokens ────────────────────────────────────────────────────── */
:root {
/* Surface */
--bg-0: #080b12;
--bg-1: #0d1117;
--bg-2: #151b27;
--bg-3: #1c2435;
--bg-4: #243044;
/* Text */
--text-0: #e8ecf4;
--text-1: #b0bdd0;
--text-2: #6b7d99;
--text-3: #3e4f6a;
/* Accent: teal — data, clarity, precision */
--teal: #2dd4bf;
--teal-dim: rgba(45, 212, 191, 0.12);
--teal-mid: rgba(45, 212, 191, 0.25);
--teal-glow: rgba(45, 212, 191, 0.06);
/* Warm: amber — interaction, attention, human */
--amber: #f59e0b;
--amber-dim: rgba(245, 158, 11, 0.12);
/* Semantic */
--error: #f87171;
--error-dim: rgba(248, 113, 113, 0.12);
/* Type */
--sans: 'DM Sans', -apple-system, sans-serif;
--serif: 'Instrument Serif', Georgia, serif;
--mono: 'IBM Plex Mono', 'Menlo', monospace;
/* Shape */
--radius: 6px;
--radius-lg: 10px;
--radius-xl: 14px;
}
/* ── Base ──────────────────────────────────────────────────────── */
html, body {
height: 100%;
font-family: var(--sans);
font-weight: 400;
background: var(--bg-0);
color: var(--text-0);
line-height: 1.6;
font-size: 15px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a { color: var(--teal); text-decoration: none; }
a:hover { color: #5eead4; }
.serif-accent {
font-family: var(--serif);
font-style: italic;
font-weight: 400;
}
.mono {
font-family: var(--mono);
font-size: 0.85em;
}
/* ── Film Grain Overlay ────────────────────────────────────────── */
.grain {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
opacity: 0.035;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 256px;
}
/* ── Entrance Animations ───────────────────────────────────────── */
@keyframes rise {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes barGrow {
from { width: 0; }
to { width: var(--target-width, 0); }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse-bar {
0%, 100% { opacity: 0.4; transform: scaleX(0.6); }
50% { opacity: 1; transform: scaleX(1); }
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ═══════════════════════════════════════════════════════════════════
LANDING PAGE
═══════════════════════════════════════════════════════════════════ */
.landing {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 4rem 1.5rem 3rem;
position: relative;
}
/* Radial glow behind header */
.landing::before {
content: '';
position: absolute;
top: -120px;
left: 50%;
transform: translateX(-50%);
width: 700px;
height: 500px;
background: radial-gradient(ellipse, rgba(45, 212, 191, 0.06) 0%, transparent 70%);
pointer-events: none;
}
.landing-header {
text-align: center;
margin-bottom: 3.5rem;
animation: rise 0.6s ease both;
}
.logo-mark {
margin-bottom: 1.25rem;
}
.landing-title {
font-family: var(--sans);
font-size: 2.8rem;
font-weight: 300;
letter-spacing: -0.03em;
color: var(--text-0);
}
.tagline {
color: var(--text-2);
font-size: 1rem;
margin-top: 0.75rem;
letter-spacing: 0.01em;
}
/* ── Upload Cards ──────────────────────────────────────────────── */
.upload-section {
display: grid;
grid-template-columns: 1.15fr 1fr;
gap: 1.5rem;
max-width: 860px;
width: 100%;
}
@media (max-width: 768px) {
.upload-section { grid-template-columns: 1fr; }
}
.upload-card {
background: var(--bg-1);
border: 1px solid var(--bg-3);
border-radius: var(--radius-xl);
padding: 2rem;
position: relative;
animation: rise 0.5s ease both;
animation-delay: var(--delay, 0s);
transition: border-color 0.25s;
}
.upload-card:hover {
border-color: var(--bg-4);
}
.upload-card.primary {
border-color: rgba(45, 212, 191, 0.2);
box-shadow: 0 0 0 1px rgba(45, 212, 191, 0.05), 0 8px 40px rgba(0,0,0,0.3);
}
.upload-card.primary:hover {
border-color: rgba(45, 212, 191, 0.35);
}
.card-label {
font-family: var(--mono);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-3);
margin-bottom: 0.75rem;
}
.upload-card.primary .card-label {
color: var(--teal);
opacity: 0.7;
}
.upload-card h2 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
}
.upload-desc {
color: var(--text-2);
font-size: 0.87rem;
margin-bottom: 1.5rem;
line-height: 1.5;
}
.badge {
font-family: var(--mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.08em;
background: var(--amber-dim);
color: var(--amber);
padding: 3px 8px;
border-radius: 999px;
font-weight: 500;
vertical-align: middle;
margin-left: 0.25rem;
}
/* ── Forms ──────────────────────────────────────────────────────── */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.78rem;
font-weight: 500;
color: var(--text-2);
margin-bottom: 0.3rem;
letter-spacing: 0.01em;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.55rem 0.75rem;
background: var(--bg-2);
border: 1px solid var(--bg-3);
border-radius: var(--radius);
color: var(--text-0);
font-size: 0.87rem;
font-family: var(--sans);
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--teal);
box-shadow: 0 0 0 3px var(--teal-dim);
}
.form-group input::placeholder {
color: var(--text-3);
}
.form-group select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%236b7d99' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2.25rem;
}
/* ── File Drop ─────────────────────────────────────────────────── */
.file-drop {
border: 1.5px dashed var(--bg-4);
border-radius: var(--radius-lg);
padding: 1.75rem;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
margin-bottom: 1.5rem;
position: relative;
}
.file-drop:hover,
.file-drop.drag-over {
border-color: var(--teal);
background: var(--teal-glow);
}
.file-drop input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.file-drop-icon {
color: var(--text-3);
margin-bottom: 0.5rem;
transition: color 0.2s;
}
.file-drop:hover .file-drop-icon {
color: var(--teal);
}
.file-drop p {
color: var(--text-2);
font-size: 0.87rem;
}
.browse-link {
color: var(--teal);
border-bottom: 1px solid transparent;
transition: border-color 0.15s;
}
.browse-link:hover { border-color: var(--teal); }
.file-hint {
font-family: var(--mono);
font-size: 0.72rem;
color: var(--text-3);
margin-top: 0.35rem;
}
.file-selected {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
}
.file-name {
font-family: var(--mono);
font-size: 0.85rem;
color: var(--teal);
font-weight: 500;
}
.file-clear {
background: none;
border: none;
color: var(--text-2);
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
padding: 0.15rem 0.3rem;
border-radius: 4px;
transition: background 0.15s;
}
.file-clear:hover { background: var(--bg-3); }
/* ── Buttons ───────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.65rem 1.5rem;
border: none;
border-radius: var(--radius);
font-size: 0.87rem;
font-weight: 500;
font-family: var(--sans);
cursor: pointer;
transition: all 0.2s;
width: 100%;
letter-spacing: 0.01em;
}
.btn:active { transform: scale(0.98); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary {
background: var(--teal);
color: var(--bg-0);
}
.btn-primary:hover:not(:disabled) {
background: #5eead4;
box-shadow: 0 0 20px rgba(45, 212, 191, 0.2);
}
.btn-secondary {
background: var(--bg-3);
color: var(--text-1);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-4);
}
.btn-spinner {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.spinner {
width: 15px;
height: 15px;
border: 2px solid rgba(0,0,0,0.2);
border-top-color: var(--bg-0);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
.btn-secondary .spinner {
border-color: rgba(255,255,255,0.2);
border-top-color: var(--text-0);
}
/* ── Error ─────────────────────────────────────────────────────── */
.error-banner {
background: var(--error-dim);
border: 1px solid rgba(248, 113, 113, 0.3);
color: var(--error);
padding: 0.65rem 0.9rem;
border-radius: var(--radius);
font-size: 0.85rem;
margin-top: 1rem;
}
/* ── Landing Footer ────────────────────────────────────────────── */
.landing-footer {
margin-top: 4rem;
text-align: center;
}
.footer-rule {
width: 48px;
height: 1px;
background: var(--bg-4);
margin: 0 auto 1rem;
}
.landing-footer p {
color: var(--text-3);
font-size: 0.8rem;
letter-spacing: 0.01em;
}
/* ═══════════════════════════════════════════════════════════════════
CHAT LAYOUT
═══════════════════════════════════════════════════════════════════ */
.chat-body {
overflow: hidden;
}
.chat-layout {
display: grid;
grid-template-columns: 264px 1fr;
height: 100vh;
}
@media (max-width: 768px) {
.chat-layout { grid-template-columns: 1fr; }
.sidebar { display: none; }
}
/* ── Sidebar ───────────────────────────────────────────────────── */
.sidebar {
background: var(--bg-1);
border-right: 1px solid var(--bg-3);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.sidebar-header {
padding: 1.1rem 1.25rem;
border-bottom: 1px solid var(--bg-3);
}
.logo-link {
display: flex;
align-items: center;
gap: 0.6rem;
color: var(--text-0);
}
.logo-link:hover { color: var(--text-0); }
.logo-text {
font-size: 1rem;
font-weight: 500;
letter-spacing: -0.01em;
}
/* ── Summary Panel ─────────────────────────────────────────────── */
.summary-panel {
padding: 1.25rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.summary-product h3 {
font-size: 0.95rem;
font-weight: 500;
margin-bottom: 0.4rem;
word-break: break-word;
line-height: 1.35;
}
.summary-platform-badge {
font-family: var(--mono);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--teal);
background: var(--teal-dim);
padding: 2px 8px;
border-radius: 4px;
}
.summary-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(70px, 1fr));
gap: 0.5rem;
}
.stat-cell {
background: var(--bg-2);
border-radius: var(--radius);
padding: 0.6rem 0.65rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.stat-value {
font-size: 1.15rem;
font-weight: 600;
color: var(--text-0);
line-height: 1.2;
}
.stat-label {
font-family: var(--mono);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-3);
}
.summary-date-range {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.82rem;
color: var(--text-1);
}
/* ── Rating Distribution ───────────────────────────────────────── */
.rating-dist {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.rating-dist > .stat-label {
margin-bottom: 0.15rem;
}
.dist-row {
display: flex;
align-items: center;
gap: 0.45rem;
animation: fadeIn 0.3s ease both;
animation-delay: var(--delay, 0s);
}
.dist-star {
font-family: var(--mono);
font-size: 0.72rem;
width: 1rem;
text-align: right;
color: var(--text-2);
flex-shrink: 0;
}
.dist-bar-track {
flex: 1;
height: 4px;
background: var(--bg-3);
border-radius: 2px;
overflow: hidden;
}
.dist-bar-fill {
height: 100%;
background: var(--teal);
border-radius: 2px;
width: var(--target-width, 0);
animation: barGrow 0.8s ease both;
animation-delay: calc(var(--delay, 0s) + 0.3s);
}
.dist-count {
font-family: var(--mono);
font-size: 0.7rem;
width: 1.8rem;
text-align: right;
color: var(--text-2);
flex-shrink: 0;
}
/* ── Sidebar Footer ────────────────────────────────────────────── */
.sidebar-footer {
padding: 0.75rem 1.25rem;
border-top: 1px solid var(--bg-3);
margin-top: auto;
}
.sidebar-new-btn {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--text-2);
padding: 0.45rem 0.65rem;
border-radius: var(--radius);
transition: background 0.15s, color 0.15s;
width: 100%;
}
.sidebar-new-btn:hover {
background: var(--bg-3);
color: var(--text-0);
}
/* ═══════════════════════════════════════════════════════════════════
CHAT AREA
═══════════════════════════════════════════════════════════════════ */
.chat-main {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: var(--bg-0);
/* Subtle radial glow at top */
background-image: radial-gradient(ellipse at 50% 0%, rgba(45, 212, 191, 0.03) 0%, transparent 60%);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 2rem 2.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* ── Messages ──────────────────────────────────────────────────── */
.message {
max-width: 700px;
animation: fadeIn 0.25s ease both;
animation-delay: var(--delay, 0s);
}
.user-message {
align-self: flex-end;
}
.user-message .message-content {
background: var(--bg-3);
color: var(--text-0);
padding: 0.7rem 1rem;
border-radius: var(--radius-lg) var(--radius-lg) 3px var(--radius-lg);
font-size: 0.9rem;
}
.user-message .message-content p { margin: 0; }
.assistant-message {
align-self: flex-start;
}
.assistant-message .message-content {
background: var(--bg-1);
border: 1px solid var(--bg-3);
padding: 1rem 1.3rem;
border-radius: 3px var(--radius-lg) var(--radius-lg) var(--radius-lg);
line-height: 1.7;
font-size: 0.9rem;
}
/* Welcome message gets a subtle teal accent */
.welcome .message-content {
border-color: rgba(45, 212, 191, 0.15);
box-shadow: 0 0 0 1px rgba(45, 212, 191, 0.04);
}
/* ── Assistant content typography ──────────────────────────────── */
.assistant-message .message-content p {
margin-bottom: 0.6rem;
}
.assistant-message .message-content p:last-child {
margin-bottom: 0;
}
.assistant-message .message-content h1,
.assistant-message .message-content h2,
.assistant-message .message-content h3 {
margin: 1.1rem 0 0.45rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-0);
}
.assistant-message .message-content h3:first-child {
margin-top: 0;
}
.assistant-message .message-content ul,
.assistant-message .message-content ol {
padding-left: 1.4rem;
margin-bottom: 0.6rem;
}
.assistant-message .message-content li {
margin-bottom: 0.2rem;
}
.assistant-message .message-content li::marker {
color: var(--text-3);
}
.assistant-message .message-content strong {
color: var(--text-0);
font-weight: 600;
}
.assistant-message .message-content em {
color: var(--text-1);
font-style: italic;
}
.assistant-message .message-content code {
font-family: var(--mono);
font-size: 0.82em;
background: var(--bg-2);
border: 1px solid var(--bg-3);
padding: 0.12rem 0.4rem;
border-radius: 4px;
color: var(--teal);
}
.assistant-message .message-content blockquote {
border-left: 2px solid var(--teal);
padding-left: 0.9rem;
margin: 0.6rem 0;
color: var(--text-1);
font-style: italic;
}
.assistant-message .message-content table {
width: 100%;
border-collapse: collapse;
margin: 0.75rem 0;
font-size: 0.82rem;
}
.assistant-message .message-content th,
.assistant-message .message-content td {
padding: 0.45rem 0.7rem;
border: 1px solid var(--bg-3);
text-align: left;
}
.assistant-message .message-content th {
background: var(--bg-2);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-2);
}
/* ── Thinking Indicator ────────────────────────────────────────── */
.thinking .message-content {
display: flex;
flex-direction: column;
gap: 0.6rem;
min-width: 120px;
}
.thinking-dots {
display: flex;
align-items: center;
gap: 3px;
height: 20px;
}
.thinking-dots span {
display: block;
width: 28px;
height: 3px;
background: var(--teal);
border-radius: 2px;
animation: pulse-bar 1.6s ease-in-out infinite;
transform-origin: left center;
}
.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
.tool-activity {
font-family: var(--mono);
font-size: 0.72rem;
color: var(--text-3);
letter-spacing: 0.01em;
max-width: 360px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Charts ────────────────────────────────────────────────────── */
.chart-container {
margin: 0.75rem 0;
padding: 1rem;
background: var(--bg-2);
border: 1px solid var(--bg-3);
border-radius: var(--radius-lg);
max-width: 480px;
}
.chart-container canvas {
max-height: 280px;
}
/* ── Follow-up Buttons ─────────────────────────────────────────── */
.follow-ups {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.85rem;
animation: fadeIn 0.3s ease both;
animation-delay: var(--delay, 0.1s);
}
.follow-up-btn {
background: var(--teal-dim);
color: var(--teal);
border: 1px solid rgba(45, 212, 191, 0.15);
border-radius: 999px;
padding: 0.35rem 0.9rem;
font-size: 0.78rem;
font-family: var(--sans);
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.follow-up-btn:hover {
background: var(--teal-mid);
border-color: rgba(45, 212, 191, 0.35);
box-shadow: 0 0 12px rgba(45, 212, 191, 0.1);
}
/* ── Chat Input ────────────────────────────────────────────────── */
.chat-input-area {
padding: 0.75rem 2.5rem 1.25rem;
border-top: 1px solid var(--bg-3);
background: var(--bg-0);
}
.input-row {
display: flex;
gap: 0.6rem;
align-items: flex-end;
}
.input-row textarea {
flex: 1;
resize: none;
padding: 0.7rem 1rem;
background: var(--bg-1);
border: 1px solid var(--bg-3);
border-radius: var(--radius-lg);
color: var(--text-0);
font-size: 0.9rem;
font-family: var(--sans);
line-height: 1.5;
max-height: 150px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.input-row textarea:focus {
outline: none;
border-color: rgba(45, 212, 191, 0.4);
box-shadow: 0 0 0 3px var(--teal-dim);
}
.input-row textarea:disabled {
opacity: 0.4;
}
.input-row textarea::placeholder {
color: var(--text-3);
}
.btn-send {
width: 40px;
height: 40px;
padding: 0;
border-radius: 50%;
flex-shrink: 0;
background: var(--teal);
color: var(--bg-0);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
}
.btn-send:hover:not(:disabled) {
background: #5eead4;
box-shadow: 0 0 16px rgba(45, 212, 191, 0.25);
}
.btn-send:active { transform: scale(0.93); }
.btn-send:disabled { opacity: 0.35; cursor: not-allowed; }
.input-hint {
text-align: center;
font-family: var(--mono);
font-size: 0.65rem;
color: var(--text-3);
margin-top: 0.55rem;
letter-spacing: 0.02em;
}
/* ── Scrollbar ─────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--bg-4); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-3); }
The file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/static/style.css has been updated successfully.
// Color palette var CHART_COLORS = [ "rgba(99, 102, 241, 0.8)", // indigo "rgba(16, 185, 129, 0.8)", // emerald "rgba(245, 158, 11, 0.8)", // amber "rgba(239, 68, 68, 0.8)", // red "rgba(139, 92, 246, 0.8)", // violet "rgba(6, 182, 212, 0.8)", // cyan "rgba(236, 72, 153, 0.8)", // pink ]; var CHART_BORDERS = [ "rgba(99, 102, 241, 1)", "rgba(16, 185, 129, 1)", "rgba(245, 158, 11, 1)", "rgba(239, 68, 68, 1)", "rgba(139, 92, 246, 1)", "rgba(6, 182, 212, 1)", "rgba(236, 72, 153, 1)", ];
// Color palette — observatory teal palette with warm accents var CHART_COLORS = [ "rgba(45, 212, 191, 0.75)", // teal "rgba(245, 158, 11, 0.75)", // amber "rgba(94, 234, 212, 0.65)", // light teal "rgba(248, 113, 113, 0.7)", // soft red "rgba(129, 140, 248, 0.7)", // indigo "rgba(52, 211, 153, 0.7)", // emerald "rgba(251, 191, 36, 0.7)", // gold ]; var CHART_BORDERS = [ "rgba(45, 212, 191, 1)", "rgba(245, 158, 11, 1)", "rgba(94, 234, 212, 1)", "rgba(248, 113, 113, 1)", "rgba(129, 140, 248, 1)", "rgba(52, 211, 153, 1)", "rgba(251, 191, 36, 1)", ];
The file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/static/app.js has been updated successfully.
font: { size: 14, weight: "600" },
color: "#e2e8f0",
},
legend: {
labels: { color: "#94a3b8" },
},
},
scales:
config.type !== "pie" && config.type !== "doughnut"
? {
x: {
ticks: { color: "#94a3b8" },
grid: { color: "rgba(148, 163, 184, 0.1)" },
},
y: {
ticks: { color: "#94a3b8" },
grid: { color: "rgba(148, 163, 184, 0.1)" },
beginAtZero: true,
},
}
: undefined, font: { size: 13, weight: "500", family: "'DM Sans', sans-serif" },
color: "#b0bdd0",
padding: { bottom: 12 },
},
legend: {
labels: { color: "#6b7d99", font: { family: "'DM Sans', sans-serif", size: 11 } },
},
},
scales:
config.type !== "pie" && config.type !== "doughnut"
? {
x: {
ticks: { color: "#6b7d99", font: { family: "'IBM Plex Mono', monospace", size: 10 } },
grid: { color: "rgba(107, 125, 153, 0.08)" },
},
y: {
ticks: { color: "#6b7d99", font: { family: "'IBM Plex Mono', monospace", size: 10 } },
grid: { color: "rgba(107, 125, 153, 0.08)" },
beginAtZero: true,
},
}
: undefined,The file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/static/app.js has been updated successfully.
curl -s http://localhost:8000/ | grep -o 'Instrument+Serif' | head -1 && echo "Font loaded" curl -s http://localhost:8000/ | grep -o 'Observatory' | head -1 || echo "CSS comment present" curl -s http://localhost:8000/ | grep -o 'grain' | head -1 && echo "Grain overlay present" curl -s http://localhost:8000/ | grep -o 'serif-accent' | head -1 && echo "Serif accent present" curl -s http://localhost:8000/chat/e38c015e-0785-4f7d-bb4d-090603292b37 | grep -o 'summary-stats-grid' | head -1 && echo "Stats grid present" curl -s http://localhost:8000/chat/e38c015e-0785-4f7d-bb4d-090603292b37 | grep -o 'pulse-bar' | head -1 || echo "Pulse bar in CSS"
Instrument+Serif Font loaded grain Grain overlay present serif-accent Serif accent present summary-stats-grid Stats grid present
Everything's rendering. Here's what changed in the design pass:
Typography - Instrument Serif (italic) for the "Lens" in the logo — editorial, unexpected for a data tool - DM Sans for all body text — geometric, clean, better personality than Inter - IBM Plex Mono for data labels, stats, hints — warmer than typical monospace
Color
- Shifted from generic Slate/Indigo to a deep blue-black (#080b12) base with teal (#2dd4bf) as the data-clarity accent and warm amber for attention moments (badges, warnings)
- Teal glow effects on primary card borders, focus states, send button hover
Atmosphere - SVG noise grain overlay at 3.5% opacity — adds film-like depth - Radial teal glow behind the landing header and at the top of the chat area - Cards have subtle box-shadows instead of flat borders
Chat experience - Thinking indicator is now three pulsing horizontal bars instead of bouncing dots — feels like data being analysed - Tool activity text is monospace and truncated with ellipsis - Follow-up pills have a subtle glow on hover - Welcome message gets a teal border accent - Rating distribution bars animate in with staggered delays
Sidebar
- Stats in a compact grid with stat-cell cards
- Platform shown as a teal mono badge
- Animated bar chart for rating distribution
- "New Analysis" button is minimal with a + icon
Landing - Concentric circle SVG logo mark (observatory/lens motif) - Primary card visually dominant (slightly wider column, teal border) - Custom SVG chevron for select dropdowns - File drop zone with SVG upload icon that tints teal on hover
Open http://localhost:8000 and http://localhost:8000/chat/e38c015e-0785-4f7d-bb4d-090603292b37 to see it.
Give it a more standard web app layout (Sidebar on the left with controls + chat history). A new analysis button should surface a modal with the upload csv/link options.
I'll restructure to a standard sidebar + main area layout with a modal for uploads. This touches templates, CSS, a bit of JS, and a small backend addition for listing past sessions.
{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/store.py"
} 1→"""File-based session and message persistence."""
2→
3→from __future__ import annotations
4→
5→import json
6→import os
7→from pathlib import Path
8→
9→from .models import ChatMessage, IngestionSummary, Session
10→
11→DATA_DIR = Path(os.getenv("DATA_DIR", "data"))
12→
13→
14→def _session_dir(session_id: str) -> Path:
15→ d = DATA_DIR / session_id
16→ d.mkdir(parents=True, exist_ok=True)
17→ return d
18→
19→
20→# ── Session lifecycle ────────────────────────────────────────────────
21→
22→def save_session(session: Session) -> None:
23→ path = _session_dir(session.session_id) / "session.json"
24→ path.write_text(session.model_dump_json(indent=2))
25→
26→
27→def load_session(session_id: str) -> Session | None:
28→ path = _session_dir(session_id) / "session.json"
29→ if not path.exists():
30→ return None
31→ return Session.model_validate_json(path.read_text())
32→
33→
34→def set_status(session_id: str, status: str) -> None:
35→ session = load_session(session_id)
36→ if session:
37→ session.status = status
38→ save_session(session)
39→
40→
41→def update_summary(session_id: str, summary: IngestionSummary) -> None:
42→ session = load_session(session_id)
43→ if session:
44→ session.summary = summary
45→ session.product_name = summary.product_name
46→ session.platform = summary.platform
47→ save_session(session)
48→
49→
50→# ── Chat messages ────────────────────────────────────────────────────
51→
52→def append_message(session_id: str, message: ChatMessage) -> None:
53→ session = load_session(session_id)
54→ if session:
55→ session.messages.append(message)
56→ save_session(session)
57→
58→
59→def get_messages(session_id: str) -> list[ChatMessage]:
60→ session = load_session(session_id)
61→ return session.messages if session else []
62→
63→
64→# ── Reviews (raw JSON for reference) ────────────────────────────────
65→
66→def save_reviews_json(session_id: str, reviews: list[dict]) -> None:
67→ path = _session_dir(session_id) / "reviews.json"
68→ path.write_text(json.dumps(reviews, default=str, indent=2))
69→
70→
71→def load_reviews_json(session_id: str) -> list[dict]:
72→ path = _session_dir(session_id) / "reviews.json"
73→ if not path.exists():
74→ return []
75→ return json.loads(path.read_text())
76→{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py"
} 1→"""FastAPI application for ReviewLens AI."""
2→
3→from __future__ import annotations
4→
5→import asyncio
6→import html as html_module
7→import json
8→import os
9→import uuid
10→from collections import deque
11→from datetime import datetime
12→from pathlib import Path
13→from typing import Any
14→
15→import markdown
16→from dotenv import load_dotenv
17→from fastapi import FastAPI, File, Form, Request, UploadFile
18→from fastapi.responses import HTMLResponse, JSONResponse
19→from fastapi.staticfiles import StaticFiles
20→from fastapi.templating import Jinja2Templates
21→from sse_starlette.sse import EventSourceResponse
22→
23→from . import store, vectordb
24→from .agent import handle_message
25→from .ingest import build_summary, parse_csv, scrape_url
26→from .models import ChatMessage, IngestionSummary, Session
27→
28→load_dotenv()
29→
30→BASE_DIR = Path(__file__).resolve().parent.parent
31→app = FastAPI(title="ReviewLens AI")
32→app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
33→templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
34→
35→# ── In-memory SSE event queues (per-session) ────────────────────────
36→# For streaming agent activity and chat responses to the frontend.
37→_event_queues: dict[str, deque[dict[str, str]]] = {}
38→_response_events: dict[str, asyncio.Event] = {}
39→
40→
41→def _get_queue(session_id: str) -> deque[dict[str, str]]:
42→ if session_id not in _event_queues:
43→ _event_queues[session_id] = deque()
44→ return _event_queues[session_id]
45→
46→
47→def _get_response_event(session_id: str) -> asyncio.Event:
48→ if session_id not in _response_events:
49→ _response_events[session_id] = asyncio.Event()
50→ return _response_events[session_id]
51→
52→
53→async def _emit(session_id: str, message: str, level: str = "info") -> None:
54→ """Push an SSE event to the session's queue."""
55→ _get_queue(session_id).append({"event": level, "data": message})
56→
57→
58→# ── Health check ─────────────────────────────────────────────────────
59→
60→@app.get("/health")
61→async def health():
62→ return {"status": "ok"}
63→
64→
65→# ── Landing page ─────────────────────────────────────────────────────
66→
67→@app.get("/", response_class=HTMLResponse)
68→async def index(request: Request):
69→ return templates.TemplateResponse("index.html", {"request": request})
70→
71→
72→# ── CSV Upload ───────────────────────────────────────────────────────
73→
74→@app.post("/upload")
75→async def upload_csv(
76→ request: Request,
77→ file: UploadFile = File(...),
78→ product_name: str = Form(""),
79→ platform: str = Form(""),
80→):
81→ session_id = str(uuid.uuid4())
82→
83→ # Read and parse CSV
84→ content = await file.read()
85→ try:
86→ reviews = parse_csv(content, platform=platform, product_name=product_name)
87→ except Exception as e:
88→ return templates.TemplateResponse(
89→ "partials/error.html",
90→ {"request": request, "error": f"Failed to parse CSV: {e}"},
91→ status_code=400,
92→ )
93→
94→ if not reviews:
95→ return templates.TemplateResponse(
96→ "partials/error.html",
97→ {"request": request, "error": "No reviews found in the CSV. Make sure it has a text/review column."},
98→ status_code=400,
99→ )
100→
101→ # Build summary
102→ summary = build_summary(reviews, source_type="csv", product_name=product_name, platform=platform)
103→
104→ # Create session
105→ session = Session(
106→ session_id=session_id,
107→ product_name=summary.product_name,
108→ platform=summary.platform,
109→ summary=summary,
110→ status="indexing",
111→ )
112→ store.save_session(session)
113→
114→ # Save raw reviews
115→ store.save_reviews_json(session_id, [r.model_dump(mode="json") for r in reviews])
116→
117→ # Index into ChromaDB
118→ indexed = vectordb.index_reviews(session_id, reviews)
119→ summary.total_reviews = indexed
120→ store.update_summary(session_id, summary)
121→ store.set_status(session_id, "ready")
122→
123→ # Redirect to chat
124→ return HTMLResponse(
125→ status_code=200,
126→ content=f'<script>window.location.href="/chat/{session_id}";</script>',
127→ headers={"HX-Redirect": f"/chat/{session_id}"},
128→ )
129→
130→
131→# ── URL Scrape ───────────────────────────────────────────────────────
132→
133→@app.post("/scrape")
134→async def scrape(
135→ request: Request,
136→ url: str = Form(...),
137→ product_name: str = Form(""),
138→ platform: str = Form(""),
139→):
140→ session_id = str(uuid.uuid4())
141→
142→ try:
143→ reviews = await scrape_url(url, platform=platform)
144→ except Exception as e:
145→ return templates.TemplateResponse(
146→ "partials/error.html",
147→ {"request": request, "error": f"Scraping failed: {e}. Try uploading a CSV instead."},
148→ status_code=400,
149→ )
150→
151→ if not reviews:
152→ return templates.TemplateResponse(
153→ "partials/error.html",
154→ {"request": request, "error": "No reviews could be extracted from that URL. Try uploading a CSV instead."},
155→ status_code=400,
156→ )
157→
158→ summary = build_summary(reviews, source_type="url", product_name=product_name, platform=platform)
159→
160→ session = Session(
161→ session_id=session_id,
162→ product_name=summary.product_name,
163→ platform=summary.platform,
164→ summary=summary,
165→ status="indexing",
166→ )
167→ store.save_session(session)
168→ store.save_reviews_json(session_id, [r.model_dump(mode="json") for r in reviews])
169→
170→ indexed = vectordb.index_reviews(session_id, reviews)
171→ summary.total_reviews = indexed
172→ store.update_summary(session_id, summary)
173→ store.set_status(session_id, "ready")
174→
175→ return HTMLResponse(
176→ status_code=200,
177→ content=f'<script>window.location.href="/chat/{session_id}";</script>',
178→ headers={"HX-Redirect": f"/chat/{session_id}"},
179→ )
180→
181→
182→# ── Chat page ────────────────────────────────────────────────────────
183→
184→@app.get("/chat/{session_id}", response_class=HTMLResponse)
185→async def chat_page(request: Request, session_id: str):
186→ session = store.load_session(session_id)
187→ if not session:
188→ return HTMLResponse("<h1>Session not found</h1>", status_code=404)
189→
190→ return templates.TemplateResponse(
191→ "chat.html",
192→ {
193→ "request": request,
194→ "session": session,
195→ "summary": session.summary,
196→ "messages": session.messages,
197→ },
198→ )
199→
200→
201→# ── Send chat message ───────────────────────────────────────────────
202→
203→@app.post("/chat/{session_id}/send")
204→async def send_message(
205→ request: Request,
206→ session_id: str,
207→ message: str = Form(...),
208→):
209→ session = store.load_session(session_id)
210→ if not session:
211→ return JSONResponse({"error": "Session not found"}, status_code=404)
212→
213→ # Save user message
214→ user_msg = ChatMessage(role="user", content=message)
215→ store.append_message(session_id, user_msg)
216→
217→ # Signal that we're processing
218→ event = _get_response_event(session_id)
219→ event.clear()
220→
221→ # Render user message HTML immediately
222→ user_html = _render_message(user_msg)
223→
224→ # Spawn agent task in background
225→ asyncio.create_task(_run_agent_and_respond(session_id, message, session))
226→
227→ # Return user message + thinking indicator
228→ thinking_html = (
229→ '<div id="thinking-indicator" class="message assistant-message thinking">'
230→ '<div class="message-content">'
231→ '<div class="thinking-dots"><span></span><span></span><span></span></div>'
232→ '</div></div>'
233→ )
234→ return HTMLResponse(user_html + thinking_html)
235→
236→
237→async def _run_agent_and_respond(session_id: str, message: str, session: Session):
238→ """Run agent in background and push response via SSE."""
239→ try:
240→ history = store.get_messages(session_id)
241→
242→ assistant_msg = await handle_message(
243→ session_id=session_id,
244→ user_message=message,
245→ conversation_history=history[:-1], # Exclude the message we just added
246→ summary=session.summary,
247→ emit_fn=_emit,
248→ )
249→
250→ store.append_message(session_id, assistant_msg)
251→
252→ # Push the rendered response via SSE
253→ html = _render_message(assistant_msg)
254→ _get_queue(session_id).append({"event": "message", "data": html})
255→
256→ except Exception as e:
257→ error_msg = ChatMessage(
258→ role="assistant",
259→ content=f"Sorry, I encountered an error: {e}",
260→ )
261→ store.append_message(session_id, error_msg)
262→ html = _render_message(error_msg)
263→ _get_queue(session_id).append({"event": "message", "data": html})
264→
265→ # Signal done
266→ _get_response_event(session_id).set()
267→
268→
269→# ── SSE stream ───────────────────────────────────────────────────────
270→
271→@app.get("/chat/{session_id}/stream")
272→async def chat_stream(session_id: str):
273→ """SSE endpoint for streaming agent activity and responses."""
274→
275→ async def event_generator():
276→ queue = _get_queue(session_id)
277→ event = _get_response_event(session_id)
278→
279→ while True:
280→ # Drain queued events
281→ while queue:
282→ item = queue.popleft()
283→ yield {"event": item["event"], "data": item["data"]}
284→
285→ # If this was a message event, we're done with this request
286→ if item["event"] == "message":
287→ yield {"event": "done", "data": ""}
288→ return
289→
290→ # Check if we should stop
291→ if event.is_set() and not queue:
292→ yield {"event": "done", "data": ""}
293→ return
294→
295→ await asyncio.sleep(0.15)
296→
297→ return EventSourceResponse(event_generator())
298→
299→
300→# ── Ingestion summary endpoint ───────────────────────────────────────
301→
302→@app.get("/api/summary/{session_id}", response_class=HTMLResponse)
303→async def get_summary(request: Request, session_id: str):
304→ session = store.load_session(session_id)
305→ if not session:
306→ return HTMLResponse("Session not found", status_code=404)
307→
308→ return templates.TemplateResponse(
309→ "partials/summary.html",
310→ {"request": request, "summary": session.summary},
311→ )
312→
313→
314→# ── HTML rendering helpers ───────────────────────────────────────────
315→
316→def _render_message(msg: ChatMessage) -> str:
317→ """Render a ChatMessage as HTML for the chat interface."""
318→ role_class = "user-message" if msg.role == "user" else "assistant-message"
319→ escaped = html_module.escape(msg.content)
320→
321→ if msg.role == "assistant":
322→ # Convert markdown to HTML for assistant messages
323→ content_html = markdown.markdown(
324→ msg.content,
325→ extensions=["tables", "fenced_code"],
326→ )
327→ else:
328→ content_html = f"<p>{escaped}</p>"
329→
330→ parts = [f'<div class="message {role_class}">']
331→ parts.append(f'<div class="message-content">{content_html}</div>')
332→
333→ # Render charts
334→ for i, chart in enumerate(msg.charts):
335→ chart_id = f"chart-{uuid.uuid4().hex[:8]}"
336→ parts.append(
337→ f'<div class="chart-container">'
338→ f'<canvas id="{chart_id}"></canvas>'
339→ f'<script>renderChart("{chart_id}", {json.dumps(chart)});</script>'
340→ f'</div>'
341→ )
342→
343→ # Render follow-up buttons
344→ if msg.follow_ups:
345→ parts.append('<div class="follow-ups">')
346→ for q in msg.follow_ups:
347→ escaped_q = html_module.escape(q)
348→ parts.append(
349→ f'<button class="follow-up-btn" onclick="sendFollowUp(this)" '
350→ f'data-question="{escaped_q}">{escaped_q}</button>'
351→ )
352→ parts.append('</div>')
353→
354→ parts.append('</div>')
355→ return "\n".join(parts)
356→I'll restructure into a single-page app shell with sidebar, modal, chat history, and add light mode. This touches most files.
def load_reviews_json(session_id: str) -> list[dict]:
path = _session_dir(session_id) / "reviews.json"
if not path.exists():
return []
return json.loads(path.read_text())def load_reviews_json(session_id: str) -> list[dict]:
path = _session_dir(session_id) / "reviews.json"
if not path.exists():
return []
return json.loads(path.read_text())
# ── Session listing ──────────────────────────────────────────────────
def list_sessions() -> list[Session]:
"""Return all sessions, newest first."""
sessions = []
if not DATA_DIR.exists():
return sessions
for d in DATA_DIR.iterdir():
if not d.is_dir():
continue
path = d / "session.json"
if path.exists():
try:
sessions.append(Session.model_validate_json(path.read_text()))
except Exception:
continue
sessions.sort(key=lambda s: s.created_at, reverse=True)
return sessionsThe file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/store.py has been updated successfully.
"""FastAPI application for ReviewLens AI."""
from __future__ import annotations
import asyncio
import html as html_module
import json
import os
import uuid
from collections import deque
from datetime import datetime
from pathlib import Path
from typing import Any
import markdown
from dotenv import load_dotenv
from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sse_starlette.sse import EventSourceResponse
from . import store, vectordb
from .agent import handle_message
from .ingest import build_summary, parse_csv, scrape_url
from .models import ChatMessage, IngestionSummary, Session
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
app = FastAPI(title="ReviewLens AI")
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
# ── In-memory SSE event queues (per-session) ────────────────────────
_event_queues: dict[str, deque[dict[str, str]]] = {}
_response_events: dict[str, asyncio.Event] = {}
def _get_queue(session_id: str) -> deque[dict[str, str]]:
if session_id not in _event_queues:
_event_queues[session_id] = deque()
return _event_queues[session_id]
def _get_response_event(session_id: str) -> asyncio.Event:
if session_id not in _response_events:
_response_events[session_id] = asyncio.Event()
return _response_events[session_id]
async def _emit(session_id: str, message: str, level: str = "info") -> None:
_get_queue(session_id).append({"event": level, "data": message})
# ── Health check ─────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
# ── App shell (home + chat share the same layout) ───────────────────
def _shell_context(request: Request, session=None):
"""Build template context for the app shell."""
sessions = store.list_sessions()
ctx = {
"request": request,
"sessions": sessions,
"session": session,
"summary": session.summary if session else None,
"messages": session.messages if session else [],
"active_id": session.session_id if session else None,
}
return ctx
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse("app.html", _shell_context(request))
@app.get("/chat/{session_id}", response_class=HTMLResponse)
async def chat_page(request: Request, session_id: str):
session = store.load_session(session_id)
if not session:
return HTMLResponse("<h1>Session not found</h1>", status_code=404)
return templates.TemplateResponse("app.html", _shell_context(request, session))
# ── CSV Upload ───────────────────────────────────────────────────────
@app.post("/upload")
async def upload_csv(
request: Request,
file: UploadFile = File(...),
product_name: str = Form(""),
platform: str = Form(""),
):
session_id = str(uuid.uuid4())
content = await file.read()
try:
reviews = parse_csv(content, platform=platform, product_name=product_name)
except Exception as e:
return templates.TemplateResponse(
"partials/error.html",
{"request": request, "error": f"Failed to parse CSV: {e}"},
status_code=400,
)
if not reviews:
return templates.TemplateResponse(
"partials/error.html",
{"request": request, "error": "No reviews found in the CSV. Make sure it has a text/review column."},
status_code=400,
)
summary = build_summary(reviews, source_type="csv", product_name=product_name, platform=platform)
session = Session(
session_id=session_id,
product_name=summary.product_name,
platform=summary.platform,
summary=summary,
status="indexing",
)
store.save_session(session)
store.save_reviews_json(session_id, [r.model_dump(mode="json") for r in reviews])
indexed = vectordb.index_reviews(session_id, reviews)
summary.total_reviews = indexed
store.update_summary(session_id, summary)
store.set_status(session_id, "ready")
return HTMLResponse(
status_code=200,
content=f'<script>window.location.href="/chat/{session_id}";</script>',
headers={"HX-Redirect": f"/chat/{session_id}"},
)
# ── URL Scrape ───────────────────────────────────────────────────────
@app.post("/scrape")
async def scrape(
request: Request,
url: str = Form(...),
product_name: str = Form(""),
platform: str = Form(""),
):
session_id = str(uuid.uuid4())
try:
reviews = await scrape_url(url, platform=platform)
except Exception as e:
return templates.TemplateResponse(
"partials/error.html",
{"request": request, "error": f"Scraping failed: {e}. Try uploading a CSV instead."},
status_code=400,
)
if not reviews:
return templates.TemplateResponse(
"partials/error.html",
{"request": request, "error": "No reviews could be extracted from that URL. Try uploading a CSV instead."},
status_code=400,
)
summary = build_summary(reviews, source_type="url", product_name=product_name, platform=platform)
session = Session(
session_id=session_id,
product_name=summary.product_name,
platform=summary.platform,
summary=summary,
status="indexing",
)
store.save_session(session)
store.save_reviews_json(session_id, [r.model_dump(mode="json") for r in reviews])
indexed = vectordb.index_reviews(session_id, reviews)
summary.total_reviews = indexed
store.update_summary(session_id, summary)
store.set_status(session_id, "ready")
return HTMLResponse(
status_code=200,
content=f'<script>window.location.href="/chat/{session_id}";</script>',
headers={"HX-Redirect": f"/chat/{session_id}"},
)
# ── Send chat message ───────────────────────────────────────────────
@app.post("/chat/{session_id}/send")
async def send_message(
request: Request,
session_id: str,
message: str = Form(...),
):
session = store.load_session(session_id)
if not session:
return JSONResponse({"error": "Session not found"}, status_code=404)
user_msg = ChatMessage(role="user", content=message)
store.append_message(session_id, user_msg)
event = _get_response_event(session_id)
event.clear()
user_html = _render_message(user_msg)
asyncio.create_task(_run_agent_and_respond(session_id, message, session))
thinking_html = (
'<div id="thinking-indicator" class="message assistant-message thinking">'
'<div class="message-content">'
'<div class="thinking-dots"><span></span><span></span><span></span></div>'
'</div></div>'
)
return HTMLResponse(user_html + thinking_html)
async def _run_agent_and_respond(session_id: str, message: str, session: Session):
try:
history = store.get_messages(session_id)
assistant_msg = await handle_message(
session_id=session_id,
user_message=message,
conversation_history=history[:-1],
summary=session.summary,
emit_fn=_emit,
)
store.append_message(session_id, assistant_msg)
html = _render_message(assistant_msg)
_get_queue(session_id).append({"event": "message", "data": html})
except Exception as e:
error_msg = ChatMessage(
role="assistant",
content=f"Sorry, I encountered an error: {e}",
)
store.append_message(session_id, error_msg)
html = _render_message(error_msg)
_get_queue(session_id).append({"event": "message", "data": html})
_get_response_event(session_id).set()
# ── SSE stream ───────────────────────────────────────────────────────
@app.get("/chat/{session_id}/stream")
async def chat_stream(session_id: str):
async def event_generator():
queue = _get_queue(session_id)
event = _get_response_event(session_id)
while True:
while queue:
item = queue.popleft()
yield {"event": item["event"], "data": item["data"]}
if item["event"] == "message":
yield {"event": "done", "data": ""}
return
if event.is_set() and not queue:
yield {"event": "done", "data": ""}
return
await asyncio.sleep(0.15)
return EventSourceResponse(event_generator())
# ── HTML rendering helpers ───────────────────────────────────────────
def _render_message(msg: ChatMessage) -> str:
role_class = "user-message" if msg.role == "user" else "assistant-message"
escaped = html_module.escape(msg.content)
if msg.role == "assistant":
content_html = markdown.markdown(
msg.content,
extensions=["tables", "fenced_code"],
)
else:
content_html = f"<p>{escaped}</p>"
parts = [f'<div class="message {role_class}">']
parts.append(f'<div class="message-content">{content_html}</div>')
for i, chart in enumerate(msg.charts):
chart_id = f"chart-{uuid.uuid4().hex[:8]}"
parts.append(
f'<div class="chart-container">'
f'<canvas id="{chart_id}"></canvas>'
f'<script>renderChart("{chart_id}", {json.dumps(chart)});</script>'
f'</div>'
)
if msg.follow_ups:
parts.append('<div class="follow-ups">')
for q in msg.follow_ups:
escaped_q = html_module.escape(q)
parts.append(
f'<button class="follow-up-btn" onclick="sendFollowUp(this)" '
f'data-question="{escaped_q}">{escaped_q}</button>'
)
parts.append('</div>')
parts.append('</div>')
return "\n".join(parts)
The file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py has been updated successfully.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ReviewLens{% if summary %} — {{ summary.product_name }}{% endif %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=Instrument+Serif:ital@0;1&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<div class="grain"></div>
<div class="app-layout">
<!-- ── Sidebar ──────────────────────────────────────────── -->
<aside class="sidebar">
<div class="sidebar-header">
<a href="/" class="logo-link">
<svg class="logo-icon" width="20" height="20" viewBox="0 0 32 32" fill="none">
<circle cx="16" cy="16" r="14" stroke="currentColor" stroke-width="1.5" opacity="0.4"/>
<circle cx="16" cy="16" r="8" stroke="currentColor" stroke-width="1.5"/>
<circle cx="16" cy="16" r="2.5" fill="currentColor"/>
</svg>
<span class="logo-text">Review<span class="serif-accent">Lens</span></span>
</a>
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle light/dark">
<svg class="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<svg class="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
</div>
<button class="new-analysis-btn" onclick="openModal()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
New Analysis
</button>
{% if session %}
<!-- Active session summary -->
<div class="sidebar-section">
<div class="section-label">Current Dataset</div>
<div class="active-dataset">
<div class="dataset-name">{{ summary.product_name }}</div>
<div class="dataset-meta">
<span class="platform-tag">{{ summary.platform }}</span>
<span class="dataset-stat">{{ summary.total_reviews }} reviews</span>
{% if summary.average_rating %}
<span class="dataset-stat">{{ "%.1f"|format(summary.average_rating) }} avg</span>
{% endif %}
</div>
{% if summary.rating_distribution %}
<div class="mini-dist">
{% for star in ['5','4','3','2','1'] %}
{% set count = summary.rating_distribution.get(star, 0) %}
{% set pct = (count / summary.total_reviews * 100) if summary.total_reviews else 0 %}
<div class="mini-dist-row">
<span class="mini-dist-label">{{ star }}</span>
<div class="mini-dist-track">
<div class="mini-dist-fill" style="--w: {{ pct }}%"></div>
</div>
<span class="mini-dist-count">{{ count }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Chat history -->
<div class="sidebar-section sidebar-history">
<div class="section-label">History</div>
<nav class="session-list">
{% for s in sessions %}
<a href="/chat/{{ s.session_id }}"
class="session-item{% if active_id == s.session_id %} active{% endif %}">
<span class="session-name">{{ s.product_name or 'Untitled' }}</span>
<span class="session-detail">{{ s.summary.total_reviews }} reviews · {{ s.platform }}</span>
</a>
{% else %}
<p class="empty-history">No analyses yet</p>
{% endfor %}
</nav>
</div>
</aside>
<!-- ── Main Area ────────────────────────────────────────── -->
<main class="main-area">
{% if session %}
<!-- Chat view -->
<div class="chat-messages" id="chat-messages">
{% for msg in messages %}
{% if msg.role == 'assistant' %}
<div class="message assistant-message">
<div class="message-content">{{ msg.content }}</div>
</div>
{% else %}
<div class="message user-message">
<div class="message-content"><p>{{ msg.content }}</p></div>
</div>
{% endif %}
{% endfor %}
{% if not messages %}
<div class="message assistant-message welcome">
<div class="message-content">
<p>I have <strong>{{ summary.total_reviews }} reviews</strong> loaded for <strong>{{ summary.product_name }}</strong> from <strong>{{ summary.platform }}</strong>{% if summary.date_range %}, spanning <strong>{{ summary.date_range }}</strong>{% endif %}.</p>
<p>I can only answer questions grounded in this dataset. What would you like to explore?</p>
</div>
<div class="follow-ups">
<button class="follow-up-btn" onclick="sendFollowUp(this)" data-question="What's the overall sentiment breakdown?">Sentiment breakdown</button>
<button class="follow-up-btn" onclick="sendFollowUp(this)" data-question="What are the most common complaints?">Top complaints</button>
<button class="follow-up-btn" onclick="sendFollowUp(this)" data-question="Show me the rating distribution over time">Ratings over time</button>
<button class="follow-up-btn" onclick="sendFollowUp(this)" data-question="What do people praise the most?">Most praised aspects</button>
</div>
</div>
{% endif %}
</div>
<div class="chat-input-area">
<form id="chat-form" onsubmit="sendMessage(event)">
<div class="input-row">
<textarea id="chat-input" name="message" placeholder="Ask about the reviews..." rows="1" autofocus></textarea>
<button type="submit" class="btn-send" id="send-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>
</svg>
</button>
</div>
</form>
<p class="input-hint">Analyses only the uploaded dataset. No external knowledge.</p>
</div>
{% else %}
<!-- Empty state -->
<div class="empty-state">
<svg width="48" height="48" viewBox="0 0 32 32" fill="none" class="empty-icon">
<circle cx="16" cy="16" r="14" stroke="currentColor" stroke-width="1" opacity="0.3"/>
<circle cx="16" cy="16" r="8" stroke="currentColor" stroke-width="1" opacity="0.5"/>
<circle cx="16" cy="16" r="2.5" fill="currentColor" opacity="0.6"/>
</svg>
<h2>ReviewLens <span class="serif-accent">AI</span></h2>
<p>Upload customer reviews and explore them through a guardrailed Q&A interface.</p>
<button class="btn btn-primary" onclick="openModal()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
New Analysis
</button>
</div>
{% endif %}
</main>
</div>
<!-- ── New Analysis Modal ───────────────────────────────────── -->
<div class="modal-backdrop" id="modal-backdrop" onclick="closeModal()"></div>
<div class="modal" id="analysis-modal">
<div class="modal-header">
<h2>New Analysis</h2>
<button class="modal-close" onclick="closeModal()">×</button>
</div>
<div class="modal-tabs">
<button class="modal-tab active" onclick="switchTab(this, 'tab-csv')">CSV Upload</button>
<button class="modal-tab" onclick="switchTab(this, 'tab-url')">URL Scrape <span class="badge">Best-effort</span></button>
</div>
<!-- CSV Tab -->
<div class="modal-tab-content active" id="tab-csv">
<form id="csv-form"
hx-post="/upload"
hx-encoding="multipart/form-data"
hx-target="#csv-result"
hx-indicator="#csv-spinner">
<div class="form-group">
<label for="product_name">Product / Entity</label>
<input type="text" id="product_name" name="product_name" placeholder="e.g., AcmeCo Widget Pro" required>
</div>
<div class="form-group">
<label for="platform">Platform</label>
<select id="platform" name="platform">
<option value="">Auto-detect</option>
<option value="Amazon">Amazon</option>
<option value="Google Maps">Google Maps</option>
<option value="G2">G2</option>
<option value="Capterra">Capterra</option>
<option value="Trustpilot">Trustpilot</option>
<option value="Yelp">Yelp</option>
<option value="App Store">App Store</option>
<option value="Other">Other</option>
</select>
</div>
<div class="file-drop" id="file-drop">
<input type="file" id="file" name="file" accept=".csv" required>
<div class="file-drop-content">
<svg class="file-drop-icon" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="12" y1="18" x2="12" y2="12"/>
<polyline points="9 15 12 12 15 15"/>
</svg>
<p>Drop CSV here or <span class="browse-link">browse</span></p>
<p class="file-hint">Columns: text/review, rating, date, author</p>
</div>
<div class="file-selected" style="display:none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<span class="file-name"></span>
<button type="button" class="file-clear" onclick="clearFile()">×</button>
</div>
</div>
<button type="submit" class="btn btn-primary">
<span class="btn-text">Analyse Reviews</span>
<span class="btn-spinner" id="csv-spinner" style="display:none">
<span class="spinner"></span> Indexing...
</span>
</button>
<div id="csv-result"></div>
</form>
</div>
<!-- URL Tab -->
<div class="modal-tab-content" id="tab-url">
<form id="scrape-form"
hx-post="/scrape"
hx-target="#scrape-result"
hx-indicator="#scrape-spinner">
<div class="form-group">
<label for="scrape-product">Product / Entity</label>
<input type="text" id="scrape-product" name="product_name" placeholder="e.g., AcmeCo Widget Pro">
</div>
<div class="form-group">
<label for="scrape-platform">Platform</label>
<select id="scrape-platform" name="platform">
<option value="">Auto-detect</option>
<option value="Amazon">Amazon</option>
<option value="Google Maps">Google Maps</option>
<option value="G2">G2</option>
<option value="Capterra">Capterra</option>
<option value="Trustpilot">Trustpilot</option>
<option value="Yelp">Yelp</option>
</select>
</div>
<div class="form-group">
<label for="url">Review Page URL</label>
<input type="url" id="url" name="url" placeholder="https://..." required>
</div>
<button type="submit" class="btn btn-secondary">
<span class="btn-text">Scrape & Analyse</span>
<span class="btn-spinner" id="scrape-spinner" style="display:none">
<span class="spinner"></span> Scraping...
</span>
</button>
<div id="scrape-result"></div>
</form>
</div>
</div>
<script>
var SESSION_ID = "{{ active_id or '' }}";
</script>
<script src="/static/app.js"></script>
</body>
</html>
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/templates/app.html
/* ═══════════════════════════════════════════════════════════════════
ReviewLens AI
═══════════════════════════════════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Tokens: Dark (default) ────────────────────────────────────── */
:root {
--bg-0: #080b12;
--bg-1: #0d1117;
--bg-2: #151b27;
--bg-3: #1c2435;
--bg-4: #243044;
--text-0: #e8ecf4;
--text-1: #b0bdd0;
--text-2: #6b7d99;
--text-3: #3e4f6a;
--accent: #2dd4bf;
--accent-hover: #5eead4;
--accent-dim: rgba(45, 212, 191, 0.12);
--accent-mid: rgba(45, 212, 191, 0.25);
--accent-glow: rgba(45, 212, 191, 0.06);
--amber: #f59e0b;
--amber-dim: rgba(245, 158, 11, 0.12);
--error: #f87171;
--error-dim: rgba(248, 113, 113, 0.12);
--sans: 'DM Sans', -apple-system, sans-serif;
--serif: 'Instrument Serif', Georgia, serif;
--mono: 'IBM Plex Mono', 'Menlo', monospace;
--radius: 6px;
--radius-lg: 10px;
--sidebar-w: 260px;
color-scheme: dark;
}
/* ── Tokens: Light ─────────────────────────────────────────────── */
html.light {
--bg-0: #ffffff;
--bg-1: #f8f9fb;
--bg-2: #f0f2f5;
--bg-3: #e2e6ec;
--bg-4: #cdd3de;
--text-0: #111827;
--text-1: #374151;
--text-2: #6b7280;
--text-3: #9ca3af;
--accent: #0d9488;
--accent-hover: #0f766e;
--accent-dim: rgba(13, 148, 136, 0.08);
--accent-mid: rgba(13, 148, 136, 0.15);
--accent-glow: rgba(13, 148, 136, 0.04);
--amber: #d97706;
--amber-dim: rgba(217, 119, 6, 0.08);
--error: #dc2626;
--error-dim: rgba(220, 38, 38, 0.08);
color-scheme: light;
}
html.light .grain { display: none; }
html.light .logo-icon { color: var(--accent); }
/* ── Base ──────────────────────────────────────────────────────── */
html, body {
height: 100%;
font-family: var(--sans);
background: var(--bg-0);
color: var(--text-0);
line-height: 1.6;
font-size: 15px;
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
.serif-accent {
font-family: var(--serif);
font-style: italic;
font-weight: 400;
}
.mono { font-family: var(--mono); font-size: 0.85em; }
/* ── Grain ─────────────────────────────────────────────────────── */
.grain {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-size: 256px;
}
/* ── Animations ────────────────────────────────────────────────── */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse-bar {
0%, 100% { opacity: 0.4; transform: scaleX(0.6); }
50% { opacity: 1; transform: scaleX(1); }
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes barGrow {
from { width: 0; }
to { width: var(--w, 0); }
}
/* ═══════════════════════════════════════════════════════════════════
APP LAYOUT
═══════════════════════════════════════════════════════════════════ */
.app-layout {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
height: 100vh;
}
@media (max-width: 768px) {
.app-layout { grid-template-columns: 1fr; }
.sidebar { display: none; }
}
/* ── Sidebar ───────────────────────────────────────────────────── */
.sidebar {
background: var(--bg-1);
border-right: 1px solid var(--bg-3);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--bg-3);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.logo-link {
display: flex;
align-items: center;
gap: 0.55rem;
color: var(--text-0);
}
.logo-link:hover { color: var(--text-0); }
.logo-icon { color: var(--accent); }
.logo-text {
font-size: 0.95rem;
font-weight: 500;
letter-spacing: -0.01em;
}
/* Theme toggle */
.theme-toggle {
background: none;
border: none;
color: var(--text-2);
cursor: pointer;
padding: 0.3rem;
border-radius: var(--radius);
display: flex;
align-items: center;
transition: color 0.15s, background 0.15s;
}
.theme-toggle:hover { color: var(--text-0); background: var(--bg-2); }
html:not(.light) .icon-sun { display: block; }
html:not(.light) .icon-moon { display: none; }
html.light .icon-sun { display: none; }
html.light .icon-moon { display: block; }
/* New Analysis button */
.new-analysis-btn {
margin: 0.75rem 0.75rem 0;
padding: 0.55rem 0.75rem;
background: var(--accent-dim);
color: var(--accent);
border: 1px solid rgba(45, 212, 191, 0.15);
border-radius: var(--radius-lg);
font-family: var(--sans);
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
transition: background 0.15s, border-color 0.15s;
flex-shrink: 0;
}
html.light .new-analysis-btn {
border-color: rgba(13, 148, 136, 0.2);
}
.new-analysis-btn:hover {
background: var(--accent-mid);
border-color: var(--accent);
}
/* Sidebar sections */
.sidebar-section {
padding: 0.75rem 1rem;
flex-shrink: 0;
}
.sidebar-history {
flex: 1;
overflow-y: auto;
min-height: 0;
border-top: 1px solid var(--bg-3);
}
.section-label {
font-family: var(--mono);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-3);
margin-bottom: 0.5rem;
}
/* Active dataset summary */
.active-dataset {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.dataset-name {
font-size: 0.88rem;
font-weight: 500;
word-break: break-word;
line-height: 1.3;
}
.dataset-meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.platform-tag {
font-family: var(--mono);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--accent);
background: var(--accent-dim);
padding: 1px 6px;
border-radius: 3px;
}
.dataset-stat {
font-family: var(--mono);
font-size: 0.68rem;
color: var(--text-2);
}
/* Mini rating distribution */
.mini-dist {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-top: 0.35rem;
}
.mini-dist-row {
display: flex;
align-items: center;
gap: 0.35rem;
}
.mini-dist-label {
font-family: var(--mono);
font-size: 0.62rem;
width: 0.7rem;
text-align: right;
color: var(--text-3);
}
.mini-dist-track {
flex: 1;
height: 3px;
background: var(--bg-3);
border-radius: 2px;
overflow: hidden;
}
.mini-dist-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
width: var(--w, 0);
animation: barGrow 0.6s ease both;
}
.mini-dist-count {
font-family: var(--mono);
font-size: 0.6rem;
width: 1.4rem;
text-align: right;
color: var(--text-3);
}
/* Session list */
.session-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.session-item {
display: flex;
flex-direction: column;
gap: 1px;
padding: 0.5rem 0.6rem;
border-radius: var(--radius);
color: var(--text-1);
transition: background 0.12s;
text-decoration: none;
}
.session-item:hover {
background: var(--bg-2);
color: var(--text-0);
}
.session-item.active {
background: var(--accent-dim);
color: var(--text-0);
}
.session-name {
font-size: 0.82rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-detail {
font-family: var(--mono);
font-size: 0.62rem;
color: var(--text-3);
}
.empty-history {
font-size: 0.8rem;
color: var(--text-3);
padding: 0.5rem 0;
}
/* ═══════════════════════════════════════════════════════════════════
MAIN AREA
═══════════════════════════════════════════════════════════════════ */
.main-area {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: var(--bg-0);
}
/* ── Empty State ───────────────────────────────────────────────── */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem;
text-align: center;
}
.empty-icon { color: var(--text-3); }
.empty-state h2 {
font-size: 1.6rem;
font-weight: 300;
letter-spacing: -0.02em;
}
.empty-state p {
color: var(--text-2);
font-size: 0.9rem;
max-width: 380px;
}
.empty-state .btn {
width: auto;
margin-top: 0.5rem;
}
/* ── Chat ──────────────────────────────────────────────────────── */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 2rem 2.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.message {
max-width: 700px;
animation: fadeIn 0.2s ease both;
}
.user-message { align-self: flex-end; }
.user-message .message-content {
background: var(--bg-3);
color: var(--text-0);
padding: 0.65rem 1rem;
border-radius: var(--radius-lg) var(--radius-lg) 3px var(--radius-lg);
font-size: 0.9rem;
}
.user-message .message-content p { margin: 0; }
.assistant-message { align-self: flex-start; }
.assistant-message .message-content {
background: var(--bg-1);
border: 1px solid var(--bg-3);
padding: 1rem 1.25rem;
border-radius: 3px var(--radius-lg) var(--radius-lg) var(--radius-lg);
line-height: 1.7;
font-size: 0.9rem;
}
.welcome .message-content {
border-color: rgba(45, 212, 191, 0.15);
}
html.light .welcome .message-content {
border-color: rgba(13, 148, 136, 0.2);
}
/* Message content typography */
.assistant-message .message-content p { margin-bottom: 0.6rem; }
.assistant-message .message-content p:last-child { margin-bottom: 0; }
.assistant-message .message-content h1,
.assistant-message .message-content h2,
.assistant-message .message-content h3 {
margin: 1rem 0 0.4rem;
font-size: 0.95rem;
font-weight: 600;
}
.assistant-message .message-content h3:first-child { margin-top: 0; }
.assistant-message .message-content ul,
.assistant-message .message-content ol {
padding-left: 1.4rem;
margin-bottom: 0.6rem;
}
.assistant-message .message-content li { margin-bottom: 0.2rem; }
.assistant-message .message-content li::marker { color: var(--text-3); }
.assistant-message .message-content strong { color: var(--text-0); font-weight: 600; }
.assistant-message .message-content code {
font-family: var(--mono);
font-size: 0.82em;
background: var(--bg-2);
border: 1px solid var(--bg-3);
padding: 0.1rem 0.35rem;
border-radius: 4px;
color: var(--accent);
}
.assistant-message .message-content blockquote {
border-left: 2px solid var(--accent);
padding-left: 0.9rem;
margin: 0.6rem 0;
color: var(--text-1);
font-style: italic;
}
.assistant-message .message-content table {
width: 100%;
border-collapse: collapse;
margin: 0.75rem 0;
font-size: 0.82rem;
}
.assistant-message .message-content th,
.assistant-message .message-content td {
padding: 0.4rem 0.65rem;
border: 1px solid var(--bg-3);
text-align: left;
}
.assistant-message .message-content th {
background: var(--bg-2);
font-weight: 600;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-2);
}
/* ── Thinking ──────────────────────────────────────────────────── */
.thinking .message-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 100px;
}
.thinking-dots {
display: flex;
align-items: center;
gap: 3px;
height: 18px;
}
.thinking-dots span {
display: block;
width: 24px;
height: 3px;
background: var(--accent);
border-radius: 2px;
animation: pulse-bar 1.6s ease-in-out infinite;
transform-origin: left center;
}
.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
.tool-activity {
font-family: var(--mono);
font-size: 0.7rem;
color: var(--text-3);
max-width: 340px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Charts ────────────────────────────────────────────────────── */
.chart-container {
margin: 0.75rem 0;
padding: 1rem;
background: var(--bg-2);
border: 1px solid var(--bg-3);
border-radius: var(--radius-lg);
max-width: 480px;
}
.chart-container canvas { max-height: 280px; }
/* ── Follow-ups ────────────────────────────────────────────────── */
.follow-ups {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.75rem;
}
.follow-up-btn {
background: var(--accent-dim);
color: var(--accent);
border: 1px solid rgba(45, 212, 191, 0.15);
border-radius: 999px;
padding: 0.32rem 0.85rem;
font-size: 0.78rem;
font-family: var(--sans);
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
html.light .follow-up-btn {
border-color: rgba(13, 148, 136, 0.2);
}
.follow-up-btn:hover {
background: var(--accent-mid);
border-color: var(--accent);
}
/* ── Chat Input ────────────────────────────────────────────────── */
.chat-input-area {
padding: 0.75rem 2.5rem 1.25rem;
border-top: 1px solid var(--bg-3);
background: var(--bg-0);
flex-shrink: 0;
}
.input-row {
display: flex;
gap: 0.6rem;
align-items: flex-end;
}
.input-row textarea {
flex: 1;
resize: none;
padding: 0.65rem 1rem;
background: var(--bg-1);
border: 1px solid var(--bg-3);
border-radius: var(--radius-lg);
color: var(--text-0);
font-size: 0.9rem;
font-family: var(--sans);
line-height: 1.5;
max-height: 150px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.input-row textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
.input-row textarea:disabled { opacity: 0.4; }
.input-row textarea::placeholder { color: var(--text-3); }
.btn-send {
width: 38px;
height: 38px;
padding: 0;
border-radius: 50%;
flex-shrink: 0;
background: var(--accent);
color: var(--bg-0);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, transform 0.1s;
}
html.light .btn-send { color: #fff; }
.btn-send:hover:not(:disabled) { background: var(--accent-hover); }
.btn-send:active { transform: scale(0.93); }
.btn-send:disabled { opacity: 0.35; cursor: not-allowed; }
.input-hint {
text-align: center;
font-family: var(--mono);
font-size: 0.62rem;
color: var(--text-3);
margin-top: 0.5rem;
letter-spacing: 0.02em;
}
/* ═══════════════════════════════════════════════════════════════════
MODAL
═══════════════════════════════════════════════════════════════════ */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 1000;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
html.light .modal-backdrop { background: rgba(0,0,0,0.3); }
.modal-backdrop.open {
opacity: 1;
pointer-events: auto;
}
.modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.96);
z-index: 1001;
background: var(--bg-1);
border: 1px solid var(--bg-3);
border-radius: 14px;
width: 480px;
max-width: 92vw;
max-height: 85vh;
overflow-y: auto;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
box-shadow: 0 24px 80px rgba(0,0,0,0.5);
}
html.light .modal {
box-shadow: 0 24px 80px rgba(0,0,0,0.12);
}
.modal.open {
opacity: 1;
pointer-events: auto;
transform: translate(-50%, -50%) scale(1);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem 0.75rem;
}
.modal-header h2 {
font-size: 1.1rem;
font-weight: 500;
}
.modal-close {
background: none;
border: none;
color: var(--text-2);
font-size: 1.4rem;
cursor: pointer;
padding: 0.2rem 0.4rem;
border-radius: var(--radius);
line-height: 1;
transition: color 0.12s, background 0.12s;
}
.modal-close:hover { color: var(--text-0); background: var(--bg-2); }
/* Modal tabs */
.modal-tabs {
display: flex;
gap: 0;
padding: 0 1.5rem;
border-bottom: 1px solid var(--bg-3);
}
.modal-tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-2);
font-family: var(--sans);
font-size: 0.82rem;
font-weight: 500;
padding: 0.6rem 0.75rem;
cursor: pointer;
transition: color 0.12s, border-color 0.12s;
}
.modal-tab:hover { color: var(--text-0); }
.modal-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.modal-tab-content {
display: none;
padding: 1.25rem 1.5rem 1.5rem;
}
.modal-tab-content.active { display: block; }
/* ── Forms (shared) ────────────────────────────────────────────── */
.form-group { margin-bottom: 0.9rem; }
.form-group label {
display: block;
font-size: 0.76rem;
font-weight: 500;
color: var(--text-2);
margin-bottom: 0.25rem;
letter-spacing: 0.01em;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.5rem 0.7rem;
background: var(--bg-2);
border: 1px solid var(--bg-3);
border-radius: var(--radius);
color: var(--text-0);
font-size: 0.87rem;
font-family: var(--sans);
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
.form-group input::placeholder { color: var(--text-3); }
.form-group select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%236b7d99' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.7rem center;
padding-right: 2rem;
}
/* File drop */
.file-drop {
border: 1.5px dashed var(--bg-4);
border-radius: var(--radius-lg);
padding: 1.5rem;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
margin-bottom: 1.25rem;
position: relative;
}
.file-drop:hover,
.file-drop.drag-over {
border-color: var(--accent);
background: var(--accent-glow);
}
.file-drop input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.file-drop-icon {
color: var(--text-3);
margin-bottom: 0.4rem;
transition: color 0.15s;
}
.file-drop:hover .file-drop-icon { color: var(--accent); }
.file-drop p { color: var(--text-2); font-size: 0.85rem; }
.browse-link { color: var(--accent); }
.file-hint {
font-family: var(--mono);
font-size: 0.68rem;
color: var(--text-3);
margin-top: 0.3rem;
}
.file-selected {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.file-name {
font-family: var(--mono);
font-size: 0.82rem;
color: var(--accent);
font-weight: 500;
}
.file-clear {
background: none;
border: none;
color: var(--text-2);
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
padding: 0.1rem 0.25rem;
border-radius: 3px;
}
.file-clear:hover { background: var(--bg-3); }
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
padding: 0.6rem 1.25rem;
border: none;
border-radius: var(--radius);
font-size: 0.85rem;
font-weight: 500;
font-family: var(--sans);
cursor: pointer;
transition: all 0.15s;
width: 100%;
}
.btn:active { transform: scale(0.98); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--accent); color: #fff; }
html:not(.light) .btn-primary { color: var(--bg-0); }
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
.btn-secondary { background: var(--bg-3); color: var(--text-1); }
.btn-secondary:hover:not(:disabled) { background: var(--bg-4); }
.btn-spinner { display: inline-flex; align-items: center; gap: 0.4rem; }
.spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(0,0,0,0.15);
border-top-color: var(--bg-0);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
.btn-secondary .spinner {
border-color: rgba(255,255,255,0.15);
border-top-color: var(--text-0);
}
.badge {
font-family: var(--mono);
font-size: 0.58rem;
text-transform: uppercase;
letter-spacing: 0.06em;
background: var(--amber-dim);
color: var(--amber);
padding: 2px 6px;
border-radius: 999px;
font-weight: 500;
vertical-align: middle;
margin-left: 0.2rem;
}
/* Error */
.error-banner {
background: var(--error-dim);
border: 1px solid rgba(248, 113, 113, 0.25);
color: var(--error);
padding: 0.6rem 0.8rem;
border-radius: var(--radius);
font-size: 0.82rem;
margin-top: 0.75rem;
}
/* ── Scrollbar ─────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--bg-4); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-3); }
The file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/static/style.css has been updated successfully.
/* ReviewLens AI */
// ── Theme ───────────────────────────────────────────────────────────
(function initTheme() {
var saved = localStorage.getItem("rl-theme");
if (saved === "light") {
document.documentElement.classList.add("light");
} else if (saved === "dark") {
document.documentElement.classList.remove("light");
} else if (window.matchMedia("(prefers-color-scheme: light)").matches) {
document.documentElement.classList.add("light");
}
})();
function toggleTheme() {
var html = document.documentElement;
html.classList.toggle("light");
localStorage.setItem("rl-theme", html.classList.contains("light") ? "light" : "dark");
}
// ── Modal ───────────────────────────────────────────────────────────
function openModal() {
document.getElementById("modal-backdrop").classList.add("open");
document.getElementById("analysis-modal").classList.add("open");
}
function closeModal() {
document.getElementById("modal-backdrop").classList.remove("open");
document.getElementById("analysis-modal").classList.remove("open");
}
function switchTab(btn, tabId) {
// Deactivate all tabs
var tabs = btn.parentElement.querySelectorAll(".modal-tab");
tabs.forEach(function (t) { t.classList.remove("active"); });
btn.classList.add("active");
// Deactivate all content
var contents = btn.closest(".modal").querySelectorAll(".modal-tab-content");
contents.forEach(function (c) { c.classList.remove("active"); });
document.getElementById(tabId).classList.add("active");
}
// Close modal on Escape
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") closeModal();
});
// ── File upload drag & drop ─────────────────────────────────────────
(function () {
var dropZone = document.getElementById("file-drop");
var fileInput = document.getElementById("file");
if (!dropZone || !fileInput) return;
dropZone.addEventListener("click", function () {
fileInput.click();
});
dropZone.addEventListener("dragover", function (e) {
e.preventDefault();
dropZone.classList.add("drag-over");
});
dropZone.addEventListener("dragleave", function () {
dropZone.classList.remove("drag-over");
});
dropZone.addEventListener("drop", function (e) {
e.preventDefault();
dropZone.classList.remove("drag-over");
if (e.dataTransfer.files.length) {
fileInput.files = e.dataTransfer.files;
showFileName(e.dataTransfer.files[0].name);
}
});
fileInput.addEventListener("change", function () {
if (fileInput.files.length) {
showFileName(fileInput.files[0].name);
}
});
function showFileName(name) {
var content = dropZone.querySelector(".file-drop-content");
var selected = dropZone.querySelector(".file-selected");
var nameEl = dropZone.querySelector(".file-name");
content.style.display = "none";
selected.style.display = "flex";
nameEl.textContent = name;
}
})();
function clearFile() {
var dropZone = document.getElementById("file-drop");
var fileInput = document.getElementById("file");
var content = dropZone.querySelector(".file-drop-content");
var selected = dropZone.querySelector(".file-selected");
content.style.display = "";
selected.style.display = "none";
fileInput.value = "";
}
// ── HTMX loading states ─────────────────────────────────────────────
document.addEventListener("htmx:beforeRequest", function (e) {
var form = e.detail.elt;
var btn = form.querySelector("button[type=submit]");
if (btn) {
btn.disabled = true;
var text = btn.querySelector(".btn-text");
var spinner = btn.querySelector(".btn-spinner");
if (text) text.style.display = "none";
if (spinner) spinner.style.display = "inline-flex";
}
});
document.addEventListener("htmx:afterRequest", function (e) {
var form = e.detail.elt;
var btn = form.querySelector("button[type=submit]");
if (btn) {
btn.disabled = false;
var text = btn.querySelector(".btn-text");
var spinner = btn.querySelector(".btn-spinner");
if (text) text.style.display = "";
if (spinner) spinner.style.display = "none";
}
});
// ── Chat ────────────────────────────────────────────────────────────
var chatMessages = document.getElementById("chat-messages");
var chatInput = document.getElementById("chat-input");
var chatForm = document.getElementById("chat-form");
var sendBtn = document.getElementById("send-btn");
var eventSource = null;
function scrollToBottom() {
if (chatMessages) {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
}
if (chatInput) {
chatInput.addEventListener("input", function () {
this.style.height = "auto";
this.style.height = Math.min(this.scrollHeight, 150) + "px";
});
chatInput.addEventListener("keydown", function (e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
chatForm.dispatchEvent(new Event("submit"));
}
});
}
function sendMessage(e) {
e.preventDefault();
if (!chatInput) return;
var message = chatInput.value.trim();
if (!message) return;
chatInput.disabled = true;
sendBtn.disabled = true;
// Remove old follow-ups
var old = chatMessages.querySelectorAll(".follow-ups");
old.forEach(function (el) { el.remove(); });
// User bubble
var userDiv = document.createElement("div");
userDiv.className = "message user-message";
userDiv.innerHTML = '<div class="message-content"><p>' + escapeHtml(message) + "</p></div>";
chatMessages.appendChild(userDiv);
// Thinking
var thinkingDiv = document.createElement("div");
thinkingDiv.id = "thinking-indicator";
thinkingDiv.className = "message assistant-message thinking";
thinkingDiv.innerHTML =
'<div class="message-content">' +
'<div class="thinking-dots"><span></span><span></span><span></span></div>' +
"</div>";
chatMessages.appendChild(thinkingDiv);
scrollToBottom();
var formData = new FormData();
formData.append("message", message);
fetch("/chat/" + SESSION_ID + "/send", { method: "POST", body: formData })
.then(function () { listenForResponse(); })
.catch(function (err) {
removeThinking();
appendError("Failed to send: " + err.message);
chatInput.disabled = false;
sendBtn.disabled = false;
});
chatInput.value = "";
chatInput.style.height = "auto";
}
function listenForResponse() {
if (eventSource) eventSource.close();
eventSource = new EventSource("/chat/" + SESSION_ID + "/stream");
eventSource.addEventListener("tool", function (e) {
var thinking = document.getElementById("thinking-indicator");
if (thinking) {
var content = thinking.querySelector(".message-content");
content.innerHTML =
'<div class="thinking-dots"><span></span><span></span><span></span></div>' +
'<div class="tool-activity">' + escapeHtml(e.data) + "</div>";
scrollToBottom();
}
});
eventSource.addEventListener("message", function (e) {
removeThinking();
var temp = document.createElement("div");
temp.innerHTML = e.data;
while (temp.firstChild) chatMessages.appendChild(temp.firstChild);
runChartScripts();
scrollToBottom();
chatInput.disabled = false;
sendBtn.disabled = false;
chatInput.focus();
});
eventSource.addEventListener("done", function () {
eventSource.close();
eventSource = null;
removeThinking();
chatInput.disabled = false;
sendBtn.disabled = false;
});
eventSource.onerror = function () {
eventSource.close();
eventSource = null;
removeThinking();
chatInput.disabled = false;
sendBtn.disabled = false;
};
}
function removeThinking() {
var el = document.getElementById("thinking-indicator");
if (el) el.remove();
}
function appendError(msg) {
var div = document.createElement("div");
div.className = "message assistant-message error";
div.innerHTML = '<div class="message-content"><p>' + escapeHtml(msg) + "</p></div>";
chatMessages.appendChild(div);
scrollToBottom();
}
function sendFollowUp(btn) {
var question = btn.getAttribute("data-question");
if (chatInput) {
chatInput.value = question;
chatForm.dispatchEvent(new Event("submit"));
}
}
// ── Chart rendering ─────────────────────────────────────────────────
var CHART_COLORS = [
"rgba(45, 212, 191, 0.75)",
"rgba(245, 158, 11, 0.75)",
"rgba(94, 234, 212, 0.65)",
"rgba(248, 113, 113, 0.7)",
"rgba(129, 140, 248, 0.7)",
"rgba(52, 211, 153, 0.7)",
"rgba(251, 191, 36, 0.7)",
];
var CHART_BORDERS = [
"rgba(45, 212, 191, 1)",
"rgba(245, 158, 11, 1)",
"rgba(94, 234, 212, 1)",
"rgba(248, 113, 113, 1)",
"rgba(129, 140, 248, 1)",
"rgba(52, 211, 153, 1)",
"rgba(251, 191, 36, 1)",
];
function getChartTextColor() {
return document.documentElement.classList.contains("light") ? "#374151" : "#b0bdd0";
}
function getChartGridColor() {
return document.documentElement.classList.contains("light")
? "rgba(0, 0, 0, 0.06)"
: "rgba(107, 125, 153, 0.08)";
}
function renderChart(canvasId, config) {
var canvas = document.getElementById(canvasId);
if (!canvas) return;
var textColor = getChartTextColor();
var gridColor = getChartGridColor();
var datasets = config.data.datasets.map(function (ds, i) {
var isPie = config.type === "pie" || config.type === "doughnut";
var colors = isPie ? CHART_COLORS.slice(0, ds.data.length) : [CHART_COLORS[i % CHART_COLORS.length]];
var borders = isPie ? CHART_BORDERS.slice(0, ds.data.length) : [CHART_BORDERS[i % CHART_BORDERS.length]];
return {
label: ds.label,
data: ds.data,
backgroundColor: colors.length === 1 ? colors[0] : colors,
borderColor: borders.length === 1 ? borders[0] : borders,
borderWidth: config.type === "line" ? 2 : 1,
tension: 0.3,
fill: config.type === "line",
};
});
new Chart(canvas, {
type: config.type,
data: { labels: config.data.labels, datasets: datasets },
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
title: {
display: true,
text: config.title,
font: { size: 13, weight: "500", family: "'DM Sans', sans-serif" },
color: textColor,
padding: { bottom: 12 },
},
legend: {
labels: { color: textColor, font: { family: "'DM Sans', sans-serif", size: 11 } },
},
},
scales:
config.type !== "pie" && config.type !== "doughnut"
? {
x: {
ticks: { color: textColor, font: { family: "'IBM Plex Mono', monospace", size: 10 } },
grid: { color: gridColor },
},
y: {
ticks: { color: textColor, font: { family: "'IBM Plex Mono', monospace", size: 10 } },
grid: { color: gridColor },
beginAtZero: true,
},
}
: undefined,
},
});
}
function runChartScripts() {
if (!chatMessages) return;
var scripts = chatMessages.querySelectorAll("script");
scripts.forEach(function (script) {
if (script.textContent.indexOf("renderChart") !== -1 && !script.dataset.executed) {
script.dataset.executed = "true";
eval(script.textContent);
}
});
}
// ── Utilities ────────────────────────────────────────────────────────
function escapeHtml(str) {
var div = document.createElement("div");
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
if (chatMessages) {
var observer = new MutationObserver(scrollToBottom);
observer.observe(chatMessages, { childList: true, subtree: true });
}
scrollToBottom();
The file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/static/app.js has been updated successfully.
rm /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/templates/index.html /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/templates/chat.html 2>/dev/null; echo "Removed old templates"
source .venv/bin/activate && python3 -c "
import ast, pathlib
for f in pathlib.Path('app').glob('*.py'):
ast.parse(f.read_text())
print('Python OK')
"# Server should have auto-reloaded. Test both routes. curl -s http://localhost:8000/ | grep -c 'app-layout' && echo "Home: app layout present" curl -s http://localhost:8000/ | grep -c 'analysis-modal' && echo "Home: modal present" curl -s http://localhost:8000/ | grep -c 'empty-state' && echo "Home: empty state present" curl -s http://localhost:8000/ | grep -c 'theme-toggle' && echo "Home: theme toggle present" echo "---" curl -s http://localhost:8000/chat/e38c015e-0785-4f7d-bb4d-090603292b37 | grep -c 'chat-messages' && echo "Chat: messages area present" curl -s http://localhost:8000/chat/e38c015e-0785-4f7d-bb4d-090603292b37 | grep -c 'session-item active' && echo "Chat: active session highlighted" curl -s http://localhost:8000/chat/e38c015e-0785-4f7d-bb4d-090603292b37 | grep -c 'mini-dist' && echo "Chat: mini rating dist present" curl -s http://localhost:8000/chat/e38c015e-0785-4f7d-bb4d-090603292b37 | grep -c 'new-analysis-btn' && echo "Chat: new analysis button present"
1 Home: app layout present 1 Home: modal present 1 Home: empty state present 1 Home: theme toggle present --- 1 Chat: messages area present 1 Chat: active session highlighted 26 Chat: mini rating dist present 1 Chat: new analysis button present
Everything's rendering correctly. Here's what changed:
Layout restructure:
- Single app.html template used by both / and /chat/{id} — no more separate landing page
- Persistent sidebar on the left with: logo + theme toggle, "New Analysis" button, current dataset summary (when in a chat), session history list
- Main area shows either the empty state (with a CTA to open the modal) or the active chat
- Active session is highlighted in the sidebar history
Modal: - "New Analysis" button (in sidebar and empty state) opens a centered modal - Modal has tabbed UI: "CSV Upload" (default) and "URL Scrape" tabs - Escape key or clicking backdrop closes it - Animated open/close with scale + opacity transition
Light mode:
- Sun/moon toggle in the sidebar header
- Persists to localStorage
- Respects prefers-color-scheme on first visit
- Full token swap: backgrounds go white/light gray, text inverts, accent shifts to darker teal for contrast, grain overlay hidden
- Chart colors adapt via getChartTextColor()/getChartGridColor()
Backend:
- Added store.list_sessions() — scans data dir for all sessions, sorted newest-first
- Both / and /chat/{id} share _shell_context() which passes sessions list + active session
Open http://localhost:8000 to see the empty state, and http://localhost:8000/chat/e38c015e-0785-4f7d-bb4d-090603292b37 for the chat view. Try the theme toggle in the top-right of the sidebar.
hint: Using 'master' as the name for the initial branch. This default branch name hint: will change to "main" in Git 3.0. To configure the initial branch name hint: to use in all of your new repositories, which will suppress this warning, hint: call: hint: hint: git config --global init.defaultBranch <name> hint: hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and hint: 'development'. The just-created branch can be renamed via this command: hint: hint: git branch -m <name> hint: hint: Disable this message with "git config set advice.defaultBranchName false" Initialized empty Git repository in /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/.git/
# Python __pycache__/ *.py[cod] *.egg-info/ dist/ build/ *.egg # Virtual environment .venv/ venv/ # Environment .env # Data & state data/ # macOS .DS_Store # IDE .idea/ .vscode/ *.swp *.swo
File created successfully at: /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/.gitignore
On branch main No commits yet Untracked files: (use "git add <file>..." to include in what will be committed) .env.example .gitignore Dockerfile README.md app/__init__.py app/agent.py app/ingest.py app/main.py app/models.py app/prompts.py app/store.py app/tools.py app/vectordb.py render.yaml requirements.txt static/app.js static/style.css templates/app.html templates/partials/error.html templates/partials/summary.html nothing added to commit but untracked files present (use "git add" to track)
git add .env.example .gitignore Dockerfile README.md render.yaml requirements.txt app/ static/ templates/
git commit -m "$(cat <<'EOF' Initial commit: ReviewLens AI Review intelligence portal — CSV/URL ingestion, ChromaDB vector search, conversational Claude agent with scope-guarded Q&A, inline Chart.js, FastAPI + HTMX + SSE streaming. Dark/light theme. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> EOF )"
ok [main (root-commit) 731a68e] Initial commit: ReviewLens AI 20 files changed, 3365 insertions(+)
gh repo create reviewlens --private --source=. --push
https://github.com/shaynelarocque/reviewlens To https://github.com/shaynelarocque/reviewlens.git * [new branch] HEAD -> main branch 'main' set up to track 'origin/main'.