summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/app/e2e/actions.ts2
-rw-r--r--packages/app/e2e/session/session.spec.ts2
-rw-r--r--packages/app/src/components/session/session-context-tab.tsx9
-rw-r--r--packages/app/src/pages/session/composer/session-question-dock.tsx4
-rw-r--r--packages/app/src/pages/session/file-tabs.tsx88
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx9
-rw-r--r--packages/app/src/pages/session/review-tab.tsx4
-rw-r--r--packages/ui/src/components/scroll-view.css61
-rw-r--r--packages/ui/src/components/scroll-view.tsx217
-rw-r--r--packages/ui/src/components/session-review.tsx9
-rw-r--r--packages/ui/src/styles/index.css1
-rw-r--r--packages/ui/src/styles/tailwind/utilities.css28
12 files changed, 345 insertions, 89 deletions
diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts
index d42c0fceb..a7ccba617 100644
--- a/packages/app/e2e/actions.ts
+++ b/packages/app/e2e/actions.ts
@@ -225,7 +225,7 @@ export async function hoverSessionItem(page: Page, sessionID: string) {
export async function openSessionMoreMenu(page: Page, sessionID: string) {
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
- const scroller = page.locator(".session-scroller").first()
+ const scroller = page.locator(".scroll-view__viewport").first()
await expect(scroller).toBeVisible()
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts
index 93eaee5cb..68d992949 100644
--- a/packages/app/e2e/session/session.spec.ts
+++ b/packages/app/e2e/session/session.spec.ts
@@ -44,7 +44,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i)
- const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
+ const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await expect(input).toBeFocused()
await input.fill(renamedTitle)
diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx
index 162e016c6..1ea97c395 100644
--- a/packages/app/src/components/session/session-context-tab.tsx
+++ b/packages/app/src/components/session/session-context-tab.tsx
@@ -11,6 +11,7 @@ import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { Code } from "@opencode-ai/ui/code"
import { Markdown } from "@opencode-ai/ui/markdown"
+import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language"
import { getSessionContextMetrics } from "./session-context-metrics"
@@ -268,9 +269,9 @@ export function SessionContextTab() {
})
return (
- <div
- class="@container h-full overflow-y-auto no-scrollbar pb-10"
- ref={(el) => {
+ <ScrollView
+ class="@container h-full pb-10"
+ viewportRef={(el) => {
scroll = el
restoreScroll()
}}
@@ -336,6 +337,6 @@ export function SessionContextTab() {
</Accordion>
</div>
</div>
- </div>
+ </ScrollView>
)
}
diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx
index 1ccac937c..fd2ced3dc 100644
--- a/packages/app/src/pages/session/composer/session-question-dock.tsx
+++ b/packages/app/src/pages/session/composer/session-question-dock.tsx
@@ -62,7 +62,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const measure = () => {
if (!root) return
- const scroller = document.querySelector(".session-scroller")
+ const scroller = document.querySelector(".scroll-view__viewport")
const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined
const top =
head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0
@@ -95,7 +95,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
window.addEventListener("resize", update)
const dock = root?.closest('[data-component="session-prompt-dock"]')
- const scroller = document.querySelector(".session-scroller")
+ const scroller = document.querySelector(".scroll-view__viewport")
const observer = new ResizeObserver(update)
if (dock instanceof HTMLElement) observer.observe(dock)
if (scroller instanceof HTMLElement) observer.observe(scroller)
diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx
index ebc1f5922..032756cab 100644
--- a/packages/app/src/pages/session/file-tabs.tsx
+++ b/packages/app/src/pages/session/file-tabs.tsx
@@ -9,6 +9,7 @@ import { showToast } from "@opencode-ai/ui/toast"
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
import { Mark } from "@opencode-ai/ui/logo"
import { Tabs } from "@opencode-ai/ui/tabs"
+import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { useLayout } from "@/context/layout"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useComments } from "@/context/comments"
@@ -509,51 +510,52 @@ export function FileTabContent(props: { tab: string }) {
)
return (
- <Tabs.Content
- value={props.tab}
- class="mt-3 relative"
- ref={(el: HTMLDivElement) => {
- scroll = el
- restoreScroll()
- }}
- onScroll={handleScroll}
- >
- <Switch>
- <Match when={state()?.loaded && isImage()}>
- <div class="px-6 py-4 pb-40">
- <img
- src={imageDataUrl()}
- alt={path()}
- class="max-w-full"
- onLoad={() => requestAnimationFrame(restoreScroll)}
- />
- </div>
- </Match>
- <Match when={state()?.loaded && isSvg()}>
- <div class="flex flex-col gap-4 px-6 py-4">
- {renderCode(svgContent() ?? "", "")}
- <Show when={svgPreviewUrl()}>
- <div class="flex justify-center pb-40">
- <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
+ <Tabs.Content value={props.tab} class="mt-3 relative h-full">
+ <ScrollView
+ class="h-full"
+ viewportRef={(el: HTMLDivElement) => {
+ scroll = el
+ restoreScroll()
+ }}
+ onScroll={handleScroll as any}
+ >
+ <Switch>
+ <Match when={state()?.loaded && isImage()}>
+ <div class="px-6 py-4 pb-40">
+ <img
+ src={imageDataUrl()}
+ alt={path()}
+ class="max-w-full"
+ onLoad={() => requestAnimationFrame(restoreScroll)}
+ />
+ </div>
+ </Match>
+ <Match when={state()?.loaded && isSvg()}>
+ <div class="flex flex-col gap-4 px-6 py-4">
+ {renderCode(svgContent() ?? "", "")}
+ <Show when={svgPreviewUrl()}>
+ <div class="flex justify-center pb-40">
+ <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
+ </div>
+ </Show>
+ </div>
+ </Match>
+ <Match when={state()?.loaded && isBinary()}>
+ <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
+ <Mark class="w-14 opacity-10" />
+ <div class="flex flex-col gap-2 max-w-md">
+ <div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
+ <div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
</div>
- </Show>
- </div>
- </Match>
- <Match when={state()?.loaded && isBinary()}>
- <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
- <Mark class="w-14 opacity-10" />
- <div class="flex flex-col gap-2 max-w-md">
- <div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
- <div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
</div>
- </div>
- </Match>
- <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
- <Match when={state()?.loading}>
- <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
- </Match>
- <Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
- </Switch>
+ </Match>
+ <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
+ <Match when={state()?.loading}>
+ <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
+ </Match>
+ <Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
+ </Switch>
+ </ScrollView>
</Tabs.Content>
)
}
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index 6ac89a3a7..b13ccb474 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -8,6 +8,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
+import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
@@ -322,8 +323,8 @@ export function MessageTimeline(props: {
<Icon name="arrow-down-to-line" />
</button>
</div>
- <div
- ref={props.setScrollRef}
+ <ScrollView
+ viewportRef={props.setScrollRef}
onWheel={(e) => {
const root = e.currentTarget
const delta = normalizeWheelDelta({
@@ -367,7 +368,7 @@ export function MessageTimeline(props: {
if (props.isDesktop) props.onScrollSpyScroll()
}}
onClick={props.onAutoScrollInteraction}
- class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
+ class="relative min-w-0 w-full h-full"
style={{
"--session-title-height": showHeader() ? "40px" : "0px",
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
@@ -548,7 +549,7 @@ export function MessageTimeline(props: {
)}
</For>
</div>
- </div>
+ </ScrollView>
</div>
</Show>
)
diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx
index 3a9f63949..9349e9937 100644
--- a/packages/app/src/pages/session/review-tab.tsx
+++ b/packages/app/src/pages/session/review-tab.tsx
@@ -143,9 +143,9 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
open={props.view().review.open()}
onOpenChange={props.view().review.setOpen}
classes={{
- root: props.classes?.root ?? "pb-6",
+ root: props.classes?.root ?? "pb-6 pr-3",
header: props.classes?.header ?? "px-3",
- container: props.classes?.container ?? "px-3",
+ container: props.classes?.container ?? "pl-3",
}}
diffs={props.diffs()}
diffStyle={props.diffStyle}
diff --git a/packages/ui/src/components/scroll-view.css b/packages/ui/src/components/scroll-view.css
new file mode 100644
index 000000000..f81ae2976
--- /dev/null
+++ b/packages/ui/src/components/scroll-view.css
@@ -0,0 +1,61 @@
+.scroll-view {
+ position: relative;
+ overflow: hidden;
+}
+
+.scroll-view__viewport {
+ height: 100%;
+ width: 100%;
+ overflow-y: auto;
+ scrollbar-width: none;
+ outline: none;
+}
+
+.scroll-view__viewport::-webkit-scrollbar {
+ display: none;
+}
+
+.scroll-view__thumb {
+ position: absolute;
+ right: 0;
+ top: 0;
+ width: 16px;
+ transition: opacity 200ms ease;
+ cursor: default;
+ user-select: none;
+ opacity: 0;
+}
+
+.scroll-view__thumb::after {
+ content: "";
+ position: absolute;
+ right: 4px;
+ top: 0;
+ bottom: 0;
+ width: 6px;
+ border-radius: 9999px;
+ background-color: var(--border-weak-base);
+ backdrop-filter: blur(4px);
+ transition: background-color 150ms ease;
+}
+
+.scroll-view__thumb:hover::after,
+.scroll-view__thumb[data-dragging="true"]::after {
+ background-color: var(--border-strong-base);
+}
+
+.dark .scroll-view__thumb::after,
+[data-theme="dark"] .scroll-view__thumb::after {
+ background-color: var(--border-weak-base);
+}
+
+.dark .scroll-view__thumb:hover::after,
+[data-theme="dark"] .scroll-view__thumb:hover::after,
+.dark .scroll-view__thumb[data-dragging="true"]::after,
+[data-theme="dark"] .scroll-view__thumb[data-dragging="true"]::after {
+ background-color: var(--border-strong-base);
+}
+
+.scroll-view__thumb[data-visible="true"] {
+ opacity: 1;
+}
diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx
new file mode 100644
index 000000000..acc54c8c3
--- /dev/null
+++ b/packages/ui/src/components/scroll-view.tsx
@@ -0,0 +1,217 @@
+import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
+
+export interface ScrollViewProps extends ComponentProps<"div"> {
+ viewportRef?: (el: HTMLDivElement) => void
+ orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb
+}
+
+export function ScrollView(props: ScrollViewProps) {
+ const merged = mergeProps({ orientation: "vertical" }, props)
+ const [local, events, rest] = splitProps(
+ merged,
+ ["class", "children", "viewportRef", "orientation", "style"],
+ [
+ "onScroll",
+ "onWheel",
+ "onTouchStart",
+ "onTouchMove",
+ "onTouchEnd",
+ "onTouchCancel",
+ "onPointerDown",
+ "onClick",
+ "onKeyDown",
+ ],
+ )
+
+ let rootRef!: HTMLDivElement
+ let viewportRef!: HTMLDivElement
+ let thumbRef!: HTMLDivElement
+
+ const [isHovered, setIsHovered] = createSignal(false)
+ const [isDragging, setIsDragging] = createSignal(false)
+
+ const [thumbHeight, setThumbHeight] = createSignal(0)
+ const [thumbTop, setThumbTop] = createSignal(0)
+ const [showThumb, setShowThumb] = createSignal(false)
+
+ const updateThumb = () => {
+ if (!viewportRef) return
+ const { scrollTop, scrollHeight, clientHeight } = viewportRef
+
+ if (scrollHeight <= clientHeight || scrollHeight === 0) {
+ setShowThumb(false)
+ return
+ }
+
+ setShowThumb(true)
+ const trackPadding = 8
+ const trackHeight = clientHeight - trackPadding * 2
+
+ const minThumbHeight = 32
+ // Calculate raw thumb height based on ratio
+ let height = (clientHeight / scrollHeight) * trackHeight
+ height = Math.max(height, minThumbHeight)
+
+ const maxScrollTop = scrollHeight - clientHeight
+ const maxThumbTop = trackHeight - height
+
+ const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0
+
+ // Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
+ const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
+
+ setThumbHeight(height)
+ setThumbTop(boundedTop)
+ }
+
+ onMount(() => {
+ if (local.viewportRef) {
+ local.viewportRef(viewportRef)
+ }
+
+ const observer = new ResizeObserver(() => {
+ updateThumb()
+ })
+
+ observer.observe(viewportRef)
+ // Also observe the first child if possible to catch content changes
+ if (viewportRef.firstElementChild) {
+ observer.observe(viewportRef.firstElementChild)
+ }
+
+ onCleanup(() => {
+ observer.disconnect()
+ })
+
+ updateThumb()
+ })
+
+ let startY = 0
+ let startScrollTop = 0
+
+ const onThumbPointerDown = (e: PointerEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setIsDragging(true)
+ startY = e.clientY
+ startScrollTop = viewportRef.scrollTop
+
+ thumbRef.setPointerCapture(e.pointerId)
+
+ const onPointerMove = (e: PointerEvent) => {
+ const deltaY = e.clientY - startY
+ const { scrollHeight, clientHeight } = viewportRef
+ const maxScrollTop = scrollHeight - clientHeight
+ const maxThumbTop = clientHeight - thumbHeight()
+
+ if (maxThumbTop > 0) {
+ const scrollDelta = deltaY * (maxScrollTop / maxThumbTop)
+ viewportRef.scrollTop = startScrollTop + scrollDelta
+ }
+ }
+
+ const onPointerUp = (e: PointerEvent) => {
+ setIsDragging(false)
+ thumbRef.releasePointerCapture(e.pointerId)
+ thumbRef.removeEventListener("pointermove", onPointerMove)
+ thumbRef.removeEventListener("pointerup", onPointerUp)
+ }
+
+ thumbRef.addEventListener("pointermove", onPointerMove)
+ thumbRef.addEventListener("pointerup", onPointerUp)
+ }
+
+ // Keybinds implementation
+ // We ensure the viewport has a tabindex so it can receive focus
+ // We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
+ // but native usually handles this perfectly. Let's explicitly ensure it behaves well.
+ const onKeyDown = (e: KeyboardEvent) => {
+ // If user is focused on an input inside the scroll view, don't hijack keys
+ if (document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) {
+ return
+ }
+
+ const scrollAmount = viewportRef.clientHeight * 0.8
+ const lineAmount = 40
+
+ switch (e.key) {
+ case "PageDown":
+ e.preventDefault()
+ viewportRef.scrollBy({ top: scrollAmount, behavior: "smooth" })
+ break
+ case "PageUp":
+ e.preventDefault()
+ viewportRef.scrollBy({ top: -scrollAmount, behavior: "smooth" })
+ break
+ case "Home":
+ e.preventDefault()
+ viewportRef.scrollTo({ top: 0, behavior: "smooth" })
+ break
+ case "End":
+ e.preventDefault()
+ viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
+ break
+ case "ArrowUp":
+ e.preventDefault()
+ viewportRef.scrollBy({ top: -lineAmount, behavior: "smooth" })
+ break
+ case "ArrowDown":
+ e.preventDefault()
+ viewportRef.scrollBy({ top: lineAmount, behavior: "smooth" })
+ break
+ }
+ }
+
+ return (
+ <div
+ ref={rootRef}
+ class={`scroll-view ${local.class || ""}`}
+ style={local.style}
+ onPointerEnter={() => setIsHovered(true)}
+ onPointerLeave={() => setIsHovered(false)}
+ {...rest}
+ >
+ {/* Viewport */}
+ <div
+ ref={viewportRef}
+ class="scroll-view__viewport"
+ onScroll={(e) => {
+ updateThumb()
+ if (typeof events.onScroll === "function") events.onScroll(e as any)
+ }}
+ onWheel={events.onWheel as any}
+ onTouchStart={events.onTouchStart as any}
+ onTouchMove={events.onTouchMove as any}
+ onTouchEnd={events.onTouchEnd as any}
+ onTouchCancel={events.onTouchCancel as any}
+ onPointerDown={events.onPointerDown as any}
+ onClick={events.onClick as any}
+ tabIndex={0}
+ role="region"
+ aria-label="scrollable content"
+ onKeyDown={(e) => {
+ onKeyDown(e)
+ if (typeof events.onKeyDown === "function") events.onKeyDown(e as any)
+ }}
+ >
+ {local.children}
+ </div>
+
+ {/* Thumb Overlay */}
+ <Show when={showThumb()}>
+ <div
+ ref={thumbRef}
+ onPointerDown={onThumbPointerDown}
+ class="scroll-view__thumb"
+ data-visible={isHovered() || isDragging()}
+ data-dragging={isDragging()}
+ style={{
+ height: `${thumbHeight()}px`,
+ transform: `translateY(${thumbTop()}px)`,
+ "z-index": 100, // ensure it displays over content
+ }}
+ />
+ </Show>
+ </div>
+ )
+}
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx
index fd85fb485..15464d3ba 100644
--- a/packages/ui/src/components/session-review.tsx
+++ b/packages/ui/src/components/session-review.tsx
@@ -7,6 +7,7 @@ import { Icon } from "./icon"
import { LineComment, LineCommentEditor } from "./line-comment"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { Tooltip } from "./tooltip"
+import { ScrollView } from "./scroll-view"
import { useDiffComponent } from "../context/diff"
import { useI18n } from "../context/i18n"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
@@ -274,13 +275,13 @@ export const SessionReview = (props: SessionReviewProps) => {
})
return (
- <div
+ <ScrollView
data-component="session-review"
- ref={(el) => {
+ viewportRef={(el) => {
scroll = el
props.scrollRef?.(el)
}}
- onScroll={props.onScroll}
+ onScroll={props.onScroll as any}
classList={{
...(props.classList ?? {}),
[props.classes?.root ?? ""]: !!props.classes?.root,
@@ -709,6 +710,6 @@ export const SessionReview = (props: SessionReviewProps) => {
</Accordion>
</Show>
</div>
- </div>
+ </ScrollView>
)
}
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index efe00e5f1..c0af0ac9b 100644
--- a/packages/ui/src/styles/index.css
+++ b/packages/ui/src/styles/index.css
@@ -44,6 +44,7 @@
@import "../components/select.css" layer(components);
@import "../components/spinner.css" layer(components);
@import "../components/switch.css" layer(components);
+@import "../components/scroll-view.css" layer(components);
@import "../components/session-review.css" layer(components);
@import "../components/session-turn.css" layer(components);
@import "../components/sticky-accordion-header.css" layer(components);
diff --git a/packages/ui/src/styles/tailwind/utilities.css b/packages/ui/src/styles/tailwind/utilities.css
index be305b4cb..4318b9ec1 100644
--- a/packages/ui/src/styles/tailwind/utilities.css
+++ b/packages/ui/src/styles/tailwind/utilities.css
@@ -8,34 +8,6 @@
}
}
-@utility session-scroller {
- &::-webkit-scrollbar {
- width: 10px;
- height: 10px;
- }
-
- &::-webkit-scrollbar-track {
- background: transparent;
- border-radius: 5px;
- }
-
- &::-webkit-scrollbar-thumb {
- background: var(--border-weak-base);
- border-radius: 5px;
- border: 3px solid transparent;
- background-clip: padding-box;
- }
-
- &::-webkit-scrollbar-thumb:hover {
- background: var(--border-weak-base);
- }
-
- & {
- scrollbar-width: thin;
- scrollbar-color: var(--border-weak-base) transparent;
- }
-}
-
@utility badge-mask {
-webkit-mask-image: radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px);
mask-image: radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px);