diff --git a/Tools/windows/Tests/ScrubbedShellStartup.Live.Tests.ps1 b/Tools/windows/Tests/ScrubbedShellStartup.Live.Tests.ps1 index 117219d3..35591fae 100644 --- a/Tools/windows/Tests/ScrubbedShellStartup.Live.Tests.ps1 +++ b/Tools/windows/Tests/ScrubbedShellStartup.Live.Tests.ps1 @@ -224,7 +224,7 @@ function Invoke-Case( } } "complete" { - foreach ($page in 1..4) { + foreach ($page in 1..5) { if (-not [ScrubbedStartupNative]::Command($onboarding, 4)) { throw "$name onboarding rejected primary action on page $page" } diff --git a/graphcode-windows/src/OnboardingAccessibilityProvider.cpp b/graphcode-windows/src/OnboardingAccessibilityProvider.cpp index 0ec93bc8..cfebc949 100644 --- a/graphcode-windows/src/OnboardingAccessibilityProvider.cpp +++ b/graphcode-windows/src/OnboardingAccessibilityProvider.cpp @@ -94,14 +94,14 @@ static bool hasNativeFocus(HWND hwnd) { static bool isAvailable(int id, int page) { if (id == kRoot || id == kPage || id == kSkip || id == kPrimary) return true; if (id == kBack) return page > 0; - if (id >= kClaudeCode && id <= kCodex) return page == 3; + if (id >= kClaudeCode && id <= kCodex) return page == 4; return false; } static std::vector childrenForPage(int page) { std::vector children{kPage, kSkip}; if (page > 0) children.push_back(kBack); - if (page == 3) { + if (page == 4) { children.push_back(kClaudeCode); children.push_back(kCopilotCli); children.push_back(kCodex); @@ -115,9 +115,10 @@ static const wchar_t *pageName(int page) { L"Welcome to GraphCode: Agents you can watch", L"How to read a loop", L"Four kinds of loop", + L"One window per line of work", L"Which agent runs them", }; - return names[page >= 0 && page < 4 ? page : 0]; + return names[page >= 0 && page < 5 ? page : 0]; } static const wchar_t *automationId(int id) { @@ -140,7 +141,7 @@ static const wchar_t *nameFor(int id, int page) { case kPage: return pageName(page); case kSkip: return L"Skip onboarding"; case kBack: return L"Back"; - case kPrimary: return page == 3 ? L"Get Started" : L"Continue"; + case kPrimary: return page == 4 ? L"Get Started" : L"Continue"; case kClaudeCode: return L"Claude Code"; case kCopilotCli: return L"Copilot CLI"; case kCodex: return L"Codex"; @@ -568,7 +569,7 @@ extern "C" LRESULT gc_onboarding_uia_get_object( extern "C" HRESULT gc_onboarding_uia_update( IRawElementProviderSimple *provider, int page, int focused, int backend) { - if (!provider || page < 0 || page > 3 || + if (!provider || page < 0 || page > 4 || backend < 0 || backend > 2 || !onboarding_uia::isAvailable(focused, page) || !onboarding_uia::isAction(focused)) diff --git a/graphcode-windows/src/WindowsOnboarding.zig b/graphcode-windows/src/WindowsOnboarding.zig index 845c1a09..96fa8147 100644 --- a/graphcode-windows/src/WindowsOnboarding.zig +++ b/graphcode-windows/src/WindowsOnboarding.zig @@ -20,7 +20,7 @@ extern fn gc_onboarding_uia_update( backend: c_int, ) c.HRESULT; -pub const page_count: u8 = 4; +pub const page_count: u8 = 5; pub const Backend = enum { claudeCode, @@ -297,7 +297,7 @@ fn applyKeyboard(state: *State, key: usize, shift: bool) bool { fn focusOrder(page: u8) []const Action { return switch (page) { 0 => &.{ .skip, .primary }, - 1, 2 => &.{ .skip, .back, .primary }, + 1, 2, 3 => &.{ .skip, .back, .primary }, else => &.{ .skip, .back, .claude_code, .copilot_cli, .codex, .primary }, }; } @@ -317,7 +317,7 @@ fn actionAvailable(page: u8, action: Action) bool { return switch (action) { .skip, .primary => true, .back => page > 0, - .claude_code, .copilot_cli, .codex => page == 3, + .claude_code, .copilot_cli, .codex => page == 4, }; } @@ -364,7 +364,7 @@ fn applyClick(state: *State, x: i32, y: i32) bool { invokeAction(state, .primary); return true; } - if (state.page == 3) { + if (state.page == 4) { if (inside(x, y, rect(58, 182, 502, 248))) invokeAction(state, .claude_code); if (inside(x, y, rect(58, 258, 502, 324))) invokeAction(state, .copilot_cli); if (inside(x, y, rect(58, 334, 502, 400))) invokeAction(state, .codex); @@ -387,6 +387,7 @@ fn paint( 0 => paintWelcome(hdc, allocator), 1 => paintReading(hdc, allocator), 2 => paintTypes(hdc, allocator), + 3 => paintWorkspaces(hdc, allocator), else => paintBackends(hdc, allocator, backend, focused_action), } paintFooter(hdc, allocator, page, focused_action); @@ -432,6 +433,90 @@ fn paintTypes(hdc: c.HDC, allocator: std.mem.Allocator) void { text(hdc, allocator, "Wire them together by dragging from a card's + handle. Click + without dragging to grow a new loop already connected to it.", rect(58, 446, 502, 512), 13, rgb(158, 158, 165), c.DT_CENTER | c.DT_WORDBREAK, false); } +const workspace_title = "One window per line of work"; +const workspace_body = "A workspace has its own projects, loops and daemon. Start one when a second kind of work would otherwise crowd the first."; +const workspace_make_one = "File > Workspace > New Workspace..."; +const workspace_switch = "Ctrl+Alt+PageUp / Ctrl+Alt+PageDown"; +const workspace_closing = "You do not need one yet — one workspace is the right number until it isn't."; + +const WorkspacePageLayout = struct { + left_window: c.RECT, + divider: c.RECT, + right_window: c.RECT, + facts: c.RECT, + closing: c.RECT, +}; + +fn workspacePageLayout() WorkspacePageLayout { + return .{ + .left_window = rect(58, 176, 230, 260), + .divider = rect(242, 182, 318, 258), + .right_window = rect(330, 176, 502, 260), + .facts = rect(58, 292, 502, 424), + .closing = rect(58, 454, 502, 494), + }; +} + +fn paintWorkspaces(hdc: c.HDC, allocator: std.mem.Allocator) void { + const layout = workspacePageLayout(); + text(hdc, allocator, workspace_title, rect(32, 62, 528, 100), 25, rgb(245, 245, 247), c.DT_CENTER | c.DT_SINGLELINE, true); + text(hdc, allocator, workspace_body, rect(48, 106, 512, 156), 14, rgb(168, 168, 174), c.DT_CENTER | c.DT_WORDBREAK, false); + + workspaceMiniWindow(hdc, allocator, layout.left_window, "Default", rgb(10, 132, 255), &.{ 154, 117, 136, 92 }); + workspaceDivider(hdc, allocator, layout.divider); + workspaceMiniWindow(hdc, allocator, layout.right_window, "Client Work", rgb(216, 166, 87), &.{ 126, 154, 83 }); + + workspaceFact(hdc, allocator, layout.facts.top, "Separate", "projects, loops, terminal layouts, agent default"); + workspaceFact(hdc, allocator, layout.facts.top + 30, "Shared", "the app itself, and nothing else"); + workspaceFact(hdc, allocator, layout.facts.top + 60, "Make one", workspace_make_one); + workspaceFact(hdc, allocator, layout.facts.top + 90, "Switch", workspace_switch); + + text(hdc, allocator, workspace_closing, layout.closing, 11, rgb(126, 126, 133), c.DT_CENTER | c.DT_WORDBREAK, false); +} + +fn workspaceMiniWindow( + hdc: c.HDC, + allocator: std.mem.Allocator, + bounds: c.RECT, + name: []const u8, + tint: u32, + widths: []const i32, +) void { + rounded(hdc, bounds, rgb(35, 35, 38), rgb(66, 66, 72), 8); + fill(hdc, rect(bounds.left + 1, bounds.top + 1, bounds.right - 1, bounds.top + 19), rgb(47, 47, 51)); + fill(hdc, rect(bounds.left + 8, bounds.top + 7, bounds.left + 14, bounds.top + 13), tint); + text(hdc, allocator, name, rect(bounds.left + 20, bounds.top + 4, bounds.right - 7, bounds.top + 17), 9, rgb(190, 190, 196), c.DT_LEFT | c.DT_SINGLELINE, false); + line(hdc, bounds.left + 1, bounds.top + 19, bounds.right - 1, bounds.top + 19, rgb(58, 58, 63), 1); + + for (widths, 0..) |width, index| { + const bar_color = switch (index) { + 0 => rgb(71, 126, 87), + 2 => rgb(128, 100, 55), + else => rgb(67, 67, 73), + }; + rounded( + hdc, + rect(bounds.left + 9, bounds.top + 29 + @as(i32, @intCast(index)) * 11, bounds.left + 9 + width, bounds.top + 34 + @as(i32, @intCast(index)) * 11), + bar_color, + bar_color, + 3, + ); + } +} + +fn workspaceDivider(hdc: c.HDC, allocator: std.mem.Allocator, bounds: c.RECT) void { + const middle = @divTrunc(bounds.left + bounds.right, 2); + line(hdc, bounds.left + 8, bounds.top + 18, bounds.right - 8, bounds.top + 18, rgb(70, 70, 76), 1); + fill(hdc, rect(middle - 10, bounds.top + 8, middle + 10, bounds.top + 29), rgb(35, 35, 38)); + text(hdc, allocator, "x", rect(middle - 10, bounds.top + 10, middle + 10, bounds.top + 27), 9, rgb(102, 102, 109), c.DT_CENTER | c.DT_SINGLELINE, true); + text(hdc, allocator, "nothing\ncrosses", rect(bounds.left, bounds.top + 38, bounds.right, bounds.bottom), 10, rgb(105, 105, 112), c.DT_CENTER | c.DT_WORDBREAK, false); +} + +fn workspaceFact(hdc: c.HDC, allocator: std.mem.Allocator, y: i32, label: []const u8, value: []const u8) void { + text(hdc, allocator, label, rect(58, y, 154, y + 22), 11, rgb(126, 126, 133), c.DT_RIGHT | c.DT_SINGLELINE, false); + text(hdc, allocator, value, rect(163, y, 502, y + 24), 12, rgb(184, 184, 190), c.DT_LEFT | c.DT_SINGLELINE | c.DT_END_ELLIPSIS, false); +} + fn paintBackends( hdc: c.HDC, allocator: std.mem.Allocator, @@ -484,7 +569,7 @@ fn paintFooter(hdc: c.HDC, allocator: std.mem.Allocator, page: u8, focused_actio rounded(hdc, rect(418, 564, 540, 604), if (primary) rgb(10, 132, 255) else rgb(48, 48, 52), if (primary) rgb(10, 132, 255) else rgb(78, 78, 84), 9); text(hdc, allocator, if (primary) "Get Started" else "Continue", rect(418, 576, 540, 598), 13, rgb(245, 245, 247), c.DT_CENTER | c.DT_SINGLELINE, true); if (focused_action == .primary) focusOutline(hdc, rect(418, 564, 540, 604)); - var x: i32 = 254; + var x: i32 = 246; for (0..page_count) |index| { const color = if (index == page) rgb(10, 132, 255) else rgb(92, 92, 98); const brush = c.CreateSolidBrush(color); @@ -606,8 +691,8 @@ fn rgb(red: u8, green: u8, blue: u8) u32 { return @as(u32, red) | (@as(u32, green) << 8) | (@as(u32, blue) << 16); } -test "Windows onboarding matches the four-page macOS contract" { - try std.testing.expectEqual(@as(u8, 4), page_count); +test "Windows onboarding matches the five-page macOS contract" { + try std.testing.expectEqual(@as(u8, 5), page_count); } test "onboarding click flow navigates every page and selects a backend" { @@ -617,10 +702,14 @@ test "onboarding click flow navigates every page and selects a backend" { try std.testing.expect(applyClick(&state, 500, 580)); try std.testing.expect(applyClick(&state, 500, 580)); try std.testing.expectEqual(@as(u8, 3), state.page); + try std.testing.expect(!applyClick(&state, 200, 280)); + try std.testing.expectEqual(Backend.claudeCode, state.backend); + try std.testing.expect(applyClick(&state, 500, 580)); + try std.testing.expectEqual(@as(u8, 4), state.page); try std.testing.expect(applyClick(&state, 200, 280)); try std.testing.expectEqual(Backend.copilotCLI, state.backend); try std.testing.expect(applyClick(&state, 50, 580)); - try std.testing.expectEqual(@as(u8, 2), state.page); + try std.testing.expectEqual(@as(u8, 3), state.page); try std.testing.expect(applyClick(&state, 500, 580)); try std.testing.expect(applyClick(&state, 500, 580)); try std.testing.expect(state.closed); @@ -647,7 +736,7 @@ test "onboarding keyboard focus traversal and invocation is deterministic" { test "onboarding keyboard backend selection remains in the deterministic focus order" { var state = State{ .allocator = std.testing.allocator, - .page = 3, + .page = 4, .backend = .claudeCode, }; @@ -664,6 +753,47 @@ test "onboarding keyboard backend selection remains in the deterministic focus o try std.testing.expectEqual(Action.copilot_cli, state.focused_action); } +test "onboarding workspace page focus excludes backend actions" { + var state = State{ + .allocator = std.testing.allocator, + .page = 3, + .backend = .claudeCode, + }; + + try std.testing.expect(applyKeyboard(&state, c.VK_TAB, false)); + try std.testing.expectEqual(Action.skip, state.focused_action); + try std.testing.expect(applyKeyboard(&state, c.VK_TAB, false)); + try std.testing.expectEqual(Action.back, state.focused_action); + try std.testing.expect(applyKeyboard(&state, c.VK_TAB, false)); + try std.testing.expectEqual(Action.primary, state.focused_action); +} + +test "onboarding workspace page copy matches macOS with Windows commands" { + try std.testing.expectEqualStrings("One window per line of work", workspace_title); + try std.testing.expectEqualStrings( + "A workspace has its own projects, loops and daemon. Start one when a second kind of work would otherwise crowd the first.", + workspace_body, + ); + try std.testing.expectEqualStrings("File > Workspace > New Workspace...", workspace_make_one); + try std.testing.expectEqualStrings("Ctrl+Alt+PageUp / Ctrl+Alt+PageDown", workspace_switch); + try std.testing.expectEqualStrings( + "You do not need one yet — one workspace is the right number until it isn't.", + workspace_closing, + ); +} + +test "onboarding workspace illustration and facts stay above the footer" { + const layout = workspacePageLayout(); + try std.testing.expect(layout.left_window.left >= 0); + try std.testing.expect(layout.left_window.right < layout.divider.left); + try std.testing.expect(layout.divider.right < layout.right_window.left); + try std.testing.expect(layout.right_window.right <= client_width); + try std.testing.expect(layout.left_window.bottom < layout.facts.top); + try std.testing.expect(layout.right_window.bottom < layout.facts.top); + try std.testing.expect(layout.facts.bottom < layout.closing.top); + try std.testing.expect(layout.closing.bottom < 564); +} + test "onboarding routes WM_GETOBJECT to named Invoke-capable actions" { const source = @embedFile("WindowsOnboarding.zig"); const proc_start = std.mem.indexOf(u8, source, "fn windowProc") orelse @@ -677,6 +807,9 @@ test "onboarding routes WM_GETOBJECT to named Invoke-capable actions" { const native_source = @embedFile("OnboardingAccessibilityProvider.cpp"); try std.testing.expect(std.mem.indexOf(u8, native_source, "UIA_InvokePatternId") != null); + try std.testing.expect(std.mem.indexOf(u8, native_source, "One window per line of work") != null); + try std.testing.expect(std.mem.indexOf(u8, native_source, "page == 4") != null); + try std.testing.expect(std.mem.indexOf(u8, native_source, "page > 4") != null); try std.testing.expect(std.mem.indexOf(u8, native_source, "Get Started") != null); } diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 918a44d0..421597e5 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -661,7 +661,7 @@ native keyboard/accelerator/window proof; the workspace row remains Partial. | macOS surface | Required visible behavior | Windows evidence | Status | |---|---|---|---| -| Four-page onboarding | Visual terminology tour, Skip/Back/Continue/Get Started, backend selection, reopen, persisted seen state | Custom rounded Win32 onboarding retains all four pages, Skip/Back/Continue/Get Started, backend selection, and Help > GraphCode Basics reopen. The 2026-10-01 manual package launch from `59ebed1` correctly demoted the earlier unsupported `Validated` claim by exposing four defects. Current accepted main fixes their source paths without supplying the final package flight: [#573](https://github.com/scgopi/GraphCode/pull/573) honors the effective `GRAPHCODE_SUPPORT_DIR` (3/3 focused, 10/10 full onboarding tests) and closes [#554](https://github.com/scgopi/GraphCode/issues/554); [#577](https://github.com/scgopi/GraphCode/pull/577) makes the startup endpoint available before the rendezvous secret exists (focused GREEN, 177/177 DaemonClient tests) and closes [#555](https://github.com/scgopi/GraphCode/issues/555). App-native stack `#581` then merged [#579](https://github.com/scgopi/GraphCode/pull/579), scheduling the daemon callback/connection before genuine first-run onboarding enters its modal loop, and [#580](https://github.com/scgopi/GraphCode/pull/580), adding a named UIA fragment tree plus shared deterministic UIA/mouse/Tab/Shift+Tab/Enter/Space action and focus behavior. The stack passed 734/734 App tests and an isolated shown-window probe that discovered all four pages/actions, invoked navigation/backend controls, set UIA focus, and posted `WM_KEYDOWN` traversal. These are source, focused runtime, and shown-window results, not the ordinary installed production path or desktop-level native keyboard evidence. [#556](https://github.com/scgopi/GraphCode/issues/556) therefore remains open as source-fixed/evidence-remaining until the exact packaged install → first launch → onboarding → connected Welcome path runs with production `graphcoded`, an owned desktop, native keyboard operation, isolated persisted marker evidence, and any assistive-technology behavior claimed. Reopen/page content exist, but the whole macOS-equivalent visible/runtime contract is not yet qualified. The beta25 Dev Box qualification (`0.1.78-windows.beta25`, installed package, production `graphcoded`, 2560x1440 at 100%, injected `SendInput` and UIA; return bundle reviewed 2026-10-13) ran the installed package from a scrubbed 11-key environment against production `graphcoded`: first run showed all four pages, Back, the backend choices and Get Started; Escape, Skip, Complete and an existing marker each behaved as expected, and a relaunch did not reshow onboarding. Navigation used injected mouse clicks at UIA centers; native keyboard operation of onboarding was not exercised, so [#556](https://github.com/scgopi/GraphCode/issues/556) and this row stay open. Narrowed 2026-10-13 to macOS scope (requirements macOS lacks were dropped; see the issue). **Tracked in [#556](https://github.com/scgopi/GraphCode/issues/556) (testing gap).** | Partial | +| Five-page onboarding | Five-page visual terminology tour including “One window per line of work”, Skip/Back/Continue/Get Started, backend selection, reopen, persisted seen state | The [#779](https://github.com/scgopi/GraphCode/issues/779) source slice adds the workspace lesson at page 4 in macOS order, keeps backend selection final, extends the named UIA page/action mapping, and preserves the existing marker and Help > GraphCode Basics reopen paths. Behavioral REDs observed the old four-page count (`expected 5, found 4`) and page-4 focus incorrectly reaching `claude_code`; focused GREEN was 1/1, workspace copy/focus/geometry was 3/3, and the full onboarding Zig root was 16/16. The scrubbed first-run contract now advances five times but was not executed locally; no product build, paired screenshot, physical-keyboard run or screen-reader observation is claimed. Before this slice, the custom rounded Win32 onboarding retained four pages, Skip/Back/Continue/Get Started, backend selection, and Help > GraphCode Basics reopen. The 2026-10-01 manual package launch from `59ebed1` correctly demoted the earlier unsupported `Validated` claim by exposing four defects. Current accepted main fixes their source paths without supplying the final package flight: [#573](https://github.com/scgopi/GraphCode/pull/573) honors the effective `GRAPHCODE_SUPPORT_DIR` (3/3 focused, 10/10 full onboarding tests) and closes [#554](https://github.com/scgopi/GraphCode/issues/554); [#577](https://github.com/scgopi/GraphCode/pull/577) makes the startup endpoint available before the rendezvous secret exists (focused GREEN, 177/177 DaemonClient tests) and closes [#555](https://github.com/scgopi/GraphCode/issues/555). App-native stack `#581` then merged [#579](https://github.com/scgopi/GraphCode/pull/579), scheduling the daemon callback/connection before genuine first-run onboarding enters its modal loop, and [#580](https://github.com/scgopi/GraphCode/pull/580), adding a named UIA fragment tree plus shared deterministic UIA/mouse/Tab/Shift+Tab/Enter/Space action and focus behavior. The stack passed 734/734 App tests and an isolated shown-window probe that discovered all four pages/actions, invoked navigation/backend controls, set UIA focus, and posted `WM_KEYDOWN` traversal. These are source, focused runtime, and shown-window results, not the ordinary installed production path or desktop-level native keyboard evidence. [#556](https://github.com/scgopi/GraphCode/issues/556) therefore remains open as source-fixed/evidence-remaining until the exact packaged install → first launch → onboarding → connected Welcome path runs with production `graphcoded`, an owned desktop, native keyboard operation, isolated persisted marker evidence, and any assistive-technology behavior claimed. Reopen/page content exist, but the whole macOS-equivalent visible/runtime contract is not yet qualified. The beta25 Dev Box qualification (`0.1.78-windows.beta25`, installed package, production `graphcoded`, 2560x1440 at 100%, injected `SendInput` and UIA; return bundle reviewed 2026-10-13) ran the installed package from a scrubbed 11-key environment against production `graphcoded`: first run showed all four pages, Back, the backend choices and Get Started; Escape, Skip, Complete and an existing marker each behaved as expected, and a relaunch did not reshow onboarding. Navigation used injected mouse clicks at UIA centers; native keyboard operation of onboarding was not exercised, so [#556](https://github.com/scgopi/GraphCode/issues/556) remains open and [#779](https://github.com/scgopi/GraphCode/issues/779) still needs its paired visual acceptance. Narrowed 2026-10-13 to macOS scope (requirements macOS lacks were dropped; see the issue). **Tracked in [#556](https://github.com/scgopi/GraphCode/issues/556) (testing gap) and [#779](https://github.com/scgopi/GraphCode/issues/779) (visual parity gap).** | Partial | | No-project Welcome detail | Graph icon, pitch, explanatory copy, Open Folder action, inline error | Windows matches the centered Graph identity, pitch, explanatory copy, and single Open Folder action. Persistent project-ingress failures now also render as a bounded, wrapped inline canvas alert without replacing navigation; focused geometry coverage and the populated UIA gate verify the alert text and live bounds | Partial | | Empty global graph | “Nothing running yet”, explanatory copy, Open Folder action, New Loop action | The dedicated overview empty state exposes both bounded Open Folder and New Loop actions; New Loop targets the daemon's `graphcode://global` project. The live UIA gate switches to an empty model, verifies both visible native controls, invokes New Loop, and observes the node form | Partial | | Empty project canvas | Project-specific empty message and New Loop action | The dedicated “No loops yet” project state exposes its visible New Loop action. The live UIA gate installs an empty local project, invokes that exact command, and observes the project-scoped node form | Partial |