refactor: unify group node headers with shared GroupHeader component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-03-11 10:09:12 +01:00
co-authored by Claude Opus 4.6
parent c0d193e0c7
commit 77c3ef6abf
6 changed files with 195 additions and 153 deletions
@@ -0,0 +1,104 @@
<script lang="ts">
import { NOTE_COLORS, NoteColor } from './noteColors'
import { stopPropagation, preventDefault } from 'svelte/legacy'
import { ChevronRight } from 'lucide-svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
interface Props {
summary?: string
color?: string
collapsed: boolean
editMode: boolean
onToggleCollapse: () => void
onSummaryUpdate?: (text: string) => void
}
let { summary, color, collapsed, editMode, onToggleCollapse, onSummaryUpdate }: Props =
$props()
let colorConfig = $derived(
NOTE_COLORS[(color as NoteColor) ?? NoteColor.BLUE] ?? NOTE_COLORS[NoteColor.BLUE]
)
// Inline summary editing
let editingSummary = $state(false)
let summaryInput = $state('')
let textInputComponent: TextInput | undefined = $state(undefined)
function startEditingSummary() {
if (!editMode) return
editingSummary = true
summaryInput = summary ?? ''
requestAnimationFrame(() => {
textInputComponent?.focus()
textInputComponent?.select()
})
}
function saveSummary() {
editingSummary = false
const trimmed = summaryInput.trim()
if (trimmed !== (summary ?? '')) {
onSummaryUpdate?.(trimmed)
}
}
function handleSummaryKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
saveSummary()
} else if (event.key === 'Escape') {
editingSummary = false
}
}
// Animate chevron rotation on mount
let chevronRotated = $state(collapsed)
$effect(() => {
requestAnimationFrame(() => {
chevronRotated = !collapsed
})
})
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="flex items-center h-[22px] w-full px-2 relative cursor-pointer {colorConfig.background} {colorConfig.text} {collapsed
? 'rounded-t-md'
: 'rounded-md'}"
onclick={stopPropagation(preventDefault(onToggleCollapse))}
onpointerdown={stopPropagation(preventDefault(() => {}))}
title={collapsed ? 'Expand group' : 'Collapse group'}
>
<div
class="flex items-center justify-center shrink-0 opacity-60 transition-transform duration-100"
class:rotate-90={chevronRotated}
>
<ChevronRight size={12} />
</div>
<div class="absolute inset-x-0 flex items-center justify-center h-full pointer-events-none px-7">
{#if editingSummary}
<TextInput
bind:this={textInputComponent}
bind:value={summaryInput}
size="xs"
class="!bg-transparent !border-transparent !shadow-none !text-2xs !font-medium !p-0 !m-0 !min-w-0 w-full text-center !min-h-0 !h-auto nodrag nowheel pointer-events-auto"
inputProps={{
placeholder: 'Group',
onblur: saveSummary,
onkeydown: handleSummaryKeydown,
spellcheck: false
}}
/>
{:else}
<span
class="text-2xs font-medium truncate text-center pointer-events-auto {editMode
? 'cursor-text rounded px-0.5 -mx-0.5 hover:bg-black/10 dark:hover:bg-white/10'
: ''}"
onclick={editMode ? stopPropagation(preventDefault(startEditingSummary)) : undefined}
onpointerdown={editMode ? stopPropagation(preventDefault(() => {})) : undefined}
>{summary || 'Group'}</span
>
{/if}
</div>
</div>
@@ -40,12 +40,10 @@
})
</script>
<div class="flex items-center">
<div class="flex items-center gap-1">
{#each displayModules as mod, i (mod.id)}
<div
class="w-6 h-6 rounded-full overflow-hidden bg-surface-secondary flex items-center justify-center shrink-0 shadow-sm"
style:z-index={displayModules.length - i}
class:-ml-1={i > 0}
class="w-6 h-6 rounded-full overflow-hidden bg-surface-tertiary flex items-center justify-center shrink-0 shadow-sm"
>
<FlowModuleIcon module={mod} size={14} />
</div>
@@ -4,9 +4,8 @@
import { getGroupEditorContext, type FlowGroup } from './groupEditor.svelte'
import { NoteColor, NOTE_COLORS } from './noteColors'
import GroupActionBar from './GroupActionBar.svelte'
import StepCountTab from './StepCountTab.svelte'
import GroupHeader from './GroupHeader.svelte'
import type { CollapsedSubflowN } from './graphBuilder.svelte'
import { stopPropagation, preventDefault } from 'svelte/legacy'
interface Props {
hoveredNodeId: string | null
@@ -21,35 +20,6 @@
// Menu open state
let menuOpen = $state(false)
// Inline summary editing
let editingGroupId = $state<string | null>(null)
let summaryInput = $state('')
let summaryInputEl = $state<HTMLInputElement | undefined>(undefined)
function startEditingSummary(groupId: string, current: string) {
if (!editMode) return
editingGroupId = groupId
summaryInput = current
requestAnimationFrame(() => {
summaryInputEl?.focus()
summaryInputEl?.select()
})
}
function saveSummary(groupId: string) {
editingGroupId = null
const trimmed = summaryInput.trim()
groupEditorContext?.groupEditor.updateSummary(groupId, trimmed)
}
function handleSummaryKeydown(event: KeyboardEvent, groupId: string) {
if (event.key === 'Enter') {
saveSummary(groupId)
} else if (event.key === 'Escape') {
editingGroupId = null
}
}
// Action bar hover state to prevent flicker
let actionBarHovered = $state(false)
@@ -86,63 +56,27 @@
if (group.module_ids.length === 0) return null
const { minX, minY, maxX, maxY } = calculateNodesBoundsWithOffset(group.module_ids, allNodes)
const padding = 16
const topPadding = 28
const topPadding = 34
const headerHeight = 22
const halfHeader = headerHeight / 2
return {
x: minX - padding,
y: minY - topPadding,
y: minY - topPadding + halfHeader,
width: maxX - minX + 2 * padding,
height: maxY - minY + topPadding + padding
height: maxY - minY + topPadding - halfHeader + padding,
headerY: minY - topPadding
}
}
// Outline color mapping — default uses /60 opacity, hover uses full opacity
const GROUP_OUTLINE_COLORS: Record<NoteColor, string> = {
[NoteColor.YELLOW]: 'outline-yellow-400/60 dark:outline-yellow-600/60',
[NoteColor.BLUE]: 'outline-blue-400/60 dark:outline-blue-600/60',
[NoteColor.GREEN]: 'outline-green-400/60 dark:outline-green-600/60',
[NoteColor.PURPLE]: 'outline-purple-400/60 dark:outline-purple-600/60',
[NoteColor.PINK]: 'outline-pink-400/60 dark:outline-pink-600/60',
[NoteColor.ORANGE]: 'outline-orange-400/60 dark:outline-orange-600/60',
[NoteColor.RED]: 'outline-red-400/60 dark:outline-red-600/60',
[NoteColor.CYAN]: 'outline-cyan-400/60 dark:outline-cyan-600/60',
[NoteColor.LIME]: 'outline-lime-400/60 dark:outline-lime-600/60',
[NoteColor.GRAY]: 'outline-gray-400/60 dark:outline-gray-600/60'
}
const GROUP_OUTLINE_COLORS_HOVER: Record<NoteColor, string> = {
[NoteColor.YELLOW]: 'outline-yellow-400 dark:outline-yellow-600',
[NoteColor.BLUE]: 'outline-blue-400 dark:outline-blue-600',
[NoteColor.GREEN]: 'outline-green-400 dark:outline-green-600',
[NoteColor.PURPLE]: 'outline-purple-400 dark:outline-purple-600',
[NoteColor.PINK]: 'outline-pink-400 dark:outline-pink-600',
[NoteColor.ORANGE]: 'outline-orange-400 dark:outline-orange-600',
[NoteColor.RED]: 'outline-red-400 dark:outline-red-600',
[NoteColor.CYAN]: 'outline-cyan-400 dark:outline-cyan-600',
[NoteColor.LIME]: 'outline-lime-400 dark:outline-lime-600',
[NoteColor.GRAY]: 'outline-gray-400 dark:outline-gray-600'
}
function getOutlineColorClass(color?: string, hovered?: boolean): string {
const map = hovered ? GROUP_OUTLINE_COLORS_HOVER : GROUP_OUTLINE_COLORS
return map[(color as NoteColor) ?? NoteColor.BLUE] ?? map[NoteColor.BLUE]
}
const GROUP_BG_COLORS: Record<NoteColor, string> = {
[NoteColor.YELLOW]: 'bg-yellow-400/5 dark:bg-yellow-600/5',
[NoteColor.BLUE]: 'bg-blue-400/5 dark:bg-blue-600/5',
[NoteColor.GREEN]: 'bg-green-400/5 dark:bg-green-600/5',
[NoteColor.PURPLE]: 'bg-purple-400/5 dark:bg-purple-600/5',
[NoteColor.PINK]: 'bg-pink-400/5 dark:bg-pink-600/5',
[NoteColor.ORANGE]: 'bg-orange-400/5 dark:bg-orange-600/5',
[NoteColor.RED]: 'bg-red-400/5 dark:bg-red-600/5',
[NoteColor.CYAN]: 'bg-cyan-400/5 dark:bg-cyan-600/5',
[NoteColor.LIME]: 'bg-lime-400/5 dark:bg-lime-600/5',
[NoteColor.GRAY]: 'bg-gray-400/5 dark:bg-gray-600/5'
const config = NOTE_COLORS[(color as NoteColor) ?? NoteColor.BLUE] ?? NOTE_COLORS[NoteColor.BLUE]
return hovered ? config.outline : config.outlineHover
}
function getBgColorClass(color?: string): string {
return (
GROUP_BG_COLORS[(color as NoteColor) ?? NoteColor.BLUE] ?? GROUP_BG_COLORS[NoteColor.BLUE]
NOTE_COLORS[(color as NoteColor) ?? NoteColor.BLUE]?.backgroundLight ??
NOTE_COLORS[NoteColor.BLUE].backgroundLight
)
}
@@ -184,7 +118,7 @@
<!-- Bounding box background + outline (behind nodes) -->
<ViewportPortal target="back">
<div
class="absolute rounded-lg outline outline-1 pointer-events-none transition-colors duration-150 {getOutlineColorClass(
class="absolute rounded-lg outline outline-1 -outline-offset-1 pointer-events-none transition-colors duration-150 {getOutlineColorClass(
group.color,
visibleGroup?.id === group.id
)} {getBgColorClass(group.color)}"
@@ -194,58 +128,25 @@
></div>
</ViewportPortal>
<!-- StepCountTab (left) + summary + ellipsis menu (right) -->
<!-- Group header + ellipsis menu -->
<ViewportPortal target="front">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute flex items-start justify-between"
style="pointer-events: auto; transform: translate({bounds.x}px, {bounds.y}px); width: {bounds.width}px;"
class="absolute flex flex-col items-center"
style="pointer-events: auto; transform: translate({bounds.x}px, {bounds.headerY}px); width: {bounds.width}px;"
style:z-index="4"
>
<div class="relative" style="margin-left: 16px;">
<StepCountTab
stepCount={group.module_ids.length}
<div class="relative" style="width: 275px;">
<GroupHeader
summary={group.summary}
color={group.color}
collapsed={false}
short
onExpand={() => toggleCollapse(group.id)}
{editMode}
onToggleCollapse={() => toggleCollapse(group.id)}
onSummaryUpdate={(text) =>
groupEditorContext?.groupEditor.updateSummary(group.id, text)}
/>
{#if editingGroupId === group.id}
{@const textColorClass =
NOTE_COLORS[(group.color as NoteColor) ?? NoteColor.BLUE]?.text ?? ''}
<input
bind:this={summaryInputEl}
bind:value={summaryInput}
class="absolute !text-3xs !font-medium !h-4 !bg-transparent !outline-none {textColorClass}"
style="top: 2px; left:-3px; width: 160px; padding: 2px 2px;"
onblur={() => saveSummary(group.id)}
onkeydown={(e) => handleSummaryKeydown(e, group.id)}
onclick={stopPropagation(preventDefault(() => {}))}
onpointerdown={stopPropagation(preventDefault(() => {}))}
spellcheck={false}
/>
{:else}
{@const textColorClass =
NOTE_COLORS[(group.color as NoteColor) ?? NoteColor.BLUE]?.text ?? ''}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="absolute text-3xs font-medium truncate max-w-[150px] text-opacity-60 {textColorClass} {editMode
? 'cursor-text rounded px-0.5 -mx-0.5 hover:text-opacity-100'
: ''}"
style="top: 2px; left: 0px;"
onclick={editMode
? stopPropagation(
preventDefault(() => startEditingSummary(group.id, group.summary ?? ''))
)
: undefined}
onpointerdown={editMode ? stopPropagation(preventDefault(() => {})) : undefined}
>{group.summary || (editMode ? 'Group' : '')}</span
>
{/if}
</div>
{#if editMode}
<div class="relative" style="margin-top: -8px;">
{#if editMode}
<GroupActionBar
note={group.note}
color={group.color}
@@ -262,8 +163,8 @@
visibleGroup = undefined
}}
/>
</div>
{/if}
{/if}
</div>
</div>
</ViewportPortal>
{/if}
@@ -258,10 +258,11 @@ export function getGroupEditorContext(): GroupEditorContext | undefined {
return getContext<GroupEditorContext | undefined>(CONTEXT_KEY)
}
/** Extra vertical space pushed above the topmost node of each group for the tab */
export const GROUP_HEADER_HEIGHT = 0
/** Height of the group header bar */
export const GROUP_HEADER_HEIGHT = 22
const GROUP_TOP_MARGIN = 28
/** Extra margin between the header and the first node */
const GROUP_TOP_MARGIN = 12
/**
* Compute adjusted node positions for collapsed groups whose note is visible.
@@ -14,6 +14,7 @@ export enum NoteColor {
export interface NoteColorConfig {
background: string
backgroundLight: string
outline: string
outlineHover: string
text: string
@@ -24,6 +25,7 @@ export interface NoteColorConfig {
export const NOTE_COLORS: Record<NoteColor, NoteColorConfig> = {
[NoteColor.YELLOW]: {
background: 'bg-yellow-200 dark:bg-yellow-900',
backgroundLight: 'bg-yellow-400/5 dark:bg-yellow-600/5',
outline: 'outline-yellow-300 dark:outline-yellow-600',
outlineHover: 'outline-yellow-300/60 dark:outline-yellow-600/60',
text: 'text-yellow-900 dark:text-yellow-100',
@@ -31,6 +33,7 @@ export const NOTE_COLORS: Record<NoteColor, NoteColorConfig> = {
},
[NoteColor.BLUE]: {
background: 'bg-blue-100 dark:bg-blue-950',
backgroundLight: 'bg-blue-400/5 dark:bg-blue-600/5',
outline: 'outline-blue-300 dark:outline-blue-600',
outlineHover: 'outline-blue-300/60 dark:outline-blue-600/60',
text: 'text-blue-900 dark:text-blue-100',
@@ -38,6 +41,7 @@ export const NOTE_COLORS: Record<NoteColor, NoteColorConfig> = {
},
[NoteColor.GREEN]: {
background: 'bg-green-200 dark:bg-green-900',
backgroundLight: 'bg-green-400/5 dark:bg-green-600/5',
outline: 'outline-green-300 dark:outline-green-600',
outlineHover: 'outline-green-300/60 dark:outline-green-600/60',
text: 'text-green-900 dark:text-green-100',
@@ -45,6 +49,7 @@ export const NOTE_COLORS: Record<NoteColor, NoteColorConfig> = {
},
[NoteColor.PURPLE]: {
background: 'bg-purple-200 dark:bg-purple-900',
backgroundLight: 'bg-purple-400/5 dark:bg-purple-600/5',
outline: 'outline-purple-300 dark:outline-purple-600',
outlineHover: 'outline-purple-300/60 dark:outline-purple-600/60',
text: 'text-purple-900 dark:text-purple-100',
@@ -52,6 +57,7 @@ export const NOTE_COLORS: Record<NoteColor, NoteColorConfig> = {
},
[NoteColor.PINK]: {
background: 'bg-pink-200 dark:bg-pink-900',
backgroundLight: 'bg-pink-400/5 dark:bg-pink-600/5',
outline: 'outline-pink-300 dark:outline-pink-600',
outlineHover: 'outline-pink-300/60 dark:outline-pink-600/60',
text: 'text-pink-900 dark:text-pink-100',
@@ -59,6 +65,7 @@ export const NOTE_COLORS: Record<NoteColor, NoteColorConfig> = {
},
[NoteColor.ORANGE]: {
background: 'bg-orange-200 dark:bg-orange-900',
backgroundLight: 'bg-orange-400/5 dark:bg-orange-600/5',
outline: 'outline-orange-300 dark:outline-orange-600',
outlineHover: 'outline-orange-300/60 dark:outline-orange-600/60',
text: 'text-orange-900 dark:text-orange-100',
@@ -66,6 +73,7 @@ export const NOTE_COLORS: Record<NoteColor, NoteColorConfig> = {
},
[NoteColor.RED]: {
background: 'bg-red-200 dark:bg-red-900',
backgroundLight: 'bg-red-400/5 dark:bg-red-600/5',
outline: 'outline-red-300 dark:outline-red-600',
outlineHover: 'outline-red-300/60 dark:outline-red-600/60',
text: 'text-red-900 dark:text-red-100',
@@ -73,6 +81,7 @@ export const NOTE_COLORS: Record<NoteColor, NoteColorConfig> = {
},
[NoteColor.CYAN]: {
background: 'bg-cyan-200 dark:bg-cyan-900',
backgroundLight: 'bg-cyan-400/5 dark:bg-cyan-600/5',
outline: 'outline-cyan-300 dark:outline-cyan-600',
outlineHover: 'outline-cyan-300/60 dark:outline-cyan-600/60',
text: 'text-cyan-900 dark:text-cyan-100',
@@ -80,6 +89,7 @@ export const NOTE_COLORS: Record<NoteColor, NoteColorConfig> = {
},
[NoteColor.LIME]: {
background: 'bg-lime-200 dark:bg-lime-900',
backgroundLight: 'bg-lime-400/5 dark:bg-lime-600/5',
outline: 'outline-lime-300 dark:outline-lime-600',
outlineHover: 'outline-lime-300/60 dark:outline-lime-600/60',
text: 'text-lime-900 dark:text-lime-100',
@@ -87,6 +97,7 @@ export const NOTE_COLORS: Record<NoteColor, NoteColorConfig> = {
},
[NoteColor.GRAY]: {
background: 'bg-gray-200 dark:bg-gray-800',
backgroundLight: 'bg-gray-400/5 dark:bg-gray-600/5',
outline: 'outline-gray-300 dark:outline-gray-600',
outlineHover: 'outline-gray-300/60 dark:outline-gray-600/60',
text: 'text-gray-900 dark:text-gray-100',
@@ -2,10 +2,13 @@
import NodeWrapper from './NodeWrapper.svelte'
import type { CollapsedGroupN } from '../../graphBuilder.svelte'
import { getGraphContext } from '../../graphContext'
import GroupNodeCard from '../../GroupNodeCard.svelte'
import GroupHeader from '../../GroupHeader.svelte'
import GroupActionBar from '../../GroupActionBar.svelte'
import GroupModuleIcons from '../../GroupModuleIcons.svelte'
import GroupNoteArea from '../../GroupNoteArea.svelte'
import { getGroupEditorContext } from '../../groupEditor.svelte'
import StepCountTab from '../../StepCountTab.svelte'
import { NOTE_COLORS, NoteColor } from '../../noteColors'
import { twMerge } from 'tailwind-merge'
interface Props {
data: CollapsedGroupN['data']
@@ -23,6 +26,10 @@
groupEditorContext?.groupEditor.getGroups().find((g) => g.id === data.groupId)
)
let noteColorConfig = $derived(
NOTE_COLORS[(data.color as NoteColor) ?? NoteColor.BLUE] ?? NOTE_COLORS[NoteColor.BLUE]
)
let hover = $state(false)
let menuOpen = $state(false)
</script>
@@ -31,27 +38,47 @@
{#snippet children({ darkMode })}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="relative" onmouseenter={() => (hover = true)} onmouseleave={() => (hover = false)}>
<StepCountTab
stepCount={data.stepCount}
color={data.color}
onExpand={() => data.eventHandlers.expandGroup(data.groupId)}
/>
<div
class={twMerge(
'w-full module cursor-pointer max-w-full',
'rounded-md overflow-clip outline outline-1 -outline-offset-1',
noteColorConfig.outlineHover,
noteColorConfig.backgroundLight
)}
style="width: 275px;"
>
<div class="relative z-1">
<GroupHeader
summary={data.summary}
color={data.color}
collapsed={true}
editMode={data.editMode}
onToggleCollapse={() => data.eventHandlers.expandGroup(data.groupId)}
onSummaryUpdate={(text) =>
groupEditorContext?.groupEditor.updateSummary(data.groupId, text)}
/>
{#if data.modules && data.modules.length > 0}
<div class="flex items-center justify-center w-full gap-1.5 px-2 h-[34px]">
<GroupModuleIcons modules={data.modules} />
</div>
{/if}
</div>
<GroupNodeCard
summary={data.summary}
color={data.color}
{selected}
note={data.note}
showNote={data.showNotes && data.note != null}
editMode={data.editMode}
modules={data.modules}
onSummaryUpdate={(text) =>
groupEditorContext?.groupEditor.updateSummary(data.groupId, text)}
onNoteUpdate={(text) => groupEditorContext?.groupEditor.updateNote(data.groupId, text)}
onHeightChange={(h) => {
groupEditorContext?.groupEditor.setNoteHeight(data.groupId, h)
}}
/>
{#if data.showNotes && data.note != null}
<div class="relative z-1">
<GroupNoteArea
note={data.note ?? ''}
color={data.color}
editMode={data.editMode}
onHeightChange={(h) => {
groupEditorContext?.groupEditor.setNoteHeight(data.groupId, h)
}}
onNoteUpdate={(text) =>
groupEditorContext?.groupEditor.updateNote(data.groupId, text)}
/>
</div>
{/if}
</div>
{#if data.editMode}
<GroupActionBar