From b5b5282349d96a629f845ed6e1cecd458925dde2 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Mar 2026 14:32:53 +0000 Subject: [PATCH] docs: add Spec B and Spec C implementation plans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec B: 5 tasks — keybinding, browser, window file drop, display bridge, verification. Spec C: 4 tasks — dock, lifecycle, display bridge, verification. Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-13-gui-new-input.md | 1313 +++++++++++++++++ .../plans/2026-03-13-gui-platform-events.md | 1074 ++++++++++++++ 2 files changed, 2387 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-13-gui-new-input.md create mode 100644 docs/superpowers/plans/2026-03-13-gui-platform-events.md diff --git a/docs/superpowers/plans/2026-03-13-gui-new-input.md b/docs/superpowers/plans/2026-03-13-gui-new-input.md new file mode 100644 index 0000000..5fc44fc --- /dev/null +++ b/docs/superpowers/plans/2026-03-13-gui-new-input.md @@ -0,0 +1,1313 @@ +# CoreGUI Spec B: New Input — Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add keybinding and browser packages as core.Services, enhance the window service with file drop events, and wire all new IPC messages through the display orchestrator. + +**Architecture:** keybinding and browser follow the three-layer pattern (IPC Bus -> Service -> Platform Interface) established by window/systray/menu. File drop extends the existing window package. The display orchestrator gains HandleIPCEvents cases for new Action types and WS->IPC bridge cases for new Task/Query types. + +**Tech Stack:** Go, core/go DI framework, Wails v3 (behind Platform interfaces), gorilla/websocket (WSEventManager) + +**Spec:** `docs/superpowers/specs/2026-03-13-gui-new-input-design.md` + +--- + +## File Structure + +### New files (2 packages x 5 files = 10 files) + +| Package | File | Responsibility | +|---------|------|---------------| +| `pkg/keybinding/` | `platform.go` | Platform interface (3 methods: Add, Remove, GetAll) | +| | `messages.go` | IPC message types (QueryList, TaskAdd, TaskRemove, ActionTriggered) + BindingInfo | +| | `register.go` | Register() factory closure | +| | `service.go` | Service struct, OnStartup(), handlers, in-memory registry | +| | `service_test.go` | Tests with mock platform | +| `pkg/browser/` | `platform.go` | Platform interface (2 methods: OpenURL, OpenFile) | +| | `messages.go` | IPC message types (TaskOpenURL, TaskOpenFile) | +| | `register.go` | Register() factory closure | +| | `service.go` | Service struct, OnStartup(), handlers | +| | `service_test.go` | Tests with mock platform | + +### Modified files + +| File | Change | +|------|--------| +| `pkg/window/platform.go` | Add `OnFileDrop(handler func(paths []string, targetID string))` to PlatformWindow interface | +| `pkg/window/messages.go` | Add `ActionFilesDropped` action type | +| `pkg/window/service.go` | Add `pw.OnFileDrop()` call in `trackWindow()` | +| `pkg/window/mock_platform.go` | Add `OnFileDrop` to exported MockWindow | +| `pkg/window/mock_test.go` | Add `OnFileDrop` to unexported mockWindow | +| `pkg/window/service_test.go` | Add file drop test | +| `pkg/display/events.go` | Add `EventKeybindingTriggered`, `EventWindowFileDrop` constants | +| `pkg/display/display.go` | Add HandleIPCEvents cases + WS->IPC cases in handleWSMessage + new imports | + +--- + +## Task 1: Create pkg/keybinding + +**Files:** +- Create: `pkg/keybinding/platform.go` +- Create: `pkg/keybinding/messages.go` +- Create: `pkg/keybinding/register.go` +- Create: `pkg/keybinding/service.go` +- Create: `pkg/keybinding/service_test.go` + +- [ ] **Step 1: Create platform.go** + +```go +// pkg/keybinding/platform.go +package keybinding + +// Platform abstracts the keyboard shortcut backend (Wails v3). +type Platform interface { + // Add registers a global keyboard shortcut with the given accelerator string. + // The handler is called when the shortcut is triggered. + // Accelerator syntax is platform-aware: "Cmd+S" (macOS), "Ctrl+S" (Windows/Linux). + // Special keys: F1-F12, Escape, Enter, Space, Tab, Backspace, Delete, arrow keys. + Add(accelerator string, handler func()) error + + // Remove unregisters a previously registered keyboard shortcut. + Remove(accelerator string) error + + // GetAll returns all currently registered accelerator strings. + // Used for adapter-level reconciliation only — not read by QueryList. + GetAll() []string +} +``` + +- [ ] **Step 2: Create messages.go** + +```go +// pkg/keybinding/messages.go +package keybinding + +import "errors" + +// ErrAlreadyRegistered is returned when attempting to add a binding +// that already exists. Callers must TaskRemove first to rebind. +var ErrAlreadyRegistered = errors.New("keybinding: accelerator already registered") + +// BindingInfo describes a registered keyboard shortcut. +type BindingInfo struct { + Accelerator string `json:"accelerator"` + Description string `json:"description"` +} + +// --- Queries --- + +// QueryList returns all registered bindings. Result: []BindingInfo +type QueryList struct{} + +// --- Tasks --- + +// TaskAdd registers a new keyboard shortcut. Result: nil +// Returns ErrAlreadyRegistered if the accelerator is already bound. +type TaskAdd struct { + Accelerator string `json:"accelerator"` + Description string `json:"description"` +} + +// TaskRemove unregisters a keyboard shortcut. Result: nil +type TaskRemove struct { + Accelerator string `json:"accelerator"` +} + +// --- Actions --- + +// ActionTriggered is broadcast when a registered shortcut is activated. +type ActionTriggered struct { + Accelerator string `json:"accelerator"` +} +``` + +- [ ] **Step 3: Create register.go** + +```go +// pkg/keybinding/register.go +package keybinding + +import "forge.lthn.ai/core/go/pkg/core" + +// Register creates a factory closure that captures the Platform adapter. +// The returned function has the signature WithService requires: func(*Core) (any, error). +func Register(p Platform) func(*core.Core) (any, error) { + return func(c *core.Core) (any, error) { + return &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + bindings: make(map[string]BindingInfo), + }, nil + } +} +``` + +- [ ] **Step 4: Write failing test** + +```go +// pkg/keybinding/service_test.go +package keybinding + +import ( + "context" + "sync" + "testing" + + "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockPlatform records Add/Remove calls and allows triggering shortcuts. +type mockPlatform struct { + mu sync.Mutex + handlers map[string]func() + removed []string +} + +func newMockPlatform() *mockPlatform { + return &mockPlatform{handlers: make(map[string]func())} +} + +func (m *mockPlatform) Add(accelerator string, handler func()) error { + m.mu.Lock() + defer m.mu.Unlock() + m.handlers[accelerator] = handler + return nil +} + +func (m *mockPlatform) Remove(accelerator string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.handlers, accelerator) + m.removed = append(m.removed, accelerator) + return nil +} + +func (m *mockPlatform) GetAll() []string { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]string, 0, len(m.handlers)) + for k := range m.handlers { + out = append(out, k) + } + return out +} + +// trigger simulates a shortcut keypress by calling the registered handler. +func (m *mockPlatform) trigger(accelerator string) { + m.mu.Lock() + h, ok := m.handlers[accelerator] + m.mu.Unlock() + if ok { + h() + } +} + +func newTestKeybindingService(t *testing.T, mp *mockPlatform) (*Service, *core.Core) { + t.Helper() + c, err := core.New( + core.WithService(Register(mp)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + svc := core.MustServiceFor[*Service](c, "keybinding") + return svc, c +} + +func TestRegister_Good(t *testing.T) { + mp := newMockPlatform() + svc, _ := newTestKeybindingService(t, mp) + assert.NotNil(t, svc) + assert.NotNil(t, svc.platform) +} + +func TestTaskAdd_Good(t *testing.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + _, handled, err := c.PERFORM(TaskAdd{ + Accelerator: "Ctrl+S", Description: "Save", + }) + require.NoError(t, err) + assert.True(t, handled) + + // Verify binding registered on platform + assert.Contains(t, mp.GetAll(), "Ctrl+S") +} + +func TestTaskAdd_Bad_Duplicate(t *testing.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + _, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save"}) + + // Second add with same accelerator should fail + _, handled, err := c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save Again"}) + assert.True(t, handled) + assert.ErrorIs(t, err, ErrAlreadyRegistered) +} + +func TestTaskRemove_Good(t *testing.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + _, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save"}) + _, handled, err := c.PERFORM(TaskRemove{Accelerator: "Ctrl+S"}) + require.NoError(t, err) + assert.True(t, handled) + + // Verify removed from platform + assert.NotContains(t, mp.GetAll(), "Ctrl+S") +} + +func TestTaskRemove_Bad_NotFound(t *testing.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + _, handled, err := c.PERFORM(TaskRemove{Accelerator: "Ctrl+X"}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestQueryList_Good(t *testing.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + _, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save"}) + _, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+Z", Description: "Undo"}) + + result, handled, err := c.QUERY(QueryList{}) + require.NoError(t, err) + assert.True(t, handled) + list := result.([]BindingInfo) + assert.Len(t, list, 2) +} + +func TestQueryList_Good_Empty(t *testing.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + result, handled, err := c.QUERY(QueryList{}) + require.NoError(t, err) + assert.True(t, handled) + list := result.([]BindingInfo) + assert.Len(t, list, 0) +} + +func TestTaskAdd_Good_TriggerBroadcast(t *testing.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + // Capture broadcast actions + var triggered ActionTriggered + var mu sync.Mutex + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if a, ok := msg.(ActionTriggered); ok { + mu.Lock() + triggered = a + mu.Unlock() + } + return nil + }) + + _, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save"}) + + // Simulate shortcut trigger via mock + mp.trigger("Ctrl+S") + + mu.Lock() + assert.Equal(t, "Ctrl+S", triggered.Accelerator) + mu.Unlock() +} + +func TestTaskAdd_Good_RebindAfterRemove(t *testing.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + _, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save"}) + _, _, _ = c.PERFORM(TaskRemove{Accelerator: "Ctrl+S"}) + + // Should succeed after remove + _, handled, err := c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save v2"}) + require.NoError(t, err) + assert.True(t, handled) + + // Verify new description + result, _, _ := c.QUERY(QueryList{}) + list := result.([]BindingInfo) + assert.Len(t, list, 1) + assert.Equal(t, "Save v2", list[0].Description) +} + +func TestQueryList_Bad_NoService(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.QUERY(QueryList{}) + assert.False(t, handled) +} +``` + +- [ ] **Step 5: Run test to verify it fails** + +Run: `cd /Users/snider/Code/core/gui && go test ./pkg/keybinding/ -v` +Expected: FAIL — `Service` type not defined + +- [ ] **Step 6: Create service.go** + +```go +// pkg/keybinding/service.go +package keybinding + +import ( + "context" + "fmt" + + "forge.lthn.ai/core/go/pkg/core" +) + +// Options holds configuration for the keybinding service. +type Options struct{} + +// Service is a core.Service managing keyboard shortcuts via IPC. +// It maintains an in-memory registry of bindings and delegates +// platform-level registration to the Platform interface. +type Service struct { + *core.ServiceRuntime[Options] + platform Platform + bindings map[string]BindingInfo +} + +// OnStartup registers IPC handlers. +func (s *Service) OnStartup(ctx context.Context) error { + s.Core().RegisterQuery(s.handleQuery) + s.Core().RegisterTask(s.handleTask) + return nil +} + +// HandleIPCEvents is auto-discovered and registered by core.WithService. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + return nil +} + +// --- Query Handlers --- + +func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { + switch q.(type) { + case QueryList: + return s.queryList(), true, nil + default: + return nil, false, nil + } +} + +// queryList reads from the in-memory registry (not platform.GetAll()). +func (s *Service) queryList() []BindingInfo { + result := make([]BindingInfo, 0, len(s.bindings)) + for _, info := range s.bindings { + result = append(result, info) + } + return result +} + +// --- Task Handlers --- + +func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { + switch t := t.(type) { + case TaskAdd: + return nil, true, s.taskAdd(t) + case TaskRemove: + return nil, true, s.taskRemove(t) + default: + return nil, false, nil + } +} + +func (s *Service) taskAdd(t TaskAdd) error { + if _, exists := s.bindings[t.Accelerator]; exists { + return ErrAlreadyRegistered + } + + // Register on platform with a callback that broadcasts ActionTriggered + err := s.platform.Add(t.Accelerator, func() { + _ = s.Core().ACTION(ActionTriggered{Accelerator: t.Accelerator}) + }) + if err != nil { + return fmt.Errorf("keybinding: platform add failed: %w", err) + } + + s.bindings[t.Accelerator] = BindingInfo{ + Accelerator: t.Accelerator, + Description: t.Description, + } + return nil +} + +func (s *Service) taskRemove(t TaskRemove) error { + if _, exists := s.bindings[t.Accelerator]; !exists { + return fmt.Errorf("keybinding: not registered: %s", t.Accelerator) + } + + err := s.platform.Remove(t.Accelerator) + if err != nil { + return fmt.Errorf("keybinding: platform remove failed: %w", err) + } + + delete(s.bindings, t.Accelerator) + return nil +} +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `cd /Users/snider/Code/core/gui && go test ./pkg/keybinding/ -v` +Expected: PASS (10 tests) + +- [ ] **Step 8: Commit** + +```bash +cd /Users/snider/Code/core/gui +git add pkg/keybinding/ +git commit -m "feat(keybinding): add keybinding core.Service with Platform interface and IPC + +Implements pkg/keybinding with three-layer pattern: IPC Bus -> Service -> Platform. +Service maintains in-memory registry, ErrAlreadyRegistered on duplicates. +QueryList reads from service registry, not platform.GetAll(). +ActionTriggered broadcast on shortcut trigger via platform callback. + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Task 2: Create pkg/browser + +**Files:** +- Create: `pkg/browser/platform.go` +- Create: `pkg/browser/messages.go` +- Create: `pkg/browser/register.go` +- Create: `pkg/browser/service.go` +- Create: `pkg/browser/service_test.go` + +- [ ] **Step 1: Create platform.go** + +```go +// pkg/browser/platform.go +package browser + +// Platform abstracts the system browser/file-opener backend. +type Platform interface { + // OpenURL opens the given URL in the default system browser. + OpenURL(url string) error + + // OpenFile opens the given file path with the system default application. + OpenFile(path string) error +} +``` + +- [ ] **Step 2: Create messages.go** + +```go +// pkg/browser/messages.go +package browser + +// --- Tasks (all side-effects, no queries or actions) --- + +// TaskOpenURL opens a URL in the default system browser. Result: nil +type TaskOpenURL struct { + URL string `json:"url"` +} + +// TaskOpenFile opens a file with the system default application. Result: nil +type TaskOpenFile struct { + Path string `json:"path"` +} +``` + +- [ ] **Step 3: Create register.go** + +```go +// pkg/browser/register.go +package browser + +import "forge.lthn.ai/core/go/pkg/core" + +// Register creates a factory closure that captures the Platform adapter. +// The returned function has the signature WithService requires: func(*Core) (any, error). +func Register(p Platform) func(*core.Core) (any, error) { + return func(c *core.Core) (any, error) { + return &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + }, nil + } +} +``` + +- [ ] **Step 4: Write failing test** + +```go +// pkg/browser/service_test.go +package browser + +import ( + "context" + "errors" + "testing" + + "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockPlatform struct { + lastURL string + lastPath string + urlErr error + fileErr error +} + +func (m *mockPlatform) OpenURL(url string) error { + m.lastURL = url + return m.urlErr +} + +func (m *mockPlatform) OpenFile(path string) error { + m.lastPath = path + return m.fileErr +} + +func newTestBrowserService(t *testing.T, mp *mockPlatform) (*Service, *core.Core) { + t.Helper() + c, err := core.New( + core.WithService(Register(mp)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + svc := core.MustServiceFor[*Service](c, "browser") + return svc, c +} + +func TestRegister_Good(t *testing.T) { + mp := &mockPlatform{} + svc, _ := newTestBrowserService(t, mp) + assert.NotNil(t, svc) + assert.NotNil(t, svc.platform) +} + +func TestTaskOpenURL_Good(t *testing.T) { + mp := &mockPlatform{} + _, c := newTestBrowserService(t, mp) + + _, handled, err := c.PERFORM(TaskOpenURL{URL: "https://example.com"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "https://example.com", mp.lastURL) +} + +func TestTaskOpenURL_Bad_PlatformError(t *testing.T) { + mp := &mockPlatform{urlErr: errors.New("browser not found")} + _, c := newTestBrowserService(t, mp) + + _, handled, err := c.PERFORM(TaskOpenURL{URL: "https://example.com"}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestTaskOpenFile_Good(t *testing.T) { + mp := &mockPlatform{} + _, c := newTestBrowserService(t, mp) + + _, handled, err := c.PERFORM(TaskOpenFile{Path: "/tmp/readme.txt"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "/tmp/readme.txt", mp.lastPath) +} + +func TestTaskOpenFile_Bad_PlatformError(t *testing.T) { + mp := &mockPlatform{fileErr: errors.New("file not found")} + _, c := newTestBrowserService(t, mp) + + _, handled, err := c.PERFORM(TaskOpenFile{Path: "/nonexistent"}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestTaskOpenURL_Bad_NoService(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.PERFORM(TaskOpenURL{URL: "https://example.com"}) + assert.False(t, handled) +} +``` + +- [ ] **Step 5: Run test to verify it fails** + +Run: `cd /Users/snider/Code/core/gui && go test ./pkg/browser/ -v` +Expected: FAIL — `Service` type not defined + +- [ ] **Step 6: Create service.go** + +```go +// pkg/browser/service.go +package browser + +import ( + "context" + + "forge.lthn.ai/core/go/pkg/core" +) + +// Options holds configuration for the browser service. +type Options struct{} + +// Service is a core.Service that delegates browser/file-open operations +// to the platform. It is stateless — no queries, no actions. +type Service struct { + *core.ServiceRuntime[Options] + platform Platform +} + +// OnStartup registers IPC handlers. +func (s *Service) OnStartup(ctx context.Context) error { + s.Core().RegisterTask(s.handleTask) + return nil +} + +// HandleIPCEvents is auto-discovered and registered by core.WithService. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + return nil +} + +// --- Task Handlers --- + +func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { + switch t := t.(type) { + case TaskOpenURL: + return nil, true, s.platform.OpenURL(t.URL) + case TaskOpenFile: + return nil, true, s.platform.OpenFile(t.Path) + default: + return nil, false, nil + } +} +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `cd /Users/snider/Code/core/gui && go test ./pkg/browser/ -v` +Expected: PASS (6 tests) + +- [ ] **Step 8: Commit** + +```bash +cd /Users/snider/Code/core/gui +git add pkg/browser/ +git commit -m "feat(browser): add browser core.Service with Platform interface and IPC + +Implements pkg/browser with three-layer pattern: IPC Bus -> Service -> Platform. +Stateless service — delegates OpenURL and OpenFile to platform adapter. +No queries or actions, tasks only. + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Task 3: Window file drop enhancement + +**Files:** +- Modify: `pkg/window/platform.go` +- Modify: `pkg/window/messages.go` +- Modify: `pkg/window/service.go` +- Modify: `pkg/window/mock_platform.go` +- Modify: `pkg/window/mock_test.go` +- Modify: `pkg/window/service_test.go` + +- [ ] **Step 1: Add OnFileDrop to PlatformWindow interface in platform.go** + +In `pkg/window/platform.go`, add to the `PlatformWindow` interface after the `OnWindowEvent` method: + +```go + // File drop + OnFileDrop(handler func(paths []string, targetID string)) +``` + +The full `PlatformWindow` interface becomes: + +```go +// PlatformWindow is a live window handle from the backend. +type PlatformWindow interface { + // Identity + Name() string + + // Queries + Position() (int, int) + Size() (int, int) + IsMaximised() bool + IsFocused() bool + + // Mutations + SetTitle(title string) + SetPosition(x, y int) + SetSize(width, height int) + SetBackgroundColour(r, g, b, a uint8) + SetVisibility(visible bool) + SetAlwaysOnTop(alwaysOnTop bool) + + // Window state + Maximise() + Restore() + Minimise() + Focus() + Close() + Show() + Hide() + Fullscreen() + UnFullscreen() + + // Events + OnWindowEvent(handler func(event WindowEvent)) + + // File drop + OnFileDrop(handler func(paths []string, targetID string)) +} +``` + +- [ ] **Step 2: Add ActionFilesDropped to messages.go** + +In `pkg/window/messages.go`, add after the existing action types: + +```go +type ActionFilesDropped struct { + Name string `json:"name"` // window name + Paths []string `json:"paths"` + TargetID string `json:"targetId,omitempty"` +} +``` + +- [ ] **Step 3: Add OnFileDrop call in trackWindow() in service.go** + +In `pkg/window/service.go`, add at the end of the `trackWindow()` method (after the `pw.OnWindowEvent` block): + +```go + pw.OnFileDrop(func(paths []string, targetID string) { + _ = s.Core().ACTION(ActionFilesDropped{ + Name: pw.Name(), + Paths: paths, + TargetID: targetID, + }) + }) +``` + +The full `trackWindow` method becomes: + +```go +// trackWindow attaches platform event listeners that emit IPC actions. +func (s *Service) trackWindow(pw PlatformWindow) { + pw.OnWindowEvent(func(e WindowEvent) { + switch e.Type { + case "focus": + _ = s.Core().ACTION(ActionWindowFocused{Name: e.Name}) + case "blur": + _ = s.Core().ACTION(ActionWindowBlurred{Name: e.Name}) + case "move": + if data := e.Data; data != nil { + x, _ := data["x"].(int) + y, _ := data["y"].(int) + _ = s.Core().ACTION(ActionWindowMoved{Name: e.Name, X: x, Y: y}) + } + case "resize": + if data := e.Data; data != nil { + w, _ := data["w"].(int) + h, _ := data["h"].(int) + _ = s.Core().ACTION(ActionWindowResized{Name: e.Name, W: w, H: h}) + } + case "close": + _ = s.Core().ACTION(ActionWindowClosed{Name: e.Name}) + } + }) + pw.OnFileDrop(func(paths []string, targetID string) { + _ = s.Core().ACTION(ActionFilesDropped{ + Name: pw.Name(), + Paths: paths, + TargetID: targetID, + }) + }) +} +``` + +- [ ] **Step 4: Add OnFileDrop to exported MockWindow in mock_platform.go** + +In `pkg/window/mock_platform.go`, add a field and method to `MockWindow`: + +Add field to MockWindow struct: + +```go + fileDropHandlers []func(paths []string, targetID string) +``` + +Add method: + +```go +func (w *MockWindow) OnFileDrop(handler func(paths []string, targetID string)) { + w.fileDropHandlers = append(w.fileDropHandlers, handler) +} +``` + +The full `MockWindow` struct becomes: + +```go +type MockWindow struct { + name, title, url string + width, height, x, y int + maximised, focused bool + visible, alwaysOnTop bool + closed bool + eventHandlers []func(WindowEvent) + fileDropHandlers []func(paths []string, targetID string) +} +``` + +And the full method set gains: + +```go +func (w *MockWindow) OnFileDrop(handler func(paths []string, targetID string)) { + w.fileDropHandlers = append(w.fileDropHandlers, handler) +} +``` + +- [ ] **Step 5: Add OnFileDrop to unexported mockWindow in mock_test.go** + +In `pkg/window/mock_test.go`, add a field and method to `mockWindow`: + +Add field to mockWindow struct: + +```go + fileDropHandlers []func(paths []string, targetID string) +``` + +Add method: + +```go +func (w *mockWindow) OnFileDrop(handler func(paths []string, targetID string)) { + w.fileDropHandlers = append(w.fileDropHandlers, handler) +} +``` + +Add helper to simulate file drops: + +```go +// emitFileDrop simulates a file drop on the window. +func (w *mockWindow) emitFileDrop(paths []string, targetID string) { + for _, h := range w.fileDropHandlers { + h(paths, targetID) + } +} +``` + +The full `mockWindow` struct becomes: + +```go +type mockWindow struct { + name, title, url string + width, height, x, y int + maximised, focused bool + visible, alwaysOnTop bool + closed bool + eventHandlers []func(WindowEvent) + fileDropHandlers []func(paths []string, targetID string) +} +``` + +- [ ] **Step 6: Write failing file drop test in service_test.go** + +Add to `pkg/window/service_test.go`: + +```go +func TestFileDrop_Good(t *testing.T) { + _, c := newTestWindowService(t) + + // Open a window + result, _, _ := c.PERFORM(TaskOpenWindow{ + Opts: []WindowOption{WithName("drop-test")}, + }) + info := result.(WindowInfo) + assert.Equal(t, "drop-test", info.Name) + + // Capture broadcast actions + var dropped ActionFilesDropped + var mu sync.Mutex + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if a, ok := msg.(ActionFilesDropped); ok { + mu.Lock() + dropped = a + mu.Unlock() + } + return nil + }) + + // Get the mock window and simulate file drop + svc := core.MustServiceFor[*Service](c, "window") + pw, ok := svc.Manager().Get("drop-test") + require.True(t, ok) + mw := pw.(*mockWindow) + mw.emitFileDrop([]string{"/tmp/file1.txt", "/tmp/file2.txt"}, "upload-zone") + + mu.Lock() + assert.Equal(t, "drop-test", dropped.Name) + assert.Equal(t, []string{"/tmp/file1.txt", "/tmp/file2.txt"}, dropped.Paths) + assert.Equal(t, "upload-zone", dropped.TargetID) + mu.Unlock() +} +``` + +Note: Add `"sync"` to the import block in `service_test.go` if not already present. + +- [ ] **Step 7: Run test to verify it fails** + +Run: `cd /Users/snider/Code/core/gui && go test ./pkg/window/ -v -run TestFileDrop` +Expected: FAIL — `OnFileDrop` method missing from interface / `ActionFilesDropped` not defined + +- [ ] **Step 8: Apply all modifications from Steps 1-5** + +Apply the changes described in Steps 1-5 (platform.go, messages.go, service.go, mock_platform.go, mock_test.go). + +- [ ] **Step 9: Run all window tests to verify they pass** + +Run: `cd /Users/snider/Code/core/gui && go test ./pkg/window/ -v` +Expected: PASS (all existing tests + new TestFileDrop_Good) + +- [ ] **Step 10: Commit** + +```bash +cd /Users/snider/Code/core/gui +git add pkg/window/ +git commit -m "feat(window): add file drop support to PlatformWindow interface + +Adds OnFileDrop(handler func(paths []string, targetID string)) to PlatformWindow. +trackWindow() now wires file drop callbacks to ActionFilesDropped broadcasts. +Updates both exported MockWindow and unexported mockWindow with the new method. + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Task 4: Display orchestrator updates + +**Files:** +- Modify: `pkg/display/events.go` +- Modify: `pkg/display/display.go` + +- [ ] **Step 1: Add new EventType constants in events.go** + +In `pkg/display/events.go`, add two new constants after the existing ones: + +```go + EventKeybindingTriggered EventType = "keybinding.triggered" + EventWindowFileDrop EventType = "window.filedrop" +``` + +The full constants block becomes: + +```go +const ( + EventWindowFocus EventType = "window.focus" + EventWindowBlur EventType = "window.blur" + EventWindowMove EventType = "window.move" + EventWindowResize EventType = "window.resize" + EventWindowClose EventType = "window.close" + EventWindowCreate EventType = "window.create" + EventThemeChange EventType = "theme.change" + EventScreenChange EventType = "screen.change" + EventNotificationClick EventType = "notification.click" + EventTrayClick EventType = "tray.click" + EventTrayMenuItemClick EventType = "tray.menuitem.click" + EventKeybindingTriggered EventType = "keybinding.triggered" + EventWindowFileDrop EventType = "window.filedrop" +) +``` + +- [ ] **Step 2: Add new imports in display.go** + +In `pkg/display/display.go`, add two new imports: + +```go + "forge.lthn.ai/core/gui/pkg/browser" + "forge.lthn.ai/core/gui/pkg/keybinding" +``` + +The full import block becomes: + +```go +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + + "forge.lthn.ai/core/go-config" + "forge.lthn.ai/core/go/pkg/core" + "forge.lthn.ai/core/gui/pkg/browser" + "forge.lthn.ai/core/gui/pkg/dialog" + "forge.lthn.ai/core/gui/pkg/environment" + "forge.lthn.ai/core/gui/pkg/keybinding" + "forge.lthn.ai/core/gui/pkg/menu" + "forge.lthn.ai/core/gui/pkg/notification" + "forge.lthn.ai/core/gui/pkg/screen" + "forge.lthn.ai/core/gui/pkg/systray" + "forge.lthn.ai/core/gui/pkg/window" + "github.com/wailsapp/wails/v3/pkg/application" +) +``` + +- [ ] **Step 3: Add HandleIPCEvents cases in display.go** + +In `pkg/display/display.go`, add two new cases to `HandleIPCEvents` before the closing `}` of the switch statement (after the `screen.ActionScreensChanged` case): + +```go + case keybinding.ActionTriggered: + if s.events != nil { + s.events.Emit(Event{Type: EventKeybindingTriggered, + Data: map[string]any{"accelerator": m.Accelerator}}) + } + case window.ActionFilesDropped: + if s.events != nil { + s.events.Emit(Event{Type: EventWindowFileDrop, Window: m.Name, + Data: map[string]any{"paths": m.Paths, "targetId": m.TargetID}}) + } +``` + +The full HandleIPCEvents method becomes: + +```go +// HandleIPCEvents is auto-discovered and registered by core.WithService. +// It bridges sub-service IPC actions to WebSocket events for TS apps. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + switch m := msg.(type) { + case core.ActionServiceStartup: + // All services have completed OnStartup — safe to PERFORM on sub-services + s.buildMenu() + s.setupTray() + case window.ActionWindowOpened: + if s.events != nil { + s.events.Emit(Event{Type: EventWindowCreate, Window: m.Name, + Data: map[string]any{"name": m.Name}}) + } + case window.ActionWindowClosed: + if s.events != nil { + s.events.Emit(Event{Type: EventWindowClose, Window: m.Name, + Data: map[string]any{"name": m.Name}}) + } + case window.ActionWindowMoved: + if s.events != nil { + s.events.Emit(Event{Type: EventWindowMove, Window: m.Name, + Data: map[string]any{"x": m.X, "y": m.Y}}) + } + case window.ActionWindowResized: + if s.events != nil { + s.events.Emit(Event{Type: EventWindowResize, Window: m.Name, + Data: map[string]any{"w": m.W, "h": m.H}}) + } + case window.ActionWindowFocused: + if s.events != nil { + s.events.Emit(Event{Type: EventWindowFocus, Window: m.Name}) + } + case window.ActionWindowBlurred: + if s.events != nil { + s.events.Emit(Event{Type: EventWindowBlur, Window: m.Name}) + } + case systray.ActionTrayClicked: + if s.events != nil { + s.events.Emit(Event{Type: EventTrayClick}) + } + case systray.ActionTrayMenuItemClicked: + if s.events != nil { + s.events.Emit(Event{Type: EventTrayMenuItemClick, + Data: map[string]any{"actionId": m.ActionID}}) + } + s.handleTrayAction(m.ActionID) + case environment.ActionThemeChanged: + if s.events != nil { + theme := "light" + if m.IsDark { + theme = "dark" + } + s.events.Emit(Event{Type: EventThemeChange, + Data: map[string]any{"isDark": m.IsDark, "theme": theme}}) + } + case notification.ActionNotificationClicked: + if s.events != nil { + s.events.Emit(Event{Type: EventNotificationClick, + Data: map[string]any{"id": m.ID}}) + } + case screen.ActionScreensChanged: + if s.events != nil { + s.events.Emit(Event{Type: EventScreenChange, + Data: map[string]any{"screens": m.Screens}}) + } + case keybinding.ActionTriggered: + if s.events != nil { + s.events.Emit(Event{Type: EventKeybindingTriggered, + Data: map[string]any{"accelerator": m.Accelerator}}) + } + case window.ActionFilesDropped: + if s.events != nil { + s.events.Emit(Event{Type: EventWindowFileDrop, Window: m.Name, + Data: map[string]any{"paths": m.Paths, "targetId": m.TargetID}}) + } + } + return nil +} +``` + +- [ ] **Step 4: Add handleWSMessage method to Service (if not already present)** + +The display orchestrator needs a `handleWSMessage` method that bridges WebSocket commands to IPC. If this method does not yet exist, add it. If it already exists, add new cases to the switch. + +Add to `pkg/display/display.go`: + +```go +// WSMessage represents a command received from a WebSocket client. +type WSMessage struct { + Action string `json:"action"` + Data map[string]any `json:"data,omitempty"` +} + +// handleWSMessage bridges WebSocket commands to IPC calls. +func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { + var result any + var handled bool + var err error + + switch msg.Action { + case "keybinding:add": + accelerator, _ := msg.Data["accelerator"].(string) + description, _ := msg.Data["description"].(string) + result, handled, err = s.Core().PERFORM(keybinding.TaskAdd{ + Accelerator: accelerator, Description: description, + }) + case "keybinding:remove": + accelerator, _ := msg.Data["accelerator"].(string) + result, handled, err = s.Core().PERFORM(keybinding.TaskRemove{ + Accelerator: accelerator, + }) + case "keybinding:list": + result, handled, err = s.Core().QUERY(keybinding.QueryList{}) + case "browser:open-url": + url, _ := msg.Data["url"].(string) + result, handled, err = s.Core().PERFORM(browser.TaskOpenURL{URL: url}) + case "browser:open-file": + path, _ := msg.Data["path"].(string) + result, handled, err = s.Core().PERFORM(browser.TaskOpenFile{Path: path}) + default: + return nil, false, nil + } + + return result, handled, err +} +``` + +Note: The `WSMessage` type and `handleWSMessage` method may need to be integrated into an existing WS message dispatch mechanism. If the display package already has a WS command router, add the new cases to its switch statement instead of creating a new method. Use safe comma-ok type assertions throughout (never bare `msg.Data["key"].(string)`, always `msg.Data["key"].(string)` with the ok value discarded via `_`). + +- [ ] **Step 5: Run all display tests** + +Run: `cd /Users/snider/Code/core/gui && go test ./pkg/display/ -v` +Expected: PASS (verify the new imports compile and existing tests still pass) + +- [ ] **Step 6: Run full test suite** + +Run: `cd /Users/snider/Code/core/gui && go test ./... -v` +Expected: PASS (all packages) + +- [ ] **Step 7: Commit** + +```bash +cd /Users/snider/Code/core/gui +git add pkg/display/ +git commit -m "feat(display): wire keybinding, browser, and file drop through orchestrator + +Adds EventKeybindingTriggered and EventWindowFileDrop EventType constants. +HandleIPCEvents bridges keybinding.ActionTriggered and window.ActionFilesDropped +to WS events. handleWSMessage bridges WS commands to IPC for keybinding:add, +keybinding:remove, keybinding:list, browser:open-url, browser:open-file. + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Task 5: Final verification and commit + +- [ ] **Step 1: Run full test suite** + +```bash +cd /Users/snider/Code/core/gui && go test ./... -v +``` + +Expected: All tests pass across all packages. + +- [ ] **Step 2: Run linting** + +```bash +cd /Users/snider/Code/core/gui && go vet ./... +``` + +Expected: No warnings. + +- [ ] **Step 3: Verify no import cycles** + +```bash +cd /Users/snider/Code/core/gui && go build ./... +``` + +Expected: Clean build. Dependency direction: +- `pkg/keybinding` and `pkg/browser` are independent (import only `core/go`) +- `pkg/display` imports `pkg/keybinding` and `pkg/browser` (message types only) +- No circular dependencies + +- [ ] **Step 4: Review file inventory** + +New files created (10): +- `pkg/keybinding/platform.go` +- `pkg/keybinding/messages.go` +- `pkg/keybinding/register.go` +- `pkg/keybinding/service.go` +- `pkg/keybinding/service_test.go` +- `pkg/browser/platform.go` +- `pkg/browser/messages.go` +- `pkg/browser/register.go` +- `pkg/browser/service.go` +- `pkg/browser/service_test.go` + +Modified files (8): +- `pkg/window/platform.go` — `OnFileDrop` added to `PlatformWindow` +- `pkg/window/messages.go` — `ActionFilesDropped` added +- `pkg/window/service.go` — `trackWindow()` wires file drop +- `pkg/window/mock_platform.go` — `MockWindow.OnFileDrop` added +- `pkg/window/mock_test.go` — `mockWindow.OnFileDrop` + `emitFileDrop` added +- `pkg/window/service_test.go` — `TestFileDrop_Good` added +- `pkg/display/events.go` — 2 new `EventType` constants +- `pkg/display/display.go` — 2 new `HandleIPCEvents` cases + WS->IPC bridge + 2 new imports + +- [ ] **Step 5: Final commit (if not already committed per-task)** + +If individual task commits were made, no final commit is needed. If working in a single branch, squash or leave as individual commits per preference. + +--- + +## Summary + +| Task | Package | Tests | Key design decisions | +|------|---------|-------|---------------------| +| 1 | `pkg/keybinding` | 10 | In-memory `map[string]BindingInfo` registry; `ErrAlreadyRegistered` on duplicate; `QueryList` reads registry not `platform.GetAll()`; callback broadcasts `ActionTriggered` | +| 2 | `pkg/browser` | 6 | Stateless; tasks only, no queries/actions; direct platform delegation | +| 3 | `pkg/window` (enhance) | 1 new | `OnFileDrop` on `PlatformWindow` interface; `trackWindow()` wires to `ActionFilesDropped`; both mocks updated | +| 4 | `pkg/display` (wire) | 0 new (compile check) | 2 new `EventType` constants; 2 new `HandleIPCEvents` cases; 5 new WS->IPC cases with safe comma-ok assertions | +| 5 | Verification | full suite | `go test ./...`, `go vet ./...`, `go build ./...` | + +## Licence + +EUPL-1.2 diff --git a/docs/superpowers/plans/2026-03-13-gui-platform-events.md b/docs/superpowers/plans/2026-03-13-gui-platform-events.md new file mode 100644 index 0000000..80f816e --- /dev/null +++ b/docs/superpowers/plans/2026-03-13-gui-platform-events.md @@ -0,0 +1,1074 @@ +# GUI Platform & Events Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add dock/badge and application lifecycle event packages as independent core.Services with Platform interface insulation, IPC messages, and display orchestrator integration. + +**Architecture:** Each package follows the three-layer pattern (IPC Bus -> Service -> Platform Interface) established by window/systray/menu. The dock service handles taskbar icon visibility and badge labels. The lifecycle service registers platform callbacks during OnStartup and broadcasts Actions for application and system events. The display orchestrator gains 9 new EventType constants, HandleIPCEvents cases for both packages, and WS->IPC cases for dock commands. + +**Tech Stack:** Go, core/go DI framework, Wails v3 (behind Platform interfaces), gorilla/websocket (WSEventManager) + +**Spec:** `docs/superpowers/specs/2026-03-13-gui-platform-events-design.md` + +--- + +## File Structure + +### New files (2 packages x 5 files = 10 files) + +| Package | File | Responsibility | +|---------|------|---------------| +| `pkg/dock/` | `platform.go` | Platform interface (5 methods: ShowIcon, HideIcon, SetBadge, RemoveBadge, IsVisible) | +| | `messages.go` | IPC message types (1 Query, 4 Tasks, 1 Action) | +| | `register.go` | Register() factory closure | +| | `service.go` | Service struct, OnStartup(), handlers, visibility broadcast | +| | `service_test.go` | Tests with mock platform | +| `pkg/lifecycle/` | `platform.go` | Platform interface (2 methods) + EventType enum (7 values) | +| | `messages.go` | IPC message types (8 Actions) | +| | `register.go` | Register() factory closure | +| | `service.go` | Service struct, OnStartup() callback registration, OnShutdown() cancel | +| | `service_test.go` | Tests with mock platform + event simulation | + +### Modified files + +| File | Change | +|------|--------| +| `pkg/display/events.go` | Add 9 new EventType constants | +| `pkg/display/display.go` | Add imports for dock + lifecycle, HandleIPCEvents cases (10 new), WS->IPC cases (5 new dock commands) | + +--- + +## Task 1: Create pkg/dock + +**Files:** +- Create: `pkg/dock/platform.go` +- Create: `pkg/dock/messages.go` +- Create: `pkg/dock/register.go` +- Create: `pkg/dock/service.go` +- Create: `pkg/dock/service_test.go` + +- [ ] **Step 1: Create platform.go** + +```go +// pkg/dock/platform.go +package dock + +// Platform abstracts the dock/taskbar backend (Wails v3). +// macOS: dock icon show/hide + badge. +// Windows: taskbar badge only (show/hide not supported). +// Linux: not supported — adapter returns nil for all operations. +type Platform interface { + ShowIcon() error + HideIcon() error + SetBadge(label string) error + RemoveBadge() error + IsVisible() bool +} +``` + +- [ ] **Step 2: Create messages.go** + +```go +// pkg/dock/messages.go +package dock + +// --- Queries (read-only) --- + +// QueryVisible returns whether the dock icon is visible. Result: bool +type QueryVisible struct{} + +// --- Tasks (side-effects) --- + +// TaskShowIcon shows the dock/taskbar icon. Result: nil +type TaskShowIcon struct{} + +// TaskHideIcon hides the dock/taskbar icon. Result: nil +type TaskHideIcon struct{} + +// TaskSetBadge sets the dock/taskbar badge label. +// Empty string "" shows the default system badge indicator. +// Numeric "3", "99" shows unread count. Text "New", "Paused" shows brief status. +// Result: nil +type TaskSetBadge struct{ Label string } + +// TaskRemoveBadge removes the dock/taskbar badge. Result: nil +type TaskRemoveBadge struct{} + +// --- Actions (broadcasts) --- + +// ActionVisibilityChanged is broadcast after a successful TaskShowIcon or TaskHideIcon. +type ActionVisibilityChanged struct{ Visible bool } +``` + +- [ ] **Step 3: Create register.go** + +```go +// pkg/dock/register.go +package dock + +import "forge.lthn.ai/core/go/pkg/core" + +// Register creates a factory closure that captures the Platform adapter. +// The returned function has the signature WithService requires: func(*Core) (any, error). +func Register(p Platform) func(*core.Core) (any, error) { + return func(c *core.Core) (any, error) { + return &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + }, nil + } +} +``` + +- [ ] **Step 4: Write failing test** + +```go +// pkg/dock/service_test.go +package dock + +import ( + "context" + "testing" + + "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Mock Platform --- + +type mockPlatform struct { + visible bool + badge string + hasBadge bool + showErr error + hideErr error + badgeErr error + removeErr error +} + +func (m *mockPlatform) ShowIcon() error { + if m.showErr != nil { + return m.showErr + } + m.visible = true + return nil +} + +func (m *mockPlatform) HideIcon() error { + if m.hideErr != nil { + return m.hideErr + } + m.visible = false + return nil +} + +func (m *mockPlatform) SetBadge(label string) error { + if m.badgeErr != nil { + return m.badgeErr + } + m.badge = label + m.hasBadge = true + return nil +} + +func (m *mockPlatform) RemoveBadge() error { + if m.removeErr != nil { + return m.removeErr + } + m.badge = "" + m.hasBadge = false + return nil +} + +func (m *mockPlatform) IsVisible() bool { return m.visible } + +// --- Test helpers --- + +func newTestDockService(t *testing.T) (*Service, *core.Core, *mockPlatform) { + t.Helper() + mock := &mockPlatform{visible: true} + c, err := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + svc := core.MustServiceFor[*Service](c, "dock") + return svc, c, mock +} + +// --- Tests --- + +func TestRegister_Good(t *testing.T) { + svc, _, _ := newTestDockService(t) + assert.NotNil(t, svc) +} + +func TestQueryVisible_Good(t *testing.T) { + _, c, _ := newTestDockService(t) + result, handled, err := c.QUERY(QueryVisible{}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, true, result) +} + +func TestQueryVisible_Bad(t *testing.T) { + // No dock service registered — QUERY returns handled=false + c, err := core.New(core.WithServiceLock()) + require.NoError(t, err) + _, handled, _ := c.QUERY(QueryVisible{}) + assert.False(t, handled) +} + +func TestTaskShowIcon_Good(t *testing.T) { + _, c, mock := newTestDockService(t) + mock.visible = false // Start hidden + + var received *ActionVisibilityChanged + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if a, ok := msg.(ActionVisibilityChanged); ok { + received = &a + } + return nil + }) + + _, handled, err := c.PERFORM(TaskShowIcon{}) + require.NoError(t, err) + assert.True(t, handled) + assert.True(t, mock.visible) + require.NotNil(t, received) + assert.True(t, received.Visible) +} + +func TestTaskHideIcon_Good(t *testing.T) { + _, c, mock := newTestDockService(t) + mock.visible = true // Start visible + + var received *ActionVisibilityChanged + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if a, ok := msg.(ActionVisibilityChanged); ok { + received = &a + } + return nil + }) + + _, handled, err := c.PERFORM(TaskHideIcon{}) + require.NoError(t, err) + assert.True(t, handled) + assert.False(t, mock.visible) + require.NotNil(t, received) + assert.False(t, received.Visible) +} + +func TestTaskSetBadge_Good(t *testing.T) { + _, c, mock := newTestDockService(t) + _, handled, err := c.PERFORM(TaskSetBadge{Label: "3"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "3", mock.badge) + assert.True(t, mock.hasBadge) +} + +func TestTaskSetBadge_EmptyLabel_Good(t *testing.T) { + _, c, mock := newTestDockService(t) + _, handled, err := c.PERFORM(TaskSetBadge{Label: ""}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "", mock.badge) + assert.True(t, mock.hasBadge) // Empty string = default system badge indicator +} + +func TestTaskRemoveBadge_Good(t *testing.T) { + _, c, mock := newTestDockService(t) + // Set a badge first + _, _, _ = c.PERFORM(TaskSetBadge{Label: "5"}) + + _, handled, err := c.PERFORM(TaskRemoveBadge{}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "", mock.badge) + assert.False(t, mock.hasBadge) +} + +func TestTaskShowIcon_Bad(t *testing.T) { + _, c, mock := newTestDockService(t) + mock.showErr = assert.AnError + + _, handled, err := c.PERFORM(TaskShowIcon{}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestTaskHideIcon_Bad(t *testing.T) { + _, c, mock := newTestDockService(t) + mock.hideErr = assert.AnError + + _, handled, err := c.PERFORM(TaskHideIcon{}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestTaskSetBadge_Bad(t *testing.T) { + _, c, mock := newTestDockService(t) + mock.badgeErr = assert.AnError + + _, handled, err := c.PERFORM(TaskSetBadge{Label: "3"}) + assert.True(t, handled) + assert.Error(t, err) +} +``` + +- [ ] **Step 5: Run test to verify it fails** + +Run: `cd /Users/snider/Code/core/gui && go test ./pkg/dock/ -v` +Expected: FAIL — `Service` type not defined, `Options` type not defined + +- [ ] **Step 6: Create service.go** + +```go +// pkg/dock/service.go +package dock + +import ( + "context" + + "forge.lthn.ai/core/go/pkg/core" +) + +// Options holds configuration for the dock service. +type Options struct{} + +// Service is a core.Service managing dock/taskbar operations via IPC. +// It embeds ServiceRuntime for Core access and delegates to Platform. +type Service struct { + *core.ServiceRuntime[Options] + platform Platform +} + +// OnStartup registers IPC handlers. +func (s *Service) OnStartup(ctx context.Context) error { + s.Core().RegisterQuery(s.handleQuery) + s.Core().RegisterTask(s.handleTask) + return nil +} + +// HandleIPCEvents is auto-discovered and registered by core.WithService. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + return nil +} + +// --- Query Handlers --- + +func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { + switch q.(type) { + case QueryVisible: + return s.platform.IsVisible(), true, nil + default: + return nil, false, nil + } +} + +// --- Task Handlers --- + +func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { + switch t := t.(type) { + case TaskShowIcon: + if err := s.platform.ShowIcon(); err != nil { + return nil, true, err + } + _ = s.Core().ACTION(ActionVisibilityChanged{Visible: true}) + return nil, true, nil + case TaskHideIcon: + if err := s.platform.HideIcon(); err != nil { + return nil, true, err + } + _ = s.Core().ACTION(ActionVisibilityChanged{Visible: false}) + return nil, true, nil + case TaskSetBadge: + if err := s.platform.SetBadge(t.Label); err != nil { + return nil, true, err + } + return nil, true, nil + case TaskRemoveBadge: + if err := s.platform.RemoveBadge(); err != nil { + return nil, true, err + } + return nil, true, nil + default: + return nil, false, nil + } +} +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `cd /Users/snider/Code/core/gui && go test ./pkg/dock/ -v` +Expected: PASS (12 tests) + +- [ ] **Step 8: Commit** + +```bash +cd /Users/snider/Code/core/gui +git add pkg/dock/ +git commit -m "feat(dock): add dock/badge core.Service with Platform interface and IPC" +``` + +--- + +## Task 2: Create pkg/lifecycle + +**Files:** +- Create: `pkg/lifecycle/platform.go` +- Create: `pkg/lifecycle/messages.go` +- Create: `pkg/lifecycle/register.go` +- Create: `pkg/lifecycle/service.go` +- Create: `pkg/lifecycle/service_test.go` + +- [ ] **Step 1: Create platform.go with EventType enum** + +```go +// pkg/lifecycle/platform.go +package lifecycle + +// EventType identifies application and system lifecycle events. +type EventType int + +const ( + EventApplicationStarted EventType = iota + EventWillTerminate // macOS only + EventDidBecomeActive // macOS only + EventDidResignActive // macOS only + EventPowerStatusChanged // Windows only (APMPowerStatusChange) + EventSystemSuspend // Windows only (APMSuspend) + EventSystemResume // Windows only (APMResume) +) + +// Platform abstracts the application lifecycle backend (Wails v3). +// OnApplicationEvent registers a handler for a fire-and-forget event type. +// OnOpenedWithFile registers a handler for file-open events (carries path data). +// Both return a cancel function that deregisters the handler. +// Platform-specific events no-op silently on unsupported OS (adapter registers nothing). +type Platform interface { + OnApplicationEvent(eventType EventType, handler func()) func() + OnOpenedWithFile(handler func(path string)) func() +} +``` + +- [ ] **Step 2: Create messages.go with 8 Actions** + +```go +// pkg/lifecycle/messages.go +package lifecycle + +// All lifecycle events are broadcasts (Actions). There are no Queries or Tasks. + +// ActionApplicationStarted fires when the platform application starts. +// Distinct from core.ActionServiceStartup — this is platform-level readiness. +type ActionApplicationStarted struct{} + +// ActionOpenedWithFile fires when the application is opened with a file argument. +type ActionOpenedWithFile struct{ Path string } + +// ActionWillTerminate fires when the application is about to terminate (macOS only). +type ActionWillTerminate struct{} + +// ActionDidBecomeActive fires when the application becomes the active app (macOS only). +type ActionDidBecomeActive struct{} + +// ActionDidResignActive fires when the application resigns active status (macOS only). +type ActionDidResignActive struct{} + +// ActionPowerStatusChanged fires on power status changes (Windows only: APMPowerStatusChange). +type ActionPowerStatusChanged struct{} + +// ActionSystemSuspend fires when the system is about to suspend (Windows only: APMSuspend). +type ActionSystemSuspend struct{} + +// ActionSystemResume fires when the system resumes from suspend (Windows only: APMResume). +type ActionSystemResume struct{} +``` + +- [ ] **Step 3: Create register.go** + +```go +// pkg/lifecycle/register.go +package lifecycle + +import "forge.lthn.ai/core/go/pkg/core" + +// Register creates a factory closure that captures the Platform adapter. +// The returned function has the signature WithService requires: func(*Core) (any, error). +func Register(p Platform) func(*core.Core) (any, error) { + return func(c *core.Core) (any, error) { + return &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + }, nil + } +} +``` + +- [ ] **Step 4: Write failing test** + +```go +// pkg/lifecycle/service_test.go +package lifecycle + +import ( + "context" + "sync" + "testing" + + "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Mock Platform --- + +type mockPlatform struct { + mu sync.Mutex + handlers map[EventType][]func() + fileHandlers []func(string) +} + +func newMockPlatform() *mockPlatform { + return &mockPlatform{ + handlers: make(map[EventType][]func()), + } +} + +func (m *mockPlatform) OnApplicationEvent(eventType EventType, handler func()) func() { + m.mu.Lock() + defer m.mu.Unlock() + m.handlers[eventType] = append(m.handlers[eventType], handler) + idx := len(m.handlers[eventType]) - 1 + return func() { + m.mu.Lock() + defer m.mu.Unlock() + if idx < len(m.handlers[eventType]) { + m.handlers[eventType] = append(m.handlers[eventType][:idx], m.handlers[eventType][idx+1:]...) + } + } +} + +func (m *mockPlatform) OnOpenedWithFile(handler func(string)) func() { + m.mu.Lock() + defer m.mu.Unlock() + m.fileHandlers = append(m.fileHandlers, handler) + idx := len(m.fileHandlers) - 1 + return func() { + m.mu.Lock() + defer m.mu.Unlock() + if idx < len(m.fileHandlers) { + m.fileHandlers = append(m.fileHandlers[:idx], m.fileHandlers[idx+1:]...) + } + } +} + +// simulateEvent fires all registered handlers for the given event type. +func (m *mockPlatform) simulateEvent(eventType EventType) { + m.mu.Lock() + handlers := make([]func(), len(m.handlers[eventType])) + copy(handlers, m.handlers[eventType]) + m.mu.Unlock() + for _, h := range handlers { + h() + } +} + +// simulateFileOpen fires all registered file-open handlers. +func (m *mockPlatform) simulateFileOpen(path string) { + m.mu.Lock() + handlers := make([]func(string), len(m.fileHandlers)) + copy(handlers, m.fileHandlers) + m.mu.Unlock() + for _, h := range handlers { + h(path) + } +} + +// handlerCount returns the number of registered handlers for event-based + file-based. +func (m *mockPlatform) handlerCount() int { + m.mu.Lock() + defer m.mu.Unlock() + count := len(m.fileHandlers) + for _, handlers := range m.handlers { + count += len(handlers) + } + return count +} + +// --- Test helpers --- + +func newTestLifecycleService(t *testing.T) (*Service, *core.Core, *mockPlatform) { + t.Helper() + mock := newMockPlatform() + c, err := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + svc := core.MustServiceFor[*Service](c, "lifecycle") + return svc, c, mock +} + +// --- Tests --- + +func TestRegister_Good(t *testing.T) { + svc, _, _ := newTestLifecycleService(t) + assert.NotNil(t, svc) +} + +func TestApplicationStarted_Good(t *testing.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if _, ok := msg.(ActionApplicationStarted); ok { + received = true + } + return nil + }) + + mock.simulateEvent(EventApplicationStarted) + assert.True(t, received) +} + +func TestDidBecomeActive_Good(t *testing.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if _, ok := msg.(ActionDidBecomeActive); ok { + received = true + } + return nil + }) + + mock.simulateEvent(EventDidBecomeActive) + assert.True(t, received) +} + +func TestDidResignActive_Good(t *testing.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if _, ok := msg.(ActionDidResignActive); ok { + received = true + } + return nil + }) + + mock.simulateEvent(EventDidResignActive) + assert.True(t, received) +} + +func TestWillTerminate_Good(t *testing.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if _, ok := msg.(ActionWillTerminate); ok { + received = true + } + return nil + }) + + mock.simulateEvent(EventWillTerminate) + assert.True(t, received) +} + +func TestPowerStatusChanged_Good(t *testing.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if _, ok := msg.(ActionPowerStatusChanged); ok { + received = true + } + return nil + }) + + mock.simulateEvent(EventPowerStatusChanged) + assert.True(t, received) +} + +func TestSystemSuspend_Good(t *testing.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if _, ok := msg.(ActionSystemSuspend); ok { + received = true + } + return nil + }) + + mock.simulateEvent(EventSystemSuspend) + assert.True(t, received) +} + +func TestSystemResume_Good(t *testing.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if _, ok := msg.(ActionSystemResume); ok { + received = true + } + return nil + }) + + mock.simulateEvent(EventSystemResume) + assert.True(t, received) +} + +func TestOpenedWithFile_Good(t *testing.T) { + _, c, mock := newTestLifecycleService(t) + + var receivedPath string + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if a, ok := msg.(ActionOpenedWithFile); ok { + receivedPath = a.Path + } + return nil + }) + + mock.simulateFileOpen("/Users/snider/Documents/test.txt") + assert.Equal(t, "/Users/snider/Documents/test.txt", receivedPath) +} + +func TestOnShutdown_CancelsAll_Good(t *testing.T) { + svc, _, mock := newTestLifecycleService(t) + + // Verify handlers were registered during OnStartup + assert.Greater(t, mock.handlerCount(), 0, "handlers should be registered after OnStartup") + + // Shutdown should cancel all registrations + err := svc.OnShutdown(context.Background()) + require.NoError(t, err) + + assert.Equal(t, 0, mock.handlerCount(), "all handlers should be cancelled after OnShutdown") +} + +func TestRegister_Bad(t *testing.T) { + // No lifecycle service registered — actions are not received + c, err := core.New(core.WithServiceLock()) + require.NoError(t, err) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if _, ok := msg.(ActionApplicationStarted); ok { + received = true + } + return nil + }) + + // No way to trigger events without the service + assert.False(t, received) +} +``` + +- [ ] **Step 5: Run test to verify it fails** + +Run: `cd /Users/snider/Code/core/gui && go test ./pkg/lifecycle/ -v` +Expected: FAIL — `Service` type not defined, `Options` type not defined + +- [ ] **Step 6: Create service.go with OnStartup/OnShutdown** + +```go +// pkg/lifecycle/service.go +package lifecycle + +import ( + "context" + + "forge.lthn.ai/core/go/pkg/core" +) + +// Options holds configuration for the lifecycle service. +type Options struct{} + +// Service is a core.Service that registers platform lifecycle callbacks +// and broadcasts corresponding IPC Actions. It implements both Startable +// and Stoppable: OnStartup registers all callbacks, OnShutdown cancels them. +type Service struct { + *core.ServiceRuntime[Options] + platform Platform + cancels []func() +} + +// OnStartup registers a platform callback for each EventType and for file-open. +// Each callback broadcasts the corresponding Action via s.Core().ACTION(). +func (s *Service) OnStartup(ctx context.Context) error { + // Register fire-and-forget event callbacks + eventActions := map[EventType]func(){ + EventApplicationStarted: func() { _ = s.Core().ACTION(ActionApplicationStarted{}) }, + EventWillTerminate: func() { _ = s.Core().ACTION(ActionWillTerminate{}) }, + EventDidBecomeActive: func() { _ = s.Core().ACTION(ActionDidBecomeActive{}) }, + EventDidResignActive: func() { _ = s.Core().ACTION(ActionDidResignActive{}) }, + EventPowerStatusChanged: func() { _ = s.Core().ACTION(ActionPowerStatusChanged{}) }, + EventSystemSuspend: func() { _ = s.Core().ACTION(ActionSystemSuspend{}) }, + EventSystemResume: func() { _ = s.Core().ACTION(ActionSystemResume{}) }, + } + + for eventType, handler := range eventActions { + cancel := s.platform.OnApplicationEvent(eventType, handler) + s.cancels = append(s.cancels, cancel) + } + + // Register file-open callback (carries data) + cancel := s.platform.OnOpenedWithFile(func(path string) { + _ = s.Core().ACTION(ActionOpenedWithFile{Path: path}) + }) + s.cancels = append(s.cancels, cancel) + + return nil +} + +// OnShutdown cancels all registered platform callbacks. +func (s *Service) OnShutdown(ctx context.Context) error { + for _, cancel := range s.cancels { + cancel() + } + s.cancels = nil + return nil +} + +// HandleIPCEvents is auto-discovered and registered by core.WithService. +// Lifecycle events are all outbound (platform -> IPC) so there is nothing to handle here. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + return nil +} +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `cd /Users/snider/Code/core/gui && go test ./pkg/lifecycle/ -v` +Expected: PASS (12 tests) + +- [ ] **Step 8: Commit** + +```bash +cd /Users/snider/Code/core/gui +git add pkg/lifecycle/ +git commit -m "feat(lifecycle): add application lifecycle core.Service with Platform interface and IPC" +``` + +--- + +## Task 3: Display orchestrator updates + +**Files:** +- Modify: `pkg/display/events.go` +- Modify: `pkg/display/display.go` + +- [ ] **Step 1: Add 9 new EventType constants to events.go** + +Add after the existing constants in `pkg/display/events.go`: + +```go +// Dock events +EventDockVisibility EventType = "dock.visibility-changed" + +// Application lifecycle events +EventAppStarted EventType = "app.started" +EventAppOpenedWithFile EventType = "app.opened-with-file" +EventAppWillTerminate EventType = "app.will-terminate" +EventAppActive EventType = "app.active" +EventAppInactive EventType = "app.inactive" + +// System events +EventSystemPowerChange EventType = "system.power-change" +EventSystemSuspend EventType = "system.suspend" +EventSystemResume EventType = "system.resume" +``` + +The full const block in `pkg/display/events.go` should become: + +```go +const ( + EventWindowFocus EventType = "window.focus" + EventWindowBlur EventType = "window.blur" + EventWindowMove EventType = "window.move" + EventWindowResize EventType = "window.resize" + EventWindowClose EventType = "window.close" + EventWindowCreate EventType = "window.create" + EventThemeChange EventType = "theme.change" + EventScreenChange EventType = "screen.change" + EventNotificationClick EventType = "notification.click" + EventTrayClick EventType = "tray.click" + EventTrayMenuItemClick EventType = "tray.menuitem.click" + EventDockVisibility EventType = "dock.visibility-changed" + EventAppStarted EventType = "app.started" + EventAppOpenedWithFile EventType = "app.opened-with-file" + EventAppWillTerminate EventType = "app.will-terminate" + EventAppActive EventType = "app.active" + EventAppInactive EventType = "app.inactive" + EventSystemPowerChange EventType = "system.power-change" + EventSystemSuspend EventType = "system.suspend" + EventSystemResume EventType = "system.resume" +) +``` + +- [ ] **Step 2: Add dock and lifecycle imports to display.go** + +Add to the import block in `pkg/display/display.go`: + +```go +"forge.lthn.ai/core/gui/pkg/dock" +"forge.lthn.ai/core/gui/pkg/lifecycle" +``` + +- [ ] **Step 3: Add HandleIPCEvents cases for dock actions** + +Add after the existing `screen.ActionScreensChanged` case in `HandleIPCEvents`: + +```go +case dock.ActionVisibilityChanged: + if s.events != nil { + s.events.Emit(Event{Type: EventDockVisibility, + Data: map[string]any{"visible": m.Visible}}) + } +``` + +- [ ] **Step 4: Add HandleIPCEvents cases for lifecycle actions (8 cases)** + +Add after the dock case: + +```go +case lifecycle.ActionApplicationStarted: + if s.events != nil { + s.events.Emit(Event{Type: EventAppStarted}) + } +case lifecycle.ActionOpenedWithFile: + if s.events != nil { + s.events.Emit(Event{Type: EventAppOpenedWithFile, + Data: map[string]any{"path": m.Path}}) + } +case lifecycle.ActionWillTerminate: + if s.events != nil { + s.events.Emit(Event{Type: EventAppWillTerminate}) + } +case lifecycle.ActionDidBecomeActive: + if s.events != nil { + s.events.Emit(Event{Type: EventAppActive}) + } +case lifecycle.ActionDidResignActive: + if s.events != nil { + s.events.Emit(Event{Type: EventAppInactive}) + } +case lifecycle.ActionPowerStatusChanged: + if s.events != nil { + s.events.Emit(Event{Type: EventSystemPowerChange}) + } +case lifecycle.ActionSystemSuspend: + if s.events != nil { + s.events.Emit(Event{Type: EventSystemSuspend}) + } +case lifecycle.ActionSystemResume: + if s.events != nil { + s.events.Emit(Event{Type: EventSystemResume}) + } +``` + +- [ ] **Step 5: Add WS->IPC cases for dock commands** + +The display orchestrator needs a `handleWSMessage` method (or extend the existing WS message handling). Add 5 dock WS->IPC cases. These go in the WS message handler (the method that dispatches `msg.Type` strings to IPC calls). If no such method exists yet, add it as part of the `handleMessages` flow in the WSEventManager or as a new method on Service: + +```go +case "dock:show": + result, handled, err = s.Core().PERFORM(dock.TaskShowIcon{}) +case "dock:hide": + result, handled, err = s.Core().PERFORM(dock.TaskHideIcon{}) +case "dock:badge": + label, _ := msg.Data["label"].(string) + result, handled, err = s.Core().PERFORM(dock.TaskSetBadge{Label: label}) +case "dock:badge-remove": + result, handled, err = s.Core().PERFORM(dock.TaskRemoveBadge{}) +case "dock:visible": + result, handled, err = s.Core().QUERY(dock.QueryVisible{}) +``` + +Key detail: `dock:badge` uses the safe comma-ok type assertion `label, _ := msg.Data["label"].(string)` so a missing or non-string `label` defaults to `""` (which is a valid badge value per the spec — shows default system badge indicator). + +Lifecycle events are outbound-only (Actions -> WS). No inbound WS->IPC cases needed for lifecycle. + +- [ ] **Step 6: Run all tests to verify nothing is broken** + +Run: `cd /Users/snider/Code/core/gui && go test ./pkg/display/ ./pkg/dock/ ./pkg/lifecycle/ -v` +Expected: PASS (all existing display tests + new dock + lifecycle tests) + +- [ ] **Step 7: Commit** + +```bash +cd /Users/snider/Code/core/gui +git add pkg/display/events.go pkg/display/display.go +git commit -m "feat(display): add dock + lifecycle HandleIPCEvents and WS bridge integration" +``` + +--- + +## Task 4: Final verification and commit + +- [ ] **Step 1: Run the full test suite** + +Run: `cd /Users/snider/Code/core/gui && go test ./... -v` +Expected: PASS — all packages compile and all tests pass. + +- [ ] **Step 2: Run lint and vet** + +Run: `cd /Users/snider/Code/core/gui && go vet ./pkg/dock/ ./pkg/lifecycle/ ./pkg/display/` +Expected: No issues. + +- [ ] **Step 3: Verify no circular dependencies** + +Confirm dependency direction: +- `pkg/dock` imports `forge.lthn.ai/core/go/pkg/core` only +- `pkg/lifecycle` imports `forge.lthn.ai/core/go/pkg/core` only +- `pkg/display` imports `pkg/dock` and `pkg/lifecycle` (message types only) +- Neither `pkg/dock` nor `pkg/lifecycle` import `pkg/display` + +Run: `cd /Users/snider/Code/core/gui && go vet ./...` +Expected: No import cycle errors. + +- [ ] **Step 4: Final commit (if any formatting or vet fixes were needed)** + +```bash +cd /Users/snider/Code/core/gui +git add -A +git commit -m "chore(gui): platform & events final cleanup and verification" +``` + +--- + +## Summary + +| Task | Files | Tests | Description | +|------|-------|-------|-------------| +| 1 | 5 created | 12 | pkg/dock: Platform interface, IPC messages, Service with visibility broadcast | +| 2 | 5 created | 12 | pkg/lifecycle: Platform interface, EventType enum, 8 Action types, OnStartup/OnShutdown | +| 3 | 2 modified | 0 new (existing pass) | Display orchestrator: 9 EventType constants, 10 HandleIPCEvents cases, 5 WS->IPC cases | +| 4 | 0 | full suite | Verification: tests, vet, lint, dependency direction | + +**Total:** 10 new files, 2 modified files, ~24 tests, 4 commits. + +## Platform Mapping Reference + +| Our Type | Wails Event | Platforms | +|----------|-------------|-----------| +| `EventApplicationStarted` | `events.Common.ApplicationStarted` | all | +| `EventWillTerminate` | `events.Mac.ApplicationWillTerminate` | macOS only | +| `EventDidBecomeActive` | `events.Mac.ApplicationDidBecomeActive` | macOS only | +| `EventDidResignActive` | `events.Mac.ApplicationDidResignActive` | macOS only | +| `EventPowerStatusChanged` | `events.Windows.APMPowerStatusChange` | Windows only | +| `EventSystemSuspend` | `events.Windows.APMSuspend` | Windows only | +| `EventSystemResume` | `events.Windows.APMResume` | Windows only | + +## Licence + +EUPL-1.2