/** * Viewport facts the Owliver workspace is laid out from. * * Separate from both the panel and its context because both need them: the * panel decides which presentation to render, the provider decides which state * to start in, and a breakpoint that two modules each keep their own copy of is * a breakpoint that will eventually disagree with itself. Keeping it here also * keeps the two from importing each other in a cycle. */ import * as React from 'react'; /** * Below this width there is no room for a column *beside* the dashboard — a * 380px track on a 375px phone collapses `main` to nothing — so Owliver stops * being a column at all and becomes an overlay over the page. * * Deliberately Tailwind's `md` (768px), not `lg`: tablets already lay the inline * column out acceptably, so they keep the two-column workspace and only phones * change presentation. Exported so the provider can start a phone with the * workspace closed from the same number the layout switches on — two places * asking "is this a phone" must never be able to disagree. */ export const STACK_BREAKPOINT = 768; export const PHONE_QUERY = `(max-width: ${STACK_BREAKPOINT - 1}px)`; /** Tracks viewport width so the panel can be clamped. */ export function useViewportWidth() { const [width, setWidth] = React.useState(() => typeof window === 'undefined' ? 1440 : window.innerWidth ); React.useEffect(() => { const onResize = () => setWidth(window.innerWidth); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); return width; } /** * Whether the shell is in its phone layout. * * `matchMedia` rather than a width comparison, so the switch happens on exactly * the same boundary the stylesheet uses. `window.innerWidth` is not that * boundary on a phone: when anything on the page overflows, mobile browsers * widen the layout viewport to fit it, `innerWidth` reports the widened value, * and the layout that was supposed to rescue the page reads the viewport as a * tablet and hands it a 400px sidebar instead. A media query is evaluated * against the media, not against the content, so it cannot be talked out of the * mobile layout by the very overflow the mobile layout exists to prevent. */ export function useIsPhone() { const [phone, setPhone] = React.useState(() => { try { return window.matchMedia(PHONE_QUERY).matches; } catch { return false; } }); React.useEffect(() => { const mql = window.matchMedia(PHONE_QUERY); const onChange = (e) => setPhone(e.matches); setPhone(mql.matches); mql.addEventListener('change', onChange); return () => mql.removeEventListener('change', onChange); }, []); return phone; } /** * The height the user can actually see, while a keyboard is open. * * `100dvh` follows the address bar but knows nothing about the on-screen * keyboard: with the keyboard up, a `100dvh` sheet keeps its full height and * puts its own composer underneath the keys — the one control the sheet exists * to offer. `visualViewport` is the region left over, so the sheet is sized to * that and the composer stays on screen. * * Returns `null` where the API is missing, and the caller falls back to the * `100dvh` class. Nothing here is a fixed pixel height. */ export function useVisualViewport() { const [box, setBox] = React.useState(null); React.useEffect(() => { const vv = window.visualViewport; if (!vv) return undefined; const update = () => setBox({ height: Math.round(vv.height), offsetTop: Math.round(vv.offsetTop) }); update(); vv.addEventListener('resize', update); vv.addEventListener('scroll', update); return () => { vv.removeEventListener('resize', update); vv.removeEventListener('scroll', update); }; }, []); return box; }