diff --git a/package.json b/package.json
index b4d5c7a..3a2299d 100644
--- a/package.json
+++ b/package.json
@@ -49,7 +49,6 @@
"@types/jsdom": "28.0.1",
"@vitest/browser-playwright": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
- "bits-ui": "2.18.1",
"clsx": "^2.1.1",
"dprint": "0.54.0",
"jsdom": "29.1.1",
diff --git a/src/features/AdjustTypography/ui/TypographyMenu/TypographyMenu.svelte b/src/features/AdjustTypography/ui/TypographyMenu/TypographyMenu.svelte
index 7d8c9bd..d628a42 100644
--- a/src/features/AdjustTypography/ui/TypographyMenu/TypographyMenu.svelte
+++ b/src/features/AdjustTypography/ui/TypographyMenu/TypographyMenu.svelte
@@ -11,11 +11,11 @@ import {
Button,
ComboControl,
ControlGroup,
+ Popover,
Slider,
} from '$shared/ui';
import Settings2Icon from '@lucide/svelte/icons/settings-2';
import XIcon from '@lucide/svelte/icons/x';
-import { Popover } from 'bits-ui';
import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
@@ -74,33 +74,21 @@ $effect(() => {
{#if !hidden}
{#if responsive.isMobileOrTablet}
+
+
+
+
+ {#snippet template()}
+
+
+ {#snippet trigger(props)}
+
+ {/snippet}
+ {#snippet children()}
+ Popover content
+ {/snippet}
+
+
+ {/snippet}
+
+
+
+ {#snippet template()}
+
+
+ {#snippet trigger(props)}
+
+ {/snippet}
+ {#snippet children()}
+ Popover content
+ {/snippet}
+
+
+ {/snippet}
+
+
+
+
+ {#snippet template()}
+
+
+ {#snippet trigger(props)}
+
+ {/snippet}
+ {#snippet children({ close })}
+
+
Menu header
+
+ Aligned to the trigger's end edge.
+
+
+
+ {/snippet}
+
+
+ {/snippet}
+
+
+
+
+ {#snippet template()}
+
+
+ {#snippet trigger(props)}
+
+ {/snippet}
+ {#snippet children()}
+
+
+
+ {/snippet}
+
+
+ {/snippet}
+
diff --git a/src/shared/ui/Popover/Popover.svelte b/src/shared/ui/Popover/Popover.svelte
new file mode 100644
index 0000000..c1443d1
--- /dev/null
+++ b/src/shared/ui/Popover/Popover.svelte
@@ -0,0 +1,225 @@
+
+
+
+{@render trigger(triggerProps)}
+
+
+
+ {@render children({ close })}
+
diff --git a/src/shared/ui/Popover/Popover.svelte.test.ts b/src/shared/ui/Popover/Popover.svelte.test.ts
new file mode 100644
index 0000000..603c7a9
--- /dev/null
+++ b/src/shared/ui/Popover/Popover.svelte.test.ts
@@ -0,0 +1,49 @@
+import {
+ fireEvent,
+ render,
+ screen,
+} from '@testing-library/svelte';
+import Harness from './PopoverHarness.svelte';
+
+/**
+ * Resolve the popover content element (the [popover] ancestor of the test content).
+ */
+function getContent(): HTMLElement {
+ return screen.getByTestId('content').closest('[popover]') as HTMLElement;
+}
+
+describe('Popover', () => {
+ it('renders the trigger with aria wiring, closed by default', () => {
+ render(Harness);
+ const trigger = screen.getByRole('button', { name: 'Open' });
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
+ expect(trigger).toHaveAttribute('aria-haspopup', 'dialog');
+ expect(trigger).toHaveAttribute('popovertarget');
+ expect(getContent()).toHaveAttribute('data-state', 'closed');
+ });
+
+ it('opens via the popover toggle and syncs aria-expanded + data-state', async () => {
+ render(Harness);
+ const trigger = screen.getByRole('button', { name: 'Open' });
+ // jsdom does not auto-invoke popovertarget; call the API the browser would.
+ getContent().showPopover();
+ await Promise.resolve();
+ expect(getContent()).toHaveAttribute('data-state', 'open');
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('opens when the parent sets open=true (state -> browser)', async () => {
+ render(Harness, { open: true });
+ await Promise.resolve();
+ expect(getContent()).toHaveAttribute('data-state', 'open');
+ });
+
+ it('close() hides the popover and resets aria-expanded', async () => {
+ render(Harness, { open: true });
+ await Promise.resolve();
+ const trigger = screen.getByRole('button', { name: 'Open' });
+ await fireEvent.click(screen.getByTestId('close'));
+ expect(getContent()).toHaveAttribute('data-state', 'closed');
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
+ });
+});
diff --git a/src/shared/ui/Popover/PopoverHarness.svelte b/src/shared/ui/Popover/PopoverHarness.svelte
new file mode 100644
index 0000000..566e154
--- /dev/null
+++ b/src/shared/ui/Popover/PopoverHarness.svelte
@@ -0,0 +1,21 @@
+
+
+
+
+ {#snippet trigger(props)}
+
+ {/snippet}
+ {#snippet children({ close })}
+
+
+
+ {/snippet}
+
diff --git a/src/shared/ui/Popover/popover-position.test.ts b/src/shared/ui/Popover/popover-position.test.ts
new file mode 100644
index 0000000..25f13d8
--- /dev/null
+++ b/src/shared/ui/Popover/popover-position.test.ts
@@ -0,0 +1,119 @@
+import {
+ type Align,
+ type Side,
+ computePosition,
+} from './popover-position';
+
+/**
+ * Build a DOMRect-like object (jsdom/node has no layout).
+ */
+function rect(x: number, y: number, width: number, height: number): DOMRect {
+ return {
+ x,
+ y,
+ width,
+ height,
+ top: y,
+ left: x,
+ right: x + width,
+ bottom: y + height,
+ toJSON: () => ({}),
+ } as DOMRect;
+}
+
+const viewport = { width: 1000, height: 800 };
+const content = { width: 200, height: 100 };
+
+function compute(side: Side, align: Align, sideOffset = 0, trigger = rect(400, 400, 100, 40)) {
+ return computePosition({ triggerRect: trigger, contentRect: content, viewport, side, align, sideOffset });
+}
+
+describe('computePosition', () => {
+ it('places below the trigger for side="bottom"', () => {
+ const r = compute('bottom', 'center');
+ expect(r.side).toBe('bottom');
+ expect(r.y).toBe(440); // trigger.bottom (400+40)
+ });
+
+ it('places above the trigger for side="top"', () => {
+ const r = compute('top', 'center');
+ expect(r.side).toBe('top');
+ expect(r.y).toBe(300); // trigger.top (400) - content.height (100)
+ });
+
+ it('applies sideOffset on the main axis', () => {
+ const r = compute('bottom', 'center', 8);
+ expect(r.y).toBe(448);
+ });
+
+ it('aligns center on the cross axis (vertical side)', () => {
+ const r = compute('bottom', 'center');
+ // trigger center x = 450; content half = 100 -> 350
+ expect(r.x).toBe(350);
+ });
+
+ it('aligns start and end on the cross axis (vertical side)', () => {
+ expect(compute('bottom', 'start').x).toBe(400); // trigger.left
+ expect(compute('bottom', 'end').x).toBe(300); // trigger.right(500) - content.width(200)
+ });
+
+ it('places left/right with vertical cross-axis alignment', () => {
+ const right = compute('right', 'start');
+ expect(right.side).toBe('right');
+ expect(right.x).toBe(500); // trigger.right
+ expect(right.y).toBe(400); // trigger.top (align start)
+ const left = compute('left', 'center');
+ expect(left.side).toBe('left');
+ expect(left.x).toBe(200); // trigger.left(400) - content.width(200)
+ });
+
+ it('flips top->bottom when there is no room above', () => {
+ const nearTop = rect(400, 20, 100, 40); // only 20px above, content needs 100
+ const r = computePosition({
+ triggerRect: nearTop,
+ contentRect: content,
+ viewport,
+ side: 'top',
+ align: 'center',
+ sideOffset: 0,
+ });
+ expect(r.side).toBe('bottom');
+ expect(r.y).toBe(60); // nearTop.bottom
+ });
+
+ it('does NOT flip when neither side fits (keeps requested side)', () => {
+ const tall = { width: 200, height: 700 };
+ const r = computePosition({
+ triggerRect: rect(400, 400, 100, 40),
+ contentRect: tall,
+ viewport,
+ side: 'top',
+ align: 'center',
+ sideOffset: 0,
+ });
+ expect(r.side).toBe('top');
+ });
+
+ it('shifts on the cross axis to stay within the viewport', () => {
+ const nearRight = rect(950, 400, 40, 40); // center x ~970, content 200 would overflow right
+ const r = computePosition({
+ triggerRect: nearRight,
+ contentRect: content,
+ viewport,
+ side: 'bottom',
+ align: 'center',
+ sideOffset: 0,
+ });
+ expect(r.x).toBe(800); // clamped to viewport.width(1000) - content.width(200)
+ const nearLeft = rect(10, 400, 40, 40);
+ const r2 = computePosition({
+ triggerRect: nearLeft,
+ contentRect: content,
+ viewport,
+ side: 'bottom',
+ align: 'center',
+ sideOffset: 0,
+ });
+ expect(r2.x).toBe(0); // clamped to 0
+ });
+});
diff --git a/src/shared/ui/Popover/popover-position.ts b/src/shared/ui/Popover/popover-position.ts
new file mode 100644
index 0000000..f9b1a11
--- /dev/null
+++ b/src/shared/ui/Popover/popover-position.ts
@@ -0,0 +1,149 @@
+import { clampNumber } from '$shared/lib/utils';
+
+/**
+ * Side of the trigger the content prefers to open toward.
+ */
+export type Side = 'top' | 'bottom' | 'left' | 'right';
+
+/**
+ * Cross-axis alignment of the content relative to the trigger.
+ */
+export type Align = 'start' | 'center' | 'end';
+
+/**
+ * Inputs for a single placement computation. All geometry is injected
+ * (no DOM reads) so the function stays pure and unit-testable.
+ */
+type ComputeArgs = {
+ /**
+ * Trigger bounding rect (viewport coordinates).
+ */
+ triggerRect: DOMRect;
+ /**
+ * Measured content size.
+ */
+ contentRect: { width: number; height: number };
+ /**
+ * Viewport size.
+ */
+ viewport: { width: number; height: number };
+ /**
+ * Preferred side.
+ */
+ side: Side;
+ /**
+ * Cross-axis alignment.
+ */
+ align: Align;
+ /**
+ * Gap between trigger and content on the main axis.
+ */
+ sideOffset: number;
+};
+
+/**
+ * Resolved placement: fixed-position coordinates plus the side actually used
+ * (may differ from the requested side after a flip).
+ */
+type ComputeResult = { x: number; y: number; side: Side };
+
+const OPPOSITE: Record
= {
+ top: 'bottom',
+ bottom: 'top',
+ left: 'right',
+ right: 'left',
+};
+
+/**
+ * True for sides whose main axis is vertical (content sits above/below).
+ */
+function isVertical(side: Side): boolean {
+ return side === 'top' || side === 'bottom';
+}
+
+/**
+ * Main-axis coordinate (top for vertical sides, left for horizontal sides).
+ */
+function mainAxisCoord(side: Side, t: DOMRect, c: { width: number; height: number }, offset: number): number {
+ switch (side) {
+ case 'top':
+ return t.top - c.height - offset;
+ case 'bottom':
+ return t.bottom + offset;
+ case 'left':
+ return t.left - c.width - offset;
+ case 'right':
+ return t.right + offset;
+ }
+}
+
+/**
+ * Whether the content fits on the given side within the viewport.
+ */
+function fitsOnSide(
+ side: Side,
+ t: DOMRect,
+ c: { width: number; height: number },
+ v: { width: number; height: number },
+ offset: number,
+): boolean {
+ const coord = mainAxisCoord(side, t, c, offset);
+ switch (side) {
+ case 'top':
+ return coord >= 0;
+ case 'left':
+ return coord >= 0;
+ case 'bottom':
+ return coord + c.height <= v.height;
+ case 'right':
+ return coord + c.width <= v.width;
+ }
+}
+
+/**
+ * Cross-axis coordinate for the requested alignment.
+ */
+function crossAxisCoord(side: Side, align: Align, t: DOMRect, c: { width: number; height: number }): number {
+ if (isVertical(side)) {
+ if (align === 'start') {
+ return t.left;
+ }
+ if (align === 'end') {
+ return t.right - c.width;
+ }
+ return t.left + t.width / 2 - c.width / 2;
+ }
+ if (align === 'start') {
+ return t.top;
+ }
+ if (align === 'end') {
+ return t.bottom - c.height;
+ }
+ return t.top + t.height / 2 - c.height / 2;
+}
+
+/**
+ * Compute an anchored placement with flip (to the opposite side when the
+ * preferred side doesn't fit but the opposite does) and shift (clamp the
+ * cross axis so the content stays within the viewport).
+ */
+export function computePosition(args: ComputeArgs): ComputeResult {
+ const { triggerRect: t, contentRect: c, viewport: v, align, sideOffset } = args;
+ let side = args.side;
+
+ if (!fitsOnSide(side, t, c, v, sideOffset) && fitsOnSide(OPPOSITE[side], t, c, v, sideOffset)) {
+ side = OPPOSITE[side];
+ }
+
+ let x: number;
+ let y: number;
+ if (isVertical(side)) {
+ y = mainAxisCoord(side, t, c, sideOffset);
+ x = clampNumber(crossAxisCoord(side, align, t, c), 0, Math.max(0, v.width - c.width));
+ } else {
+ x = mainAxisCoord(side, t, c, sideOffset);
+ y = clampNumber(crossAxisCoord(side, align, t, c), 0, Math.max(0, v.height - c.height));
+ }
+
+ return { x, y, side };
+}
diff --git a/src/shared/ui/Slider/Slider.stories.svelte b/src/shared/ui/Slider/Slider.stories.svelte
index 2ccc455..80c1fb0 100644
--- a/src/shared/ui/Slider/Slider.stories.svelte
+++ b/src/shared/ui/Slider/Slider.stories.svelte
@@ -10,7 +10,7 @@ const { Story } = defineMeta({
docs: {
description: {
component:
- 'Styled bits-ui slider component with red accent (#ff3b30). Thumb is a 45° rotated square with hover/active scale animations.',
+ 'Single-value slider (native, no bits-ui) with brand accent. Diamond thumb (45° rotated square) with hover/active scale. Supports pointer drag, click-to-seek, touch, and keyboard (arrows, Home/End, PageUp/Down).',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
@@ -39,8 +39,6 @@ const { Story } = defineMeta({