feat(client): add glass sidebar/header and surface brightness control

This commit is contained in:
neon
2026-02-17 13:05:37 -06:00
parent c7f488e88f
commit 3ff1cd5882
9 changed files with 103 additions and 41 deletions
+14 -13
View File
@@ -86,26 +86,27 @@
.dark {
--tint-c: 0.01;
--bg-lift: 0;
--background: oklch(0.145 var(--tint-c) var(--tint-h));
--background: oklch(calc(0.145 + var(--bg-lift)) var(--tint-c) var(--tint-h));
--foreground: oklch(0.985 0.004 var(--tint-h));
--card: oklch(0.205 0.012 var(--tint-h));
--card: oklch(calc(0.205 + var(--bg-lift)) 0.012 var(--tint-h));
--card-foreground: oklch(0.985 0.004 var(--tint-h));
--popover: oklch(0.205 0.012 var(--tint-h));
--popover: oklch(calc(0.205 + var(--bg-lift)) 0.012 var(--tint-h));
--popover-foreground: oklch(0.985 0.004 var(--tint-h));
--primary: oklch(0.91 var(--tint-c) var(--tint-h));
--primary-foreground: oklch(0.205 0.012 var(--tint-h));
--secondary: oklch(0.265 0.012 var(--tint-h));
--secondary: oklch(calc(0.265 + var(--bg-lift)) 0.012 var(--tint-h));
--secondary-foreground: oklch(0.985 0.004 var(--tint-h));
--muted: oklch(0.265 0.012 var(--tint-h));
--muted: oklch(calc(0.265 + var(--bg-lift)) 0.012 var(--tint-h));
--muted-foreground: oklch(0.68 0.008 var(--tint-h));
--accent: oklch(0.265 0.012 var(--tint-h));
--accent: oklch(calc(0.265 + var(--bg-lift)) 0.012 var(--tint-h));
--accent-foreground: oklch(0.985 0.004 var(--tint-h));
--destructive: oklch(0.704 0.191 22.216);
@@ -115,11 +116,11 @@
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.52 0.008 var(--tint-h));
--sidebar: oklch(0.205 0.012 var(--tint-h));
--sidebar: oklch(calc(0.205 + var(--bg-lift)) 0.012 var(--tint-h));
--sidebar-foreground: oklch(0.985 0.004 var(--tint-h));
--sidebar-primary: oklch(0.91 var(--tint-c) var(--tint-h));
--sidebar-primary-foreground: oklch(0.205 0.012 var(--tint-h));
--sidebar-accent: oklch(0.265 0.012 var(--tint-h));
--sidebar-accent: oklch(calc(0.265 + var(--bg-lift)) 0.012 var(--tint-h));
--sidebar-accent-foreground: oklch(0.985 0.004 var(--tint-h));
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.52 0.008 var(--tint-h));
@@ -131,10 +132,10 @@
--chart-5: oklch(0.645 0.246 16.439);
/* Elevation surfaces */
--surface-1: oklch(0.205 0.012 var(--tint-h));
--surface-2: oklch(0.235 0.012 var(--tint-h));
--surface-3: oklch(0.265 0.012 var(--tint-h));
--surface-4: oklch(0.295 0.012 var(--tint-h));
--surface-1: oklch(calc(0.205 + var(--bg-lift)) 0.012 var(--tint-h));
--surface-2: oklch(calc(0.235 + var(--bg-lift)) 0.012 var(--tint-h));
--surface-3: oklch(calc(0.265 + var(--bg-lift)) 0.012 var(--tint-h));
--surface-4: oklch(calc(0.295 + var(--bg-lift)) 0.012 var(--tint-h));
/* Shadows */
--elevation-xs: 0 1px 2px oklch(0 0 0 / 0.4);
@@ -144,7 +145,7 @@
--elevation-xl: 0 20px 30px -5px oklch(0 0 0 / 0.6), 0 8px 12px -6px oklch(0 0 0 / 0.4);
--elevation-2xl: 0 25px 50px -12px oklch(0 0 0 / 0.7);
--dot-color: oklch(0.3 0.01 var(--tint-h));
--dot-color: oklch(calc(0.3 + var(--bg-lift)) 0.01 var(--tint-h));
}
/* ── Accent presets ──────────────────────────────────────────
+3 -1
View File
@@ -65,7 +65,9 @@ function navigateToResult(result: GlobalSearchResult) {
</script>
<template>
<header class="flex h-14 shrink-0 items-center gap-2 border-b border-primary/20 bg-background/90 backdrop-blur-md px-3 shadow-sm sticky top-0 z-10">
<header
class="flex h-14 shrink-0 items-center gap-2 border-b border-border/50 bg-background/70 backdrop-blur-xl backdrop-saturate-150 px-3 shadow-sm sticky top-0 z-10"
>
<!-- Mobile: search active overlay -->
<template v-if="mobileSearchOpen">
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0" @click="closeMobileSearch()">
+16 -7
View File
@@ -117,7 +117,7 @@ onMounted(async () => {
style="--sidebar-border: color-mix(in oklch, var(--primary) 18%, transparent)"
class="[&>div:last-child]:shadow-[4px_0_12px_-2px_oklch(0_0_0/0.08)]"
>
<SidebarHeader class="border-b border-sidebar-border">
<SidebarHeader class="border-b border-sidebar-border bg-gradient-to-b from-primary/6 to-transparent">
<div class="flex items-center gap-2.5 px-1 py-1">
<!-- Logo mark -->
<div
@@ -148,7 +148,9 @@ onMounted(async () => {
<SidebarContent>
<!-- Libraries -->
<SidebarGroup class="pt-2">
<SidebarGroupLabel class="text-[10px] uppercase tracking-widest text-sidebar-foreground/35 font-medium group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel
class="text-[10px] uppercase tracking-widest text-sidebar-foreground/75 font-semibold group-data-[collapsible=icon]:hidden"
>
Libraries
</SidebarGroupLabel>
<SidebarGroupAction v-if="hasPermission('manage_libraries')" tooltip="New Library" @click="createLibraryOpen = true">
@@ -182,7 +184,8 @@ onMounted(async () => {
</span>
<span
v-else-if="lib.bookCount !== undefined"
class="ml-auto shrink-0 rounded-full bg-sidebar-foreground/10 px-2 py-0.5 text-xs tabular-nums text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden"
class="ml-auto shrink-0 rounded-full px-2 py-0.5 text-xs tabular-nums group-data-[collapsible=icon]:hidden transition-colors"
:class="activeLibraryId === lib.id ? 'bg-primary/15 text-primary' : 'bg-sidebar-foreground/10 text-sidebar-foreground/70'"
>
{{ lib.bookCount.toLocaleString() }}
</span>
@@ -212,7 +215,9 @@ onMounted(async () => {
<!-- Lenses -->
<SidebarGroup>
<SidebarGroupLabel class="text-[10px] uppercase tracking-widest text-sidebar-foreground/35 font-medium group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel
class="text-[10px] uppercase tracking-widest text-sidebar-foreground/75 font-semibold group-data-[collapsible=icon]:hidden"
>
Lenses
</SidebarGroupLabel>
<SidebarGroupAction tooltip="New Lens" @click="createLensOpen = true">
@@ -237,7 +242,8 @@ onMounted(async () => {
</span>
<span
v-if="lens.bookCount !== undefined"
class="ml-auto shrink-0 rounded-full bg-sidebar-foreground/10 px-2 py-0.5 text-xs tabular-nums text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden"
class="ml-auto shrink-0 rounded-full px-2 py-0.5 text-xs tabular-nums group-data-[collapsible=icon]:hidden transition-colors"
:class="activeLensId === lens.id ? 'bg-primary/15 text-primary' : 'bg-sidebar-foreground/10 text-sidebar-foreground/70'"
>
{{ lens.bookCount.toLocaleString() }}
</span>
@@ -254,7 +260,9 @@ onMounted(async () => {
<!-- Collections -->
<SidebarGroup>
<SidebarGroupLabel class="text-[10px] uppercase tracking-widest text-sidebar-foreground/35 font-medium group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel
class="text-[10px] uppercase tracking-widest text-sidebar-foreground/75 font-semibold group-data-[collapsible=icon]:hidden"
>
Collections
</SidebarGroupLabel>
<SidebarGroupAction tooltip="New Collection" @click="createCollectionOpen = true">
@@ -278,7 +286,8 @@ onMounted(async () => {
{{ collection.name }}
</span>
<span
class="ml-auto shrink-0 rounded-full bg-sidebar-foreground/10 px-2 py-0.5 text-xs tabular-nums text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden"
class="ml-auto shrink-0 rounded-full px-2 py-0.5 text-xs tabular-nums group-data-[collapsible=icon]:hidden transition-colors"
:class="activeCollectionId === collection.id ? 'bg-primary/15 text-primary' : 'bg-sidebar-foreground/10 text-sidebar-foreground/70'"
>
{{ collection.bookCount.toLocaleString() }}
</span>
+6 -17
View File
@@ -24,7 +24,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
<div
v-if="collapsible === 'none'"
data-slot="sidebar"
:class="cn('bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', props.class)"
:class="cn('bg-sidebar/80 backdrop-blur-xl backdrop-saturate-150 text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', props.class)"
v-bind="$attrs"
>
<slot />
@@ -36,7 +36,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
data-slot="sidebar"
data-mobile="true"
:side="side"
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
class="bg-sidebar/90 backdrop-blur-xl backdrop-saturate-150 text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
:style="{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
}"
@@ -60,19 +60,8 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
:data-variant="variant"
:data-side="side"
>
<!-- This is what handles the sidebar gap on desktop -->
<div
:class="
cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
)
"
/>
<!-- Zero-width sentinel — keeps the peer element in the DOM for CSS selectors -->
<div aria-hidden="true" class="w-0 shrink-0" />
<div
:class="
cn(
@@ -83,7 +72,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
props.class,
)
"
@@ -91,7 +80,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
>
<div
data-sidebar="sidebar"
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
class="bg-sidebar/50 backdrop-blur-xl backdrop-saturate-150 border-r border-sidebar-border/60 shadow-[1px_0_0_0_oklch(1_0_0/0.04)] group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
<slot />
</div>
@@ -13,6 +13,12 @@ const props = defineProps<{
:class="
cn(
'bg-background relative flex w-full flex-1 flex-col',
// Sidebar variant: padding-left replaces the old spacer div, letting the background extend under the sidebar for glass effect
'md:peer-data-[variant=sidebar]:pl-(--sidebar-width)',
'md:peer-data-[variant=sidebar]:peer-data-[collapsible=offcanvas]:pl-0',
'md:peer-data-[variant=sidebar]:peer-data-[collapsible=icon]:pl-(--sidebar-width-icon)',
'md:peer-data-[variant=sidebar]:transition-[padding-left] md:peer-data-[variant=sidebar]:duration-200 md:peer-data-[variant=sidebar]:ease-linear',
// Inset variant: existing margin-based layout unchanged
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
props.class,
)
+1 -1
View File
@@ -36,7 +36,7 @@ export { default as SidebarTrigger } from './SidebarTrigger.vue'
export { useSidebar } from './utils'
export const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding,box-shadow] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-primary/10 data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[active=true]:shadow-[inset_2px_0_0_var(--primary)] data-[active=true]:rounded-l-none data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! group-data-[collapsible=icon]:data-[active=true]:shadow-none group-data-[collapsible=icon]:data-[active=true]:rounded-md [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
@@ -104,6 +104,32 @@ const { coverSize, gridGap } = useDisplaySettings()
</button>
</div>
</div>
<!-- Dark mode brightness -->
<div v-if="themeStore.theme === 'dark'" class="px-5 py-4 bg-card">
<div class="flex items-center justify-between mb-3">
<div>
<p class="text-sm font-medium text-foreground">Surface brightness</p>
<p class="text-xs text-muted-foreground mt-0.5">Lighten dark mode surfaces</p>
</div>
<button
v-if="themeStore.brightness > 0"
class="text-xs text-muted-foreground hover:text-foreground transition-colors"
@click="themeStore.setBrightness(0)"
>
Reset
</button>
</div>
<input
:value="themeStore.brightness"
@input="themeStore.setBrightness(Number(($event.target as HTMLInputElement).value))"
type="range"
min="0"
max="100"
step="5"
class="w-full accent-primary cursor-pointer"
/>
</div>
</div>
</div>
+30 -1
View File
@@ -110,6 +110,8 @@ export const useThemeStore = defineStore('theme', () => {
const storedBackground = storage.get<Background>('background', 'dots')
const background = ref<Background>(BACKGROUND_IDS.includes(storedBackground) ? storedBackground : 'dots')
const brightness = ref<number>(storage.get<number>('brightness', 0))
function applyTheme(t: Theme) {
document.documentElement.classList.toggle('dark', t === 'dark')
}
@@ -124,6 +126,11 @@ export const useThemeStore = defineStore('theme', () => {
if (r !== 'default') document.documentElement.classList.add(`radius-${r}`)
}
function applyBrightness(b: number) {
const lift = (b / 100) * 0.12
document.documentElement.style.setProperty('--bg-lift', lift.toFixed(4))
}
function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
}
@@ -136,10 +143,23 @@ export const useThemeStore = defineStore('theme', () => {
radius.value = r
}
function applyBackground(b: Background) {
BACKGROUND_IDS.forEach((id) => {
const cssClass = BACKGROUND_OPTIONS.find((o) => o.id === id)?.cssClass
if (cssClass) document.body.classList.remove(cssClass)
})
const cssClass = BACKGROUND_OPTIONS.find((o) => o.id === b)?.cssClass
if (cssClass) document.body.classList.add(cssClass)
}
function setBackground(b: Background) {
background.value = b
}
function setBrightness(b: number) {
brightness.value = Math.min(100, Math.max(0, b))
}
watch(
theme,
(t) => {
@@ -167,10 +187,19 @@ export const useThemeStore = defineStore('theme', () => {
watch(
background,
(b) => {
applyBackground(b)
storage.set('background', b)
},
{ immediate: true },
)
watch(
brightness,
(b) => {
applyBrightness(b)
storage.set('brightness', b)
},
{ immediate: true },
)
return { theme, accent, radius, background, toggleTheme, setAccent, setRadius, setBackground }
return { theme, accent, radius, background, brightness, toggleTheme, setAccent, setRadius, setBackground, setBrightness }
})