]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: remove render effects (#26083)
authorPascal <redacted>
Fri, 24 Jul 2026 19:43:23 +0000 (21:43 +0200)
committerGitHub <redacted>
Fri, 24 Jul 2026 19:43:23 +0000 (21:43 +0200)
* ui: remove viewport fade in and smooth autoscroll bottom snap

fadeInView mounted every message and markdown block at opacity 0 and
relied on an IntersectionObserver to reveal it. When the observer never
fires (blocks mounted offscreen during long agentic loops) the content
stays invisible forever while still present in the DOM. Remove the
action, its orphaned isElementInViewport util and all call sites:
blocks now render visible immediately.

AutoScrollController.scrollToBottom defaulted to behavior smooth and is
invoked every 100 ms while streaming. Each tick restarts an easing
animation toward a moving scrollHeight, producing a random elastic bump
of a few pixels when the user reaches the bottom and autoscroll
reengages. Default to instant scrolling; the user facing scroll down
button keeps its smooth behavior.

* ui: skip rendering of offscreen chat messages via content-visibility

Apply content-visibility auto with contain-intrinsic-size to chat
messages so the browser skips layout and paint for messages outside
the viewport. The DOM stays complete: component state, find-in-page,
text selection, and the mutation based autoscroll are unaffected, and
browsers without support simply ignore the properties.

* ui: remove conversation switch fade

Switching conversations faded the message list out and in over 500 ms
plus a 300 ms route delay, deferring the message refresh behind two
requestAnimationFrame calls. Remove the fade, its navigation hooks and
dead state, and refresh messages directly so switching is only bound
by actual render time.

* ui: describe present behavior in comments and drop unused parameter

* ui: anchor the context gauge popup to the form with plain CSS

The stats card was portaled to body and repositioned in script on
every ancestor scroll event, trailing the page by one frame while
streaming. Render it as an absolutely positioned sibling of the input
box inside the already relative form, so nothing runs during scroll.

The card sits just above the dial, centered on it and overlapping the
textarea, from a single measurement of the dial center and top taken
when it opens; the dial and the card share the same positioning frame,
so the values stay exact while the card is open. Mouse pointers open
on hover with a grace delay to reach the card, touch pointers toggle
on tap, any press outside the card and the dial closes it, and Enter
and Space toggle from the keyboard. The card lives outside the input
box because its overflow-hidden and backdrop-filter would clip any
positioned descendant.

* ui: extract context gauge popup constants and relocate its state store

Move the placement values and the close grace delay to
lib/constants/context-gauge-popup.ts, matching the auto-scroll
constants layout, and move the popup state module from the component
folder to lib/stores where runes modules live in this codebase.

* ui: declare the context gauge popup card ref as $state

14 files changed:
tools/ui/src/lib/actions/fade-in-view.svelte.ts [deleted file]
tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ChatFormContextGauge.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserPending.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenServerError.svelte
tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte
tools/ui/src/lib/constants/context-gauge-popup.ts [new file with mode: 0644]
tools/ui/src/lib/constants/index.ts
tools/ui/src/lib/hooks/use-auto-scroll.svelte.ts
tools/ui/src/lib/stores/context-gauge-popup.svelte.ts [new file with mode: 0644]
tools/ui/src/lib/utils/viewport.ts [deleted file]

diff --git a/tools/ui/src/lib/actions/fade-in-view.svelte.ts b/tools/ui/src/lib/actions/fade-in-view.svelte.ts
deleted file mode 100644 (file)
index 9a59181..0000000
+++ /dev/null
@@ -1,49 +0,0 @@
-import { isElementInViewport } from '$lib/utils/viewport';
-
-/**
- * Svelte action that fades in an element when it enters the viewport.
- * Uses IntersectionObserver for efficient viewport detection.
- *
- * If skipIfVisible is set and the element is already visible in the viewport
- * when the action attaches (e.g. a markdown block promoted from unstable
- * during streaming), the fade is skipped entirely to avoid a flash.
- */
-export function fadeInView(
-       node: HTMLElement,
-       options: { duration?: number; y?: number; delay?: number; skipIfVisible?: boolean } = {}
-) {
-       const { duration = 300, y = 0, delay = 0, skipIfVisible = false } = options;
-
-       if (skipIfVisible && isElementInViewport(node)) {
-               return;
-       }
-
-       node.style.opacity = '0';
-       node.style.transform = `translateY(${y}px)`;
-       node.style.transition = `opacity ${duration}ms ease-out, transform ${duration}ms ease-out`;
-
-       $effect(() => {
-               const observer = new IntersectionObserver(
-                       (entries) => {
-                               for (const entry of entries) {
-                                       if (entry.isIntersecting) {
-                                               setTimeout(() => {
-                                                       requestAnimationFrame(() => {
-                                                               node.style.opacity = '1';
-                                                               node.style.transform = 'translateY(0)';
-                                                       });
-                                               }, delay);
-                                               observer.disconnect();
-                                       }
-                               }
-                       },
-                       { threshold: 0.05 }
-               );
-
-               observer.observe(node);
-
-               return () => {
-                       observer.disconnect();
-               };
-       });
-}
index 9b2077b8dcbd897a344227b8886a5125726af117..85683908cc719a998067d6526bedf8253fc2f92f 100644 (file)
@@ -25,6 +25,7 @@
                SpecialFileType
        } from '$lib/enums';
        import { config } from '$lib/stores/settings.svelte';
