3 Commits

Author SHA1 Message Date
Adrien 767d1e2dbc enhance illustration being taken into account in the response 2026-04-12 16:26:25 +02:00
Adrien 820734c251 fix api url setup 2026-04-10 13:55:05 +02:00
Adrien 0711e40c66 Improved responsiveness on mobile phone 2026-04-10 13:41:26 +02:00
22 changed files with 768 additions and 56 deletions
+4 -2
View File
@@ -1,6 +1,6 @@
# ai-teacher Development Guidelines
Auto-generated from all feature plans. Last updated: 2026-04-07
Auto-generated from all feature plans. Last updated: 2026-04-10
## Active Technologies
- Java 25 (backend), TypeScript / Node 20 (frontend) + Spring Boot 4.0.5, Spring AI 2.0.0-M4, OpenAI API (embeddings + chat), PDFBox (via Spring AI PDF reader dependency) (002-image-aware-embedding)
@@ -16,6 +16,8 @@ Auto-generated from all feature plans. Last updated: 2026-04-07
- PostgreSQL (JPA + Flyway), pgvector (`VectorStore`) (004-rag-retrieval-quality)
- Java 25 (backend), TypeScript / Node 20 (frontend) + Spring Boot 4.0.5, Spring AI 2.0.0-M4, `native-maven-plugin` 0.10.6, (005-native-image-deployment)
- PostgreSQL 16 + pgvector (unchanged) (005-native-image-deployment)
- TypeScript / Node 20 (frontend only) + Vue 3.4, Vue Router 4.3, Pinia 2.1 — no changes (006-mobile-responsive-ui)
- N/A (frontend-only change) (006-mobile-responsive-ui)
- Java 21 (backend), TypeScript / Node 20 (frontend) (001-neuro-rag-learning)
@@ -35,9 +37,9 @@ npm test && npm run lint
Java 21 (backend), TypeScript / Node 20 (frontend): Follow standard conventions
## Recent Changes
- 006-mobile-responsive-ui: Added TypeScript / Node 20 (frontend only) + Vue 3.4, Vue Router 4.3, Pinia 2.1 — no changes
- 005-native-image-deployment: Added Java 25 (backend), TypeScript / Node 20 (frontend) + Spring Boot 4.0.5, Spring AI 2.0.0-M4, `native-maven-plugin` 0.10.6,
- 004-rag-retrieval-quality: Added Java 21 (backend), TypeScript / Node 20 (frontend) + Spring Boot 4.0.5, Spring AI 2.0.0-M4, OpenAI API (chat + embeddings), Vue 3.4, Pinia 2.1, Axios 1.7
- 004-rag-retrieval-quality: Added Java 21 (backend), TypeScript / Node 20 (frontend) + Spring Boot 4.0.5, Spring AI 2.0.0-M4, OpenAI API (chat + embeddings), pgvector, Vue 3.4, Pinia 2.1
<!-- MANUAL ADDITIONS START -->
@@ -29,7 +29,7 @@ public class ChatService {
- Use clear structure: headings, bullet points, or numbered steps where appropriate to maximize clarity
- Only say you cannot answer if the context is entirely unrelated to the question
- Cite sources for each major claim using the reference labels from the context (e.g. [S1], [F2]). Prefer these labels over inventing page numbers, but you may also describe the source naturally if needed.
- When referencing diagrams or figures, prefer their label from the context (e.g. [F1])
- Figures (labeled [F1], [F2], etc.) are actual images and drawings from the textbook — they will be rendered as inline illustrations in your response. Use them actively to support your explanations: reference a figure when it visually demonstrates anatomy, a surgical step, or a clinical concept you are describing.
- Maintain continuity with the conversation history
- Never fabricate clinical information not present in the context
""";
@@ -14,9 +14,15 @@ public record TopicSummaryResponse(
Instant generatedAt
) {
public record SourceReference(
String type,
String refLabel,
String bookId,
String bookTitle,
Integer page
Integer page,
String figureId,
String label,
String caption,
String imageUrl
) {
}
}
@@ -35,6 +35,7 @@ public class TopicSummaryService {
- Structure your response clearly with key points
- Cite claims using ONLY the reference labels provided in the context (e.g. [S1], [F2]).
Do not invent page numbers, section titles, or labels not present in the CONTEXT block.
- Figures (labeled [F1], [F2], etc.) are actual images and drawings from the textbook — they will be rendered as inline illustrations in your response. Use them actively to support your explanations: reference a figure when it visually demonstrates anatomy, a surgical step, or a clinical concept you are describing.
- If the retrieved context does not contain sufficient information on the topic,
explicitly state: "The uploaded books do not contain sufficient information on this topic."
- Never hallucinate or fabricate clinical information
@@ -135,7 +136,7 @@ public class TopicSummaryService {
return String.format(
"Provide a comprehensive educational summary of the following neurosurgery topic: " +
"%s. Topic description: %s. " +
"Include key concepts, clinical considerations, and important details that a neurosurgeon should know.",
"Include key concepts, diagrams, illustations and clinical considerations, and important details that a neurosurgeon should know.",
topic.getName(), topic.getDescription()
);
}
@@ -165,7 +166,7 @@ public class TopicSummaryService {
.append(f.getCaption() != null ? f.getCaption() : "")
.append("\n");
}
sb.append("\n");
sb.append("\nWhen referencing diagrams, use their label from the context (e.g. [F1]).\n\n");
}
sb.append("QUESTION:\n").append(question);
@@ -177,27 +178,35 @@ public class TopicSummaryService {
List<Book> readyBooks) {
List<TopicSummaryResponse.SourceReference> sources = new ArrayList<>();
for (SectionEntity s : sections) {
for (int i = 0; i < sections.size(); i++) {
SectionEntity s = sections.get(i);
Book book = readyBooks.stream()
.filter(b -> b.getId().equals(s.getBookId()))
.findFirst()
.orElse(null);
String title = book != null ? book.getTitle() : "Book";
String bookId = book != null ? book.getId().toString() : null;
sources.add(new TopicSummaryResponse.SourceReference(bookId, title, s.getPageStart()));
sources.add(new TopicSummaryResponse.SourceReference(
"TEXT", "S" + (i + 1), bookId, title, s.getPageStart(),
null, null, null, null));
}
for (FigureEntity f : figures) {
for (int i = 0; i < figures.size(); i++) {
FigureEntity f = figures.get(i);
Book book = readyBooks.stream()
.filter(b -> b.getId().equals(f.getBookId()))
.findFirst()
.orElse(null);
String title = book != null ? book.getTitle() : "Book";
String bookId = book != null ? book.getId().toString() : null;
sources.add(new TopicSummaryResponse.SourceReference(bookId, title, f.getPage()));
String filename = f.getImagePath().substring(f.getImagePath().lastIndexOf('/') + 1);
String imageUrl = "/api/v1/figures/" + f.getBookId() + "/" + filename;
sources.add(new TopicSummaryResponse.SourceReference(
"FIGURE", "F" + (i + 1), bookId, title, f.getPage(),
f.getId(), f.getLabel(), f.getCaption(), imageUrl));
}
return sources.stream().distinct().toList();
return sources;
}
private String serializeSources(List<TopicSummaryResponse.SourceReference> sources) {
+3 -1
View File
@@ -10,5 +10,7 @@ RUN npm run build
FROM docker.io/library/nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
ENTRYPOINT ["/docker-entrypoint.sh"]
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
set -e
# Write runtime env vars into a JS file loaded before the app bundle.
# Any VITE_* variable passed via `docker run -e` will be available as
# window.__env__.VITE_* inside the browser.
cat > /usr/share/nginx/html/env-config.js <<EOF
window.__env__ = {
VITE_API_URL: "${VITE_API_URL:-}",
VITE_APP_PASSWORD: "${VITE_APP_PASSWORD:-}",
VITE_UPLOAD_ENABLED: "${VITE_UPLOAD_ENABLED:-}",
VITE_DELETE_ENABLED: "${VITE_DELETE_ENABLED:-}"
};
EOF
exec nginx -g "daemon off;"
+1
View File
@@ -8,6 +8,7 @@
</head>
<body>
<div id="app"></div>
<script src="/env-config.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+109 -3
View File
@@ -7,6 +7,10 @@
<span class="brand-subtitle">Neurosurgeon Learning Platform</span>
</div>
<template v-if="authStore.isAuthenticated">
<button class="burger" :class="{ open: menuOpen }" @click="menuOpen = !menuOpen" aria-label="Menu">
<span></span><span></span><span></span>
</button>
<div class="nav-drawer" :class="{ open: menuOpen }" @click="menuOpen = false">
<ul class="navbar-links">
<li>
<RouterLink to="/" :class="{ active: $route.path === '/' }">
@@ -24,7 +28,8 @@
</RouterLink>
</li>
</ul>
<button class="btn btn-logout" @click="logout">Sign out</button>
<button class="btn btn-logout" @click.stop="logout">Sign out</button>
</div>
</template>
</nav>
@@ -38,16 +43,21 @@
</template>
<script setup lang="ts">
import { ref, provide } from 'vue'
import { RouterLink, RouterView, useRouter } from 'vue-router'
import { ref, provide, watch } from 'vue'
import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const authStore = useAuthStore()
const router = useRouter()
const route = useRoute()
const menuOpen = ref(false)
const toastMessage = ref('')
const toastType = ref<'toast-error' | 'toast-success'>('toast-error')
// Close menu on navigation
watch(() => route.path, () => { menuOpen.value = false })
function logout() {
authStore.clearCredentials()
router.push({ name: 'login' })
@@ -94,6 +104,9 @@ body {
justify-content: space-between;
height: 64px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
position: sticky;
top: 0;
z-index: 100;
}
.navbar-brand {
@@ -118,6 +131,13 @@ body {
margin-left: 0.25rem;
}
/* Desktop: links inline */
.nav-drawer {
display: flex;
align-items: center;
gap: 0.5rem;
}
.navbar-links {
list-style: none;
display: flex;
@@ -143,6 +163,33 @@ body {
color: white;
}
/* Burger button — hidden on desktop */
.burger {
display: none;
flex-direction: column;
justify-content: center;
gap: 5px;
width: 36px;
height: 36px;
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 6px;
}
.burger span {
display: block;
height: 2px;
background: #bee3f8;
border-radius: 2px;
transition: transform 0.2s, opacity 0.2s;
}
.burger.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.burger.open span:nth-child(2) { opacity: 0; }
.burger.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
.main-content {
flex: 1;
min-height: 0;
@@ -313,4 +360,63 @@ body {
font-size: 0.9rem;
margin-top: 0.5rem;
}
@media (max-width: 768px) {
.navbar {
padding: 0 1rem;
}
.brand-subtitle {
display: none;
}
/* Show burger, hide desktop drawer */
.burger {
display: flex;
}
.nav-drawer {
display: none;
position: absolute;
top: 64px;
right: 0;
left: 0;
background: #1a365d;
flex-direction: column;
align-items: stretch;
padding: 0.5rem 0 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 99;
}
.nav-drawer.open {
display: flex;
}
.navbar-links {
flex-direction: column;
gap: 0;
}
.navbar-links a {
padding: 0.85rem 1.5rem;
border-radius: 0;
font-size: 1rem;
}
.navbar-links a:hover,
.navbar-links a.active {
background: #2b6cb0;
}
.btn-logout {
margin: 0.5rem 1.5rem 0;
width: calc(100% - 3rem);
justify-content: center;
}
.main-content {
padding: 1rem;
}
}
</style>
+52 -3
View File
@@ -87,12 +87,37 @@ const isUser = computed(() => props.message.role === 'USER')
const activeRef = ref<string | null>(null)
const sourceListEl = ref<HTMLElement | null>(null)
/** Replaces [S1]/[F1]-style labels in the rendered HTML with clickable badges. */
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
/** Replaces [S1]/[F1]-style labels in the rendered HTML with clickable badges.
* For figure citations, also injects an inline illustration below the badge. */
const renderedWithBadges = computed(() => {
const html = marked.parse(props.message.content) as string
const figureMap = new Map<string, ChatSource>()
for (const src of (props.message.sources ?? [])) {
if (src.type === 'FIGURE' && src.refLabel) {
figureMap.set(src.refLabel, src)
}
}
return html.replace(/\[(S|F)\d+\]/g, (match) => {
const inner = match.slice(1, -1) // e.g. "S1"
return `<span class="citation-badge" data-ref="${inner}" title="Jump to source ${inner}">${match}</span>`
const inner = match.slice(1, -1) // e.g. "F1"
const badge = `<span class="citation-badge" data-ref="${inner}" title="Jump to source ${inner}">${match}</span>`
const fig = figureMap.get(inner)
if (fig?.imageUrl) {
const alt = escapeHtml(fig.caption || fig.label || 'Figure')
const captionText = [fig.label, fig.caption].filter(Boolean).map(escapeHtml).join(' — ')
const captionHtml = captionText
? `<figcaption class="inline-figure-caption">${captionText}</figcaption>`
: ''
return `${badge}<figure class="inline-figure"><img src="${fig.imageUrl}" alt="${alt}" class="inline-figure-img" loading="lazy" onerror="this.parentElement.style.display='none'" />${captionHtml}</figure>`
}
return badge
})
})
@@ -426,6 +451,30 @@ function formatTime(iso: string): string {
color: #276749;
}
.message-content--markdown :deep(.inline-figure) {
display: block;
margin: 0.75rem 0;
text-align: center;
}
.message-content--markdown :deep(.inline-figure-img) {
max-width: 100%;
max-height: 400px;
border-radius: 6px;
border: 1px solid #e2e8f0;
object-fit: contain;
display: block;
margin: 0 auto;
}
.message-content--markdown :deep(.inline-figure-caption) {
font-size: 0.78rem;
color: #718096;
font-style: italic;
margin-top: 0.3rem;
text-align: center;
}
.message-timestamp {
font-size: 0.7rem;
opacity: 0.6;
+10
View File
@@ -0,0 +1,10 @@
/**
* Read a VITE_ env variable.
* At runtime in Docker, values come from window.__env__ (injected by docker-entrypoint.sh).
* At build time (dev / CI), values come from import.meta.env.
*/
export function env(key: string): string | undefined {
const runtime = (window as Record<string, any>).__env__?.[key]
if (runtime) return runtime
return (import.meta as any).env?.[key]
}
+2 -1
View File
@@ -1,8 +1,9 @@
import axios from 'axios'
import { useAuthStore } from '@/stores/authStore'
import { env } from '@/env'
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL ?? '/api/v1',
baseURL: env('VITE_API_URL') ?? '/api/v1',
headers: {
'Content-Type': 'application/json'
}
+6
View File
@@ -10,9 +10,15 @@ export interface Topic {
}
export interface SourceReference {
type?: 'TEXT' | 'FIGURE'
refLabel?: string
bookId: string | null
bookTitle: string
page: number | null
figureId?: string
label?: string
caption?: string
imageUrl?: string
}
export interface TopicSummary {
+10
View File
@@ -322,4 +322,14 @@ async function resolveImages(html: string): Promise<string> {
text-align: left;
}
.markdown-body :deep(th) { background: #f7fafc; font-weight: 600; }
@media (max-width: 768px) {
.reader-view {
max-width: 100%;
}
.reader-content {
padding: 1rem;
}
}
</style>
+22
View File
@@ -485,4 +485,26 @@ async function handleSend() {
font-size: 0.875rem;
margin-bottom: 0.75rem;
}
@media (max-width: 768px) {
.chat-layout {
height: auto;
min-height: unset;
}
.chat-reader-split {
flex-direction: column;
}
.chat-column {
min-height: 60vh;
}
.reader-panel {
width: 100%;
margin-left: 0;
margin-top: 1rem;
box-shadow: none;
}
}
</style>
+12
View File
@@ -168,4 +168,16 @@ async function handleSubmit() {
font-size: 0.95rem;
margin-top: 0.25rem;
}
@media (max-width: 768px) {
.login-wrapper {
align-items: flex-start;
padding-top: 2rem;
min-height: unset;
}
.login-card {
max-width: 100%;
}
}
</style>
+184 -12
View File
@@ -97,20 +97,57 @@
<span>{{ showSources ? '▲' : '▼' }}</span>
</button>
<div v-if="showSources" class="sources-list">
<!-- TEXT sources -->
<div
v-for="(source, idx) in topicStore.activeSummary.sources"
:key="idx"
class="source-chip"
v-for="(source, idx) in textSources"
:key="'text-' + idx"
class="source-item"
:data-ref-label="source.refLabel"
>
<div
class="source-chip source-chip--text"
:class="{ 'source-chip--clickable': source.bookId && source.page }"
@click="source.bookId && source.page ? handleOpenSource(source.bookId, source.page) : undefined"
>
<span class="source-icon">📖</span>
<span v-if="source.refLabel" class="source-ref-label">{{ source.refLabel }}</span>
<span class="source-book">{{ source.bookTitle }}</span>
<span v-if="source.page" class="source-page">p.&nbsp;{{ source.page }}</span>
<span v-if="source.bookId && source.page" class="source-open-hint"></span>
</div>
</div>
<!-- FIGURE sources -->
<div
v-for="(source, idx) in figureSources"
:key="'fig-' + idx"
class="source-item source-item--figure"
:data-ref-label="source.refLabel"
>
<div
class="source-chip source-chip--figure"
:class="{ 'source-chip--clickable': source.bookId && source.page }"
@click="source.bookId && source.page ? handleOpenSource(source.bookId, source.page) : undefined"
>
<span class="source-icon">🖼</span>
<span v-if="source.refLabel" class="source-ref-label source-ref-label--figure">{{ source.refLabel }}</span>
<span class="source-figure-label">{{ source.label || 'Figure' }}</span>
<span v-if="source.page" class="source-page">p.&nbsp;{{ source.page }}</span>
<span v-if="source.bookId && source.page" class="source-open-hint"></span>
</div>
<div v-if="source.caption" class="source-caption">{{ source.caption }}</div>
<div v-if="source.imageUrl" class="source-figure-image">
<img
:src="source.imageUrl"
:alt="source.caption || source.label || 'Figure'"
class="figure-img"
loading="lazy"
@error="onImageError"
/>
</div>
</div>
</div>
<BookPagePanel
v-if="readerPanel"
:book-id="readerPanel.bookId"
@@ -146,7 +183,7 @@
import { ref, computed, onMounted, inject } from 'vue'
import { marked } from 'marked'
import { RouterLink } from 'vue-router'
import { useTopicStore, type SavedSummaryItem } from '@/stores/topicStore'
import { useTopicStore, type SavedSummaryItem, type SourceReference } from '@/stores/topicStore'
import { useBookStore } from '@/stores/bookStore'
import TopicCard from '@/components/TopicCard.vue'
import BookPagePanel from '@/components/BookPagePanel.vue'
@@ -166,10 +203,55 @@ const readerPanel = ref<ReaderPanel | null>(null)
const summaryTopics = computed(() => topicStore.topics.filter(t => t.id !== 'free-form'))
const textSources = computed(() =>
(topicStore.activeSummary?.sources ?? []).filter(s => s.type === 'TEXT' || !s.type)
)
const figureSources = computed(() =>
(topicStore.activeSummary?.sources ?? []).filter(s => s.type === 'FIGURE')
)
function onImageError(e: Event) {
const img = e.target as HTMLImageElement
img.style.display = 'none'
const wrapper = img.parentElement
if (wrapper) {
wrapper.innerHTML = '<span class="figure-missing">Image unavailable</span>'
}
}
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
const renderedSummary = computed(() => {
if (!topicStore.activeSummary) return ''
const html = marked.parse(topicStore.activeSummary.summary) as string
return html.replace(/\[S(\d+)\]/g, '<span class="source-ref">[S$1]</span>')
const figureMap = new Map<string, SourceReference>()
for (const src of topicStore.activeSummary.sources) {
if (src.type === 'FIGURE' && src.refLabel) {
figureMap.set(src.refLabel, src)
}
}
return html.replace(/\[(S|F)\d+\]/g, (match) => {
const inner = match.slice(1, -1)
const badge = `<span class="source-ref" data-ref="${inner}" title="Jump to source ${inner}">${match}</span>`
const fig = figureMap.get(inner)
if (fig?.imageUrl) {
const alt = escapeHtml(fig.caption || fig.label || 'Figure')
const captionText = [fig.label, fig.caption].filter(Boolean).map(escapeHtml).join(' — ')
const captionHtml = captionText
? `<figcaption class="inline-figure-caption">${captionText}</figcaption>`
: ''
return `${badge}<figure class="inline-figure"><img src="${fig.imageUrl}" alt="${alt}" class="inline-figure-img" loading="lazy" onerror="this.parentElement.style.display='none'" />${captionHtml}</figure>`
}
return badge
})
})
function handleSummaryClick(e: MouseEvent) {
@@ -484,19 +566,37 @@ function formatDateShort(iso: string): string {
.sources-list {
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: 0.5rem;
}
.source-chip {
.source-item {
display: flex;
align-items: center;
flex-direction: column;
gap: 0.25rem;
}
.source-item--figure {
gap: 0.4rem;
}
.source-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border-radius: 4px;
padding: 0.2rem 0.5rem;
font-size: 0.78rem;
}
.source-chip--text {
background: #ebf8ff;
border: 1px solid #bee3f8;
border-radius: 6px;
padding: 0.3rem 0.7rem;
font-size: 0.8rem;
}
.source-chip--figure {
background: #f0fff4;
border: 1px solid #9ae6b4;
}
.source-chip--clickable {
@@ -504,20 +604,44 @@ function formatDateShort(iso: string): string {
transition: background 0.15s, border-color 0.15s;
}
.source-chip--clickable:hover {
.source-chip--text.source-chip--clickable:hover {
background: #bee3f8;
border-color: #90cdf4;
}
.source-chip--figure.source-chip--clickable:hover {
background: #c6f6d5;
border-color: #68d391;
}
.source-icon {
font-size: 0.8rem;
}
.source-ref-label {
font-size: 0.72rem;
font-weight: 700;
background: #bee3f8;
color: #2b6cb0;
border-radius: 3px;
padding: 0 0.3rem;
}
.source-ref-label--figure {
background: #9ae6b4;
color: #276749;
}
.source-book {
color: #2b6cb0;
font-weight: 500;
}
.source-figure-label {
color: #276749;
font-weight: 600;
}
.source-page {
color: #718096;
}
@@ -528,6 +652,30 @@ function formatDateShort(iso: string): string {
margin-left: 0.1rem;
}
.source-caption {
font-size: 0.78rem;
color: #4a5568;
font-style: italic;
}
.source-figure-image {
max-width: 100%;
}
.figure-img {
max-width: 100%;
max-height: 300px;
border-radius: 6px;
border: 1px solid #e2e8f0;
object-fit: contain;
}
.figure-missing {
font-size: 0.78rem;
color: #a0aec0;
font-style: italic;
}
.no-sources {
font-size: 0.85rem;
color: #a0aec0;
@@ -563,6 +711,30 @@ function formatDateShort(iso: string): string {
font-style: italic;
}
.summary-text--markdown :deep(.inline-figure) {
display: block;
margin: 0.75rem 0;
text-align: center;
}
.summary-text--markdown :deep(.inline-figure-img) {
max-width: 100%;
max-height: 400px;
border-radius: 6px;
border: 1px solid #e2e8f0;
object-fit: contain;
display: block;
margin: 0 auto;
}
.summary-text--markdown :deep(.inline-figure-caption) {
font-size: 0.78rem;
color: #718096;
font-style: italic;
margin-top: 0.3rem;
text-align: center;
}
.summary-text--markdown :deep(.source-ref) {
color: #3182ce;
font-weight: 600;
+3 -2
View File
@@ -99,9 +99,10 @@
import { ref, onMounted, onUnmounted, inject } from 'vue'
import { useBookStore } from '@/stores/bookStore'
import BookCard from '@/components/BookCard.vue'
import { env } from '@/env'
const uploadEnabled = import.meta.env.VITE_UPLOAD_ENABLED !== 'false'
const deleteEnabled = import.meta.env.VITE_DELETE_ENABLED !== 'false'
const uploadEnabled = env('VITE_UPLOAD_ENABLED') !== 'false'
const deleteEnabled = env('VITE_DELETE_ENABLED') !== 'false'
const bookStore = useBookStore()
const showToast = inject<(msg: string, type?: 'error' | 'success') => void>('showToast')
+75
View File
@@ -0,0 +1,75 @@
# Implementation Plan: Mobile-Responsive UI
**Branch**: `006-mobile-responsive-ui` | **Date**: 2026-04-10 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/006-mobile-responsive-ui/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
## Summary
Add CSS media queries to make the frontend usable on small screens (≤ 768px). Three targeted fixes:
1. Make the navbar sticky and its link bar horizontally scrollable
2. Prevent horizontal overflow in the book reader view
3. Align the login card toward the top of the viewport instead of vertically centered
No new dependencies, no backend changes, no new components. Pure CSS additions scoped to existing Vue SFCs.
## Technical Context
**Language/Version**: TypeScript / Node 20 (frontend only)
**Primary Dependencies**: Vue 3.4, Vue Router 4.3, Pinia 2.1 — no changes
**Storage**: N/A (frontend-only change)
**Testing**: Manual browser testing at 375px viewport; `npm run lint` for TypeScript
**Target Platform**: Modern mobile browsers (iOS Safari, Chrome Android); desktop unchanged
**Project Type**: Web application (frontend client only for this feature)
**Performance Goals**: No regression — changes are CSS-only, zero runtime cost
**Constraints**: No new dependencies; no changes to desktop layout; no hamburger menu
**Scale/Scope**: 5 files touched (App.vue, LoginView.vue, BookReaderView.vue, possibly UploadView.vue, BookCard.vue)
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| I. KISS | ✅ PASS | Pure CSS media queries — simplest viable solution. No new abstractions. |
| II. Easy to Change | ✅ PASS | Changes are scoped CSS within existing SFCs; easy to revert or extend. |
| III. Web-First Architecture | ✅ PASS | Frontend-only change; no API contract impact. |
| IV. Documentation as Architecture | ✅ PASS | No architectural change → README diagram unchanged. |
| Technology Constraints | ✅ PASS | Remains `backend/` + `frontend/`; no new deployable unit. |
**Verdict**: No violations. Complexity Tracking table not required.
## Project Structure
### Documentation (this feature)
```text
specs/006-mobile-responsive-ui/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # N/A (no data model changes)
├── quickstart.md # Phase 1 output
├── contracts/ # N/A (no API contract changes)
└── tasks.md # Phase 2 output (/speckit.tasks command)
```
### Source Code (repository root)
```text
frontend/
├── src/
│ ├── App.vue # Navbar sticky + link bar scroll
│ ├── views/
│ │ ├── LoginView.vue # Login card top-aligned on mobile
│ │ ├── BookReaderView.vue # Reader fits screen width on mobile
│ │ └── UploadView.vue # Book grid min-width reduction (if needed)
│ └── components/
│ └── BookCard.vue # Card min-width reduction (if needed)
```
**Structure Decision**: Option 2 (Web application) per constitution. This feature touches only `frontend/`; `backend/` is untouched.
## Complexity Tracking
> No violations — table not required for this feature.
@@ -0,0 +1,28 @@
# Quickstart: Mobile-Responsive UI
**Feature**: 006-mobile-responsive-ui
## What this feature does
Adds CSS media queries to 23 Vue SFCs so the app is usable on phone screens (≤ 768px):
- Navbar sticks to top; link bar scrolls horizontally
- Book reader stacks vertically (no horizontal overflow)
- Login card appears near the top of the screen
## Files to change
| File | Change |
|------|--------|
| `frontend/src/App.vue` | Add `position: sticky; top: 0; z-index: 100` to `.navbar`. Add `@media (max-width: 768px)` block: make `.navbar-links` horizontally scrollable, reduce `.navbar` padding |
| `frontend/src/views/LoginView.vue` | Add `@media (max-width: 768px)`: change `.login-wrapper` to `align-items: flex-start; padding-top: 2rem` |
| `frontend/src/views/BookReaderView.vue` | Add `@media (max-width: 768px)`: set `.chat-reader-split` to `flex-direction: column`, remove fixed width on `.reader-panel`, set `.reader-panel` to `width: 100%` |
## How to test
1. Open browser DevTools → toggle device toolbar → select "iPhone SE" (375 × 667)
2. Navigate to each page:
- **Any page**: navbar should be visible and sticky; links should be scrollable horizontally within the bar
- **Login** (`/`): login card should appear near the top, not vertically centered
- **Book reader** (`/books/:id`): content should fill the width, no horizontal page scrollbar
## No backend changes. No new dependencies. No new files.
@@ -0,0 +1,36 @@
# Research: Mobile-Responsive UI
**Feature**: 006-mobile-responsive-ui
**Date**: 2026-04-10
## Decision 1: Navbar Sticky vs Fixed
- **Decision**: Use `position: sticky; top: 0` on `.navbar` (currently unstyled for position)
- **Rationale**: `sticky` keeps the element in normal flow until it hits the scroll threshold, avoiding the need to add `padding-top` to `<main>` to compensate for a `fixed` element that's removed from flow. Simpler with fewer side effects.
- **Alternatives considered**: `position: fixed` — works but requires matching `padding-top` on `.main-content` to prevent content from hiding under the navbar. More coupled.
## Decision 2: Navbar Links — Horizontal Scroll vs Hamburger Menu
- **Decision**: Horizontal scroll on `.navbar-links` using `overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch`
- **Rationale**: User explicitly requested horizontal scroll ("button on the right need horizontal scroll to be accessed"). This is the simplest implementation — one CSS rule. A hamburger menu would require a toggle button, JavaScript state, and an open/close animation — unjustified complexity for a POC.
- **Alternatives considered**: Hamburger/drawer menu — rejected per user request and KISS principle.
## Decision 3: Book Reader Layout on Mobile
- **Decision**: Stack reader and chat panels vertically on mobile (≤ 768px) using `flex-direction: column` on `.chat-reader-split`, remove fixed width on `.reader-panel`
- **Rationale**: The current layout in BookReaderView.vue uses a flex row with `.reader-panel` at a fixed 420px. On a 375px screen this immediately overflows. Stacking vertically is the simplest fix — one media query, two CSS rules.
- **Alternatives considered**: Tabs (reader/chat toggle) — more complex, requires JS state; rejected per KISS.
## Decision 4: Login Card Top Alignment on Mobile
- **Decision**: On ≤ 768px, change `.login-wrapper` from `align-items: center` to `align-items: flex-start` and add `padding-top: 2rem`
- **Rationale**: The wrapper is a full-height flex column (`min-height: 100vh`). Centering vertically on mobile pushes the form to ~50% of viewport height. Switching to `flex-start` with a small top padding keeps the form near the top without changing desktop behavior.
- **Alternatives considered**: Removing `min-height: 100vh` — would break the background fill; rejected.
## Decision 5: Breakpoint
- **Decision**: Single breakpoint at `max-width: 768px`
- **Rationale**: Covers all common phone widths (320px430px) while leaving tablet/desktop untouched. Consistent with common mobile-first conventions. Adding a second breakpoint (e.g., 480px) would be premature for a POC.
- **Alternatives considered**: 480px only — too narrow, misses larger phones; 1024px — too wide, affects tablets unnecessarily.
## No NEEDS CLARIFICATION items remain.
+90
View File
@@ -0,0 +1,90 @@
# Feature Specification: Mobile-Responsive UI
**Feature Branch**: `006-mobile-responsive-ui`
**Created**: 2026-04-10
**Status**: Draft
**Input**: User description: "I want the frontend to be usable in phone (small screen): Fix the nav bar on top, button on the right need horizontal scroll to be accessed. When read the books (from library section) the book section should fit the screen (width). The login section should be closer to the top of the screen"
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Sticky Navbar with Scrollable Links (Priority: P1)
On a phone, the user opens the app. The navbar stays visible at the top as they scroll. All navigation links are reachable by horizontally scrolling the link bar — no links are clipped or hidden.
**Why this priority**: Without a usable nav, the user cannot navigate between sections at all. This is the most fundamental usability blocker on mobile.
**Independent Test**: Can be tested by opening the app on a ~375px-wide viewport (iPhone SE) and verifying the navbar is fixed/sticky, all nav links are scrollable horizontally, and the page content below scrolls independently.
**Acceptance Scenarios**:
1. **Given** a 375px-wide viewport, **When** the page loads, **Then** the navbar is visible and stuck to the top of the screen
2. **Given** a 375px-wide viewport, **When** there are multiple nav links that overflow horizontally, **Then** the links are accessible by swiping/scrolling the link bar without triggering page scroll
3. **Given** a 375px-wide viewport, **When** the user scrolls the page content down, **Then** the navbar remains fixed at the top
---
### User Story 2 - Book Reader Fits Screen Width (Priority: P2)
A user opens a book from the library on their phone. The book content area fits the full screen width — no horizontal page scroll is required to read the text.
**Why this priority**: The book reader is the primary value-delivery surface of the app. If it overflows the screen, the app is unusable for its main purpose.
**Independent Test**: Can be tested by navigating to the BookReaderView on a 375px viewport and verifying no horizontal scrollbar appears and text is readable without zooming.
**Acceptance Scenarios**:
1. **Given** a 375px-wide viewport, **When** the user opens a book, **Then** the book content panel fills the screen width without overflow
2. **Given** a 375px-wide viewport, **When** the reader and chat panels are side-by-side on desktop, **Then** on mobile they stack vertically (reader on top, chat below) or the layout adapts to prevent overflow
---
### User Story 3 - Login Form Near Top of Screen (Priority: P3)
A user lands on the login page on their phone. The login form appears near the top of the visible screen rather than perfectly centered vertically, so they do not need to scroll or zoom to reach the form.
**Why this priority**: Vertical centering that places a form at the mid-screen on desktop pushes it below the keyboard fold on mobile. Fixing this improves first-contact UX.
**Independent Test**: Can be tested by opening the login page on a 375px-wide, 667px-tall viewport (iPhone SE) and verifying the login card starts within the top 40% of the viewport.
**Acceptance Scenarios**:
1. **Given** a 375px-wide viewport, **When** the login page loads, **Then** the login card is positioned toward the top of the screen (not perfectly vertically centered)
2. **Given** a phone with virtual keyboard open, **When** the user taps an input field, **Then** the form remains accessible without scrolling behind the keyboard
---
### Edge Cases
- What happens when a very long book title wraps in the navbar or book card?
- How does the nav handle exactly the boundary between "fits" and "needs scroll" (e.g., 3 vs 4 links)?
- What if the user rotates to landscape on a phone (wider but shorter viewport)?
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The navbar MUST be sticky/fixed at the top of the viewport on all screen sizes
- **FR-002**: The navbar link area MUST be horizontally scrollable on small screens to access all navigation links
- **FR-003**: Horizontal page-level scroll MUST NOT occur due to navbar overflow
- **FR-004**: The book reader content area MUST NOT exceed the viewport width on screens ≤ 768px
- **FR-005**: The login card MUST be positioned toward the top of the viewport (not vertically centered) on screens ≤ 768px
### Key Entities *(include if feature involves data)*
- N/A — this is a pure frontend CSS/layout change with no data model impact
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: On a 375px-wide viewport, the navbar is visible and fixed; all links are reachable by horizontal scroll within the navbar
- **SC-002**: On a 375px-wide viewport, the BookReaderView shows content without a horizontal scrollbar at the page level
- **SC-003**: On a 375px-wide viewport, the login card top edge is within the top 150px of the viewport
## Assumptions
- Target breakpoint for "small screen / phone" is ≤ 768px width
- No hamburger menu is required — horizontal scroll on the link bar is acceptable per user request
- The existing Vue 3 + plain CSS stack is retained (no CSS framework added)
- Desktop layout is unchanged
- No backend changes required
+58
View File
@@ -0,0 +1,58 @@
# Tasks: Mobile-Responsive UI
**Input**: Design documents from `/specs/006-mobile-responsive-ui/`
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, quickstart.md ✅
No foundational phase needed — all changes are isolated CSS additions within existing SFCs.
---
## Phase 1: User Story 1 - Sticky Navbar with Scrollable Links (Priority: P1) 🎯 MVP
**Goal**: Navbar stays fixed at top; all links are reachable by horizontal scroll on mobile.
**Independent Test**: Open DevTools → iPhone SE (375px) → verify navbar is sticky and links scroll horizontally.
- [x] T001 [US1] Add `position: sticky; top: 0; z-index: 100` to `.navbar` in `frontend/src/App.vue`
- [x] T002 [US1] Add `@media (max-width: 768px)` block to `frontend/src/App.vue`: reduce navbar padding, make `.navbar-links` horizontally scrollable (`overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; flex-shrink: 0`)
**Checkpoint**: Navbar is sticky and links are horizontally scrollable on 375px viewport.
---
## Phase 2: User Story 2 - Book Reader Fits Screen Width (Priority: P2)
**Goal**: Book reader content fills phone screen width without page-level horizontal overflow.
**Independent Test**: Open DevTools → iPhone SE → navigate to a book → verify no horizontal scrollbar, content fills width.
- [x] T003 [US2] Add `@media (max-width: 768px)` block to `frontend/src/views/BookReaderView.vue`: set `.chat-reader-split` to `flex-direction: column`, set `.reader-panel` to `width: 100%; min-width: unset; max-width: 100%`
**Checkpoint**: BookReaderView stacks vertically with no horizontal overflow on 375px viewport.
---
## Phase 3: User Story 3 - Login Form Near Top of Screen (Priority: P3)
**Goal**: Login card appears near top of viewport on mobile instead of vertically centered.
**Independent Test**: Open DevTools → iPhone SE → navigate to login → verify card top is within top 150px of viewport.
- [x] T004 [US3] Add `@media (max-width: 768px)` block to `frontend/src/views/LoginView.vue`: change `.login-wrapper` to `align-items: flex-start; padding-top: 2rem`
**Checkpoint**: Login card appears near top of screen on 375px viewport.
---
## Phase 4: Polish
- [x] T005 [P] Run `npm run lint` in `frontend/` and fix any lint errors (ESLint not configured — pre-existing, unrelated to this feature)
- [x] T006 [P] Verify `.main-content` padding is reduced on mobile in `frontend/src/App.vue` (covered in T002 media query block)
---
## Dependencies & Execution Order
- T001 → T002 (same file, sequential)
- T003, T004 independent of each other and of T001/T002 (different files)
- T005, T006 after all implementation tasks