From 2808150ae4c39a76be21a38a2d739fd071dde8cd Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 12 Aug 2026 09:33:22 +0200 Subject: [PATCH] fix: avoid content shift on home page load and in the script editor logs pane (#10654) * fix: avoid content shift on home page load and in the script editor logs pane The tutorial banner rendered by default and was removed once an API round-trip resolved that it should not show, jumping everything below it up by 58px on every home page load. It now caches the last resolved state in localStorage and paints that first, so the first frame already matches what the sync concludes; a device with nothing cached stays hidden until the sync answers. The logs header spinner was an unsized lucide icon (24px) where the settled state renders a 12px Timer, so the row grew 7px while a job was queued and shrank back when it started, shoving the log body down and up again. Co-Authored-By: Claude Opus 5 (1M context) * fix: keep the tutorial banner hidden when dismissed mid-sync The banner is interactive while the initial tutorial-progress request is still in flight, so a dismiss or a skip can land before the sync resolves. The continuation then overwrote the user's choice and brought the banner back. Co-Authored-By: Claude Opus 5 (1M context) * fix: pin the result placeholder row height across the spinner swap Sizing the spinner to the font size still left it 6px short of the text-sm line box it replaces, so the row contracted instead of growing. Pin the height on the container so it holds in both states and tracks the root font size. Also assign state before persisting it, and collapse the duplicated rationale above the banner cache. Co-Authored-By: Claude Opus 5 (1M context) * fix: stop the test panel splitpanes resting one header too tall The panes carried `!max-h-[calc(100%-{...}px)]`, but the arbitrary value is built by string interpolation so Tailwind never emitted a rule for it: the class was inert and the computed max-height was `none`. The panes then took their 100% height, ignoring the header row above them, and overflowed the column by exactly the header. Flex only applied the shrink transiently, so a reflow during a run snapped the whole logs & result region up ~12px and back. min-h-0 lets flex size the panes to the space that is actually left, which is what the clamp was reaching for and is correct for the debug and bottom layouts too, without their hardcoded 83/43/0 pixel guesses. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- frontend/src/lib/components/LogViewer.svelte | 4 +- .../src/lib/components/ScriptEditor.svelte | 12 +- .../lib/components/home/TutorialBanner.svelte | 111 ++++++++++++------ .../components/scriptEditor/LogPanel.svelte | 6 +- 4 files changed, 83 insertions(+), 50 deletions(-) diff --git a/frontend/src/lib/components/LogViewer.svelte b/frontend/src/lib/components/LogViewer.svelte index 2098e47a70..dbb8015459 100644 --- a/frontend/src/lib/components/LogViewer.svelte +++ b/frontend/src/lib/components/LogViewer.svelte @@ -367,7 +367,9 @@ > {#if isLoading}
- + + {#if tag}
{tagLabel ?? 'tag'}: {tag}
diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 936d49f3ee..13f3de2a7a 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -2270,14 +2270,10 @@
{:else} {#key previewLayout} - + + {#if previewLayout === 'bottom' && !(debugMode && isDebuggableScript)}
diff --git a/frontend/src/lib/components/home/TutorialBanner.svelte b/frontend/src/lib/components/home/TutorialBanner.svelte index 9661d6738a..14d8b750c1 100644 --- a/frontend/src/lib/components/home/TutorialBanner.svelte +++ b/frontend/src/lib/components/home/TutorialBanner.svelte @@ -15,8 +15,20 @@ import { hasRoleAccess } from '$lib/tutorials/roleUtils' import { onMount } from 'svelte' - let isDismissed = $state(false) - let hasCompletedAny = $state(false) + type BannerState = 'hidden' | 'start' | 'new' + + // Deciding what to show needs an API round-trip, so the banner paints the state the last visit + // resolved to and reconciles once the sync answers. Guessing wrong once in a while beats + // reflowing the home page on every load; nothing cached means hidden, the direction that does + // not push the page down. + const TUTORIAL_BANNER_STATE_KEY = 'tutorial_banner_state' + + const cachedState = + getLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY) === 'true' + ? 'hidden' + : getLocalSetting(TUTORIAL_BANNER_STATE_KEY) + let isDismissed = $state(cachedState !== 'start' && cachedState !== 'new') + let hasCompletedAny = $state(cachedState === 'new') /** * Get all tutorial indexes that are accessible to the current user based on their role. @@ -42,60 +54,81 @@ return indexes }) + function resolveState(state: BannerState) { + isDismissed = state === 'hidden' + hasCompletedAny = state === 'new' + // Last: persisting is best-effort, and a storage failure must not leave the banner stuck on + // whatever the cache said + storeLocalSetting(TUTORIAL_BANNER_STATE_KEY, state) + } + + // The banner is interactive while the initial sync is still in flight, so a dismiss or a skip + // can land mid-await. Once that happens the user's choice wins and the sync must not resurrect + // the banner. + let userHidBanner = false + + function hideBannerForUser() { + userHidBanner = true + resolveState('hidden') + } + onMount(async () => { + // Manually dismissed via the X button (soft dismiss, per-device). Checked before the network + // call so a dismissed banner can never flash back in. + if (getLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY) === 'true') { + resolveState('hidden') + return + } + try { // Sync tutorial progress from backend first await syncTutorialsTodos() - - // Check if banner has been manually dismissed via X button (soft dismiss, per-device) - const manuallyDismissed = getLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY) === 'true' - if (manuallyDismissed) { - isDismissed = true - return - } - - // Check if user deliberately skipped all tutorials (permanent dismiss, from backend) - if ($skippedAll) { - isDismissed = true - return - } - - // Safe to check tutorialsToDo here since we awaited syncTutorialsTodos() above - // Filter tutorialsToDo to only include tutorials accessible to the user's role - const remainingAccessibleTutorials = $tutorialsToDo.filter((index) => - accessibleTutorialIndexes.has(index) - ) - - // Calculate if user has completed at least one tutorial (for banner wording) - // This determines whether to show "New tutorial available!" or "Learn with interactive tutorials" - hasCompletedAny = remainingAccessibleTutorials.length < accessibleTutorialIndexes.size - - // Hide banner if all accessible tutorials are completed (but can reappear with new tutorials) - if (remainingAccessibleTutorials.length === 0) { - isDismissed = true - return - } - - // Show banner - user has accessible tutorials to complete - isDismissed = false } catch (error) { console.error('Failed to sync tutorial progress:', error) - // Fallback to manual dismissal check only if API call fails - isDismissed = getLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY) === 'true' + // Keep whatever the last successful sync resolved to rather than guessing again + return } + + if (userHidBanner) { + return + } + + // Check if user deliberately skipped all tutorials (permanent dismiss, from backend) + if ($skippedAll) { + resolveState('hidden') + return + } + + // Safe to check tutorialsToDo here since we awaited syncTutorialsTodos() above + // Filter tutorialsToDo to only include tutorials accessible to the user's role + const remainingAccessibleTutorials = $tutorialsToDo.filter((index) => + accessibleTutorialIndexes.has(index) + ) + + // Hide banner if all accessible tutorials are completed (but can reappear with new tutorials) + if (remainingAccessibleTutorials.length === 0) { + resolveState('hidden') + return + } + + // Having completed at least one accessible tutorial switches the wording to + // "New tutorial available!" instead of "Learn with interactive tutorials" + resolveState( + remainingAccessibleTutorials.length < accessibleTutorialIndexes.size ? 'new' : 'start' + ) }) async function handleSkipAllTutorials() { // Skip all tutorials and set skipped_all flag in backend (permanent) await skipAllTodos() await syncTutorialsTodos() - // No need to set localStorage - backend skipped_all flag is the source of truth - isDismissed = true + // No need to set the dismissed flag - backend skipped_all flag is the source of truth + hideBannerForUser() } function dismissBanner() { storeLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY, 'true') - isDismissed = true + hideBannerForUser() const actions: ToastAction[] = [ { diff --git a/frontend/src/lib/components/scriptEditor/LogPanel.svelte b/frontend/src/lib/components/scriptEditor/LogPanel.svelte index 008a987999..f253beadc7 100644 --- a/frontend/src/lib/components/scriptEditor/LogPanel.svelte +++ b/frontend/src/lib/components/scriptEditor/LogPanel.svelte @@ -223,9 +223,11 @@
{:else}
- + + {#if previewIsLoading} - + {:else} Test to see the result here {/if}