+       import ContextGaugePopup from './ChatFormContextGauge/ContextGaugePopup.svelte';
        import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
        import { isRouterMode } from '$lib/stores/server.svelte';
        import { chatStore } from '$lib/stores/chat.svelte';
                        />
                </div>
        </div>
+
+       <ContextGaugePopup />
 </form>
 
 <DialogMcpResourcesBrowser
index 855cf6ce783dcb3a1ea1be6fb37535a70b8431b5..ff6d39fdd48cf5f98ec389bb3854c2f72a2a7385 100644 (file)
@@ -1,14 +1,16 @@
 <script lang="ts">
        import { untrack } from 'svelte';
-       import * as HoverCard from '$lib/components/ui/hover-card';
        import { activeConversation, activeMessages } from '$lib/stores/conversations.svelte';
        import { chatStore, isChatStreaming, isLoading } from '$lib/stores/chat.svelte';
-       import { formatParameters } from '$lib/utils/formatters';
        import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
        import ContextGaugeDial from './ContextGaugeDial.svelte';
-       import ContextGaugeDetails from './ContextGaugeDetails.svelte';
-       import ContextGaugeLoadModel from './ContextGaugeLoadModel.svelte';
-       import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
+       import {
+               gaugeTriggerClick,
+               gaugeTriggerEnter,
+               gaugeTriggerKeydown,
+               gaugeTriggerLeave,
+               gaugeTriggerPointerDown
+       } from '$lib/stores/context-gauge-popup.svelte';
 
        const gauge = useContextGauge();
 
        $effect(() => {
                gauge.startMonitoring();
        });
-
-       const showProgressBar = $derived(
-               gauge.contextTotal !== null &&
-                       gauge.contextTotal > 0 &&
-                       (gauge.activeModelId !== null || gauge.isActiveModelLoaded)
-       );
 </script>
 
