diff --git a/.superpowers/sdd/final-fix-2-report.md b/.superpowers/sdd/final-fix-2-report.md new file mode 100644 index 0000000..0627dc7 --- /dev/null +++ b/.superpowers/sdd/final-fix-2-report.md @@ -0,0 +1,16 @@ +# Final Fix 2 Report + +## Completed + +- Reworked the mobile channel navigation into a visible three-column control grid at 390x844; Playwright now verifies that Work Plan is in the viewport and opens Tasks. +- Moved task completion state to `ProjectWorkbench`, keyed by project, so it persists across channel switches without leaking to another project. +- Implemented Copy link feedback, disabled the unavailable Archive action, and disabled global search with an explanatory title. +- Hardened browser-server validation to reject whitespace (including encoded whitespace), credentials, empty hosts, and unsupported protocols. +- Prevented desktop channel-sidebar content from stretching by aligning its grid content to the start. + +## Verification + +- `npm test -- --run` - 25 tests passed. +- `npm run build` - passed. +- `npx playwright test` - 3 tests passed. +- `git diff --check` - passed. diff --git a/apps/web/e2e/project-workbench.spec.ts b/apps/web/e2e/project-workbench.spec.ts index 08b02ee..33818a3 100644 --- a/apps/web/e2e/project-workbench.spec.ts +++ b/apps/web/e2e/project-workbench.spec.ts @@ -25,6 +25,18 @@ test('login and project channel workbench flow', async ({ page }) => { await expect(page.getByText('Open external channel')).toBeVisible(); }); +test('rejects malformed server addresses', async ({ page }) => { + await page.goto('/'); + + await page.getByLabel('Server IP or domain').fill('https://user:secret@example.com'); + await page.getByLabel('Email or username').fill('david@example.com'); + await page.getByLabel('Password').fill('secret'); + await page.getByRole('button', { name: 'Log in' }).click(); + + await expect(page.getByText('Enter a valid HTTP or HTTPS server address.')).toBeVisible(); + await expect(page.getByLabel('Server IP or domain')).toBeVisible(); +}); + test('mobile workbench keeps project navigation and inspector compact', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/'); @@ -43,6 +55,12 @@ test('mobile workbench keeps project navigation and inspector compact', async ({ await expect(rail).toHaveCSS('max-height', '76px'); await expect(inspector).toHaveCSS('max-height', '288px'); + const workPlan = page.getByRole('button', { name: 'Work Plan 8' }); + await expect(workPlan).toBeVisible(); + await expect(workPlan).toBeInViewport(); + await workPlan.click(); + await expect(page.getByRole('heading', { name: 'Tasks' })).toBeVisible(); + const [sidebarBox, stageBox] = await Promise.all([sidebar.boundingBox(), stage.boundingBox()]); expect(sidebarBox?.y).toBeLessThan(stageBox?.y ?? 0); expect((stageBox?.y ?? 0) - (sidebarBox?.y ?? 0)).toBeLessThanOrEqual(248); diff --git a/apps/web/src/features/auth/ServerLogin.svelte b/apps/web/src/features/auth/ServerLogin.svelte index be882aa..09b367d 100644 --- a/apps/web/src/features/auth/ServerLogin.svelte +++ b/apps/web/src/features/auth/ServerLogin.svelte @@ -9,16 +9,19 @@ let connectionStatus = 'Not connected. Enter a server address to continue.'; function normalizeServer(value: string): { apiBase?: string; error?: string } { - const trimmed = value.trim(); - if (!trimmed) { + if (!value.trim()) { return { error: 'Enter a server address.' }; } - const candidate = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; + if (hasWhitespace(value) || hasUnsupportedProtocol(value)) { + return { error: 'Enter a valid HTTP or HTTPS server address.' }; + } + + const candidate = /^https?:\/\//i.test(value) ? value : `http://${value}`; try { const parsed = new URL(candidate); - if (!parsed.hostname || !['http:', 'https:'].includes(parsed.protocol)) { + if (!parsed.hostname || parsed.username || parsed.password || !['http:', 'https:'].includes(parsed.protocol)) { return { error: 'Enter a valid HTTP or HTTPS server address.' }; } return { apiBase: candidate }; @@ -27,6 +30,25 @@ } } + function hasWhitespace(value: string): boolean { + if (/\s/u.test(value)) return true; + + try { + return /\s/u.test(decodeURIComponent(value)); + } catch { + return true; + } + } + + function hasUnsupportedProtocol(value: string): boolean { + const explicitProtocol = value.match(/^([a-z][a-z\d+.-]*):\/\//i); + if (explicitProtocol) { + return !['http', 'https'].includes(explicitProtocol[1].toLowerCase()); + } + + return /^[a-z][a-z\d+.-]*:(?!\d)/i.test(value); + } + function submitLogin() { error = ''; const normalized = normalizeServer(server); diff --git a/apps/web/src/features/auth/ServerLogin.test.ts b/apps/web/src/features/auth/ServerLogin.test.ts index 96887df..73e8c4a 100644 --- a/apps/web/src/features/auth/ServerLogin.test.ts +++ b/apps/web/src/features/auth/ServerLogin.test.ts @@ -51,6 +51,12 @@ describe('ServerLogin', () => { it.each([ ['', 'Enter a server address.'], ['http:// bad-server', 'Enter a valid HTTP or HTTPS server address.'], + [' https://example.com', 'Enter a valid HTTP or HTTPS server address.'], + ['https://example.com/path with spaces', 'Enter a valid HTTP or HTTPS server address.'], + ['https://example%20.com', 'Enter a valid HTTP or HTTPS server address.'], + ['https://user:secret@example.com', 'Enter a valid HTTP or HTTPS server address.'], + ['http://', 'Enter a valid HTTP or HTTPS server address.'], + ['ftp://example.com', 'Enter a valid HTTP or HTTPS server address.'], ])('rejects an invalid server value: %s', async (server, error) => { const onLogin = vi.fn(); render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } }); diff --git a/apps/web/src/features/workbench/ChannelContent.svelte b/apps/web/src/features/workbench/ChannelContent.svelte index 9e81962..6d18e6a 100644 --- a/apps/web/src/features/workbench/ChannelContent.svelte +++ b/apps/web/src/features/workbench/ChannelContent.svelte @@ -1,5 +1,5 @@ {#if channel.type === 'overview'} @@ -18,7 +20,7 @@ {:else if channel.type === 'inbox'} {:else if channel.type === 'tasks'} - + {:else if channel.type === 'ai_sessions'} {:else if channel.type === 'notes_sources'} diff --git a/apps/web/src/features/workbench/ChannelContent.test.ts b/apps/web/src/features/workbench/ChannelContent.test.ts index 63b0dd7..e5887db 100644 --- a/apps/web/src/features/workbench/ChannelContent.test.ts +++ b/apps/web/src/features/workbench/ChannelContent.test.ts @@ -1,6 +1,6 @@ import '@testing-library/jest-dom/vitest'; import { fireEvent, render, screen, within } from '@testing-library/svelte'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import ChannelContent from './ChannelContent.svelte'; import { getProjectWorkspace } from './mockData'; @@ -47,18 +47,18 @@ describe('ChannelContent', () => { expect(inspectedTitle).toBe('Confirm homepage information architecture'); }); - it('toggles a task completion state accessibly', async () => { + it('reports task completion changes to its owner', async () => { const channel = workspace.channels.find((item) => item.type === 'tasks'); if (!channel) throw new Error('missing task channel'); + const onToggleTask = vi.fn(); - render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } }); + render(ChannelContent, { props: { workspace, channel, onInspect: () => {}, onToggleTask } }); const completion = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' }); expect(completion).not.toBeChecked(); await fireEvent.click(completion); - expect(completion).toBeChecked(); - expect(within(completion.closest('article') as HTMLElement).getByText('Completed')).toBeInTheDocument(); + expect(onToggleTask).toHaveBeenCalledWith('1-task-1'); }); }); diff --git a/apps/web/src/features/workbench/ObjectInspector.svelte b/apps/web/src/features/workbench/ObjectInspector.svelte index 79fa781..9232111 100644 --- a/apps/web/src/features/workbench/ObjectInspector.svelte +++ b/apps/web/src/features/workbench/ObjectInspector.svelte @@ -3,6 +3,19 @@ export let item: InspectorItem | null; let activeTab: 'discussion' | 'properties' | 'more' = 'discussion'; + let copyFeedback = ''; + let currentItem: InspectorItem | null = null; + + $: if (item !== currentItem) { + currentItem = item; + copyFeedback = ''; + } + + function copyLink() { + const link = new URL(`?item=${encodeURIComponent(item?.title ?? '')}`, window.location.href).toString(); + void navigator.clipboard?.writeText(link); + copyFeedback = 'Link copied to clipboard.'; + }