"use client"
import * as React from "react"
export type Density = "comfortable" | "compact"
/** Where the choice is kept, so it survives a reload and travels between tabs. */
export const DENSITY_STORAGE_KEY = "vibra-density"
/** The attribute the CSS reads; `[data-density="compact"]` tightens the rhythm. */
export const DENSITY_ATTRIBUTE = "data-density"
const DEFAULT_DENSITY: Density = "comfortable"
function isDensity(value: string | null | undefined): value is Density {
return value === "comfortable" || value === "compact"
}
// One set for every hook instance on the page: a toggle in the header and a
// switch in a settings panel move together, without a provider between them.
const listeners = new Set<() => void>()
function emit() {
for (const listener of listeners) listener()
}
function subscribe(onStoreChange: () => void): () => void {
listeners.add(onStoreChange)
// Another tab writing the key is a change to the same choice.
window.addEventListener("storage", onStoreChange)
return () => {
listeners.delete(onStoreChange)
window.removeEventListener("storage", onStoreChange)
}
}
/**
* The document is the source of truth, not a React state: the attribute is what
* the CSS actually reads, and a page may set it server-side (AppShell does).
* Returning a string keeps the snapshot stable by value, which is what
* useSyncExternalStore needs.
*/
function getSnapshot(): Density {
const attribute = document.documentElement.getAttribute(DENSITY_ATTRIBUTE)
return isDensity(attribute) ? attribute : DEFAULT_DENSITY
}
function getServerSnapshot(): Density {
return DEFAULT_DENSITY
}
/** Every storage access is wrapped: a full, disabled or partitioned store never throws. */
function readStored(): Density | undefined {
try {
const stored = window.localStorage.getItem(DENSITY_STORAGE_KEY)
return isDensity(stored) ? stored : undefined
} catch {
return undefined
}
}
function writeStored(density: Density) {
try {
window.localStorage.setItem(DENSITY_STORAGE_KEY, density)
} catch {
// A reader with storage turned off still gets the switch, just not the memory.
}
}
/** Sets the attribute the whole page is laid out from, and remembers the choice. */
export function applyDensity(density: Density) {
document.documentElement.setAttribute(DENSITY_ATTRIBUTE, density)
writeStored(density)
emit()
}
/**
* The reader's row rhythm: `comfortable` (the default) or `compact`.
*
* Reads `document.documentElement`'s `data-density` and writes both it and
* `localStorage["vibra-density"]`. On mount it restores a stored choice the
* document does not already carry, so the switch survives a reload; a page
* that sets the attribute itself — a block passing `density` to AppShell —
* wins over what the reader last picked, because that is a decision the page
* has made about its own layout.
*
* Restoring it here costs a paint: every row, card and gap resizes after the
* content is on screen. An app that cares — this one does — writes the
* attribute from a blocking inline script in the document head before anything
* is drawn, and leaves this effect as the fallback.
*/
export function useDensity(): { density: Density; setDensity: (next: Density) => void } {
const density = React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
React.useEffect(() => {
if (document.documentElement.hasAttribute(DENSITY_ATTRIBUTE)) return
const stored = readStored()
if (stored) applyDensity(stored)
}, [])
const setDensity = React.useCallback((next: Density) => applyDensity(next), [])
return { density, setDensity }
}