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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-08-12 09:33:22 +02:00
committed by GitHub
parent 201d7c4eb2
commit 2808150ae4
4 changed files with 83 additions and 50 deletions
+3 -1
View File
@@ -367,7 +367,9 @@
>
{#if isLoading}
<div class="flex gap-2 items-center">
<Loader2 class="animate-spin" />
<!-- Same size as the Timer icon this swaps out for: an unsized (24px) spinner makes
this header taller than the settled state and shoves the logs down while queued -->
<Loader2 size={small ? 10 : 12} class="animate-spin" />
{#if tag}
<div class="flex flex-row items-center gap-1">
<div class="text-secondary text-2xs">{tagLabel ?? 'tag'}: {tag}</div>
@@ -2270,14 +2270,10 @@
</div>
{:else}
{#key previewLayout}
<Splitpanes
horizontal={previewLayout !== 'bottom'}
class="!max-h-[calc(100%-{debugMode && isDebuggableScript
? '83'
: previewLayout === 'bottom'
? '0'
: '43'}px)]"
>
<!-- min-h-0 lets this shrink to the space the header row leaves it. Without it the
100% height wins, the panes settle one header too tall, and any reflow during a
run snaps them up and back. -->
<Splitpanes horizontal={previewLayout !== 'bottom'} class="min-h-0">
<Pane size={previewLayout === 'bottom' ? 40 : 33}>
{#if previewLayout === 'bottom' && !(debugMode && isDebuggableScript)}
<div class="px-3 pt-2 pb-1 flex items-center gap-2">
@@ -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[] = [
{
@@ -223,9 +223,11 @@
</div>
{:else}
<div class="text-sm text-primary p-2 flex justify-between items-center">
<span>
<!-- min-h pins this to the text-sm line box, and tracks it across root font
sizes, so swapping the text for the spinner does not resize the row -->
<span class="flex items-center min-h-5">
{#if previewIsLoading}
<Loader2 class="animate-spin" />
<Loader2 size={14} class="animate-spin" />
{:else}
Test to see the result here
{/if}