+ {/* 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. */}
+
+
{stepIndex + 1}/{steps.length}
diff --git a/frontend/src/walkthrough/__tests__/WalkthroughOverlay.test.tsx b/frontend/src/walkthrough/__tests__/WalkthroughOverlay.test.tsx
index 3c21c75..1cd6869 100644
--- a/frontend/src/walkthrough/__tests__/WalkthroughOverlay.test.tsx
+++ b/frontend/src/walkthrough/__tests__/WalkthroughOverlay.test.tsx
@@ -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()
+ 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()
startTour()
diff --git a/frontend/src/walkthrough/__tests__/steps.test.ts b/frontend/src/walkthrough/__tests__/steps.test.ts
index b654943..7ad7c4b 100644
--- a/frontend/src/walkthrough/__tests__/steps.test.ts
+++ b/frontend/src/walkthrough/__tests__/steps.test.ts
@@ -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')
diff --git a/frontend/src/walkthrough/steps.ts b/frontend/src/walkthrough/steps.ts
index 4fc2100..68c1100 100644
--- a/frontend/src/walkthrough/steps.ts
+++ b/frontend/src/walkthrough/steps.ts
@@ -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',