-<HoverCard.Root>
-       <HoverCard.Trigger class="flex h-5 w-5 cursor-default items-center justify-center">
-               <ContextGaugeDial percent={gauge.contextPercent} level={gauge.colorLevel} />
-       </HoverCard.Trigger>
-
-       <HoverCard.Content
-               side="bottom"
-               class="z-50 w-64 rounded-lg border border-border/50 bg-popover p-3 text-popover-foreground shadow-lg"
-       >
-               <div class="flex flex-col gap-2">
-                       <div class="flex items-center gap-2">
-                               <span class="font-medium">Context</span>
-                               <span class="text-muted-foreground">ยท</span>
-                               <span class="font-mono text-muted-foreground">
-                                       {formatParameters(gauge.contextUsed)}
-                                       / {gauge.contextTotal !== null ? formatParameters(gauge.contextTotal) : '-'}
-                               </span>
-                       </div>
-
-                       {#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded}
-                               <ContextGaugeLoadModel
-                                       modelId={gauge.activeModelId}
-                                       isLoading={gauge.isActiveModelLoading}
-                                       onLoad={gauge.loadModel}
-                               />
-                       {:else if showProgressBar}
-                               <div class="h-1.5 w-full overflow-hidden rounded-full bg-muted">
-                                       <div
-                                               class="h-full rounded-full transition-all duration-300 {colorLevelBgClass(
-                                                       gauge.colorLevel
-                                               )}"
-                                               style="width: {gauge.contextPercent}%"
-                                       ></div>
-                               </div>
-
-                               <div class="flex justify-between text-xs text-muted-foreground">
-                                       <span>
-                                               <span class={colorLevelTextClass(gauge.colorLevel)}>{gauge.contextPercent}%</span> used
-                                       </span>
-                                       <span>
-                                               {formatParameters((gauge.contextTotal ?? 0) - gauge.contextUsed)} remaining
-                                       </span>
-                               </div>
-                       {:else}
-                               <div class="text-xs text-muted-foreground">No context info available</div>
-                       {/if}
-
-                       {#if gauge.hasAnyUsage}
-                               <ContextGaugeDetails
-                                       currentRead={gauge.currentRead}
-                                       currentFresh={gauge.currentFresh}
-                                       currentCache={gauge.currentCache}
-                                       currentOutput={gauge.currentOutput}
-                                       kvTotal={gauge.kvTotal}
-                                       cumulativeRead={gauge.cumulativeRead}
-                                       cumulativeOutput={gauge.cumulativeOutput}
-                                       cumulativeCacheTotal={gauge.cumulativeCacheTotal}
-                                       averageTokensPerSecond={gauge.averageTokensPerSecond}
-                                       transientDetails={gauge.transientDetails}
-                               />
-                       {/if}
-               </div>
-       </HoverCard.Content>
-</HoverCard.Root>
+<div
+       role="button"
+       tabindex="0"
+       aria-label="Context usage"
+       data-context-gauge-trigger
+       class="flex h-5 w-5 cursor-default items-center justify-center"
+       onclick={gaugeTriggerClick}
+       onkeydown={gaugeTriggerKeydown}
+       onpointerdown={gaugeTriggerPointerDown}
+       onpointerenter={gaugeTriggerEnter}
+       onpointerleave={gaugeTriggerLeave}
+>
+       <ContextGaugeDial percent={gauge.contextPercent} level={gauge.colorLevel} />
+</div>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte
new file mode 100644 (file)
index 0000000..81acdba
--- /dev/null
@@ -0,0 +1,106 @@
+<script lang="ts">
+       import { formatParameters } from '$lib/utils/formatters';
+       import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
+       import ContextGaugeDetails from './ContextGaugeDetails.svelte';
+       import ContextGaugeLoadModel from './ContextGaugeLoadModel.svelte';
+       import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
+       import {
+               gaugePopup,
+               gaugeCardEnter,
+               gaugeCardLeave,
+               gaugePopupClose
+       } from '$lib/stores/context-gauge-popup.svelte';
+
+       const gauge = useContextGauge();
+
+       let cardEl = $state<HTMLElement | null>(null);
+
+       // Any press outside the card and outside the dial closes the card.
+       // Presses on the dial are excluded because the dial handles its own
+       // toggle; the listener only exists while the card is open.
+       $effect(() => {
+               if (!gaugePopup.open) return;
+
+               const onPointerDown = (event: PointerEvent) => {
+                       const target = event.target;
+                       if (!(target instanceof Node)) return;
+                       if (cardEl?.contains(target)) return;
+                       if (target instanceof Element && target.closest('[data-context-gauge-trigger]')) return;
+                       gaugePopupClose();
+               };
+
+               document.addEventListener('pointerdown', onPointerDown, true);
+               return () => document.removeEventListener('pointerdown', onPointerDown, true);
+       });
+
+       const showProgressBar = $derived(
+               gauge.contextTotal !== null &&
+                       gauge.contextTotal > 0 &&
+                       (gauge.activeModelId !== null || gauge.isActiveModelLoaded)
+       );
+</script>
+
+{#if gaugePopup.open}
+       <div
+               role="status"
+               bind:this={cardEl}
+               class="absolute z-50 w-64 -translate-x-1/2 rounded-lg border border-border/50 bg-popover p-3 text-popover-foreground shadow-lg"
+               style="left: {gaugePopup.centerX}px; bottom: {gaugePopup.bottom}px"
+               onpointerenter={gaugeCardEnter}
+               onpointerleave={gaugeCardLeave}
+       >
+               <div class="flex flex-col gap-2">
+                       <div class="flex items-center gap-2">
+                               <span class="font-medium">Context</span>
+                               <span class="text-muted-foreground">ยท</span>
+                               <span class="font-mono text-muted-foreground">
+                                       {formatParameters(gauge.contextUsed)}
+                                       / {gauge.contextTotal !== null ? formatParameters(gauge.contextTotal) : '-'}
+                               </span>
+                       </div>
+
+                       {#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded}
+                               <ContextGaugeLoadModel
+                                       modelId={gauge.activeModelId}
+                                       isLoading={gauge.isActiveModelLoading}
+                                       onLoad={gauge.loadModel}
+                               />
+                       {:else if showProgressBar}
+                               <div class="h-1.5 w-full overflow-hidden rounded-full bg-muted">
+                                       <div
+                                               class="h-full rounded-full transition-all duration-300 {colorLevelBgClass(
+                                                       gauge.colorLevel
+                                               )}"
+                                               style="width: {gauge.contextPercent}%"
+                                       ></div>
+                               </div>
+
+                               <div class="flex justify-between text-xs text-muted-foreground">
+                                       <span>
+                                               <span class={colorLevelTextClass(gauge.colorLevel)}>{gauge.contextPercent}%</span> used
+                                       </span>
+                                       <span>
+                                               {formatParameters((gauge.contextTotal ?? 0) - gauge.contextUsed)} remaining
+                                       </span>
+                               </div>
+                       {:else}
+                               <div class="text-xs text-muted-foreground">No context info available</div>
+                       {/if}
+
+                       {#if gauge.hasAnyUsage}
+                               <ContextGaugeDetails
+                                       currentRead={gauge.currentRead}
+                                       currentFresh={gauge.currentFresh}
+                                       currentCache={gauge.currentCache}
+                                       currentOutput={gauge.currentOutput}
+                                       kvTotal={gauge.kvTotal}
+                                       cumulativeRead={gauge.cumulativeRead}
+                                       cumulativeOutput={gauge.cumulativeOutput}
+                                       cumulativeCacheTotal={gauge.cumulativeCacheTotal}
+                                       averageTokensPerSecond={gauge.averageTokensPerSecond}
+                                       transientDetails={gauge.transientDetails}
+                               />
+                       {/if}
+               </div>
+       </div>
+{/if}
index 560bf73abda5668b3269f551c2735ef9a199940e..8e8a14ac31bc0739d28c42224adcf62a1d062f98 100644 (file)
@@ -7,7 +7,6 @@
        import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
        import { REASONING_TAGS } from '$lib/constants/agentic';
        import { MessageRole, AttachmentType, AgenticSectionType } from '$lib/enums';
-       import { fadeInView } from '$lib/actions/fade-in-view.svelte';
        import {
                ChatMessageAssistant,
                ChatMessageUser,
        }
 </script>
 
-<div use:fadeInView class="chat-message">
+<div class="chat-message">
        {#if message.role === MessageRole.SYSTEM}
                <ChatMessageSystem
                        bind:textareaElement
                />
        {/if}
 </div>
+
+<style>
+       /*
+        * The browser skips layout and paint for messages outside the
+        * viewport. contain-intrinsic-size reuses the last rendered size
+        * once known; 500px sizes messages that have never been rendered.
+        */
+       .chat-message {
+               content-visibility: auto;
+               contain-intrinsic-size: auto 500px;
+       }
+</style>
index 58b3a42e07ac072de0a4a49fed5321d4e7afb481..1cc79fe6bca48aa690401e7b57c22a279beda398 100644 (file)
@@ -1,6 +1,5 @@
 <script lang="ts">
        import { ActionIcon, ChatMessageEditForm, ChatMessageUserBubble } from '$lib/components/app';
-       import { fadeInView } from '$lib/actions/fade-in-view.svelte';
        import { ArrowUp, Edit, Trash2 } from '@lucide/svelte';
        import { useMessageEditContext } from '$lib/hooks/use-message-edit-context.svelte';
 
@@ -30,7 +29,6 @@
 </script>
 
 <div
-       use:fadeInView
        aria-label="Pending user message"
        class="group flex flex-col items-end gap-3 transition-opacity hover:opacity-80 md:gap-2 {className} sticky bottom-32"
        role="group"
index dce0edd03138a81fe803c7ab597900328012897a..2b5ccb978e16480d9f8b6b4e1ef7e1364657c7b9 100644 (file)
@@ -1,6 +1,4 @@
 <script lang="ts">
-       import { onMount } from 'svelte';
-       import { beforeNavigate, afterNavigate } from '$app/navigation';
        import { ChatMessage, ChatMessageUserPending } from '$lib/components/app';
        import { setChatActionsContext } from '$lib/contexts';
        import { MessageRole } from '$lib/enums';
@@ -35,9 +33,6 @@
        let { messages = [], onUserAction, onMessagesReady }: Props = $props();
 
        let allConversationMessages = $state<DatabaseMessage[]>([]);
-       let isVisible = $state(false);
-       let previousConversationId = $state<string | null>(null);
-       let previousRouteId = $state<string | null>(null);
 
        const currentConfig = config();
 
                }
        }
 
-       // Track conversation changes to trigger transition even on same route
+       // Refresh messages whenever the active conversation changes
        $effect(() => {
-               const conversation = activeConversation();
-               const currentId = conversation?.id ?? null;
-
-               if (currentId !== previousConversationId && previousConversationId !== null) {
-                       // Conversation changed - trigger fade out/in
-                       isVisible = false;
-                       requestAnimationFrame(() => {
-                               refreshAllMessages();
-                               previousConversationId = currentId;
-                               requestAnimationFrame(() => {
-                                       isVisible = true;
-                               });
-                       });
-               } else {
-                       previousConversationId = currentId;
-                       if (conversation) {
-                               refreshAllMessages();
-                       }
+               if (activeConversation()) {
+                       refreshAllMessages();
                }
        });
 
                onMessagesReady?.(displayMessages.length);
        });
 
