diff --git a/frontend/package.json b/frontend/package.json index 4a2a1954..917c16a9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "scripts": { "dev": "vite dev", "build": "vite build", + "postbuild": "node scripts/emit-askama-common.mjs", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json && eslint . && stylelint \"src/**/*.{css,svelte}\" && prettier --check .", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", diff --git a/frontend/scripts/emit-askama-common.mjs b/frontend/scripts/emit-askama-common.mjs new file mode 100644 index 00000000..6c390745 --- /dev/null +++ b/frontend/scripts/emit-askama-common.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +/* + * Emit `static-dist/askama-common.css` from the SvelteKit design-token + * source of truth (`src/lib/styles/base/variables.css`) plus the auth-page + * component styles (`src/lib/styles/askama-common.css`). + * + * WHY A POST-BUILD SCRIPT: + * Vite's `writeBundle` hooks fire mid-build, before + * `@sveltejs/adapter-static` copies the finalised site to + * `../static-dist/`. Anything written to that directory during + * Vite gets wiped when adapter-static runs. A `postbuild` script + * runs after everything the SvelteKit build owns, so its output + * survives — one predictable moment, no ordering trap. + * + * WHAT IT PRODUCES: + * A single stable-named CSS file at `static-dist/askama-common.css` + * containing: + * 1. Every design token declared in `base/variables.css` (:root, + * `light-dark(...)`, dark-mode blocks, etc.) + * 2. The auth-page component rules from `askama-common.css` + * Concatenated, prefixed with a "do not edit" header, written UTF-8. + * + * SINGLE SOURCE OF TRUTH: + * If a token changes in `variables.css`, one rebuild propagates it to + * both the SPA (via Svelte's normal build pipeline) AND the askama + * templates (via this file). Two consumers, one source. No manual + * sync step. + * + * SERVER SIDE: + * Server-rendered askama templates reference: + * + * The Rust web layer serves `static-dist/askama-common.css` at that + * URL through the same ServeDir the SPA uses. No route wiring needed. + */ + +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const stylesDir = resolve(__dirname, '../src/lib/styles'); +const outputFile = resolve(__dirname, '../../static-dist/askama-common.css'); + +const header = + '/* Auto-generated by frontend/scripts/emit-askama-common.mjs.\n' + + ' * Do NOT edit by hand — regenerated on every `npm run build`.\n' + + ' * Sources: src/lib/styles/base/variables.css (design tokens)\n' + + ' * src/lib/styles/askama-common.css (auth components)\n' + + ' */\n\n'; + +const tokens = readFileSync(resolve(stylesDir, 'base/variables.css'), 'utf8'); +const components = readFileSync(resolve(stylesDir, 'askama-common.css'), 'utf8'); + +mkdirSync(dirname(outputFile), { recursive: true }); +writeFileSync(outputFile, header + tokens + '\n' + components, 'utf8'); + +const bytes = Buffer.byteLength(header + tokens + '\n' + components, 'utf8'); +console.log(`emit-askama-common: wrote ${bytes} bytes → ${outputFile}`); diff --git a/frontend/src/lib/styles/askama-common.css b/frontend/src/lib/styles/askama-common.css new file mode 100644 index 00000000..4fb38f4b --- /dev/null +++ b/frontend/src/lib/styles/askama-common.css @@ -0,0 +1,221 @@ +/* + * askama-common.css — component styles for server-rendered askama pages. + * + * BUILD PIPELINE: + * `vite.config.ts` prepends `base/variables.css` at build time (the + * `emitAskamaCommon` plugin) and writes the result to + * `static-dist/askama-common.css`. That output is the single non-hashed + * URL every askama template references: + * + * + * + * SINGLE SOURCE OF TRUTH: + * Design tokens (`--color-*`, `--space-*`, `--radius-*`, `--text-*`, + * etc.) live in `base/variables.css`. This file only carries the + * component-level rules for the class vocabulary the askama templates + * actually use. Update tokens in ONE place; the build packages both. + * + * NO JAVASCRIPT: + * Dark-mode detection uses `light-dark()` + the `color-scheme` on + * :root (declared in `variables.css`). Askama pages are pre-auth flows + * (login / magic-link error) — no per-user override needed. Browsers + * older than Chrome 123 / Safari 17.5 / Firefox 120 fall back to the + * light values; the pages are readable either way. + * + * CLASS VOCABULARY (mirrors `grep 'class=' templates/**\/*.html`): + * .auth-container .auth-panel + * .auth-logo .auth-logo-icon .auth-logo-text + * .auth-title .auth-subtitle + * .auth-form .auth-button + * .auth-drive-option .auth-drive-name .auth-drive-badge + * .magic-note + */ + +/* ── Reset ─────────────────────────────────────────────────────── */ + +html, +body { + margin: 0; + padding: 0; + height: 100%; +} + +body { + font-family: var(--font-sans); + font-size: var(--text-base); + line-height: var(--leading-normal); + background: var(--color-bg-page); + color: var(--color-text); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* ── Layout shell ──────────────────────────────────────────────── */ + +.auth-container { + min-height: 100dvh; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-6); +} + +.auth-panel { + width: min(400px, 100%); + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-2xl); + box-shadow: var(--shadow-md); + padding: var(--space-8); +} + +/* ── Logo strip ───────────────────────────────────────────────── */ + +.auth-logo { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-6); +} + +.auth-logo-icon { + width: 40px; + height: 40px; + border-radius: var(--radius-lg); + background: var(--color-accent); + display: grid; + place-items: center; +} + +.auth-logo-icon svg { + width: 26px; + height: 26px; +} + +.auth-logo-text { + font-size: var(--text-lg); + font-weight: var(--weight-semibold); + color: var(--color-text-heading); +} + +/* ── Title strip ──────────────────────────────────────────────── */ + +.auth-title { + margin: 0 0 var(--space-2); + font-size: var(--text-xl); + font-weight: var(--weight-semibold); + color: var(--color-text-heading); + line-height: var(--leading-snug); +} + +.auth-subtitle { + margin: 0 0 var(--space-5); + color: var(--color-text-secondary); + font-size: var(--text-sm); +} + +.auth-subtitle a { + color: var(--color-accent); + text-decoration: none; +} + +.auth-subtitle a:hover { + text-decoration: underline; +} + +/* ── Form + button ────────────────────────────────────────────── */ + +.auth-form { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.auth-button { + display: inline-flex; + align-items: center; + justify-content: center; + margin-top: var(--space-3); + padding: var(--space-3) var(--space-4); + border: 0; + border-radius: var(--radius-lg); + background: var(--color-accent); + color: var(--color-on-accent); + font: inherit; + font-weight: var(--weight-semibold); + cursor: pointer; + transition: background 0.12s ease; +} + +.auth-button:hover { + background: var(--color-accent-hover); +} + +.auth-button:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +/* ── Drive picker radios ──────────────────────────────────────── */ + +.auth-drive-option { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + cursor: pointer; + transition: + border-color 0.12s ease, + background 0.12s ease; +} + +.auth-drive-option:hover { + border-color: var(--color-border-medium); + background: var(--color-bg-hover); +} + +.auth-drive-option:has(input:checked) { + border-color: var(--color-accent); + background: var(--color-accent-ring); +} + +.auth-drive-option input[type='radio'] { + accent-color: var(--color-accent); + margin: 0; +} + +.auth-drive-name { + flex: 1; + font-weight: var(--weight-medium); +} + +.auth-drive-badge { + padding: 2px var(--space-2); + border-radius: 999px; + background: var(--color-accent); + color: var(--color-on-accent); + font-size: 0.6875rem; + font-weight: var(--weight-semibold); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +/* ── Magic-link note block ────────────────────────────────────── */ + +.magic-note { + margin-top: var(--space-5); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + background: var(--color-bg-input); + border: 1px solid var(--color-border); + color: var(--color-text-secondary); + font-size: var(--text-sm); +} diff --git a/frontend/src/routes/nextcloud/error/+page.svelte b/frontend/src/routes/nextcloud/error/+page.svelte index 5bb7e153..60b9f183 100644 --- a/frontend/src/routes/nextcloud/error/+page.svelte +++ b/frontend/src/routes/nextcloud/error/+page.svelte @@ -67,7 +67,7 @@ {view.title} · OxiCloud
- +

{view.title}

{view.message}