fix(frontend): open the template picker downward and size it from the popover

The popover's default positioning caps its height to the viewport, and the list
inside it carried a fixed one, so a capped box overflowed its own frame — visible
with the AI composer hidden, where the caption sits high and `placement: top`
left almost no room above it. It opens downward now, with flip fallbacks, at a
definite `min(72vh, 520px)`; the list fills what the header leaves, which is
still the definite height it needs to page.

`creating` on the create form becomes `onCreatingChange`: `$bindable(default)` on
an optional prop is banned, and this is something the form reports rather than
state it shares.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9
This commit is contained in:
Guilhem Lemouel
2026-09-03 17:37:08 +02:00
co-authored by Claude Opus 5
parent 5537247082
commit b3aa4a4cd3
4 changed files with 39 additions and 24 deletions
@@ -16,7 +16,6 @@
let { onPick }: Props = $props()
let list: InfiniteList | undefined = $state(undefined)
let loaded = $state(0)
// The hub serves its whole catalogue in one response, so paging happens here: the list
// asks for a window and gets a slice of what `hubProjectCatalogue` already holds. Should
@@ -46,7 +45,9 @@
})
</script>
<div class="flex w-[380px] flex-col">
<!-- The popover gives this box a definite height; the list takes what the header leaves and
scrolls inside it, which is also what lets it page. -->
<div class="flex min-h-0 w-[380px] flex-col">
<!-- The hub is named once, as the link to it: a footer row saying the same thing again is
a second line spent on somewhere the reader is not going. -->
<p class="px-3 pb-2 pt-3 text-[11.5px] leading-snug text-hint">
@@ -62,17 +63,8 @@
— imported as a folder in this workspace.
</p>
<!-- A definite height is what makes the list page: its scroll container only asks for
the next window once it actually scrolls. Given only to a list long enough to need
it, so a hub with a handful of projects gets a popover that hugs them. -->
<div class="border-t border-border-light">
<InfiniteList
bind:this={list}
bind:length={loaded}
noBorder
rounded={false}
containerClass={loaded > 4 ? 'h-[420px]' : ''}
>
<div class="min-h-0 flex-1 border-t border-border-light">
<InfiniteList bind:this={list} noBorder rounded={false} containerClass="h-full">
{#snippet customRow({ item }: { item: HubProjectPick })}
{@const Icon = hubAppIcon(item.iconApps[0] ?? '')}
<tr>
@@ -68,9 +68,22 @@
class="border-t border-dashed border-border-light px-4 pb-[22px] pt-[18px] text-center text-[13.5px] leading-relaxed text-hint"
>
Your scripts, flows and apps will show up here.
<!-- Opens downward into the page rather than upward into the hero: the caption sits high
when the AI composer is hidden, so the room is below it. `fitViewport` caps the box on
a short viewport, which is why the height below is definite and the list inside fills
it — a squeezed box with a fixed-height list inside overflows its own frame. -->
<Popover
placement="top"
contentClasses="p-0"
floatingConfig={{
placement: 'bottom',
strategy: 'absolute',
gutter: 8,
overflowPadding: 16,
flip: { fallbackPlacements: ['top', 'bottom-start', 'top-start'] },
fitViewport: true,
overlap: false
}}
contentClasses="p-0 flex"
contentStyle="height: min(72vh, 520px);"
class="border-b border-transparent text-accent hover:border-accent"
triggerAttrs={{ 'aria-label': 'Start from a template' }}
on:openChange={(e) => e.detail && logFeatureUsage('home', 'template_picker_open')}
@@ -22,16 +22,23 @@
/** Where to go once the workspace exists. It is already the active one by then. */
onCreated: (workspaceId: string) => void
/**
* True from the click until the navigation, so a surface with chrome of its own around
* this form — onboarding's Previous button — can stand down for the hand-over instead
* of offering a way back out of a workspace that now exists.
* Told when the form starts handing over to the new workspace and if it comes back, so
* a surface with chrome of its own around this one — onboarding's Previous button — can
* stand down for the hand-over instead of offering a way back out of a workspace that
* now exists. A callback rather than a bound prop: this is something the form reports,
* not state it shares, and `$bindable(default)` on an optional prop is banned.
*/
creating?: boolean
onCreatingChange?: (creating: boolean) => void
}
let { onCreated, creating = $bindable(false) }: Props = $props()
let { onCreated, onCreatingChange }: Props = $props()
let name = $state('')
let creating = $state(false)
function setCreating(next: boolean) {
creating = next
onCreatingChange?.(next)
}
// The full form — id, colour, username, invites — for the person who wants it. Forced on
// when the instance does not derive usernames: one is required and a name field has
@@ -91,7 +98,7 @@
async function create() {
if (problem || creating) return
creating = true
setCreating(true)
const workspaceName = name.trim()
const started = Date.now()
try {
@@ -102,7 +109,7 @@
true
)
advanced = true
creating = false
setCreating(false)
return
}
await WorkspaceService.createWorkspace({
@@ -123,7 +130,7 @@
} catch (error) {
console.error('Could not create the workspace:', error)
sendUserToast('Could not create the workspace: ' + (error?.body || error?.message), true)
creating = false
setCreating(false)
}
}
</script>
@@ -315,7 +315,10 @@
<!-- The same one-field form the workspace picker falls back to, so a user who leaves
onboarding early meets it again rather than something new. It owns the name, the
id, the advanced form and the hand-over into the workspace. -->
<SimpleCreateWorkspace onCreated={leaveOnboarding} bind:creating={creatingWorkspace} />
<SimpleCreateWorkspace
onCreated={leaveOnboarding}
onCreatingChange={(v) => (creatingWorkspace = v)}
/>
{#if !skippedSurvey && !creatingWorkspace}
<div class="flex flex-row justify-start items-center pt-6">