BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: auth UI polish

+339 -154
+2 -1
.gitignore
··· 1 1 node_modules 2 - dist 2 + dist 3 + .sandbox/
+2 -19
src/App.css
··· 59 59 60 60 body { 61 61 @apply m-0 min-h-screen font-sans text-on-surface; 62 - background: 63 - radial-gradient(circle at 14% 12%, rgba(125, 175, 255, 0.22), transparent 32%), 64 - radial-gradient(circle at 88% 22%, rgba(0, 115, 222, 0.18), transparent 28%), 65 - radial-gradient(circle at 72% 88%, rgba(125, 175, 255, 0.12), transparent 30%), 66 - var(--surface-container-lowest); 67 - } 68 - 69 - body::before { 70 - content: ""; 71 - position: fixed; 72 - inset: 0; 73 - pointer-events: none; 74 - opacity: 0.18; 75 - background-image: 76 - linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px), 77 - linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px); 78 - background-size: 120px 120px; 79 - mask-image: radial-gradient(circle at center, black 40%, transparent 90%); 62 + background: var(--surface-container-lowest); 80 63 } 81 64 82 65 #root { ··· 88 71 } 89 72 90 73 @utility panel-surface { 91 - @apply rounded-3xl bg-white/3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]; 74 + @apply rounded-2xl bg-white/3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]; 92 75 } 93 76 94 77 @utility pill-action {
+55 -71
src/App.tsx
··· 66 66 const railColumns = createMemo(() => (railCompact() ? "5.75rem minmax(0,1fr)" : "16rem minmax(0,1fr)")); 67 67 const metaLabel = createMemo(() => { 68 68 if (app.bootstrapping) { 69 - return "signing you back in"; 69 + return "reconnecting"; 70 70 } 71 71 72 72 if (app.activeSession) { 73 - return "signed in"; 73 + return "connected"; 74 74 } 75 75 76 - return "ready to sign in"; 76 + return "ready"; 77 77 }); 78 78 79 79 async function loadBootstrap() { ··· 114 114 const trimmed = identifier.trim(); 115 115 if (!validateIdentifier(trimmed)) { 116 116 triggerShake(); 117 - setApp("errorMessage", "Enter a valid Bluesky handle, DID, or PDS URL."); 117 + setApp("errorMessage", "Please enter a valid handle or DID."); 118 118 return; 119 119 } 120 120 ··· 204 204 return ( 205 205 <> 206 206 <main 207 - class="grid min-h-screen grid-cols-[var(--app-rail-cols)] transition-[grid-template-columns] duration-300 ease-out max-[1180px]:grid-cols-1" 207 + class="grid min-h-screen grid-cols-(--app-rail-cols) transition-[grid-template-columns] duration-300 ease-out max-[1180px]:grid-cols-1" 208 208 style={{ "--app-rail-cols": railColumns() }}> 209 209 <AppRail 210 210 activeSession={app.activeSession} ··· 220 220 onToggleSwitcher={() => setApp("showSwitcher", (open) => !open)} /> 221 221 222 222 <section 223 - class="m-5 grid gap-8 rounded-4xl bg-[linear-gradient(160deg,rgba(14,14,14,0.92),rgba(25,25,25,0.98))] p-8 shadow-[0_24px_40px_rgba(125,175,255,0.05)] max-[1360px]:p-7 max-[1180px]:m-0 max-[1180px]:min-h-[calc(100vh-5.5rem)] max-[1180px]:rounded-none max-[1180px]:p-6 max-[760px]:gap-6 max-[760px]:p-5" 223 + class="m-5 grid gap-6 rounded-2xl bg-surface p-6 shadow-[0_24px_40px_rgba(125,175,255,0.05)] max-[1360px]:p-6 max-[1180px]:m-0 max-[1180px]:min-h-[calc(100vh-5.5rem)] max-[1180px]:rounded-none max-[1180px]:p-5 max-[760px]:gap-5 max-[760px]:p-4" 224 224 aria-busy={app.bootstrapping}> 225 225 {props.children} 226 226 </section> ··· 280 280 ) { 281 281 return ( 282 282 <aside 283 - class="flex min-h-screen flex-col gap-8 overflow-visible bg-surface-container-lowest px-6 pb-6 pt-8 transition-[padding,gap] duration-300 ease-out max-[1180px]:min-h-0 max-[1180px]:grid max-[1180px]:grid-cols-[auto_auto_minmax(18rem,1fr)] max-[1180px]:items-center max-[1180px]:gap-4 max-[1180px]:p-4 max-[760px]:grid-cols-1" 284 - classList={{ "items-center px-4": props.collapsed, "gap-6": props.collapsed }} 283 + class="flex min-h-screen flex-col gap-6 overflow-visible bg-surface-container-lowest px-6 pb-6 pt-6 transition-[padding,gap] duration-300 ease-out max-[1180px]:min-h-0 max-[1180px]:grid max-[1180px]:grid-cols-[auto_auto_minmax(18rem,1fr)] max-[1180px]:items-center max-[1180px]:gap-4 max-[1180px]:p-4 max-[760px]:grid-cols-1" 284 + classList={{ "items-center px-4": props.collapsed, "gap-5": props.collapsed }} 285 285 aria-label="Primary navigation"> 286 286 <RailHeader collapsed={props.collapsed} onToggleCollapse={props.onToggleCollapse} /> 287 - <RailNavigation hasSession={props.hasSession} /> 287 + <RailNavigation collapsed={props.collapsed} hasSession={props.hasSession} /> 288 288 <AccountSwitcher 289 289 activeSession={props.activeSession} 290 290 accounts={props.accounts} ··· 302 302 function RailHeader(props: { collapsed: boolean; onToggleCollapse: () => void }) { 303 303 return ( 304 304 <div 305 - class="flex items-start justify-between gap-3 max-[1180px]:items-center" 306 - classList={{ "w-full flex-col items-center gap-4": props.collapsed }}> 305 + class="flex items-center justify-between gap-3 max-[1180px]:items-center" 306 + classList={{ "w-full flex-col gap-3": props.collapsed }}> 307 307 <Wordmark compact={props.collapsed} iconClass="text-primary" /> 308 308 <button 309 - class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-white/[0.04] text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-white/[0.08] hover:text-on-surface max-[1180px]:hidden" 309 + class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-white/4 text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 hover:text-on-surface max-[1180px]:hidden" 310 310 type="button" 311 311 aria-label={props.collapsed ? "Expand app rail" : "Collapse app rail"} 312 312 aria-pressed={props.collapsed} ··· 319 319 ); 320 320 } 321 321 322 - function RailNavigation(props: { hasSession: boolean }) { 322 + function RailNavigation(props: { collapsed: boolean; hasSession: boolean }) { 323 323 return ( 324 - <div class="grid gap-2 max-[1180px]:flex max-[1180px]:items-center"> 325 - <Show when={props.hasSession} fallback={<RailButton end href="/auth" label="Accounts" icon="profile" />}> 324 + <div class="grid gap-1 max-[1180px]:flex max-[1180px]:items-center"> 325 + <Show 326 + when={props.hasSession} 327 + fallback={<RailButton end compact={props.collapsed} href="/auth" label="Accounts" icon="profile" />}> 326 328 <> 327 - <RailButton end href="/timeline" label="Timeline" icon="timeline" /> 328 - <RailButton end href="/search" label="Search" icon="search" /> 329 - <RailButton end href="/notifications" label="Notifications" icon="notifications" /> 330 - <RailButton end href="/explorer" label="Explorer" icon="explorer" /> 329 + <RailButton end compact={props.collapsed} href="/timeline" label="Timeline" icon="timeline" /> 330 + <RailButton end compact={props.collapsed} href="/search" label="Search" icon="search" /> 331 + <RailButton end compact={props.collapsed} href="/notifications" label="Notifications" icon="notifications" /> 332 + <RailButton end compact={props.collapsed} href="/explorer" label="Explorer" icon="explorer" /> 331 333 </> 332 334 </Show> 333 335 </div> ··· 354 356 onSwitch: (did: string) => void; 355 357 }, 356 358 ) { 357 - return ( 358 - <> 359 - <HeaderPanel metaLabel={props.metaLabel} /> 360 - <AuthHero 361 - activeAccount={props.activeAccount} 362 - bootstrapping={props.bootstrapping} 363 - loggingIn={props.loggingIn} 364 - loginValue={props.loginValue} 365 - reauthNeeded={props.reauthNeeded} 366 - shakeCount={props.shakeCount} 367 - onInput={props.onInput} 368 - onReauth={props.onReauth} 369 - onSubmit={props.onSubmit} /> 370 - <AccountLedger 371 - accounts={props.accounts} 372 - activeDid={props.activeDid} 373 - busyDid={props.switchingDid} 374 - logoutDid={props.logoutDid} 375 - onSwitch={props.onSwitch} 376 - onLogout={props.onLogout} /> 377 - </> 378 - ); 379 - } 359 + const hasAccounts = () => props.accounts.length > 0; 380 360 381 - function AuthHero( 382 - props: { 383 - activeAccount: AccountSummary | null; 384 - bootstrapping: boolean; 385 - loggingIn: boolean; 386 - loginValue: string; 387 - reauthNeeded: boolean; 388 - shakeCount: number; 389 - onInput: (value: string) => void; 390 - onReauth: () => void; 391 - onSubmit: () => void; 392 - }, 393 - ) { 394 361 return ( 395 - <div class="grid gap-6 grid-cols-[minmax(0,1.25fr)_minmax(20rem,0.9fr)] max-[1320px]:grid-cols-1"> 396 - <SessionSpotlight 397 - activeSession={props.activeAccount 398 - ? { did: props.activeAccount.did, handle: props.activeAccount.handle } 399 - : null} 400 - activeAccount={props.activeAccount} 401 - bootstrapping={props.bootstrapping} 402 - reauthNeeded={props.reauthNeeded} 403 - onReauth={props.onReauth} /> 404 - <LoginPanel 405 - value={props.loginValue} 406 - pending={props.loggingIn} 407 - shakeCount={props.shakeCount} 408 - onInput={props.onInput} 409 - onSubmit={props.onSubmit} /> 410 - </div> 362 + <Show 363 + when={hasAccounts()} 364 + fallback={ 365 + <div class="grid place-items-center py-8"> 366 + <div class="w-full max-w-md"> 367 + <LoginPanel 368 + value={props.loginValue} 369 + pending={props.loggingIn} 370 + shakeCount={props.shakeCount} 371 + onInput={props.onInput} 372 + onSubmit={props.onSubmit} /> 373 + </div> 374 + </div> 375 + }> 376 + <> 377 + <HeaderPanel metaLabel={props.metaLabel} /> 378 + <SessionSpotlight 379 + activeSession={props.activeAccount 380 + ? { did: props.activeAccount.did, handle: props.activeAccount.handle } 381 + : null} 382 + activeAccount={props.activeAccount} 383 + bootstrapping={props.bootstrapping} 384 + reauthNeeded={props.reauthNeeded} 385 + onReauth={props.onReauth} /> 386 + <AccountLedger 387 + accounts={props.accounts} 388 + activeDid={props.activeDid} 389 + busyDid={props.switchingDid} 390 + logoutDid={props.logoutDid} 391 + onSwitch={props.onSwitch} 392 + onLogout={props.onLogout} /> 393 + </> 394 + </Show> 411 395 ); 412 396 } 413 397
+6 -4
src/components/AccountLedger.tsx
··· 15 15 16 16 export function AccountLedger(props: AccountLedgerProps) { 17 17 return ( 18 - <article class="panel-surface grid gap-6 p-6"> 18 + <article class="panel-surface grid gap-5 p-5"> 19 19 <div class="flex items-baseline justify-between gap-3"> 20 20 <p class="overline-copy text-[0.75rem] text-on-surface-variant">Accounts</p> 21 21 <p class="m-0 text-xs leading-[1.55] text-on-surface-variant">{props.accounts.length} added</p> ··· 24 24 <Show 25 25 when={props.accounts.length > 0} 26 26 fallback={ 27 - <p class="overline-copy text-[0.72rem] text-on-surface-variant">Accounts you add will show up here.</p> 27 + <p class="overline-copy text-[0.72rem] text-on-surface-variant"> 28 + Your accounts will appear here once you sign in. 29 + </p> 28 30 }> 29 31 <div class="grid gap-3" role="list"> 30 32 <For each={props.accounts}> ··· 60 62 61 63 return ( 62 64 <Motion.div 63 - class="grid items-center gap-4 rounded-2xl bg-white/2.5 p-4 max-[920px]:grid-cols-1 grid-cols-[minmax(0,1fr)_auto]" 64 - classList={{ "bg-[linear-gradient(135deg,rgba(125,175,255,0.12),rgba(0,115,222,0.08))]": isActive() }} 65 + class="grid items-center gap-4 rounded-xl bg-white/2.5 p-4 max-[920px]:grid-cols-1 grid-cols-[minmax(0,1fr)_auto]" 66 + classList={{ "bg-primary/10": isActive() }} 65 67 role="listitem" 66 68 initial={{ opacity: 0, y: 18 }} 67 69 animate={{ opacity: 1, y: 0 }}
+25 -27
src/components/AccountSwitcher.tsx
··· 48 48 container = element; 49 49 }}> 50 50 <button 51 - class="relative w-full cursor-pointer border-0 bg-[linear-gradient(160deg,rgba(255,255,255,0.045),rgba(255,255,255,0.02))] text-on-surface shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-[linear-gradient(160deg,rgba(255,255,255,0.08),rgba(255,255,255,0.03))]" 51 + class="relative w-full cursor-pointer border-0 bg-white/4 text-on-surface shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8" 52 52 classList={{ 53 - "rounded-3xl px-4 py-[0.95rem]": !props.compact, 53 + "rounded-xl py-[0.95rem] pr-10 pl-4": !props.compact, 54 54 "grid h-14 w-14 place-items-center rounded-full p-0": !!props.compact, 55 55 }} 56 56 type="button" 57 57 aria-haspopup="menu" 58 58 aria-expanded={props.open} 59 - aria-label={props.activeSession ? `Current account ${props.activeSession.handle}` : "Add account"} 59 + aria-label={props.activeSession ? `Current account ${props.activeSession.handle}` : "Sign in"} 60 60 onClick={() => props.onToggle()}> 61 - <Presence exitBeforeEnter> 62 - <Show 63 - when={props.activeSession} 64 - keyed 65 - fallback={ 66 - <SwitcherIdentity 67 - compact={props.compact} 68 - label="?" 69 - name="Add account" 70 - meta="No active account" 71 - tone="muted" /> 72 - }> 73 - {(session) => ( 74 - <SwitcherIdentity 75 - compact={props.compact} 76 - label={session.handle} 77 - name={session.handle} 78 - meta="Current account" 79 - tone="primary" /> 80 - )} 81 - </Show> 82 - </Presence> 61 + <Show 62 + when={props.activeSession} 63 + keyed 64 + fallback={ 65 + <SwitcherIdentity 66 + compact={props.compact} 67 + label="?" 68 + name="Sign in" 69 + meta="No account connected" 70 + tone="muted" /> 71 + }> 72 + {(session) => ( 73 + <SwitcherIdentity 74 + compact={props.compact} 75 + label={session.handle} 76 + name={session.handle} 77 + meta="Current account" 78 + tone="primary" /> 79 + )} 80 + </Show> 83 81 <span 84 82 class="absolute flex items-center text-on-surface-variant" 85 83 classList={{ 86 - "right-[0.95rem] top-[1.15rem]": !props.compact, 84 + "right-[0.95rem] top-1/2 -translate-y-1/2": !props.compact, 87 85 "bottom-0 right-0 h-5 w-5 rounded-full bg-surface-container text-[0.7rem] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]": 88 86 !!props.compact, 89 87 }} ··· 97 95 <Presence> 98 96 <Show when={props.open}> 99 97 <Motion.div 100 - class="absolute rounded-3xl bg-(--surface-container-highest) p-4 shadow-[0_24px_40px_rgba(0,0,0,0.28)] backdrop-blur-[20px] max-[1180px]:bottom-auto max-[1180px]:top-[calc(100%+0.75rem)]" 98 + class="absolute rounded-2xl bg-(--surface-container-highest) p-4 shadow-[0_24px_40px_rgba(0,0,0,0.28)] backdrop-blur-[20px] max-[1180px]:bottom-auto max-[1180px]:top-[calc(100%+0.75rem)]" 101 99 classList={{ 102 100 "inset-x-0 bottom-[calc(100%+0.75rem)]": !props.compact, 103 101 "bottom-0 left-[calc(100%+0.85rem)] w-[19rem]": !!props.compact,
+28
src/components/AvatarBadge.test.tsx
··· 1 + import { render } from "@solidjs/testing-library"; 2 + import { describe, expect, it } from "vitest"; 3 + import { AvatarBadge } from "./AvatarBadge"; 4 + 5 + describe("AvatarBadge", () => { 6 + it("uses solid primary background (no gradient)", () => { 7 + render(() => <AvatarBadge label="alice.bsky.social" tone="primary" />); 8 + 9 + const badge = document.querySelector("span"); 10 + expect(badge?.className).toContain("bg-primary"); 11 + expect(badge?.className).not.toContain("gradient"); 12 + }); 13 + 14 + it("extracts initials from handle", () => { 15 + render(() => <AvatarBadge label="alice.bsky.social" tone="primary" />); 16 + 17 + const badge = document.querySelector("span"); 18 + expect(badge?.textContent).toBe("AL"); 19 + }); 20 + 21 + it("renders muted tone without primary background", () => { 22 + render(() => <AvatarBadge label="bob.bsky.social" tone="muted" />); 23 + 24 + const badge = document.querySelector("span"); 25 + expect(badge?.className).toContain("bg-white/8"); 26 + expect(badge?.className).not.toContain("bg-primary"); 27 + }); 28 + });
+1 -2
src/components/AvatarBadge.tsx
··· 12 12 <span 13 13 class="inline-flex h-10 w-10 items-center justify-center rounded-full text-[0.82rem] font-bold tracking-[0.08em]" 14 14 classList={{ 15 - "bg-[linear-gradient(135deg,var(--primary)_0%,var(--primary-dim)_100%)] text-[color:var(--on-primary-fixed)]": 16 - props.tone === "primary", 15 + "bg-primary text-[color:var(--on-primary-fixed)]": props.tone === "primary", 17 16 "bg-white/8 text-on-surface": props.tone !== "primary", 18 17 }}> 19 18 {label()}
+46
src/components/LoginPanel.test.tsx
··· 1 + import { render, screen } from "@solidjs/testing-library"; 2 + import { describe, expect, it, vi } from "vitest"; 3 + import { LoginPanel } from "./LoginPanel"; 4 + 5 + function renderPanel(overrides: Partial<Parameters<typeof LoginPanel>[0]> = {}) { 6 + const defaults = { value: "", pending: false, shakeCount: 0, onInput: vi.fn(), onSubmit: vi.fn() }; 7 + 8 + return render(() => <LoginPanel {...{ ...defaults, ...overrides }} />); 9 + } 10 + 11 + describe("LoginPanel", () => { 12 + it("renders branded header with Lazurite logo", () => { 13 + renderPanel(); 14 + 15 + expect(screen.getByText("Lazurite")).toBeInTheDocument(); 16 + expect(screen.getByText("Powered by Bluesky")).toBeInTheDocument(); 17 + expect(screen.getByText("Sign in with your Internet Handle or DID")).toBeInTheDocument(); 18 + 19 + const svg = document.querySelector("svg"); 20 + expect(svg).toBeInTheDocument(); 21 + expect(svg).toHaveAttribute("fill", "currentColor"); 22 + }); 23 + 24 + it("uses solid primary background on submit button (no gradient)", () => { 25 + renderPanel(); 26 + 27 + const button = screen.getByRole("button", { name: /continue/i }); 28 + expect(button.className).toContain("bg-primary"); 29 + expect(button.className).not.toContain("gradient"); 30 + }); 31 + 32 + it("uses rounded-xl on input (not rounded-full)", () => { 33 + renderPanel(); 34 + 35 + const input = screen.getByPlaceholderText("alice.bsky.social"); 36 + expect(input.className).toContain("rounded-xl"); 37 + expect(input.className).not.toContain("rounded-full"); 38 + }); 39 + 40 + it("shows loading state when pending", () => { 41 + renderPanel({ pending: true }); 42 + 43 + expect(screen.getByText("Opening sign-in...")).toBeInTheDocument(); 44 + expect(screen.getByRole("button")).toBeDisabled(); 45 + }); 46 + });
+17 -12
src/components/LoginPanel.tsx
··· 1 1 import { createEffect, Show } from "solid-js"; 2 2 import { Motion } from "solid-motionone"; 3 3 import { Icon } from "./shared/Icon"; 4 + import { LazuriteLogo } from "./Wordmark"; 4 5 5 6 function LoginSubmitButton(props: { pending: boolean }) { 6 7 return ( 7 - <button 8 - class="pill-action border-0 bg-[linear-gradient(135deg,var(--primary)_0%,var(--primary-dim)_100%)] text-on-primary-fixed" 9 - type="submit" 10 - disabled={props.pending}> 8 + <button class="pill-action border-0 bg-primary text-on-primary-fixed" type="submit" disabled={props.pending}> 11 9 <Show 12 10 when={props.pending} 13 11 fallback={ ··· 44 42 }); 45 43 46 44 return ( 47 - <article class="panel-surface grid gap-6 p-6"> 48 - <div class="flex items-baseline justify-between gap-3"> 49 - <p class="overline-copy text-[0.75rem] text-on-surface-variant">Add account</p> 50 - <p class="m-0 text-xs leading-[1.55] text-on-surface-variant">Enter the account you want to use.</p> 45 + <article class="panel-surface grid gap-5 p-5"> 46 + <div class="grid place-items-center gap-3 py-2"> 47 + <span class="grid place-items-center text-primary"> 48 + <LazuriteLogo class="h-14 w-14" /> 49 + </span> 50 + <div class="grid place-items-center gap-0.5"> 51 + <p class="m-0 text-[1.25rem] font-semibold tracking-[-0.02em]">Lazurite</p> 52 + <p class="m-0 text-xs text-on-surface-variant">Powered by Bluesky</p> 53 + </div> 51 54 </div> 52 55 53 56 <Motion.form ··· 59 62 event.preventDefault(); 60 63 props.onSubmit(); 61 64 }}> 62 - <label class="grid gap-[0.7rem]"> 63 - <span class="overline-copy text-[0.76rem] tracking-[0.08em] text-on-surface-variant"> 64 - Handle, DID, or URL 65 + <label class="grid gap-3"> 66 + <span class="overline-copy text-xs tracking-[0.08em] text-on-surface-variant"> 67 + {/* TODO: use tauri opener */} 68 + Sign in with your <a href="https://internethandle.org" class="text-primary underline">Internet Handle</a> 69 + or DID 65 70 </span> 66 71 <input 67 72 ref={(element) => { 68 73 input = element; 69 74 }} 70 - class="min-h-[3.4rem] w-full rounded-full border-0 bg-white/4 px-[1.15rem] text-on-surface shadow-[inset_0_0_0_1px_rgba(125,175,255,0.16)] focus:outline focus:outline-primary/50 focus:shadow-[inset_0_0_0_1px_rgba(125,175,255,0.35),0_0_28px_rgba(125,175,255,0.12)]" 75 + class="min-h-[3.4rem] w-full rounded-xl border-0 bg-white/4 px-[1.15rem] text-on-surface shadow-[inset_0_0_0_1px_rgba(125,175,255,0.16)] focus:outline focus:outline-primary/50 focus:shadow-[inset_0_0_0_1px_rgba(125,175,255,0.35),0_0_28px_rgba(125,175,255,0.12)]" 71 76 type="text" 72 77 autocomplete="username" 73 78 spellcheck={false}
+34
src/components/RailButton.test.tsx
··· 1 + import { MemoryRouter, Route } from "@solidjs/router"; 2 + import { render, screen } from "@solidjs/testing-library"; 3 + import { describe, expect, it } from "vitest"; 4 + import { RailButton } from "./RailButton"; 5 + 6 + function renderInRouter(ui: () => ReturnType<typeof RailButton>) { 7 + return render(() => ( 8 + <MemoryRouter root={ui}> 9 + <Route path="*" component={() => null} /> 10 + </MemoryRouter> 11 + )); 12 + } 13 + 14 + describe("RailButton", () => { 15 + it("shows label text when not compact", () => { 16 + renderInRouter(() => <RailButton href="/auth" label="Accounts" icon="profile" />); 17 + 18 + expect(screen.getByText("Accounts")).toBeInTheDocument(); 19 + }); 20 + 21 + it("hides label text when compact", () => { 22 + renderInRouter(() => <RailButton href="/auth" label="Accounts" icon="profile" compact />); 23 + 24 + expect(screen.queryByText("Accounts")).not.toBeInTheDocument(); 25 + }); 26 + 27 + it("uses rounded-lg (not rounded-full) for reduced rounding", () => { 28 + renderInRouter(() => <RailButton href="/auth" label="Accounts" icon="profile" />); 29 + 30 + const link = screen.getByRole("link"); 31 + expect(link.className).toContain("rounded-lg"); 32 + expect(link.className).not.toContain("rounded-full"); 33 + }); 34 + });
+8 -3
src/components/RailButton.tsx
··· 1 1 import { A } from "@solidjs/router"; 2 + import { Show } from "solid-js"; 2 3 import { Icon, type IconKind } from "./shared/Icon"; 3 4 4 - type RailButtonProps = { label: string; href: string; icon: IconKind; end?: boolean }; 5 + type RailButtonProps = { label: string; href: string; icon: IconKind; compact?: boolean; end?: boolean }; 5 6 6 7 export function RailButton(props: RailButtonProps) { 7 8 return ( 8 9 <A 9 10 href={props.href} 10 11 end={props.end} 11 - class="grid h-[3.3rem] place-items-center rounded-full border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright hover:text-on-surface" 12 + class="flex h-[2.75rem] items-center gap-2.5 rounded-lg border-0 bg-transparent text-on-surface-variant no-underline transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright hover:text-on-surface" 12 13 activeClass="bg-surface-container text-primary" 13 14 inactiveClass="" 15 + classList={{ "w-[2.75rem] justify-center": !!props.compact, "px-3": !props.compact }} 14 16 aria-label={props.label} 15 17 title={props.label}> 16 - <Icon kind={props.icon} name={props.label} aria-hidden="true" /> 18 + <Icon kind={props.icon} name={props.label} aria-hidden="true" class="shrink-0 text-[1.25rem]" /> 19 + <Show when={!props.compact}> 20 + <span class="text-[0.82rem] font-medium leading-none">{props.label}</span> 21 + </Show> 17 22 </A> 18 23 ); 19 24 }
+3 -3
src/components/ReauthBanner.tsx
··· 4 4 export function ReauthBanner(props: { onReauth: () => void }) { 5 5 return ( 6 6 <Motion.div 7 - class="flex items-center justify-between gap-4 rounded-2xl bg-primary/12 px-[1.1rem] py-4 max-[920px]:flex-col max-[920px]:items-stretch" 7 + class="flex items-center justify-between gap-4 rounded-xl bg-primary/12 px-[1.1rem] py-4 max-[920px]:flex-col max-[920px]:items-stretch" 8 8 role="status" 9 9 initial={{ opacity: 0, y: 12 }} 10 10 animate={{ opacity: 1, y: 0, scale: [1, 1.015, 1] }} 11 11 exit={{ opacity: 0, y: 8 }} 12 12 transition={{ duration: 1.8, repeat: Number.POSITIVE_INFINITY, easing: "ease-in-out" }}> 13 13 <div class="grid gap-[0.2rem]"> 14 - <p class="m-0 text-[0.95rem] font-semibold">Sign in again to reconnect this account.</p> 15 - <p class="m-0 text-xs text-on-surface-variant">We couldn&apos;t restore the last session automatically.</p> 14 + <p class="m-0 text-[0.95rem] font-semibold">Your session expired.</p> 15 + <p class="m-0 text-xs text-on-surface-variant">Sign in again to reconnect your account.</p> 16 16 </div> 17 17 <button class="pill-action border-0 bg-white/8 text-on-surface" type="button" onClick={() => props.onReauth()}> 18 18 <Icon kind="refresh" name="refresh" aria-hidden="true" class="mr-1" />
+53
src/components/Session.test.tsx
··· 1 + import { render, screen } from "@solidjs/testing-library"; 2 + import { describe, expect, it, vi } from "vitest"; 3 + import { SessionEmptyState, SessionSpotlight } from "./Session"; 4 + 5 + describe("SessionEmptyState", () => { 6 + it("renders empty state copy", () => { 7 + render(() => <SessionEmptyState />); 8 + 9 + expect(screen.getByText("No account connected yet.")).toBeInTheDocument(); 10 + expect(screen.getByText("Connect your Bluesky account to start exploring.")).toBeInTheDocument(); 11 + }); 12 + }); 13 + 14 + describe("SessionSpotlight", () => { 15 + it("renders 'Your account' label", () => { 16 + render(() => ( 17 + <SessionSpotlight 18 + activeSession={null} 19 + activeAccount={null} 20 + bootstrapping={false} 21 + reauthNeeded={false} 22 + onReauth={vi.fn()} /> 23 + )); 24 + 25 + expect(screen.getByText("Your account")).toBeInTheDocument(); 26 + }); 27 + 28 + it("shows Ready status when no session and not bootstrapping", () => { 29 + render(() => ( 30 + <SessionSpotlight 31 + activeSession={null} 32 + activeAccount={null} 33 + bootstrapping={false} 34 + reauthNeeded={false} 35 + onReauth={vi.fn()} /> 36 + )); 37 + 38 + expect(screen.getByText("Ready")).toBeInTheDocument(); 39 + }); 40 + 41 + it("shows Reconnecting status when bootstrapping", () => { 42 + render(() => ( 43 + <SessionSpotlight 44 + activeSession={null} 45 + activeAccount={null} 46 + bootstrapping 47 + reauthNeeded={false} 48 + onReauth={vi.fn()} /> 49 + )); 50 + 51 + expect(screen.getByText("Reconnecting")).toBeInTheDocument(); 52 + }); 53 + });
+6 -9
src/components/Session.tsx
··· 4 4 import { AvatarBadge } from "./AvatarBadge"; 5 5 import { ProfileSkeleton } from "./ProfileSkeleton"; 6 6 import { ReauthBanner } from "./ReauthBanner"; 7 - 8 7 export function SessionEmptyState() { 9 8 return ( 10 9 <div class="grid"> 11 - <h2 class="m-0 text-[clamp(1.4rem,2vw,1.85rem)] leading-[1.08] tracking-[-0.03em]">Sign in to get started.</h2> 12 - <p class="m-0 text-xs leading-[1.55] text-on-surface-variant"> 13 - Add a Bluesky account now. You can switch or add more later. 14 - </p> 10 + <h2 class="m-0 text-[clamp(1.4rem,2vw,1.85rem)] leading-[1.08] tracking-[-0.03em]">No account connected yet.</h2> 11 + <p class="m-0 text-xs leading-[1.55] text-on-surface-variant">Connect your Bluesky account to start exploring.</p> 15 12 </div> 16 13 ); 17 14 } ··· 44 41 const activeSession = () => props.activeSession; 45 42 const label = createMemo(() => { 46 43 if (bootstrapping()) { 47 - return "Restoring"; 44 + return "Reconnecting"; 48 45 } 49 46 50 47 if (activeSession()) { 51 - return "Signed in"; 48 + return "Connected"; 52 49 } 53 50 54 51 return "Ready"; 55 52 }); 56 53 return ( 57 - <article class="panel-surface grid min-h-76 gap-6 p-6 max-[760px]:min-h-0"> 54 + <article class="panel-surface grid gap-5 p-5"> 58 55 <div class="flex items-baseline justify-between gap-3"> 59 - <p class="overline-copy text-[0.75rem] text-on-surface-variant">Current account</p> 56 + <p class="overline-copy text-[0.75rem] text-on-surface-variant">Your account</p> 60 57 <p class="overline-copy text-[0.68rem] text-on-surface-variant">{label()}</p> 61 58 </div> 62 59
+42
src/components/Wordmark.test.tsx
··· 1 + import { render, screen } from "@solidjs/testing-library"; 2 + import { describe, expect, it } from "vitest"; 3 + import { Wordmark } from "./Wordmark"; 4 + 5 + describe("Wordmark", () => { 6 + it("renders inline SVG logo with currentColor fill", () => { 7 + render(() => <Wordmark />); 8 + 9 + const svg = document.querySelector("svg"); 10 + expect(svg).toBeInTheDocument(); 11 + expect(svg).toHaveAttribute("fill", "currentColor"); 12 + }); 13 + 14 + it("shows text labels when not compact", () => { 15 + render(() => <Wordmark />); 16 + 17 + expect(screen.getByText("Lazurite")).toBeInTheDocument(); 18 + expect(screen.getByText("Desktop")).toBeInTheDocument(); 19 + }); 20 + 21 + it("hides text labels when compact", () => { 22 + render(() => <Wordmark compact />); 23 + 24 + expect(screen.queryByText("Lazurite")).not.toBeInTheDocument(); 25 + expect(screen.queryByText("Desktop")).not.toBeInTheDocument(); 26 + }); 27 + 28 + it("does not use gradient backgrounds", () => { 29 + render(() => <Wordmark />); 30 + 31 + const container = document.querySelector("[aria-hidden='true']"); 32 + expect(container).toBeInTheDocument(); 33 + expect(container?.className).not.toContain("gradient"); 34 + }); 35 + 36 + it("applies primary color to logo container", () => { 37 + render(() => <Wordmark />); 38 + 39 + const container = document.querySelector("[aria-hidden='true']"); 40 + expect(container?.className).toContain("text-primary"); 41 + }); 42 + });
+10 -2
src/components/Wordmark.tsx
··· 1 1 import { Show } from "solid-js"; 2 2 3 + export function LazuriteLogo(props: { class?: string }) { 4 + return ( 5 + <svg class={props.class} viewBox="0 0 512 512" fill="currentColor" aria-hidden="true"> 6 + <path d="M128 16v99.3l119 118.9V120.1zm256 0L265 120.1v114.1l119-119zM16 128l104 119h114.2L115.3 128zm380.8 0l-119 119h114.1l104-119zM120 265L16 384h99.2l119-119zm157.8 0l119 119h99.1l-104-119zM247 277.8l-119 119V496l119-104.1zm18 0v114.1L384 496v-99.2z" /> 7 + </svg> 8 + ); 9 + } 10 + 3 11 export function Wordmark(props: { class?: string; compact?: boolean; iconClass?: string }) { 4 12 return ( 5 13 <div 6 14 class="flex items-center gap-3" 7 15 classList={{ "flex-col gap-2 text-center": !!props.compact, [props.class ?? ""]: !!props.class }}> 8 16 <span 9 - class="grid shrink-0 place-items-center rounded-3xl bg-[linear-gradient(165deg,rgba(255,255,255,0.06),rgba(255,255,255,0.01))] p-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04),0_0_26px_rgba(125,175,255,0.16)]" 17 + class="grid shrink-0 place-items-center rounded-xl bg-white/4 p-3 text-primary shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]" 10 18 classList={{ [props.iconClass ?? ""]: !!props.iconClass }} 11 19 aria-hidden="true"> 12 - <img class="h-9 w-9 drop-shadow-[0_0_14px_rgba(125,175,255,0.28)]" src="/lazurite.svg" alt="" /> 20 + <LazuriteLogo class="h-9 w-9" /> 13 21 </span> 14 22 <Show when={!props.compact}> 15 23 <div class="grid">
+1 -1
src/components/panels/Header.tsx
··· 4 4 return ( 5 5 <header class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-start"> 6 6 <div class="max-w-3xl"> 7 - <p class="overline-copy text-[0.72rem] text-primary">Authentication</p> 7 + <p class="overline-copy text-[0.72rem] text-primary">Welcome</p> 8 8 <h1 class="m-0 max-w-[11ch] text-balance text-[clamp(2.3rem,5vw,4.2rem)] leading-[0.94] tracking-[-0.03em] max-[760px]:text-[clamp(1.95rem,10vw,3.2rem)]"> 9 9 Join the conversation. 10 10 </h1>