feat(frontend): add session and playlist API client

This commit is contained in:
Justin Visser 2026-08-10 14:46:10 +02:00
parent 47cbeac87a
commit 96c69507ec
7 changed files with 837 additions and 279 deletions

View file

@ -1,6 +1,6 @@
# Logboek # 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. vallen.
## Dag 1 - korte sessie in de avond ## Dag 1 - korte sessie in de avond
### Opzet ### Opzet
@ -120,7 +120,7 @@ Wat ik deed:
client-disconnect stopt de stream en de grounding. client-disconnect stopt de stream en de grounding.
- Playlist-endpoint dat schrijft met een vaste naam-prefix, zodat - Playlist-endpoint dat schrijft met een vaste naam-prefix, zodat
aangemaakte playlists later in bulk op te ruimen zijn (ik gebruik mijn 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 - Request-timing middleware met per-request counters (Spotify calls, cache
hits, LLM tokens) in gestructureerde logs. hits, LLM tokens) in gestructureerde logs.
- Seed session voor gehost draaien: in live mode installeert een refresh - Seed session voor gehost draaien: in live mode installeert een refresh
@ -179,3 +179,25 @@ Waarom:
foutmelding die echte resultaten verbergt is dat niet. Bij "verras me 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 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. 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.

View file

@ -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<string, unknown> {
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<AuthState>({ status: 'checking', user: null, message: null })
const health = ref<HealthState>({ status: 'checking', mode: null, message: null })
async function loadAuth(hadLoginError: boolean): Promise<void> {
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<void> {
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<void> {
const hadLoginError = loginFailed()
await Promise.all([loadAuth(hadLoginError), loadHealth()])
}
async function logout(): Promise<void> {
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<PlaylistCreateResponse> {
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 }
}

View file

@ -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<PlaylistCreateResponse>
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<ChatTurn[]>([])
const eventLog = ref<EventLogEntry[]>([])
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<void> {
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<void> {
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,
}
}

View file

@ -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,
}

View file

@ -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<void> {
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')
}

View file

@ -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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function stringField(value: Record<string, unknown>, key: string): string {
const field = value[key]
if (typeof field !== 'string') throw new StreamParseError(`Invalid ${key} field.`)
return field
}
function numberField(value: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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}.`)
}
}

View file

@ -1,296 +1,160 @@
:root { :root {
--text: #6b6375; --c-bg: #0b0c0b;
--text-h: #08060d; --c-surface: #131513;
--bg: #fff; --c-surface-raised: #161816;
--border: #e5e4e7; --c-surface-user: #1e211e;
--code-bg: #f4f3ec; --c-surface-hover: #171a18;
--accent: #aa3bff; --c-line: #262a26;
--accent-bg: rgba(170, 59, 255, 0.1); --c-line-strong: #38403a;
--accent-border: rgba(170, 59, 255, 0.5); --c-line-user: #2e332e;
--social-bg: rgba(244, 243, 236, 0.5); --c-focus-border: #444b46;
--shadow: --c-card-border: #232722;
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; --c-art-a: #1e231f;
--c-art-b: #171a17;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif; --c-text: #e8ede8;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif; --c-text-muted: #8b948c;
--mono: ui-monospace, Consolas, monospace; --c-text-dim: #7d857e;
--c-text-faint: #666e68;
--c-text-ghost: #4e544f;
--c-text-chip: #bec5bf;
font: 18px/145% var(--sans); --c-accent: #7fc96b;
letter-spacing: 0.18px; --c-accent-hover: #9bdb88;
color-scheme: light dark; --c-accent-soft: #a6be95;
color: var(--text); --c-accent-line: #3b4f2e;
background: var(--bg); --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; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
} }
@media (prefers-color-scheme: dark) { @media (max-width: 560px) {
:root { :root {
--text: #9ca3af; --gutter: 16px;
--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);
} }
} }
body { * {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0; margin: 0;
padding: 0;
background: var(--c-bg);
} }
h1, button,
h2 { input {
font-family: var(--heading); font: inherit;
font-weight: 500;
color: var(--text-h);
} }
h1 { button:disabled {
font-size: 56px; cursor: not-allowed;
letter-spacing: -1.68px; opacity: 0.55;
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, a {
.counter { color: var(--c-accent);
font-family: var(--mono); text-decoration: none;
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
} }
code { a:hover {
font-size: 15px; color: var(--c-accent-hover);
line-height: 135%; text-decoration: underline;
padding: 4px 8px;
background: var(--code-bg);
} }
.counter { ::selection {
font-size: 16px; background: var(--c-accent);
padding: 5px 10px; color: var(--c-on-accent);
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); :focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px; outline-offset: 2px;
} }
}
.hero { .visually-hidden {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute; position: absolute;
} width: 1px;
height: 1px;
.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; padding: 0;
display: flex; margin: -1px;
gap: 8px; overflow: hidden;
margin: 32px 0 0; clip: rect(0, 0, 0, 0);
white-space: nowrap;
.logo { border: 0;
height: 18px;
} }
a { @media (prefers-reduced-motion: reduce) {
color: var(--text-h); *,
font-size: 16px; *::before,
border-radius: 6px; *::after {
background: var(--social-bg); scroll-behavior: auto !important;
display: flex; animation-duration: 0.001s !important;
padding: 6px 12px; transition-duration: 0.001s !important;
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;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
} }
} }