From f45a3785a855c8c0e6e63d78fab437566f6d9531 Mon Sep 17 00:00:00 2001 From: Roman Korvatskyi Date: Fri, 17 Jul 2026 16:38:16 +0200 Subject: [PATCH 1/2] =?UTF-8?q?poc(frontend-core):=20OverlayScrollbars=20?= =?UTF-8?q?=E2=80=94=20macOS-like=20overlay=20scrollbars=20on=20all=20plat?= =?UTF-8?q?forms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OverlayScrollArea (per-container) + GlobalOverlayScrollbars (body) built on overlayscrollbars-react: native scrolling kept, ODS-themed thumb, autoHide on idle. Fine-pointer devices only — touch keeps native scrollbars. Storybook: UI/OverlayScrollArea. Co-Authored-By: Claude Fable 5 --- openframe-frontend-core/package.json | 2 + .../src/components/ui/index.ts | 1 + .../src/components/ui/overlay-scroll-area.tsx | 108 +++ .../src/stories/OverlayScrollArea.stories.tsx | 52 ++ openframe-frontend-core/src/styles/index.css | 2 + .../src/styles/overlay-scrollbars-theme.css | 19 + .../src/styles/vendor-overlayscrollbars.css | 673 ++++++++++++++++++ 7 files changed, 857 insertions(+) create mode 100644 openframe-frontend-core/src/components/ui/overlay-scroll-area.tsx create mode 100644 openframe-frontend-core/src/stories/OverlayScrollArea.stories.tsx create mode 100644 openframe-frontend-core/src/styles/overlay-scrollbars-theme.css create mode 100644 openframe-frontend-core/src/styles/vendor-overlayscrollbars.css diff --git a/openframe-frontend-core/package.json b/openframe-frontend-core/package.json index ed29d4160..6418e521d 100644 --- a/openframe-frontend-core/package.json +++ b/openframe-frontend-core/package.json @@ -341,6 +341,8 @@ "mermaid": "^11.14.0", "nats.ws": "^1.30.3", "next-themes": "^0.2.1", + "overlayscrollbars": "^2.16.0", + "overlayscrollbars-react": "^0.5.6", "react-aria-components": "^1.11.0", "react-colorful": "^5.7.0", "react-datepicker": "^8.7.0", diff --git a/openframe-frontend-core/src/components/ui/index.ts b/openframe-frontend-core/src/components/ui/index.ts index 3a4030141..4a0543479 100644 --- a/openframe-frontend-core/src/components/ui/index.ts +++ b/openframe-frontend-core/src/components/ui/index.ts @@ -176,3 +176,4 @@ export * from './simple-markdown-renderer' export { RichMarkdownRenderer, type RichMarkdownRendererProps } from './rich-markdown-renderer' export * from './filter-pill-row' +export * from './overlay-scroll-area' diff --git a/openframe-frontend-core/src/components/ui/overlay-scroll-area.tsx b/openframe-frontend-core/src/components/ui/overlay-scroll-area.tsx new file mode 100644 index 000000000..210d6971c --- /dev/null +++ b/openframe-frontend-core/src/components/ui/overlay-scroll-area.tsx @@ -0,0 +1,108 @@ +'use client' + +import * as React from 'react' +import { OverlayScrollbars, type PartialOptions } from 'overlayscrollbars' +import { OverlayScrollbarsComponent } from 'overlayscrollbars-react' +import { cn } from '../../utils/cn' + +// ============================================================================= +// POC: standardized macOS-like overlay scrollbars on every platform. +// +// OverlayScrollbars hides the native scrollbar and draws a styleable overlay +// one on top while keeping NATIVE scrolling (wheel, touchpad momentum, +// keyboard, drag-the-thumb, click-the-track). With `autoHide` the bar shows +// only while scrolling / hovering the host and fades out when idle — the +// macOS behavior, now identical on Windows/Linux and for mouse users. +// +// Touch devices keep the native scrollbars: mobile bars are already overlay +// and auto-hiding, so the custom layer is initialized only on fine-pointer +// devices (desktop / tablet with a mouse). +// ============================================================================= + +/** Overlay scrollbars are desktop-only; touch keeps native scrolling. */ +const FINE_POINTER_QUERY = '(hover: hover) and (pointer: fine)' + +/** True once mounted on a device with a mouse/trackpad-grade pointer. */ +export function useFinePointer(): boolean { + const [fine, setFine] = React.useState(false) + React.useEffect(() => { + const mq = window.matchMedia(FINE_POINTER_QUERY) + setFine(mq.matches) + const onChange = (e: MediaQueryListEvent) => setFine(e.matches) + mq.addEventListener('change', onChange) + return () => mq.removeEventListener('change', onChange) + }, []) + return fine +} + +/** Shared ODS defaults: thin grey thumb, transparent track, show on scroll. */ +const ODS_SCROLLBAR_OPTIONS: PartialOptions = { + scrollbars: { + theme: 'os-theme-ods', + autoHide: 'scroll', + autoHideDelay: 800, + // Keep the bar visible until the first scroll so keyboard/AT users still + // get the affordance on freshly opened views. + autoHideSuspend: true, + }, +} + +export interface OverlayScrollAreaProps extends React.HTMLAttributes { + /** Extra OverlayScrollbars options merged over the ODS defaults. */ + options?: PartialOptions + children?: React.ReactNode +} + +/** + * Scroll container with the standardized ODS overlay scrollbar. + * + * On fine-pointer devices renders `OverlayScrollbarsComponent`; on touch + * devices (and during SSR / first paint) falls back to a plain + * `overflow-auto` div with native scrolling. Size the component exactly like + * the `overflow-auto` div it replaces (`h-…`/`max-h-…`/`flex-1 min-h-0`). + */ +export function OverlayScrollArea({ className, options, children, ...props }: OverlayScrollAreaProps) { + const fine = useFinePointer() + + if (!fine) { + return ( +
+ {children} +
+ ) + } + + return ( + + {children} + + ) +} + +/** + * Applies the same overlay scrollbar to the PAGE scroller (``). + * Render once near the app root (client side). No-op on touch devices. + */ +export function GlobalOverlayScrollbars() { + const fine = useFinePointer() + + React.useEffect(() => { + if (!fine) return + // Required by OverlayScrollbars for body initialization — suppresses the + // native scrollbars on both scroll roots before the overlay takes over. + document.documentElement.setAttribute('data-overlayscrollbars-initialize', '') + document.body.setAttribute('data-overlayscrollbars-initialize', '') + const instance = OverlayScrollbars(document.body, ODS_SCROLLBAR_OPTIONS) + return () => { + instance.destroy() + document.documentElement.removeAttribute('data-overlayscrollbars-initialize') + document.body.removeAttribute('data-overlayscrollbars-initialize') + } + }, [fine]) + + return null +} diff --git a/openframe-frontend-core/src/stories/OverlayScrollArea.stories.tsx b/openframe-frontend-core/src/stories/OverlayScrollArea.stories.tsx new file mode 100644 index 000000000..3d6727390 --- /dev/null +++ b/openframe-frontend-core/src/stories/OverlayScrollArea.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { OverlayScrollArea } from '../components/ui/overlay-scroll-area' + +// POC: standardized macOS-like overlay scrollbar (OverlayScrollbars) — the +// bar appears while scrolling / hovering and fades out when idle, on every +// platform. Native scrolling (wheel, touchpad momentum, thumb drag) is kept. + +const meta: Meta = { + title: 'UI/OverlayScrollArea', + component: OverlayScrollArea, + parameters: { layout: 'centered' }, +} + +export default meta +type Story = StoryObj + +const rows = (count: number) => + Array.from({ length: count }, (_, i) => ( +
+ Row {i + 1} — scrollable content +
+ )) + +export const Vertical: Story = { + render: () => ( + + {rows(40)} + + ), +} + +export const Horizontal: Story = { + render: () => ( + +
+ {Array.from({ length: 30 }, (_, i) => ( +
+ Chip {i + 1} +
+ ))} +
+
+ ), +} + +export const Both: Story = { + render: () => ( + +
{rows(40)}
+
+ ), +} diff --git a/openframe-frontend-core/src/styles/index.css b/openframe-frontend-core/src/styles/index.css index 3614e0b50..623ead89d 100644 --- a/openframe-frontend-core/src/styles/index.css +++ b/openframe-frontend-core/src/styles/index.css @@ -10,6 +10,8 @@ /* Vendor/library style overrides */ @import "./vendor-react-easy-crop.css"; @import "./vendor-react-scroll.css"; +@import "./vendor-overlayscrollbars.css"; +@import "./overlay-scrollbars-theme.css"; @tailwind base; @tailwind components; diff --git a/openframe-frontend-core/src/styles/overlay-scrollbars-theme.css b/openframe-frontend-core/src/styles/overlay-scrollbars-theme.css new file mode 100644 index 000000000..2ca3dd8c4 --- /dev/null +++ b/openframe-frontend-core/src/styles/overlay-scrollbars-theme.css @@ -0,0 +1,19 @@ +/* ODS theme for OverlayScrollbars (see components/ui/overlay-scroll-area.tsx). + Thin grey draggable thumb on a transparent track — matches the global + elevator scrollbar styling, but with macOS-like auto-hide on all platforms. */ +.os-theme-ods { + --os-size: 8px; + --os-padding-perpendicular: 2px; + --os-padding-axis: 2px; + --os-track-bg: transparent; + --os-track-bg-hover: transparent; + --os-track-bg-active: transparent; + --os-handle-bg: var(--ods-system-greys-grey); + --os-handle-bg-hover: var(--ods-system-greys-grey-hover); + --os-handle-bg-active: var(--ods-system-greys-grey-action); + --os-handle-border-radius: 4px; + --os-handle-perpendicular-size: 100%; + --os-handle-perpendicular-size-hover: 100%; + --os-handle-perpendicular-size-active: 100%; + --os-handle-min-size: 33px; +} diff --git a/openframe-frontend-core/src/styles/vendor-overlayscrollbars.css b/openframe-frontend-core/src/styles/vendor-overlayscrollbars.css new file mode 100644 index 000000000..ce9a9dbeb --- /dev/null +++ b/openframe-frontend-core/src/styles/vendor-overlayscrollbars.css @@ -0,0 +1,673 @@ +/* Vendored from overlayscrollbars@2.16.0 (styles/overlayscrollbars.css). + Do not edit — replace wholesale on package upgrade. ODS theme lives in + ./overlay-scrollbars-theme.css */ +/*! + * OverlayScrollbars + * Version: 2.16.0 + * + * Copyright (c) Rene Haas | KingSora. + * https://github.com/KingSora + * + * Released under the MIT license. + */ +.os-size-observer, +.os-size-observer-listener { + scroll-behavior: auto !important; + direction: inherit; + pointer-events: none; + overflow: hidden; + visibility: hidden; + box-sizing: border-box; +} + +.os-size-observer, +.os-size-observer-listener, +.os-size-observer-listener-item, +.os-size-observer-listener-item-final { + writing-mode: horizontal-tb; + position: absolute; + left: 0; + top: 0; +} + +.os-size-observer { + z-index: -1; + contain: strict; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + padding: inherit; + border: inherit; + box-sizing: inherit; + margin: -133px; + top: 0; + right: 0; + bottom: 0; + left: 0; + transform: scale(0.1); +} +.os-size-observer::before { + content: ""; + flex: none; + box-sizing: inherit; + padding: 10px; + width: 10px; + height: 10px; +} + +.os-size-observer-appear { + animation: os-size-observer-appear-animation 1ms forwards; +} + +.os-size-observer-listener { + box-sizing: border-box; + position: relative; + flex: auto; + padding: inherit; + border: inherit; + margin: -133px; + transform: scale(calc(1 / 0.1)); +} +.os-size-observer-listener.ltr { + margin-right: -266px; + margin-left: 0; +} +.os-size-observer-listener.rtl { + margin-left: -266px; + margin-right: 0; +} +.os-size-observer-listener:empty::before { + content: ""; + width: 100%; + height: 100%; +} +.os-size-observer-listener:empty::before, .os-size-observer-listener > .os-size-observer-listener-item { + display: block; + position: relative; + padding: inherit; + border: inherit; + box-sizing: content-box; + flex: auto; +} + +.os-size-observer-listener-scroll { + box-sizing: border-box; + display: flex; +} + +.os-size-observer-listener-item { + right: 0; + bottom: 0; + overflow: hidden; + direction: ltr; + flex: none; +} + +.os-size-observer-listener-item-final { + transition: none; +} + +@keyframes os-size-observer-appear-animation { + from { + cursor: auto; + } + to { + cursor: none; + } +} +.os-trinsic-observer { + flex: none; + box-sizing: border-box; + position: relative; + max-width: 0px; + max-height: 1px; + padding: 0; + margin: 0; + border: none; + overflow: hidden; + z-index: -1; + height: 0; + top: calc(100% + 1px); + contain: strict; +} +.os-trinsic-observer:not(:empty) { + height: calc(100% + 1px); + top: -1px; +} +.os-trinsic-observer:not(:empty) > .os-size-observer { + width: 1000%; + height: 1000%; + min-height: 1px; + min-width: 1px; +} + +/** + * hide native scrollbars + * changes to this styles need to be reflected in the environment styles to correctly detect scrollbar hiding + */ +/** + * body element + */ +html[data-overlayscrollbars-body] { + overflow: hidden; +} + +html[data-overlayscrollbars-body], +html[data-overlayscrollbars-body] > body { + width: 100%; + height: 100%; + margin: 0; +} + +html[data-overlayscrollbars-body] > body { + overflow: visible; + margin: 0; +} + +/** + * structure setup + */ +[data-overlayscrollbars] { + position: relative; +} + +[data-overlayscrollbars~=host], +[data-overlayscrollbars-padding] { + display: flex; + align-items: stretch !important; + flex-direction: row !important; + flex-wrap: nowrap !important; + scroll-behavior: auto !important; +} + +[data-overlayscrollbars-padding], +[data-overlayscrollbars-viewport]:not([data-overlayscrollbars]) { + box-sizing: inherit; + position: relative; + flex: auto; + height: auto; + width: 100%; + min-width: 0; + padding: 0; + margin: 0; + border: none; + z-index: 0; +} + +[data-overlayscrollbars-viewport]:not([data-overlayscrollbars]) { + --os-vaw: 0; + --os-vah: 0; + outline: none; +} +[data-overlayscrollbars-viewport]:not([data-overlayscrollbars]):focus { + outline: none; +} +[data-overlayscrollbars-viewport][data-overlayscrollbars-viewport~=arrange]::before { + content: ""; + position: absolute; + pointer-events: none; + z-index: -1; + min-width: 1px; + min-height: 1px; + width: var(--os-vaw); + height: var(--os-vah); +} + +/** + * wrapper elements overflow: + */ +[data-overlayscrollbars~=host], +[data-overlayscrollbars-padding] { + overflow: hidden !important; +} + +[data-overlayscrollbars~=host][data-overlayscrollbars~=noClipping], +[data-overlayscrollbars-padding~=noClipping] { + overflow: visible !important; +} + +/** + * viewport overflow: + */ +[data-overlayscrollbars-viewport] { + --os-viewport-overflow-x: hidden; + --os-viewport-overflow-y: hidden; + overflow-x: var(--os-viewport-overflow-x); + overflow-y: var(--os-viewport-overflow-y); +} + +[data-overlayscrollbars-viewport~=overflowXVisible] { + --os-viewport-overflow-x: visible; +} + +[data-overlayscrollbars-viewport~=overflowXHidden] { + --os-viewport-overflow-x: hidden; +} + +[data-overlayscrollbars-viewport~=overflowXScroll] { + --os-viewport-overflow-x: scroll; +} + +[data-overlayscrollbars-viewport~=overflowYVisible] { + --os-viewport-overflow-y: visible; +} + +[data-overlayscrollbars-viewport~=overflowYHidden] { + --os-viewport-overflow-y: hidden; +} + +[data-overlayscrollbars-viewport~=overflowYScroll] { + --os-viewport-overflow-y: scroll; +} + +[data-overlayscrollbars-viewport~=overflowImportant] { + overflow-x: var(--os-viewport-overflow-x) !important; + overflow-y: var(--os-viewport-overflow-y) !important; +} + +/** + * viewport state modifiers: + */ +[data-overlayscrollbars-viewport~=noContent]:not(#osFakeId) { + font-size: 0 !important; + line-height: 0 !important; +} + +[data-overlayscrollbars-viewport~=noContent]:not(#osFakeId)::before, +[data-overlayscrollbars-viewport~=noContent]:not(#osFakeId)::after, +[data-overlayscrollbars-viewport~=noContent]:not(#osFakeId) > *:not(#osFakeId) { + display: none !important; + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border-width: 0 !important; +} + +[data-overlayscrollbars-viewport~=measuring], +[data-overlayscrollbars-viewport~=scrolling] { + scroll-behavior: auto !important; + scroll-snap-type: none !important; +} + +[data-overlayscrollbars-viewport~=measuring][data-overlayscrollbars-viewport~=overflowXVisible] { + overflow-x: hidden !important; +} + +[data-overlayscrollbars-viewport~=measuring][data-overlayscrollbars-viewport~=overflowYVisible] { + overflow-y: hidden !important; +} + +/** + * content element: + */ +[data-overlayscrollbars-content] { + box-sizing: inherit; +} + +/** + * Display contents to bridge any flickering during deferred initialization. + */ +[data-overlayscrollbars-contents]:not(#osFakeId):not([data-overlayscrollbars-padding]):not([data-overlayscrollbars-viewport]):not([data-overlayscrollbars-content]) { + display: contents; +} + +/** + * optional & experimental grid mode + */ +[data-overlayscrollbars-grid], +[data-overlayscrollbars-grid] [data-overlayscrollbars-padding] { + display: grid; + grid-template: 1fr/1fr; +} + +[data-overlayscrollbars-grid] > [data-overlayscrollbars-padding], +[data-overlayscrollbars-grid] > [data-overlayscrollbars-viewport], +[data-overlayscrollbars-grid] > [data-overlayscrollbars-padding] > [data-overlayscrollbars-viewport] { + height: auto !important; + width: auto !important; +} + +@property --os-scroll-percent { + syntax: ""; + inherits: true; + initial-value: 0; +} +@property --os-viewport-percent { + syntax: ""; + inherits: true; + initial-value: 0; +} +.os-scrollbar { + --os-viewport-percent: 0; + --os-scroll-percent: 0; + --os-scroll-direction: 0; + --os-scroll-percent-directional: calc( + var(--os-scroll-percent) - (var(--os-scroll-percent) + (1 - var(--os-scroll-percent)) * -1) * + var(--os-scroll-direction) + ); +} + +.os-scrollbar { + contain: size layout; + contain: size layout style; + transition: opacity 0.15s, visibility 0.15s, top 0.15s, right 0.15s, bottom 0.15s, left 0.15s; + pointer-events: none; + position: absolute; + opacity: 0; + visibility: hidden; +} + +body > .os-scrollbar { + position: fixed; + z-index: 99999; +} + +.os-scrollbar-transitionless { + transition: none !important; +} + +.os-scrollbar-track { + position: relative; + padding: 0 !important; + border: none !important; +} + +.os-scrollbar-handle { + position: absolute; +} + +.os-scrollbar-track, +.os-scrollbar-handle { + pointer-events: none; + width: 100%; + height: 100%; +} + +.os-scrollbar.os-scrollbar-track-interactive .os-scrollbar-track, +.os-scrollbar.os-scrollbar-handle-interactive .os-scrollbar-handle { + pointer-events: auto; + touch-action: none; +} + +.os-scrollbar-horizontal { + bottom: 0; + left: 0; +} + +.os-scrollbar-vertical { + top: 0; + right: 0; +} + +.os-scrollbar-rtl.os-scrollbar-horizontal { + right: 0; +} + +.os-scrollbar-rtl.os-scrollbar-vertical { + right: auto; + left: 0; +} + +.os-scrollbar-visible { + opacity: 1; + visibility: visible; +} + +.os-scrollbar-auto-hide.os-scrollbar-auto-hide-hidden { + opacity: 0; + visibility: hidden; +} + +.os-scrollbar-interaction.os-scrollbar-visible { + opacity: 1; + visibility: visible; +} + +.os-scrollbar-unusable, +.os-scrollbar-unusable *, +.os-scrollbar-wheel, +.os-scrollbar-wheel * { + pointer-events: none !important; +} + +.os-scrollbar-unusable .os-scrollbar-handle { + opacity: 0 !important; + transition: none !important; +} + +.os-scrollbar-horizontal .os-scrollbar-handle { + bottom: 0; + left: calc(var(--os-scroll-percent-directional) * 100%); + transform: translateX(calc(var(--os-scroll-percent-directional) * -100%)); + width: calc(var(--os-viewport-percent) * 100%); +} + +.os-scrollbar-vertical .os-scrollbar-handle { + right: 0; + top: calc(var(--os-scroll-percent-directional) * 100%); + transform: translateY(calc(var(--os-scroll-percent-directional) * -100%)); + height: calc(var(--os-viewport-percent) * 100%); +} + +@supports (container-type: size) { + .os-scrollbar-track { + container-type: size; + } + .os-scrollbar-horizontal .os-scrollbar-handle { + left: auto; + transform: translateX(calc(var(--os-scroll-percent-directional) * 100cqw + var(--os-scroll-percent-directional) * -100%)); + } + .os-scrollbar-vertical .os-scrollbar-handle { + top: auto; + transform: translateY(calc(var(--os-scroll-percent-directional) * 100cqh + var(--os-scroll-percent-directional) * -100%)); + } + .os-scrollbar-rtl.os-scrollbar-horizontal .os-scrollbar-handle { + right: auto; + left: 0; + } +} +.os-scrollbar-rtl.os-scrollbar-vertical .os-scrollbar-handle { + right: auto; + left: 0; +} + +.os-scrollbar.os-scrollbar-horizontal.os-scrollbar-cornerless, +.os-scrollbar.os-scrollbar-horizontal.os-scrollbar-cornerless.os-scrollbar-rtl { + left: 0; + right: 0; +} + +.os-scrollbar.os-scrollbar-vertical.os-scrollbar-cornerless, +.os-scrollbar.os-scrollbar-vertical.os-scrollbar-cornerless.os-scrollbar-rtl { + top: 0; + bottom: 0; +} + +@media print { + .os-scrollbar { + display: none; + } +} +.os-scrollbar { + --os-size: 0; + --os-padding-perpendicular: 0; + --os-padding-axis: 0; + --os-track-border-radius: 0; + --os-track-bg: none; + --os-track-bg-hover: none; + --os-track-bg-active: none; + --os-track-border: none; + --os-track-border-hover: none; + --os-track-border-active: none; + --os-handle-border-radius: 0; + --os-handle-bg: none; + --os-handle-bg-hover: none; + --os-handle-bg-active: none; + --os-handle-border: none; + --os-handle-border-hover: none; + --os-handle-border-active: none; + --os-handle-min-size: 33px; + --os-handle-max-size: none; + --os-handle-perpendicular-size: 100%; + --os-handle-perpendicular-size-hover: 100%; + --os-handle-perpendicular-size-active: 100%; + --os-handle-interactive-area-offset: 0; +} + +.os-scrollbar-track { + border: var(--os-track-border); + border-radius: var(--os-track-border-radius); + background: var(--os-track-bg); + transition: opacity 0.15s, background-color 0.15s, border-color 0.15s; +} +.os-scrollbar-track:hover { + border: var(--os-track-border-hover); + background: var(--os-track-bg-hover); +} +.os-scrollbar-track:active { + border: var(--os-track-border-active); + background: var(--os-track-bg-active); +} + +.os-scrollbar-handle { + border: var(--os-handle-border); + border-radius: var(--os-handle-border-radius); + background: var(--os-handle-bg); +} +.os-scrollbar-handle:hover { + border: var(--os-handle-border-hover); + background: var(--os-handle-bg-hover); +} +.os-scrollbar-handle:active { + border: var(--os-handle-border-active); + background: var(--os-handle-bg-active); +} + +.os-scrollbar-track:before, +.os-scrollbar-handle:before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + display: block; +} + +.os-scrollbar-horizontal { + padding: var(--os-padding-perpendicular) var(--os-padding-axis); + right: var(--os-size); + height: var(--os-size); +} +.os-scrollbar-horizontal.os-scrollbar-rtl { + left: var(--os-size); + right: 0; +} +.os-scrollbar-horizontal .os-scrollbar-track:before { + top: calc(var(--os-padding-perpendicular) * -1); + bottom: calc(var(--os-padding-perpendicular) * -1); +} +.os-scrollbar-horizontal .os-scrollbar-handle { + min-width: var(--os-handle-min-size); + max-width: var(--os-handle-max-size); + height: var(--os-handle-perpendicular-size); + transition: opacity 0.15s, background-color 0.15s, border-color 0.15s, height 0.15s; +} +.os-scrollbar-horizontal .os-scrollbar-handle:before { + top: calc((var(--os-padding-perpendicular) + var(--os-handle-interactive-area-offset)) * -1); + bottom: calc(var(--os-padding-perpendicular) * -1); +} +.os-scrollbar-horizontal:hover .os-scrollbar-handle { + height: var(--os-handle-perpendicular-size-hover); +} +.os-scrollbar-horizontal:active .os-scrollbar-handle { + height: var(--os-handle-perpendicular-size-active); +} + +.os-scrollbar-vertical { + padding: var(--os-padding-axis) var(--os-padding-perpendicular); + bottom: var(--os-size); + width: var(--os-size); +} +.os-scrollbar-vertical .os-scrollbar-track:before { + left: calc(var(--os-padding-perpendicular) * -1); + right: calc(var(--os-padding-perpendicular) * -1); +} +.os-scrollbar-vertical .os-scrollbar-handle { + min-height: var(--os-handle-min-size); + max-height: var(--os-handle-max-size); + width: var(--os-handle-perpendicular-size); + transition: opacity 0.15s, background-color 0.15s, border-color 0.15s, width 0.15s; +} +.os-scrollbar-vertical .os-scrollbar-handle:before { + left: calc((var(--os-padding-perpendicular) + var(--os-handle-interactive-area-offset)) * -1); + right: calc(var(--os-padding-perpendicular) * -1); +} +.os-scrollbar-vertical.os-scrollbar-rtl .os-scrollbar-handle:before { + right: calc((var(--os-padding-perpendicular) + var(--os-handle-interactive-area-offset)) * -1); + left: calc(var(--os-padding-perpendicular) * -1); +} +.os-scrollbar-vertical:hover .os-scrollbar-handle { + width: var(--os-handle-perpendicular-size-hover); +} +.os-scrollbar-vertical:active .os-scrollbar-handle { + width: var(--os-handle-perpendicular-size-active); +} + +/* NONE THEME: */ +[data-overlayscrollbars-viewport~=measuring] > .os-scrollbar, +.os-theme-none.os-scrollbar { + display: none !important; +} + +/* DARK & LIGHT THEME: */ +.os-theme-dark, +.os-theme-light { + box-sizing: border-box; + --os-size: 10px; + --os-padding-perpendicular: 2px; + --os-padding-axis: 2px; + --os-track-border-radius: 10px; + --os-handle-interactive-area-offset: 4px; + --os-handle-border-radius: 10px; +} + +.os-theme-dark { + --os-handle-bg: rgba(0, 0, 0, 0.44); + --os-handle-bg-hover: rgba(0, 0, 0, 0.55); + --os-handle-bg-active: rgba(0, 0, 0, 0.66); +} + +.os-theme-light { + --os-handle-bg: rgba(255, 255, 255, 0.44); + --os-handle-bg-hover: rgba(255, 255, 255, 0.55); + --os-handle-bg-active: rgba(255, 255, 255, 0.66); +} + +[data-overlayscrollbars-initialize]:not([data-overlayscrollbars-viewport]), +[data-overlayscrollbars-viewport~=scrollbarHidden], +html[data-overlayscrollbars-viewport~=scrollbarHidden] > body { + scrollbar-width: none !important; +} + +[data-overlayscrollbars-initialize]:not([data-overlayscrollbars-viewport])::-webkit-scrollbar, +[data-overlayscrollbars-initialize]:not([data-overlayscrollbars-viewport])::-webkit-scrollbar-corner, +[data-overlayscrollbars-viewport~=scrollbarHidden]::-webkit-scrollbar, +[data-overlayscrollbars-viewport~=scrollbarHidden]::-webkit-scrollbar-corner, +html[data-overlayscrollbars-viewport~=scrollbarHidden] > body::-webkit-scrollbar, +html[data-overlayscrollbars-viewport~=scrollbarHidden] > body::-webkit-scrollbar-corner { + -webkit-appearance: none !important; + appearance: none !important; + display: none !important; + width: 0 !important; + height: 0 !important; +} + +[data-overlayscrollbars-initialize]:not([data-overlayscrollbars]):not(html):not(body) { + overflow: auto; +} \ No newline at end of file From 400dd056d9ef29db6eb75b8853e06718a8fd8633 Mon Sep 17 00:00:00 2001 From: Roman Korvatskyi Date: Fri, 17 Jul 2026 19:14:56 +0200 Subject: [PATCH 2/2] feat(frontend-core): roll out OverlayScrollArea across shared scroll containers OverlayScrollArea now renders its own stable viewport div (passed to OverlayScrollbars via elements.viewport): children stay its direct children, refs/onScroll/observer logic work unchanged, overlay handles live on the non-scrolling host. Swapped: DrawerBody, Modal/ModalV2 content, PageContainer detail scroller, notification drawer list, time-tracker panel, chat sidebar/ticket list/history, guide & mingo welcome columns, chat message list. Co-Authored-By: Claude Fable 5 --- .../src/components/chat/chat-message-list.tsx | 14 +-- .../src/components/chat/chat-sidebar.tsx | 9 +- .../src/components/chat/chat-ticket-list.tsx | 5 +- .../src/components/chat/guide-welcome.tsx | 10 +- .../components/chat/mingo-chat-history.tsx | 10 +- .../src/components/chat/mingo-welcome.tsx | 10 +- .../notifications/notification-drawer.tsx | 10 +- .../time-tracker/time-tracker-panel.tsx | 8 +- .../src/components/layout/page-container.tsx | 18 ++- .../src/components/ui/drawer.tsx | 11 +- .../src/components/ui/modal-v2.tsx | 8 +- .../src/components/ui/modal.tsx | 8 +- .../src/components/ui/overlay-scroll-area.tsx | 111 ++++++++++++++---- 13 files changed, 161 insertions(+), 71 deletions(-) diff --git a/openframe-frontend-core/src/components/chat/chat-message-list.tsx b/openframe-frontend-core/src/components/chat/chat-message-list.tsx index 2c164f99e..44903c3a6 100644 --- a/openframe-frontend-core/src/components/chat/chat-message-list.tsx +++ b/openframe-frontend-core/src/components/chat/chat-message-list.tsx @@ -3,6 +3,7 @@ import { useRef, useEffect, useLayoutEffect, useImperativeHandle, forwardRef } from "react" import { useStickToBottom } from "use-stick-to-bottom" import { cn } from "../../utils/cn" +import { OverlayScrollArea } from '../ui/overlay-scroll-area' import { ChatMessageEnhanced } from "./chat-message-enhanced" import { ChatMessageListSkeleton } from "./chat-message-skeleton" import { DotsLoaderIcon } from "../icons-v2-generated" @@ -491,13 +492,10 @@ const ChatMessageList = forwardRef( return (
-
( ) })}
-
+ {/* Footer-pinned streaming loader — outside the scroller so it doesn't jitter as the streaming message grows. Color is set diff --git a/openframe-frontend-core/src/components/chat/chat-sidebar.tsx b/openframe-frontend-core/src/components/chat/chat-sidebar.tsx index d981ff495..808933777 100644 --- a/openframe-frontend-core/src/components/chat/chat-sidebar.tsx +++ b/openframe-frontend-core/src/components/chat/chat-sidebar.tsx @@ -1,6 +1,7 @@ import { forwardRef, useRef, useEffect } from "react" import { cn } from "../../utils/cn" import { Button } from "../ui/button" +import { OverlayScrollArea } from '../ui/overlay-scroll-area' import { ChatPlusIcon, ChatsIcon } from "../icons-v2-generated" import { Chevron02RightIcon } from "../icons-v2-generated" import { ChatSidebarSkeleton, DialogListItemSkeleton } from "./chat-sidebar-skeleton" @@ -154,12 +155,12 @@ const ChatSidebar = forwardRef(
) : children ? ( /* Custom children content */ -
+ {children} -
+ ) : ( /* Dialogs List */ -
+
{dialogs.map((dialog) => ( (
)}
- + )} diff --git a/openframe-frontend-core/src/components/chat/chat-ticket-list.tsx b/openframe-frontend-core/src/components/chat/chat-ticket-list.tsx index cc5e760e7..0101d758d 100644 --- a/openframe-frontend-core/src/components/chat/chat-ticket-list.tsx +++ b/openframe-frontend-core/src/components/chat/chat-ticket-list.tsx @@ -3,6 +3,7 @@ import * as React from 'react' import { cn } from '../../utils/cn' import { ScrollFadeOverlay, useScrollFade } from '../ui/scroll-fade' +import { OverlayScrollArea } from '../ui/overlay-scroll-area' import { ChatTicketItem, type ChatTicketItemData } from './entity-cards/chat-ticket-item' export interface ChatTicketListProps extends React.HTMLAttributes { @@ -46,7 +47,7 @@ const ChatTicketList = React.forwardRef( !fadeBottom && "border-b rounded-b-md", )} > -
+ {tickets.map((ticket) => ( ( onClick={onTicketClick} /> ))} -
+ {/* Scroll-fade overlays — tinted with the page background so edge tickets fade into the surface behind the list in BOTH themes diff --git a/openframe-frontend-core/src/components/chat/guide-welcome.tsx b/openframe-frontend-core/src/components/chat/guide-welcome.tsx index 105017d46..cdb811c01 100644 --- a/openframe-frontend-core/src/components/chat/guide-welcome.tsx +++ b/openframe-frontend-core/src/components/chat/guide-welcome.tsx @@ -4,6 +4,7 @@ import * as React from 'react' import { cn } from '../../utils/cn' import { MingoIcon } from '../icons' import { ScrollFadeOverlay, useScrollFade } from '../ui/scroll-fade' +import { OverlayScrollArea } from '../ui/overlay-scroll-area' import { Skeleton } from '../ui/skeleton' import { ChatQuickActionRow } from './chat-quick-action-row' import { EntityIcon } from '../icon-display' @@ -139,10 +140,11 @@ export function GuideWelcome({ {/* Greeting + slash-command list share one scroll region; `relative` so the edge fades can overlay it. */}
-
{/* Greeting grows to fill (`flex-1`) so the slash-command list stays anchored below it — but its content is anchored at the TOP of the @@ -185,7 +187,7 @@ export function GuideWelcome({
{children} -
+ {/* Edge scroll-fades — visible only when content is hidden beyond them. */} diff --git a/openframe-frontend-core/src/components/chat/mingo-chat-history.tsx b/openframe-frontend-core/src/components/chat/mingo-chat-history.tsx index 11351a989..e48e5a2d7 100644 --- a/openframe-frontend-core/src/components/chat/mingo-chat-history.tsx +++ b/openframe-frontend-core/src/components/chat/mingo-chat-history.tsx @@ -5,6 +5,7 @@ import { cn } from '../../utils/cn' import { ActionsMenuDropdown, type ActionsMenuItem } from '../ui/actions-menu' import { Button } from '../ui/button' import { ScrollFadeOverlay, useScrollFade } from '../ui/scroll-fade' +import { OverlayScrollArea } from '../ui/overlay-scroll-area' import { Ellipsis01Icon, SearchXmarkIcon } from '../icons-v2-generated' import { ChatListEmptyState } from './chat-list-empty-state' import type { DialogItem } from './types/component.types' @@ -338,10 +339,11 @@ export function MingoChatHistory({ {/* Scroll region — the search INPUT now lives in the panel header (Figma 116:51217), so the list is just the grouped rows + fades. */}
-
{noSearchResults ? ( // No search matches — same centred glyph + title + caption layout as @@ -374,7 +376,7 @@ export function MingoChatHistory({ )) )} {hasMore ?
: null} -
+ {/* Scroll-fade — only while content is hidden in that direction. */} diff --git a/openframe-frontend-core/src/components/chat/mingo-welcome.tsx b/openframe-frontend-core/src/components/chat/mingo-welcome.tsx index 2ba943446..a183e4e26 100644 --- a/openframe-frontend-core/src/components/chat/mingo-welcome.tsx +++ b/openframe-frontend-core/src/components/chat/mingo-welcome.tsx @@ -8,6 +8,7 @@ import { ChatQuickActionRow } from './chat-quick-action-row' import { QuickActionChipButton } from './quick-action-chip' import { Button } from '../ui/button' import { ScrollFadeOverlay, useScrollFade } from '../ui/scroll-fade' +import { OverlayScrollArea } from '../ui/overlay-scroll-area' import { XmarkIcon } from '../icons-v2-generated/signs-and-symbols/xmark-icon' import { CompassIcon, @@ -239,10 +240,11 @@ export function MingoWelcome({ input. The wrapper is `relative` so the scroll-fade gradients can overlay the top/bottom edges. */}
-
{/* Greeting — grows to fill (`flex-1`) so it centres vertically, keeping the grid anchored at the bottom of the scroll area. Default @@ -261,7 +263,7 @@ export function MingoWelcome({

{subtitle}

-
+ {/* Edge scroll-fades — visible only when content is hidden beyond them. Fade into the panel's dark `ods-bg` surface (the default color), matching diff --git a/openframe-frontend-core/src/components/features/notifications/notification-drawer.tsx b/openframe-frontend-core/src/components/features/notifications/notification-drawer.tsx index c5ec7b47c..f52607d33 100644 --- a/openframe-frontend-core/src/components/features/notifications/notification-drawer.tsx +++ b/openframe-frontend-core/src/components/features/notifications/notification-drawer.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useRef } from 'react' +import { OverlayScrollArea } from '../../ui/overlay-scroll-area' import { BellOffIcon } from '../../icons-v2-generated/interface/bell-off-icon' import { ClockHistoryIcon, ArrowRightUpIcon } from '../../icons-v2-generated' import { useAppLayoutDrawerContainer } from '../../navigation/app-layout-context' @@ -189,9 +190,10 @@ function DrawerScrollList({ const isEmpty = unreadNotifications.length === 0 return ( -
{isEmpty && !isLoadingMore ? ( @@ -214,7 +216,7 @@ function DrawerScrollList({ {loadMore && hasMore && + ) } diff --git a/openframe-frontend-core/src/components/features/time-tracker/time-tracker-panel.tsx b/openframe-frontend-core/src/components/features/time-tracker/time-tracker-panel.tsx index 0ec80a9db..f057415aa 100644 --- a/openframe-frontend-core/src/components/features/time-tracker/time-tracker-panel.tsx +++ b/openframe-frontend-core/src/components/features/time-tracker/time-tracker-panel.tsx @@ -21,6 +21,7 @@ import { XmarkIcon, } from '../../icons-v2-generated' import { useTrackerClock } from './use-tracker-clock' +import { OverlayScrollArea } from '../../ui/overlay-scroll-area' import type { TimeTrackerData, TimeTrackerEntry, TimeTrackerStatus } from './types' interface CustomerAutocompleteOption extends AutocompleteOption { @@ -149,11 +150,12 @@ export function TimeTrackerPanel({ const visibleEntries = lastEntries.slice(0, 3) return ( -
-
+ ) } diff --git a/openframe-frontend-core/src/components/layout/page-container.tsx b/openframe-frontend-core/src/components/layout/page-container.tsx index 938e632f3..d0d7a10a5 100644 --- a/openframe-frontend-core/src/components/layout/page-container.tsx +++ b/openframe-frontend-core/src/components/layout/page-container.tsx @@ -4,6 +4,7 @@ import React from 'react' import { cn } from '../../utils/cn' import type { ActionsMenuGroup } from '../ui/actions-menu' import { PageActions, type PageActionButton } from '../ui/page-actions' +import { OverlayScrollArea } from '../ui/overlay-scroll-area' import { BackButton } from './back-button' // Legacy interface for backward compatibility (layout version) @@ -393,7 +394,7 @@ function renderAdvancedPageContainer({ switch (variant) { case 'detail': - return cn('flex-1 overflow-auto', mobilePadding, contentClassName) + return cn('flex-1 min-h-0', mobilePadding, contentClassName) case 'list': return cn('flex flex-col gap-4 md:gap-6', mobilePadding, contentClassName) case 'form': @@ -407,10 +408,17 @@ function renderAdvancedPageContainer({ return (
{renderHeader()} - -
- {children} -
+ + {variant === 'detail' ? ( + // The only scrolling variant — standardized overlay scrollbar. + + {children} + + ) : ( +
+ {children} +
+ )}
) } diff --git a/openframe-frontend-core/src/components/ui/drawer.tsx b/openframe-frontend-core/src/components/ui/drawer.tsx index cfdddfaf6..e88a8da07 100644 --- a/openframe-frontend-core/src/components/ui/drawer.tsx +++ b/openframe-frontend-core/src/components/ui/drawer.tsx @@ -7,6 +7,7 @@ import { X } from "lucide-react" import { cn } from "../../utils/cn" import { useHeaderHeight } from "../../hooks/ui/use-header-height" +import { OverlayScrollArea } from "./overlay-scroll-area" /** Unified overlay backdrop — dimmed, no blur. Single source of truth for * every full-screen backdrop (Drawer, AppLayoutDrawer, MobileBurgerMenu, @@ -530,12 +531,16 @@ DrawerDescription.displayName = "DrawerDescription" const DrawerBody = ({ className, + children, ...props }: React.HTMLAttributes) => ( -
+ > + {children} + ) DrawerBody.displayName = "DrawerBody" diff --git a/openframe-frontend-core/src/components/ui/modal-v2.tsx b/openframe-frontend-core/src/components/ui/modal-v2.tsx index 211ccc488..318673640 100644 --- a/openframe-frontend-core/src/components/ui/modal-v2.tsx +++ b/openframe-frontend-core/src/components/ui/modal-v2.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from "react" import { usePreventScroll } from "@react-aria/overlays" import { XmarkIcon } from "../icons-v2-generated" import { cn } from "../../utils/cn" +import { OverlayScrollArea } from "./overlay-scroll-area" // Duration of the open/close animation in ms — keep in sync with the // `duration-200` utilities applied to the backdrop and panel below. @@ -131,9 +132,12 @@ Modal.displayName = "ModalV2" const ModalContent = React.forwardRef( ({ children, className }, ref) => ( -
+ {children} -
+ ) ) ModalContent.displayName = "ModalV2Content" diff --git a/openframe-frontend-core/src/components/ui/modal.tsx b/openframe-frontend-core/src/components/ui/modal.tsx index eddfe47cc..fcfa87111 100644 --- a/openframe-frontend-core/src/components/ui/modal.tsx +++ b/openframe-frontend-core/src/components/ui/modal.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { useEffect } from "react" import { usePreventScroll } from "@react-aria/overlays" import { cn } from "../../utils/cn" +import { OverlayScrollArea } from "./overlay-scroll-area" interface ModalProps { isOpen: boolean @@ -82,9 +83,12 @@ Modal.displayName = "Modal" /** @deprecated Use ModalV2Content from './modal-v2' instead. */ const ModalContent = React.forwardRef( ({ children, className }, ref) => ( -
+ {children} -
+ ) ) ModalContent.displayName = "ModalContent" diff --git a/openframe-frontend-core/src/components/ui/overlay-scroll-area.tsx b/openframe-frontend-core/src/components/ui/overlay-scroll-area.tsx index 210d6971c..374f25e48 100644 --- a/openframe-frontend-core/src/components/ui/overlay-scroll-area.tsx +++ b/openframe-frontend-core/src/components/ui/overlay-scroll-area.tsx @@ -2,17 +2,16 @@ import * as React from 'react' import { OverlayScrollbars, type PartialOptions } from 'overlayscrollbars' -import { OverlayScrollbarsComponent } from 'overlayscrollbars-react' import { cn } from '../../utils/cn' // ============================================================================= -// POC: standardized macOS-like overlay scrollbars on every platform. +// Standardized macOS-like overlay scrollbars on every platform. // // OverlayScrollbars hides the native scrollbar and draws a styleable overlay // one on top while keeping NATIVE scrolling (wheel, touchpad momentum, -// keyboard, drag-the-thumb, click-the-track). With `autoHide` the bar shows -// only while scrolling / hovering the host and fades out when idle — the -// macOS behavior, now identical on Windows/Linux and for mouse users. +// keyboard, drag-the-thumb). With `autoHide` the bar shows only while +// scrolling and fades out when idle — the macOS behavior, now identical on +// Windows/Linux and for mouse users. // // Touch devices keep the native scrollbars: mobile bars are already overlay // and auto-hiding, so the custom layer is initialized only on fine-pointer @@ -47,39 +46,99 @@ const ODS_SCROLLBAR_OPTIONS: PartialOptions = { }, } +const setRef = (ref: React.Ref | undefined, value: T | null) => { + if (!ref) return + if (typeof ref === 'function') ref(value) + else (ref as React.MutableRefObject).current = value +} + export interface OverlayScrollAreaProps extends React.HTMLAttributes { + /** Sizing/box classes of the scroll container (`flex-1 min-h-0`, `max-h-…`, + * borders, background). Do NOT include `overflow-*` here. */ + className?: string + /** Classes for the scrolling element itself — the content-facing classes of + * the old scroller (`flex flex-col gap-…`, paddings). Children are its + * direct children, so `gap`/`flex` layout applies exactly as before. */ + contentClassName?: string + /** Receives the actual scrolling element — use for scrollTop/scrollHeight + * logic and as IntersectionObserver root. Set from the first commit, valid + * on both the overlay and the native fallback path. */ + viewportRef?: React.Ref /** Extra OverlayScrollbars options merged over the ODS defaults. */ options?: PartialOptions children?: React.ReactNode } /** - * Scroll container with the standardized ODS overlay scrollbar. + * Replacement for an `overflow-auto` scroll container with the standardized + * ODS overlay scrollbar. Split the old scroller's classes: sizing goes to + * `className`, content layout to `contentClassName`. The inner scroller div + * is stable across pointer modes — refs and `onScroll` (via the standard + * HTML props) keep working like on the plain div it replaces. * - * On fine-pointer devices renders `OverlayScrollbarsComponent`; on touch - * devices (and during SSR / first paint) falls back to a plain - * `overflow-auto` div with native scrolling. Size the component exactly like - * the `overflow-auto` div it replaces (`h-…`/`max-h-…`/`flex-1 min-h-0`). + * On touch devices (no fine pointer) the overlay layer is never initialized + * and the inner div scrolls with its native (already overlay) scrollbar. */ -export function OverlayScrollArea({ className, options, children, ...props }: OverlayScrollAreaProps) { - const fine = useFinePointer() +export function OverlayScrollArea({ + className, + contentClassName, + viewportRef, + options, + children, + ...props +}: OverlayScrollAreaProps) { + const hostRef = React.useRef(null) + const scrollerRef = React.useRef(null) - if (!fine) { - return ( -
- {children} -
- ) - } + // Serialize to keep the effect dep stable for inline `options` objects. + const optionsJson = JSON.stringify(options ?? null) + + // Layout effect so the viewport is upgraded before consumers' own layout + // effects (scroll-fade hooks, observers) measure it. + React.useLayoutEffect(() => { + const host = hostRef.current + const viewport = scrollerRef.current + if (!host || !viewport) return + const extra = (JSON.parse(optionsJson) ?? {}) as PartialOptions + const mq = window.matchMedia(FINE_POINTER_QUERY) + let instance: ReturnType | null = null + + const sync = () => { + if (mq.matches && !instance) { + // Our own div is passed as the viewport: children stay inside it and + // it remains the scrolling element; the library only suppresses its + // native scrollbar and appends the overlay handles to the host. + instance = OverlayScrollbars( + { target: host, elements: { viewport } }, + { ...ODS_SCROLLBAR_OPTIONS, ...extra }, + ) + } else if (!mq.matches && instance) { + instance.destroy() + instance = null + } + } + + sync() + mq.addEventListener('change', sync) + return () => { + mq.removeEventListener('change', sync) + instance?.destroy() + } + }, [optionsJson]) return ( - - {children} - +
+
{ + scrollerRef.current = el + setRef(viewportRef, el) + }} + className={cn('h-full w-full overflow-auto', contentClassName)} + {...props} + > + {children} +
+
) }