diff --git a/docs/logboek.md b/docs/logboek.md index e782cdb..fc98d85 100644 --- a/docs/logboek.md +++ b/docs/logboek.md @@ -1,6 +1,6 @@ # Logboek -Bijgehouden tijdens de bouw. Per blok: wat ik deed, waarom, wat ik heb laten +Bijgehouden tijdens de bouw. Per stap: wat ik deed, waarom, wat ik heb laten vallen. ## Dag 1 - korte sessie in de avond ### Opzet @@ -108,7 +108,7 @@ Wat ik heb laten vallen of uitgesteld: - Refinements doen geen nieuwe search-ronde: turn 2 herordent de bestaande geverifieerde pool. Sneller en consistent, maar een refinement haalt - geen nieuwe nummers op. Dit is een bewuste afweging, mocht er tijd over + geen nieuwe nummers op. Dit is een bewuste afweging, mocht er tijd over zijn is dit 1 van de uitbreidingen die ik op zou kunnen pakken. ### Streaming API @@ -120,7 +120,7 @@ Wat ik deed: client-disconnect stopt de stream en de grounding. - Playlist-endpoint dat schrijft met een vaste naam-prefix, zodat aangemaakte playlists later in bulk op te ruimen zijn (ik gebruik mijn - persoonlijke spotify account/abbonement voor de demo).. + persoonlijke spotify account/abonnement voor de demo). - Request-timing middleware met per-request counters (Spotify calls, cache hits, LLM tokens) in gestructureerde logs. - Seed session voor gehost draaien: in live mode installeert een refresh @@ -149,7 +149,7 @@ Waarom: - De quota is per developer-account en per dag; bulk-werk zoals fixtures opnemen moet dus gebudgetteerd, en het cache-ontwerp (naam-naar-id, - smaakprofiel) is noodzakelijk om binnen de quota te blijven. + smaakprofiel) is noodzakelijk om binnen de quota te blijven. ### Meer live-test fixes @@ -179,3 +179,25 @@ Waarom: foutmelding die echte resultaten verbergt is dat niet. Bij "verras me met iets nieuws" vragen valt een groot deel van de kandidaten af bij de verificatie, dus juist daar telt dit. Een weg om dit potentieel te voorkomen/verbeteren in de toekomst is het verbeteren van de prompt, of de LLM met behulp van een derde partij API die zonder de Spotify API te overbelasten gebruikt kan worden om echte nummers te vinden. + +### Frontend: fundament + +Voor de frontend heb ik gedurende de eerste paar uur op de achtergrond Open Design (een +design-tool) laten lopen. Ik ben geen visual designer, maar op deze manier lukt het mij om met minimale +effort een geschikt UI bouwpakket te ontwikkelen. De oplevering heeft zo'n vorm dat ik het +gemakkelijk door een cli agent kan laten uitbouwen in Vue. + +Wat ik deed: + +- Design tokens uit het ontwerppakket (donker thema, typografie, spacing, + motion) als basis voor alle componenten. +- Typed stream client: fetch met een ReadableStream, regelgebaseerde + NDJSON-parsing naar de contract-events, en AbortController-cancellation + zodra een nieuwe vraag start. +- API client voor sessie-status, logout en playlist-save. + +Waarom: + +- De frontend is de tweede consument van het bevroren contract: elke event + wordt tegen de TypeScript-union gevalideerd in plaats van los geparst; + wat niet valideert is een transport failure, geen gok. diff --git a/frontend/src/composables/useApi.ts b/frontend/src/composables/useApi.ts new file mode 100644 index 0000000..45618ef --- /dev/null +++ b/frontend/src/composables/useApi.ts @@ -0,0 +1,127 @@ +import { ref } from 'vue' +import type { AuthState, HealthState } from '../lib/models' +import type { CurrentUser, PlaylistCreateRequest, PlaylistCreateResponse } from '../lib/types' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function parseCurrentUser(value: unknown): CurrentUser { + if (!isRecord(value) || typeof value.display_name !== 'string') { + throw new Error('Invalid current user response.') + } + return { display_name: value.display_name } +} + +function parsePlaylistResponse(value: unknown): PlaylistCreateResponse { + if (!isRecord(value) || typeof value.url !== 'string') { + throw new Error('Invalid playlist response.') + } + return { url: value.url } +} + +function loginFailed(): boolean { + const url = new URL(window.location.href) + const failed = url.searchParams.get('login') === 'error' + if (failed) { + url.searchParams.delete('login') + window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`) + } + return failed +} + +/** Manage authentication, service mode, and playlist API operations. */ +export function useApi() { + const auth = ref({ status: 'checking', user: null, message: null }) + const health = ref({ status: 'checking', mode: null, message: null }) + + async function loadAuth(hadLoginError: boolean): Promise { + try { + const response = await fetch('/api/auth/me', { credentials: 'same-origin' }) + if (response.status === 401) { + auth.value = hadLoginError + ? { + status: 'failed', + user: null, + message: 'Spotify login did not complete. Please try again.', + } + : { status: 'anonymous', user: null, message: null } + return + } + if (!response.ok) throw new Error('Authentication check failed.') + auth.value = { + status: 'authenticated', + user: parseCurrentUser(await response.json()), + message: null, + } + } catch { + auth.value = { + status: 'failed', + user: null, + message: hadLoginError + ? 'Spotify login did not complete. Please try again.' + : 'Spotify connection status is unavailable.', + } + } + } + + async function loadHealth(): Promise { + try { + const response = await fetch('/api/health', { credentials: 'same-origin' }) + if (!response.ok) throw new Error('Health check failed.') + const body: unknown = await response.json() + if ( + !isRecord(body) || + body.status !== 'ok' || + (body.mode !== 'demo' && body.mode !== 'live') + ) { + throw new Error('Invalid health response.') + } + health.value = { status: 'ready', mode: body.mode, message: null } + } catch { + health.value = { + status: 'failed', + mode: null, + message: 'Service mode is unavailable.', + } + } + } + + async function bootstrap(): Promise { + const hadLoginError = loginFailed() + await Promise.all([loadAuth(hadLoginError), loadHealth()]) + } + + async function logout(): Promise { + if (auth.value.status !== 'authenticated') return + const user = auth.value.user + auth.value = { status: 'logging_out', user, message: null } + try { + const response = await fetch('/api/auth/logout', { + method: 'POST', + credentials: 'same-origin', + }) + if (!response.ok) throw new Error('Logout failed.') + auth.value = { status: 'anonymous', user: null, message: null } + } catch { + auth.value = { + status: 'failed', + user: null, + message: 'Spotify logout failed. Refresh before trying again.', + } + } + } + + async function createPlaylist(request: PlaylistCreateRequest): Promise { + const response = await fetch('/api/playlists', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) + if (!response.ok) throw new Error(`Playlist creation returned ${response.status}.`) + return parsePlaylistResponse(await response.json()) + } + + return { auth, health, bootstrap, logout, createPlaylist } +} diff --git a/frontend/src/composables/useChatStream.ts b/frontend/src/composables/useChatStream.ts new file mode 100644 index 0000000..ffeaaad --- /dev/null +++ b/frontend/src/composables/useChatStream.ts @@ -0,0 +1,265 @@ +import { computed, onUnmounted, ref } from 'vue' +import type { AssistantTurn, ChatTurn, EventLogEntry, TransportFailure } from '../lib/models' +import { EMPTY_PLAYLIST_STATE } from '../lib/models' +import { + isAbortError, + streamRecommendations, + StreamTransportError, +} from '../lib/recommendationStream' +import type { + HistoryTurn, + PlaylistCreateRequest, + PlaylistCreateResponse, + PriorRecommendation, + RecommendationRequest, + StreamEvent, +} from '../lib/types' + +const HISTORY_LIMIT = 12 +const PRIOR_RECOMMENDATION_LIMIT = 50 +const EVENT_LOG_LIMIT = 100 + +type PlaylistCreator = (request: PlaylistCreateRequest) => Promise + +function assistantContent(turn: AssistantTurn): string { + const trackLines = turn.tracks.map( + (event) => `${event.rank}. ${event.track.title} by ${event.track.artists.join(', ')}`, + ) + const content = [turn.intentSummary, ...trackLines].filter(Boolean).join('\n') + return content.slice(0, 2000) +} + +function buildHistory(turns: ChatTurn[]): HistoryTurn[] { + return turns + .map((turn): HistoryTurn | null => { + if (turn.role === 'user') return { role: 'user', content: turn.text } + const content = assistantContent(turn) + return content ? { role: 'assistant', content } : null + }) + .filter((turn): turn is HistoryTurn => turn !== null) + .slice(-HISTORY_LIMIT) +} + +function buildPriorRecommendations(turns: ChatTurn[]): PriorRecommendation[] { + const latest = [...turns] + .reverse() + .find((turn): turn is AssistantTurn => turn.role === 'assistant' && turn.status === 'done') + if (!latest) return [] + return latest.tracks.slice(0, PRIOR_RECOMMENDATION_LIMIT).map((event) => ({ + rank: event.rank, + track_id: event.track.id, + title: event.track.title, + artists: event.track.artists, + })) +} + +function reduceEvent(turn: AssistantTurn, event: StreamEvent): AssistantTurn { + switch (event.type) { + case 'metadata': + return { + ...turn, + requestId: event.request_id, + intentSummary: event.intent_summary, + candidateCount: event.candidate_count, + } + case 'track': + return { ...turn, tracks: [...turn.tracks, event] } + case 'warning': + return { ...turn, warnings: [...turn.warnings, event] } + case 'error': + return { ...turn, status: 'error', error: event } + case 'done': + if (event.track_count !== turn.tracks.length) { + return { + ...turn, + status: 'error', + completion: event, + transportFailure: { + kind: 'protocol', + message: 'The final track count did not match the streamed results.', + }, + } + } + return { ...turn, status: 'done', completion: event } + } +} + +function playlistName(query: string): string { + return `[discovery-by-llm] ${query}`.slice(0, 100).trim() +} + +function createTurnId(): string { + return typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +/** Reduce recommendation streams into conversation view state. */ +export function useChatStream(createPlaylist: PlaylistCreator) { + const turns = ref([]) + const eventLog = ref([]) + let activeController: AbortController | null = null + let activeTurnId: string | null = null + + const isStreaming = computed(() => + turns.value.some((turn) => turn.role === 'assistant' && turn.status === 'streaming'), + ) + const turnCount = computed(() => turns.value.filter((turn) => turn.role === 'user').length) + const isEmpty = computed(() => turns.value.length === 0) + const latestAssistant = computed(() => + [...turns.value].reverse().find((turn): turn is AssistantTurn => turn.role === 'assistant'), + ) + + function updateAssistant(id: string, update: (turn: AssistantTurn) => AssistantTurn): void { + turns.value = turns.value.map((turn) => + turn.role === 'assistant' && turn.id === id ? update(turn) : turn, + ) + } + + function addLog(type: string, detail: string): void { + eventLog.value = [ + ...eventLog.value, + { timestamp: new Date().toLocaleTimeString(), type, detail }, + ].slice(-EVENT_LOG_LIMIT) + } + + function cancelActive(shouldMarkTurn: boolean): void { + if (!activeController) return + activeController.abort() + if (shouldMarkTurn && activeTurnId) { + const failure: TransportFailure = { + kind: 'cancelled', + message: 'This request was replaced by a newer request.', + } + updateAssistant(activeTurnId, (turn) => ({ + ...turn, + status: 'error', + transportFailure: failure, + })) + } + activeController = null + activeTurnId = null + } + + async function send(query: string): Promise { + const text = query.trim() + if (!text) return + cancelActive(true) + + const request: RecommendationRequest = { + schema_version: 1, + query: text, + history: buildHistory(turns.value), + prior_recommendations: buildPriorRecommendations(turns.value), + } + const userTurn: ChatTurn = { id: createTurnId(), role: 'user', text } + const assistantTurn: AssistantTurn = { + id: createTurnId(), + role: 'assistant', + query: text, + status: 'streaming', + requestId: null, + intentSummary: '', + candidateCount: null, + tracks: [], + warnings: [], + error: null, + transportFailure: null, + completion: null, + playlist: { ...EMPTY_PLAYLIST_STATE }, + } + turns.value = [...turns.value, userTurn, assistantTurn] + + const controller = new AbortController() + activeController = controller + activeTurnId = assistantTurn.id + + try { + await streamRecommendations(request, controller.signal, (event) => { + updateAssistant(assistantTurn.id, (turn) => reduceEvent(turn, event)) + addLog(event.type, JSON.stringify(event)) + }) + } catch (error) { + if (isAbortError(error, controller.signal)) return + const failure: TransportFailure = + error instanceof StreamTransportError + ? { kind: error.kind, message: error.message } + : { kind: 'network', message: 'The recommendation request failed unexpectedly.' } + updateAssistant(assistantTurn.id, (turn) => ({ + ...turn, + status: 'error', + transportFailure: failure, + })) + addLog('transport', `${failure.kind}: ${failure.message}`) + } finally { + if (activeController === controller) { + activeController = null + activeTurnId = null + } + } + } + + async function savePlaylist(turnId: string): Promise { + const turn = turns.value.find( + (candidate): candidate is AssistantTurn => + candidate.role === 'assistant' && candidate.id === turnId, + ) + if ( + !turn || + turn.status !== 'done' || + turn.tracks.length === 0 || + turn.playlist.status === 'saving' || + turn.playlist.status === 'saved' + ) { + return + } + + const name = playlistName(turn.query) + updateAssistant(turnId, (current) => ({ + ...current, + playlist: { status: 'saving', name, url: null, message: null }, + })) + + try { + const response = await createPlaylist({ + schema_version: 1, + name, + track_uris: turn.tracks.map((event) => event.track.uri), + }) + updateAssistant(turnId, (current) => ({ + ...current, + playlist: { status: 'saved', name, url: response.url, message: null }, + })) + } catch { + updateAssistant(turnId, (current) => ({ + ...current, + playlist: { + status: 'error', + name, + url: null, + message: 'Spotify could not create the playlist. Nothing was retried.', + }, + })) + } + } + + function reset(): void { + cancelActive(false) + turns.value = [] + eventLog.value = [] + } + + onUnmounted(() => cancelActive(false)) + + return { + turns, + eventLog, + isStreaming, + turnCount, + isEmpty, + latestAssistant, + send, + savePlaylist, + reset, + } +} diff --git a/frontend/src/lib/models.ts b/frontend/src/lib/models.ts new file mode 100644 index 0000000..9711e73 --- /dev/null +++ b/frontend/src/lib/models.ts @@ -0,0 +1,67 @@ +import type { CurrentUser, DoneEvent, ErrorEvent, TrackEvent, WarningEvent } from './types' + +export type AssistantStatus = 'streaming' | 'done' | 'error' +export type AppMode = 'demo' | 'live' +export type TransportFailureKind = + 'cancelled' | 'http' | 'network' | 'parse' | 'protocol' | 'unexpected_eof' + +export interface TransportFailure { + kind: TransportFailureKind + message: string +} + +export interface PlaylistState { + status: 'idle' | 'saving' | 'saved' | 'error' + name: string | null + url: string | null + message: string | null +} + +export interface UserTurn { + id: string + role: 'user' + text: string +} + +export interface AssistantTurn { + id: string + role: 'assistant' + query: string + status: AssistantStatus + requestId: string | null + intentSummary: string + candidateCount: number | null + tracks: TrackEvent[] + warnings: WarningEvent[] + error: ErrorEvent | null + transportFailure: TransportFailure | null + completion: DoneEvent | null + playlist: PlaylistState +} + +export type ChatTurn = UserTurn | AssistantTurn + +export type AuthState = + | { status: 'checking'; user: null; message: null } + | { status: 'anonymous'; user: null; message: null } + | { status: 'authenticated'; user: CurrentUser; message: null } + | { status: 'logging_out'; user: CurrentUser; message: null } + | { status: 'failed'; user: null; message: string } + +export type HealthState = + | { status: 'checking'; mode: null; message: null } + | { status: 'ready'; mode: AppMode; message: null } + | { status: 'failed'; mode: null; message: string } + +export interface EventLogEntry { + timestamp: string + type: string + detail: string +} + +export const EMPTY_PLAYLIST_STATE: PlaylistState = { + status: 'idle', + name: null, + url: null, + message: null, +} diff --git a/frontend/src/lib/recommendationStream.ts b/frontend/src/lib/recommendationStream.ts new file mode 100644 index 0000000..982fe45 --- /dev/null +++ b/frontend/src/lib/recommendationStream.ts @@ -0,0 +1,114 @@ +import type { RecommendationRequest, StreamEvent } from './types' +import type { TransportFailureKind } from './models' +import { parseStreamEvent, StreamParseError } from './streamParser' + +export class StreamTransportError extends Error { + readonly kind: TransportFailureKind + + constructor(kind: TransportFailureKind, message: string) { + super(message) + this.name = 'StreamTransportError' + this.kind = kind + } +} + +function parseLine(line: string): StreamEvent { + let value: unknown + try { + value = JSON.parse(line) + } catch { + throw new StreamTransportError('parse', 'The response contained invalid JSON.') + } + + try { + return parseStreamEvent(value) + } catch (error) { + if (error instanceof StreamParseError) { + throw new StreamTransportError('parse', error.message) + } + throw error + } +} + +/** Post a recommendation request and consume its NDJSON event stream. */ +export async function streamRecommendations( + request: RecommendationRequest, + signal: AbortSignal, + onEvent: (event: StreamEvent) => void, +): Promise { + let response: Response + try { + response = await fetch('/api/recommendations', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal, + }) + } catch (error) { + if (signal.aborted) throw error + throw new StreamTransportError('network', 'The recommendation service could not be reached.') + } + + if (!response.ok) { + throw new StreamTransportError( + 'http', + `The recommendation service returned ${response.status}.`, + ) + } + const contentType = response.headers.get('content-type')?.split(';')[0].trim() + if (contentType !== 'application/x-ndjson') { + throw new StreamTransportError('protocol', 'The response was not an NDJSON stream.') + } + if (!response.body) { + throw new StreamTransportError('protocol', 'The response stream was empty.') + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + const { done, value } = await reader.read() + buffer += decoder.decode(value, { stream: !done }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const rawLine of lines) { + const line = rawLine.trim() + if (!line) continue + const event = parseLine(line) + onEvent(event) + if (event.type === 'done' || event.type === 'error') { + await reader.cancel() + return + } + } + + if (done) break + } + + const finalLine = buffer.trim() + if (finalLine) { + const event = parseLine(finalLine) + onEvent(event) + if (event.type === 'done' || event.type === 'error') return + } + } catch (error) { + if (signal.aborted || error instanceof StreamTransportError) throw error + throw new StreamTransportError('network', 'The recommendation stream was interrupted.') + } finally { + reader.releaseLock() + } + + throw new StreamTransportError( + 'unexpected_eof', + 'The recommendation stream ended before a final event arrived.', + ) +} + +/** Return whether a rejected stream operation was intentionally aborted. */ +export function isAbortError(error: unknown, signal: AbortSignal): boolean { + return signal.aborted || (error instanceof DOMException && error.name === 'AbortError') +} diff --git a/frontend/src/lib/streamParser.ts b/frontend/src/lib/streamParser.ts new file mode 100644 index 0000000..08ed243 --- /dev/null +++ b/frontend/src/lib/streamParser.ts @@ -0,0 +1,99 @@ +import type { StreamEvent, TrackCard } from './types' + +export class StreamParseError extends Error { + constructor(message: string) { + super(message) + this.name = 'StreamParseError' + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function stringField(value: Record, key: string): string { + const field = value[key] + if (typeof field !== 'string') throw new StreamParseError(`Invalid ${key} field.`) + return field +} + +function numberField(value: Record, key: string): number { + const field = value[key] + if (typeof field !== 'number' || !Number.isFinite(field)) { + throw new StreamParseError(`Invalid ${key} field.`) + } + return field +} + +function stringArrayField(value: Record, key: string): string[] { + const field = value[key] + if (!Array.isArray(field) || !field.every((entry) => typeof entry === 'string')) { + throw new StreamParseError(`Invalid ${key} field.`) + } + return field +} + +function nullableStringField(value: Record, key: string): string | null { + const field = value[key] + if (field !== null && typeof field !== 'string') { + throw new StreamParseError(`Invalid ${key} field.`) + } + return field +} + +function parseTrackCard(value: unknown): TrackCard { + if (!isRecord(value)) throw new StreamParseError('Invalid track field.') + return { + id: stringField(value, 'id'), + uri: stringField(value, 'uri'), + title: stringField(value, 'title'), + artists: stringArrayField(value, 'artists'), + album_name: stringField(value, 'album_name'), + album_art_url: nullableStringField(value, 'album_art_url'), + external_url: nullableStringField(value, 'external_url'), + } +} + +/** Parse one untrusted NDJSON value into the frozen event union. */ +export function parseStreamEvent(value: unknown): StreamEvent { + if (!isRecord(value) || typeof value.type !== 'string') { + throw new StreamParseError('Stream record has no valid type.') + } + + switch (value.type) { + case 'metadata': + return { + type: 'metadata', + request_id: stringField(value, 'request_id'), + intent_summary: stringField(value, 'intent_summary'), + candidate_count: numberField(value, 'candidate_count'), + } + case 'track': + return { + type: 'track', + rank: numberField(value, 'rank'), + track: parseTrackCard(value.track), + justification: stringField(value, 'justification'), + } + case 'warning': + return { + type: 'warning', + code: stringField(value, 'code'), + message: stringField(value, 'message'), + } + case 'error': + return { + type: 'error', + code: stringField(value, 'code'), + message: stringField(value, 'message'), + } + case 'done': + return { + type: 'done', + track_count: numberField(value, 'track_count'), + total_ms: numberField(value, 'total_ms'), + } + default: + throw new StreamParseError(`Unknown stream event type: ${value.type}.`) + } +} diff --git a/frontend/src/style.css b/frontend/src/style.css index 527d4fb..98bedf2 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1,296 +1,160 @@ :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + --c-bg: #0b0c0b; + --c-surface: #131513; + --c-surface-raised: #161816; + --c-surface-user: #1e211e; + --c-surface-hover: #171a18; + --c-line: #262a26; + --c-line-strong: #38403a; + --c-line-user: #2e332e; + --c-focus-border: #444b46; + --c-card-border: #232722; + --c-art-a: #1e231f; + --c-art-b: #171a17; - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; + --c-text: #e8ede8; + --c-text-muted: #8b948c; + --c-text-dim: #7d857e; + --c-text-faint: #666e68; + --c-text-ghost: #4e544f; + --c-text-chip: #bec5bf; - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); + --c-accent: #7fc96b; + --c-accent-hover: #9bdb88; + --c-accent-soft: #a6be95; + --c-accent-line: #3b4f2e; + --c-banner-bg: #111510; + --c-banner-line: #26331e; + --c-on-accent: #0b0c0b; + --c-saved: #c9a96b; + --c-saved-bg: #191712; + --c-saved-line: #37301f; + --c-error: #e0795c; + --c-dev-bg: #14120f; + + --f-display: 'Bricolage Grotesque', system-ui, sans-serif; + --f-body: 'Public Sans', system-ui, sans-serif; + --f-mono: 'JetBrains Mono', ui-monospace, monospace; + + --t-hero: 44px; + --t-brand: 21px; + --t-lead: 16px; + --t-body: 15px; + --t-title: 15.5px; + --t-small: 13.5px; + --t-meta: 11.5px; + --t-micro: 11px; + --t-caps: 10px; + + --lh-body: 1.55; + --lh-hero: 1.08; + --ls-hero: -0.025em; + --ls-caps: 0.16em; + + --s-1: 4px; + --s-2: 6px; + --s-3: 8px; + --s-4: 10px; + --s-5: 14px; + --s-6: 16px; + --s-7: 18px; + --s-8: 22px; + --s-9: 34px; + --s-10: 44px; + + --gutter: 24px; + --measure: 820px; + --dev-width: 372px; + --tap-min: 44px; + + --r-sm: 2px; + --r-md: 3px; + --r-lg: 4px; + --r-pill: 999px; + + --shadow-drawer: -24px 0 60px rgba(0, 0, 0, 0.45); + --ease: cubic-bezier(0.2, 0.8, 0.3, 1); + --dur-fast: 0.16s; + --dur-card: 0.34s; + + color-scheme: dark; + color: var(--c-text); + background: var(--c-bg); + font-family: var(--f-body); font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } } -@media (prefers-color-scheme: dark) { +@media (max-width: 560px) { :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); + --gutter: 16px; } } -body { - margin: 0; -} - -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); -} - -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#app { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; +* { box-sizing: border-box; } -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; +html, +body, +#app { width: 100%; + height: 100%; + margin: 0; + padding: 0; + background: var(--c-bg); +} - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } +button, +input { + font: inherit; +} - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +a { + color: var(--c-accent); + text-decoration: none; +} + +a:hover { + color: var(--c-accent-hover); + text-decoration: underline; +} + +::selection { + background: var(--c-accent); + color: var(--c-on-accent); +} + +:focus-visible { + outline: 2px solid var(--c-accent); + outline-offset: 2px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.001s !important; + transition-duration: 0.001s !important; } }