diff --git a/frontend/design.md b/frontend/design.md new file mode 100644 index 0000000..408ad0c --- /dev/null +++ b/frontend/design.md @@ -0,0 +1,109 @@ +# Design System Document: Language Learning App + +## 1. Overview & Creative North Star + +**Creative North Star: The Digital Archivist** + +This design system rejects the frantic, "attention-economy" aesthetic of modern web apps. Instead, it draws inspiration from high-end printed journals and architectural minimalism. The goal is to create a "Digital Paper" experience that honours the act of reading. + +We break the standard "SaaS dashboard" template by using intentional asymmetry and high-contrast typographic scales. + +Layouts with multiple sources of information should feel like a well-composed magazine spread: large, sweeping areas of `surface` punctuated by tight, authoritative `label` groupings. We do not fill space; we curate it. + +Layouts which are focused on content, e.g. reading or listening, should feel focused and intentional. + +--- + +## 2. Colors: The Palette of Focus + +Our palette is rooted in organic, desaturated tones that reduce eye strain and promote deep work. + +- **Primary (`#516356`)**: A muted Forest Green. This is our singular "Action" voice. Use it sparingly to guide the eye, not to shout. + +- **The "No-Line" Rule**: You are strictly prohibited from using 1px solid borders to define sections. Layout boundaries are created exclusively through background shifts. For example, a `surface-container-low` (`#f4f4ef`) sidebar sitting against a `surface` (`#faf9f5`) main body. + +- **Nesting & Layers**: Treat the UI as stacked sheets of fine vellum. Use `surface-container-lowest` (`#ffffff`) for the most elevated elements (like an active reading pane) and `surface-dim` (`#d6dcd2`) for recessed utility areas. + +- **The "Glass & Gradient" Rule**: To prevent the UI from feeling "dead," use subtle radial gradients on hero backgrounds (transitioning from `surface` to `surface-container-low`). For floating navigation, apply `surface` colors at 80% opacity with a `24px` backdrop-blur to create a "frosted glass" effect that lets the content bleed through softly. + +--- + +## 3. Typography: The Editorial Engine + +Typography is the primary visual asset. We use a sophisticated pairing of **Archivo** (Sans-serif) for functional UI and **Newsreader** (Serif) for the reading experience. + +- **Display & Headline (Archivo)**: These are your "Wayfinders." Use `display-lg` (3.5rem) with tight letter-spacing for article titles to create an authoritative, architectural feel. + +- **Body (Newsreader)**: This is the soul of the system. `body-lg` (1rem) is the standard for long-form reading. It must have a line-height of at least 1.6 to ensure the "Digital Paper" feel. + +- **Labels (Inter)**: Use `label-md` in all-caps with a `0.05rem` letter-spacing for metadata (e.g., "READING TIME," "DATE SAVED"). This creates a stark, functional contrast to the fluid Serif body text. + +--- + +## 4. Elevation & Depth: Tonal Layering + +We do not use shadows to mimic light; we use tone to mimic physical presence. + +- **The Layering Principle**: If a card needs to stand out, do not add a shadow. Instead, change its background to `surface-container-lowest` and place it on a `surface-container` background. The 2-step tonal shift provides all the clarity needed. + +- **Ambient Shadows**: If a component _must_ float (like a mobile action button), use a "Tonal Shadow": `color: on-surface` at 5% opacity, with a `32px` blur and `8px` Y-offset. It should look like a soft smudge of graphite, not a digital drop shadow. + +- **The "Ghost Border"**: If a button needs a stroke for accessibility, use `outline-variant` (`#afb3ac`) at 20% opacity. If you can see the line clearly, it’s too dark. + +--- + +## 5. Components: Functional Minimalism + +### Buttons + +- **Primary**: Background `primary` (`#516356`), text `on_primary` (`#e9fded`). Corner radius `md` (`0.375rem`). No border. + +- **Secondary**: Background `secondary_container` (`#dde4de`), text `on_secondary_container`. + +- **Tertiary/Ghost**: No background. Text `primary`. Use only `label-md` typography. + +### Input Fields + +- **Style**: Forgo the "box" look. Use a `surface-container-high` background with a bottom-only "Ghost Border." + +- **States**: On focus, the bottom border transitions to `primary` (`#516356`) with a width of 2px. + +### Cards & Lists + +- **The "No-Divider" Rule**: Forbid the use of horizontal rules (` + +`). Separate list items using the`3`(1rem) or`4` (1.4rem) spacing tokens. + +- **Structure**: Group items using subtle background shifts. An active list item should move from `surface` to `surface-container-lowest`. + +### The "Reading Progress" Component (System Specific) + +- A thin, 2px bar using `primary` that sits at the very top of the viewport. As the user scrolls, it grows. No container or background for the bar—it should look like a thread laying on the paper. + +--- + +## 6. Do’s and Don’ts + +### Do + +- **Do** use asymmetrical margins. For example, give the main text column a 20% left margin and a 30% right margin to create a sophisticated, editorial layout. + +- **Do** use design tokens and CSS Custom Properties (variables) + +- **Do** extract re-usable components into `app.css` so that styles can be used in multiple page. + +- **Do** prioritize the `16` (5.5rem) spacing token for top-of-page "breathability." + +- **Do** use `primary_container` for subtle highlighting of text within an article. + +- **Do** create responsive layouts, preferably using @container queries. + +### Don’t + +- **Don’t** use pure black `#000000`. Use `on_surface` (`#2f342e`) for all high-contrast text. + +- **Don’t** use icons unless absolutely necessary. Prefer text labels (`label-md`) to ensure the "Digital Paper" aesthetic remains unbroken. + +- **Don’t** use "Pop-up" modals that cover the whole screen. Use "Slide-over" panels that use the `surface-container-highest` tone to maintain the sense of a physical stack. + +- **Don't** Over-use utility classes. diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..1cd2ec0 --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,394 @@ +/* ========================================================================== + Design System: The Editorial Stillness + ========================================================================== */ + +/* -------------------------------------------------------------------------- + Fonts + -------------------------------------------------------------------------- */ + +@font-face { + font-family: archivo; /* set name */ + src: url('/fonts/Archivo-Medium.woff2'); /* url of the font */ + font-weight: 500; +} + +@font-face { + font-family: archivo; /* set name */ + src: url('/fonts/Archivo-Regular.woff2'); /* url of the font */ + font-weight: normal; +} + + +/* -------------------------------------------------------------------------- + Design Tokens + -------------------------------------------------------------------------- */ + +:root { + + /* --- Color: Primary --- */ + --color-primary: #516356; + --color-on-primary: #e9fded; + --color-primary-container: #c8d8c8; + --color-on-primary-container: #2f342e; + + /* --- Color: Secondary --- */ + --color-secondary-container: #dde4de; + --color-on-secondary-container: #2f342e; + + /* --- Color: Surface Hierarchy (light → dark = elevated → recessed) --- */ + --color-surface-container-lowest: #ffffff; /* most elevated */ + --color-surface-container-low: #f4f4ef; + --color-surface: #faf9f5; /* base */ + --color-surface-container: #eeede9; + --color-surface-container-high: #e8e8e3; + --color-surface-container-highest:#e2e1dd; + --color-surface-dim: #d6dcd2; /* recessed utility */ + + /* --- Color: On-Surface --- */ + --color-on-surface: #2f342e; /* replaces pure black */ + --color-on-surface-variant: #5c605b; + + /* --- Color: Outline --- */ + --color-outline: #8c908b; + --color-outline-variant: #afb3ac; /* use at 20% opacity as "ghost border" */ + + /* --- Typography: Font Families --- */ + --font-display: 'archivo', sans-serif; + --font-body: 'Newsreader', serif; + --font-label: 'Inter', sans-serif; + + /* --- Typography: Scale --- */ + --text-display-lg: 3.5rem; /* article titles */ + --text-display-md: 2.75rem; + --text-display-sm: 2.25rem; + --text-headline-lg: 1.875rem; + --text-headline-md: 1.5rem; + --text-headline-sm: 1.25rem; + --text-title-lg: 1.125rem; + --text-title-md: 1rem; + --text-body-lg: 1rem; /* long-form reading standard */ + --text-body-md: 0.9375rem; + --text-body-sm: 0.875rem; + --text-label-lg: 0.875rem; + --text-label-md: 0.75rem; /* metadata, all-caps */ + --text-label-sm: 0.6875rem; + + /* --- Typography: Weights --- */ + --weight-light: 300; + --weight-regular: 400; + --weight-medium: 500; + --weight-semibold: 600; + --weight-bold: 700; + + /* --- Typography: Line Height --- */ + --leading-tight: 1.2; + --leading-snug: 1.375; + --leading-normal: 1.5; + --leading-relaxed: 1.6; /* "Digital Paper" body text minimum */ + --leading-loose: 1.8; + + /* --- Typography: Letter Spacing --- */ + --tracking-tight: -0.025em; + --tracking-normal: 0em; + --tracking-wide: 0.05rem; /* label-md metadata */ + --tracking-wider: 0.08rem; + + /* --- Spacing Scale (base-4) --- */ + --space-1: 0.25rem; /* 4px */ + --space-2: 0.5rem; /* 8px */ + --space-3: 1rem; /* 16px — list item separation */ + --space-4: 1.4rem; /* ~22px — list item group separation */ + --space-5: 1.75rem; /* 28px */ + --space-6: 2rem; /* 32px */ + --space-8: 3rem; /* 48px */ + --space-10: 4rem; /* 64px */ + --space-12: 4.5rem; /* 72px */ + --space-16: 5.5rem; /* 88px — top-of-page breathability */ + + /* --- Border Radius --- */ + --radius-xs: 0.125rem; + --radius-sm: 0.25rem; + --radius-md: 0.375rem; /* primary button */ + --radius-lg: 0.75rem; + --radius-xl: 1.25rem; + --radius-full: 9999px; + + /* --- Elevation: Ambient "Tonal Shadow" --- */ + --shadow-tonal-sm: 0 4px 16px 0 color-mix(in srgb, var(--color-on-surface) 5%, transparent); + --shadow-tonal-md: 0 8px 32px 0 color-mix(in srgb, var(--color-on-surface) 5%, transparent); + + /* --- Motion --- */ + --duration-fast: 100ms; + --duration-normal: 200ms; + --duration-slow: 400ms; + --ease-standard: cubic-bezier(0.2, 0, 0, 1); + + /* --- Glass / Frosted Effect --- */ + --glass-bg: color-mix(in srgb, var(--color-surface) 80%, transparent); + --glass-blur: 24px; +} + +/* -------------------------------------------------------------------------- + Reset & Base + -------------------------------------------------------------------------- */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; +} + +body { + font-family: var(--font-body); + font-size: var(--text-body-lg); + font-weight: var(--weight-regular); + line-height: var(--leading-relaxed); + color: var(--color-on-surface); + background-color: var(--color-surface); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* -------------------------------------------------------------------------- + Typography Utilities + -------------------------------------------------------------------------- */ + +.display-lg { + font-family: var(--font-display); + font-size: var(--text-display-lg); + font-weight: var(--weight-bold); + line-height: var(--leading-tight); + letter-spacing: var(--tracking-tight); +} + +.headline-md { + font-family: var(--font-display); + font-size: var(--text-headline-md); + font-weight: var(--weight-semibold); + line-height: var(--leading-snug); +} + +.label-md { + font-family: var(--font-label); + font-size: var(--text-label-md); + font-weight: var(--weight-medium); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; +} + +.link { + color: var(--color-primary); + text-decoration: none; + font-weight: var(--weight-medium); + transition: opacity var(--duration-fast) var(--ease-standard); +} + +.link:hover { + opacity: 0.7; +} + +/* -------------------------------------------------------------------------- + Component: Form + -------------------------------------------------------------------------- */ + +.form { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.form-header { + margin-bottom: var(--space-6); +} + +.form-eyebrow { + font-family: var(--font-label); + font-size: var(--text-label-md); + font-weight: var(--weight-medium); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + color: var(--color-on-surface-variant); + margin-bottom: var(--space-1); +} + +.form-title { + font-family: var(--font-display); + font-size: var(--text-headline-lg); + font-weight: var(--weight-semibold); + line-height: var(--leading-snug); + color: var(--color-on-surface); + } + +.form-footer { + display: flex; + align-items: center; + gap: var(--space-2); + margin-top: var(--space-6); + font-family: var(--font-label); + font-size: var(--text-body-sm); + color: var(--color-on-surface-variant); +} + +/* -------------------------------------------------------------------------- + Component: Button + -------------------------------------------------------------------------- */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + border: none; + border-radius: var(--radius-md); + font-family: var(--font-display); + font-size: var(--text-label-lg); + font-weight: var(--weight-medium); + letter-spacing: var(--tracking-wide); + cursor: pointer; + text-decoration: none; + transition: + opacity var(--duration-normal) var(--ease-standard), + background-color var(--duration-normal) var(--ease-standard); +} + +.btn-primary { + background-color: var(--color-primary); + color: var(--color-on-primary); +} + +.btn-primary:hover { + opacity: 0.88; +} + +.btn-secondary { + background-color: var(--color-secondary-container); + color: var(--color-on-secondary-container); +} + +.btn-ghost { + background: none; + color: var(--color-primary); + padding-inline: var(--space-2); +} + +/* -------------------------------------------------------------------------- + Component: Input + -------------------------------------------------------------------------- */ + +.field { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.field-label { + font-family: var(--font-label); + font-size: var(--text-label-md); + font-weight: var(--weight-medium); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + color: var(--color-on-surface-variant); +} + +.field-input { + background-color: var(--color-surface-container-high); + color: var(--color-on-surface); + border: none; + border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent); + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + padding: var(--space-2) var(--space-3); + font-family: var(--font-body); + font-size: var(--text-body-lg); + line-height: var(--leading-normal); + outline: none; + width: 100%; + transition: border-color var(--duration-fast) var(--ease-standard); +} + +.field-input::placeholder { + color: var(--color-outline-variant); +} + +.field-input:focus { + border-bottom: 2px solid var(--color-primary); +} + +.field-select { + background-color: var(--color-surface-container-high); + color: var(--color-on-surface); + border: none; + border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent); + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + padding: var(--space-2) var(--space-8) var(--space-2) var(--space-3); + font-family: var(--font-body); + font-size: var(--text-body-lg); + line-height: var(--leading-normal); + outline: none; + width: 100%; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%235c605b' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right var(--space-3) center; + transition: border-color var(--duration-fast) var(--ease-standard); +} + +.field-select:focus { + border-bottom: 2px solid var(--color-primary); +} + +.field-textarea { + background-color: var(--color-surface-container-high); + color: var(--color-on-surface); + border: none; + border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent); + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + padding: var(--space-2) var(--space-3); + font-family: var(--font-body); + font-size: var(--text-body-lg); + line-height: var(--leading-relaxed); + outline: none; + width: 100%; + min-height: 14rem; + resize: vertical; + transition: border-color var(--duration-fast) var(--ease-standard); +} + +.field-textarea::placeholder { + color: var(--color-outline-variant); +} + +.field-textarea:focus { + border-bottom: 2px solid var(--color-primary); +} + +.field-hint { + font-family: var(--font-label); + font-size: var(--text-label-md); + color: var(--color-on-surface-variant); + margin-top: var(--space-1); +} + +/* -------------------------------------------------------------------------- + Component: Alert + -------------------------------------------------------------------------- */ + +.alert { + padding: var(--space-3); + border-radius: var(--radius-md); + font-family: var(--font-label); + font-size: var(--text-body-sm); +} + +.alert-error { + background-color: color-mix(in srgb, #b3261e 10%, var(--color-surface)); + color: #b3261e; + border-left: 3px solid #b3261e; +} diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 94547f2..c1c9fde 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,13 +1,42 @@ -import type { Handle } from '@sveltejs/kit'; +import { createHmac, timingSafeEqual } from 'crypto'; +import { redirect, type Handle } from '@sveltejs/kit'; +import { PRIVATE_JWT_SECRET } from '$env/static/private'; import { client } from './lib/apiClient.ts'; +function verifyJwt(token: string, secret: string): boolean { + try { + const parts = token.split('.'); + if (parts.length !== 3) return false; + const [header, payload, sig] = parts; + + const expected = createHmac('sha256', secret) + .update(`${header}.${payload}`) + .digest('base64url'); + + const expectedBuf = Buffer.from(expected); + const sigBuf = Buffer.from(sig); + if (expectedBuf.length !== sigBuf.length) return false; + if (!timingSafeEqual(expectedBuf, sigBuf)) return false; + + const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); + if (claims.exp && claims.exp < Date.now() / 1000) return false; + + return true; + } catch { + return false; + } +} + export const handle: Handle = async ({ event, resolve }) => { event.locals.apiClient = client; - const authToken = event.cookies.get('auth_token'); - event.locals.authToken = authToken || null; + const rawToken = event.cookies.get('auth_token'); + const isValid = rawToken ? verifyJwt(rawToken, PRIVATE_JWT_SECRET) : false; + event.locals.authToken = isValid ? rawToken! : null; - const response = resolve(event); + if (event.url.pathname.startsWith('/app') && !isValid) { + return redirect(307, '/login'); + } - return response; + return resolve(event); }; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 9cebde5..96f5db4 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,5 +1,6 @@ diff --git a/frontend/src/routes/app/+page.svelte b/frontend/src/routes/app/+page.svelte new file mode 100644 index 0000000..59895e7 --- /dev/null +++ b/frontend/src/routes/app/+page.svelte @@ -0,0 +1,6 @@ +
Generation
+History
+{job.id}
+This job is {job.status}. Refresh to check for updates.
+You are logged in
-{/if} - +Language Learnnig App
+