feat(walkthrough): introduce the rack canvas, and keep Next inside the card

The Getting Started tour stopped at the logical canvas, so the third design
type was invisible to anyone who had not gone looking for New Canvas → Kind.
A step between grouping and styling now points at the canvas switcher and
says what a rack canvas is for. It is `mode: 'all'` — a rack needs no backend,
it persists to localStorage in standalone like any other canvas — and it
anchors on the switcher rather than a rack control, since the tour runs on a
network canvas where none of the rack sidebar items exist.

That twelfth step also broke the card footer. The progress strip grows with
the step count and neither footer child could shrink (`min-width: auto`), so
at twelve dots the strip pushed the counter and both buttons past the right
edge of the 320px card — Next ended up outside it. The strip is now the part
that gives: it shrinks and clips, the controls hold their size, and Next stays
anchored whatever the step count.

ha-relevant: maybe
The overlay fix applies to any build of the frontend; the rack step itself is
pointless until the rack canvas is ported.
This commit is contained in:
Pouzor
2026-08-09 20:35:23 +02:00
committed by Pouzor - Rémy Jardient
parent ff740f7466
commit a70fc248bf
6 changed files with 75 additions and 5 deletions
@@ -172,6 +172,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
<div className="px-2 pt-2 pb-1 border-b border-border relative">
<button
onClick={() => setDesignSwitcherOpen((o) => !o)}
data-tour="canvas-switcher"
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-xs font-medium bg-[#21262d] border border-border hover:border-[#30363d] transition-colors cursor-pointer"
>
{activeDesignId ? (() => {
@@ -284,6 +284,38 @@ describe('Sidebar', () => {
expect(mockLogout).toHaveBeenCalledOnce()
})
})
// ── Walkthrough anchor ─────────────────────────────────────────────────────
it('carries the walkthrough anchor the rack tour step spotlights', async () => {
const { useDesignStore } = await import('@/stores/designStore')
const { STEPS } = await import('@/walkthrough/steps')
const before = useDesignStore.getState()
useDesignStore.setState({
designs: [{
id: 'd1', name: 'Main', design_type: 'network', icon: null,
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
}],
activeDesignId: 'd1',
loaded: true,
})
const { unmount } = render(<Sidebar {...defaultProps} />)
const step = STEPS.find((s) => s.id === 'rack')
expect(step?.anchor).toBe('[data-tour="canvas-switcher"]')
// The overlay resolves that selector against the DOM — an anchor the sidebar
// stops carrying leaves the step spotlighting nothing.
expect(document.querySelector(step!.anchor!)).toBeInTheDocument()
// Unmount before restoring, so the store reset never re-renders the sidebar.
unmount()
useDesignStore.setState({
designs: before.designs,
activeDesignId: before.activeDesignId,
loaded: before.loaded,
})
})
})
// ── Standalone mode ────────────────────────────────────────────────────────────
@@ -207,18 +207,20 @@ export function WalkthroughOverlay() {
)}
<div className="mt-3 flex items-center justify-between gap-2">
{/* Progress dots */}
<div className="flex items-center gap-1.5">
{/* Progress dots. The strip grows with the step count, so it is the
part that gives: it shrinks and clips rather than pushing Next out
of the fixed-width card. */}
<div data-testid="walkthrough-progress" className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
{steps.map((s, i) => (
<span
key={s.id}
className={`h-1.5 rounded-full transition-all ${
className={`h-1.5 shrink-0 rounded-full transition-all ${
i === stepIndex ? 'w-4 bg-[#00d4ff]' : 'w-1.5 bg-[#30363d]'
}`}
/>
))}
</div>
<div className="flex items-center gap-1.5">
<div className="flex shrink-0 items-center gap-1.5">
<span className="mr-1 text-[10px] font-mono text-muted-foreground">
{stepIndex + 1}/{steps.length}
</span>
@@ -36,6 +36,20 @@ describe('WalkthroughOverlay', () => {
expect(screen.getByText('Welcome to Homelable')).toBeInTheDocument()
})
it('lets the progress strip give way, not the Next button', () => {
renderWithProviders(<WalkthroughOverlay />)
startTour()
const dots = screen.getByTestId('walkthrough-progress')
expect(dots.children).toHaveLength(TOTAL)
// The card is a fixed 320px, so one dot per step eventually outgrows the
// footer. The strip shrinks and clips; the counter and buttons hold their
// size, or Next lands outside the card.
expect(dots.className).toContain('min-w-0')
expect(dots.className).toContain('overflow-hidden')
expect(screen.getByText('Next').closest('div')?.className).toContain('shrink-0')
})
it('skip closes the overlay', () => {
renderWithProviders(<WalkthroughOverlay />)
startTour()
@@ -12,13 +12,26 @@ describe('getSteps', () => {
expect(standalone.length).toBeLessThan(STEPS.length)
const ids = standalone.map((s) => s.id)
// Canvas-only steps survive; backend-only steps are filtered out.
expect(ids).toEqual(expect.arrayContaining(['welcome', 'nodes', 'grouping', 'style', 'end']))
expect(ids).toEqual(expect.arrayContaining(['welcome', 'nodes', 'grouping', 'rack', 'style', 'end']))
expect(ids).not.toContain('scan')
expect(ids).not.toContain('scan-history')
expect(ids).not.toContain('inventory')
expect(ids).not.toContain('imports')
})
it('keeps the rack step in both modes — a rack canvas needs no backend', () => {
const rack = STEPS.find((s) => s.id === 'rack')
expect(rack?.mode).toBe('all')
expect(rack?.anchor).toBe('[data-tour="canvas-switcher"]')
expect(getSteps(true).map((s) => s.id)).toContain('rack')
})
it('introduces the rack canvas before the styling step', () => {
const ids = STEPS.map((s) => s.id)
expect(ids.indexOf('rack')).toBeGreaterThan(ids.indexOf('nodes'))
expect(ids.indexOf('rack')).toBeLessThan(ids.indexOf('style'))
})
it('ends on a step with a GitHub link (the full-mode recap in standalone)', () => {
const last = getSteps(true).at(-1)
expect(last?.id).toBe('end')
+8
View File
@@ -87,6 +87,14 @@ export const STEPS: TourStep[] = [
action: 'selectTwoNodes',
mode: 'all',
},
{
id: 'rack',
title: 'Map the physical side',
body: 'A canvas can also be a Rack: real racks, the gear mounted in them, and the patch cables between their ports. Create one from the canvas switcher (New Canvas, Kind: Rack), drop a rack, mount devices with + Device, then hit Patch and drag from one port to another.',
anchor: '[data-tour="canvas-switcher"]',
placement: 'right',
mode: 'all',
},
{
id: 'style',
title: 'Make it yours',