-       onMount(() => {
-               requestAnimationFrame(() => {
-                       isVisible = true;
-               });
-       });
-
-       beforeNavigate((navigation) => {
-               isVisible = false;
-               previousRouteId = navigation.from?.route.id ?? null;
-       });
-
-       afterNavigate(() => {
-               requestAnimationFrame(() => {
-                       isVisible = true;
-               });
-       });
-
        let siblingInfoByMessageId = $derived(buildSiblingInfoMap(allConversationMessages));
 
        let displayMessages = $derived.by(() => {
        });
 </script>
 
-<div
-       class="transition-opacity duration-500 ease-out
-               {isVisible ? 'opacity-100' : 'opacity-0'}
-               {previousRouteId === '/(chat)/chat/[id]' ? '' : 'delay-300'}"
->
+<div>
        {#each displayMessages as { message, toolMessages, isLastAssistantMessage, isLastUserMessage, nextAssistantMessage, siblingInfo } (message.id)}
                <ChatMessage
                        class="mx-auto mt-12 w-full max-w-3xl"
index 713c3b461d49eac4f331c1e65a5825684aa53bf6..45538a35151d7b48193dca28c612a12ef786ea71 100644 (file)
@@ -1,7 +1,6 @@
 <script lang="ts">
        import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { AlertTriangle, Loader2, RefreshCw } from '@lucide/svelte';
-       import { fadeInView } from '$lib/actions/fade-in-view.svelte';
        import * as Alert from '$lib/components/ui/alert';
        import { serverError, serverLoading, serverStatus, serverStore } from '$lib/stores/server.svelte';
 
@@ -10,10 +9,7 @@
 </script>
 
 {#if hasError}
-       <div
-               class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1"
-               use:fadeInView={{ y: 10, duration: 250 }}
-       >
+       <div class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1">
                <Alert.Root variant={isLoadingModel ? 'default' : 'destructive'}>
                        {#if isLoadingModel}
                                <Loader2 class="{ICON_CLASS_DEFAULT} animate-spin" />
index 8ac7f94483a207208be5fa890e7a927557a9032b..2c17f42345747b63f2fdae2f161c2f33de0a4b19 100644 (file)
@@ -78,7 +78,6 @@
        import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
        import type { DatabaseMessageExtra } from '$lib/types/database';
        import { config } from '$lib/stores/settings.svelte';
-       import { fadeInView } from '$lib/actions/fade-in-view.svelte';
 
        interface Props {
                attachments?: DatabaseMessageExtra[];
                : ''}"
 >
        {#each renderedBlocks as block (block.id)}
-               <div class="markdown-block" data-block-id={block.id} use:fadeInView={{ skipIfVisible: true }}>
+               <div class="markdown-block" data-block-id={block.id}>
                        {@html block.html}
                </div>
        {/each}
diff --git a/tools/ui/src/lib/constants/context-gauge-popup.ts b/tools/ui/src/lib/constants/context-gauge-popup.ts
new file mode 100644 (file)
index 0000000..fe2e6a1
--- /dev/null
@@ -0,0 +1,8 @@
+// Half of the card width, matching the w-64 class on the card.
+export const CONTEXT_GAUGE_CARD_HALF_WIDTH_PX = 128;
+// Minimum distance kept between the card and the form edges.
+export const CONTEXT_GAUGE_EDGE_MARGIN_PX = 8;
+// Gap between the top of the dial and the bottom edge of the card.
+export const CONTEXT_GAUGE_DIAL_GAP_PX = 8;
+// Grace delay before closing, letting the pointer travel from dial to card.
+export const CONTEXT_GAUGE_CLOSE_GRACE_MS = 150;
index 68b5992c2f717799344dd75df16d36e4a42ffaf5..a80e0cb6337990627bc9df68be85606342991bf5 100644 (file)
@@ -12,6 +12,7 @@ export * from './recommended-mcp-servers';
 export * from './storage';
 export * from './attachment-menu';
 export * from './auto-scroll';
+export * from './context-gauge-popup';
 export * from './binary-detection';
 export * from './built-in-tools';
 export * from './cache';
index 8107f08d088eaf85ae538c68d59fb3fff0d674bc..f2ad50dff799d510730a6807166379fc6e52465b 100644 (file)
@@ -84,11 +84,11 @@ export class AutoScrollController {
        }
 
        /**
-        * Scrolls the container to the bottom.
+        * Scrolls the container to the bottom instantly.
         */
-       scrollToBottom(behavior: ScrollBehavior = 'smooth'): void {
+       scrollToBottom(): void {
                if (this._disabled || !this._container) return;
-               this._container.scrollTo({ top: this._container.scrollHeight, behavior });
+               this._container.scrollTop = this._container.scrollHeight;
        }
 
        /**
diff --git a/tools/ui/src/lib/stores/context-gauge-popup.svelte.ts b/tools/ui/src/lib/stores/context-gauge-popup.svelte.ts
new file mode 100644 (file)
index 0000000..441edb3
--- /dev/null
@@ -0,0 +1,90 @@
+// Shared state for the context gauge popup. The dial and the card live in
+// different DOM subtrees, so open state and placement are coordinated here.
+// centerX and bottom place the card just above the dial: both are measured
+// once at open time, relative to the closest form ancestor; the dial and
+// the card share that positioning frame, so the values stay exact for the
+// whole time the card is open.
+// Mouse pointers open on hover with a short grace delay to travel from
+// dial to card; touch pointers toggle on tap.
+import {
+       CONTEXT_GAUGE_CARD_HALF_WIDTH_PX,
+       CONTEXT_GAUGE_CLOSE_GRACE_MS,
+       CONTEXT_GAUGE_DIAL_GAP_PX,
+       CONTEXT_GAUGE_EDGE_MARGIN_PX
+} from '$lib/constants';
+
+let closeTimer: ReturnType<typeof setTimeout> | undefined;
+let lastPointerType = '';
+
+export const gaugePopup = $state({ open: false, centerX: 0, bottom: 0 });
+
+function openFrom(trigger: HTMLElement): void {
+       clearTimeout(closeTimer);
+       const frame = trigger.closest('form');
+       if (frame) {
+               const frameRect = frame.getBoundingClientRect();
+               const triggerRect = trigger.getBoundingClientRect();
+               const centerX = triggerRect.left + triggerRect.width / 2 - frameRect.left;
+               const min = CONTEXT_GAUGE_CARD_HALF_WIDTH_PX + CONTEXT_GAUGE_EDGE_MARGIN_PX;
+               const max = frameRect.width - CONTEXT_GAUGE_CARD_HALF_WIDTH_PX - CONTEXT_GAUGE_EDGE_MARGIN_PX;
+               gaugePopup.centerX = Math.min(Math.max(centerX, min), Math.max(min, max));
+               gaugePopup.bottom = frameRect.bottom - triggerRect.top + CONTEXT_GAUGE_DIAL_GAP_PX;
+       }
+       gaugePopup.open = true;
+}
+
+function toggleFrom(trigger: HTMLElement): void {
+       if (gaugePopup.open) {
+               clearTimeout(closeTimer);
+               gaugePopup.open = false;
+       } else {
+               openFrom(trigger);
+       }
+}
+
+export function gaugePopupClose(): void {
+       clearTimeout(closeTimer);
+       gaugePopup.open = false;
+}
+
+export function gaugeTriggerPointerDown(event: PointerEvent): void {
+       lastPointerType = event.pointerType;
+}
+
+export function gaugeTriggerClick(event: MouseEvent): void {
+       if (lastPointerType !== 'touch') return;
+       toggleFrom(event.currentTarget as HTMLElement);
+}
+
+export function gaugeTriggerKeydown(event: KeyboardEvent): void {
+       if (event.key !== 'Enter' && event.key !== ' ') return;
+       event.preventDefault();
+       toggleFrom(event.currentTarget as HTMLElement);
+}
+
+export function gaugeTriggerEnter(event: PointerEvent): void {
+       if (event.pointerType !== 'mouse') return;
+       openFrom(event.currentTarget as HTMLElement);
+}
+
+export function gaugeTriggerLeave(event: PointerEvent): void {
+       if (event.pointerType !== 'mouse') return;
+       scheduleClose();
+}
+
+export function gaugeCardEnter(event: PointerEvent): void {
+       if (event.pointerType !== 'mouse') return;
+       clearTimeout(closeTimer);
+}
+
+export function gaugeCardLeave(event: PointerEvent): void {
+       if (event.pointerType !== 'mouse') return;
+       scheduleClose();
+}
+
+function scheduleClose(): void {
+       clearTimeout(closeTimer);
+       closeTimer = setTimeout(() => {
+               gaugePopup.open = false;
+       }, CONTEXT_GAUGE_CLOSE_GRACE_MS);
+}
diff --git a/tools/ui/src/lib/utils/viewport.ts b/tools/ui/src/lib/utils/viewport.ts
deleted file mode 100644 (file)
index 9e9b7af..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- * Check if an element is within the current viewport.
- */
-export function isElementInViewport(node: HTMLElement): boolean {
-       const rect = node.getBoundingClientRect();
-       return (
-               rect.top < window.innerHeight &&
-               rect.bottom > 0 &&
-               rect.left < window.innerWidth &&
-               rect.right > 0
-       );
-}