Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/swiftui.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: SwiftUI package

on:
pull_request:
paths:
- 'packages/swiftui-feedback/**'
- 'plugins/swiftui-feedback/**'
- '.github/workflows/swiftui.yml'
push:
branches: [main]
paths:
- 'packages/swiftui-feedback/**'
- 'plugins/swiftui-feedback/**'
- '.github/workflows/swiftui.yml'

permissions:
contents: read

jobs:
swiftui:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Test feedback persistence and export
run: swift test --package-path packages/swiftui-feedback
- name: Verify Release excludes metadata evaluation
run: swift test -c release --package-path packages/swiftui-feedback
- name: Check helper syntax
run: python3 -m py_compile plugins/swiftui-feedback/skills/swiftui-feedback/scripts/check-targets.py
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ The companion does not control the browser, execute shell commands, or edit sour

Electron developers can explicitly install `@flyingchangescode/dev-feedback-electron` in a development Host App. This is a separate package, not part of the Chrome Web Store extension. See [packages/electron-inspector/README.md](packages/electron-inspector/README.md).

## SwiftUI developer package (prototype)

Mac developers can integrate the separate **DevFeedback** Swift package in development builds to pick tagged views, save local notes, and export selected feedback with source references. Porch Speech is the first host integration. See [package setup and testing](packages/swiftui-feedback/README.md) and the [Codex tagging plugin](plugins/swiftui-feedback/README.md).

The package is a local/vendored prototype requiring macOS 14+; it is not in the browser ZIP or published as a standalone Swift package. Release builds omit capture. Its native JSON is readable by agents through file tools; the browser MCP importer does not yet accept that schema.

## Development and release

Run `npm ci`, `npm test`, `npm run check`, `npm run audit:dependencies`, `npm run package`, and `npm run verify:package`. The browser ZIP excludes tests, MCP code, and Node dependencies.
Expand Down
2 changes: 2 additions & 0 deletions packages/swiftui-feedback/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.build/
.swiftpm/
21 changes: 21 additions & 0 deletions packages/swiftui-feedback/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Monroe Stone

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
12 changes: 12 additions & 0 deletions packages/swiftui-feedback/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// swift-tools-version: 5.9
import PackageDescription

let package = Package(
name: "DevFeedback",
platforms: [.macOS(.v14)],
products: [.library(name: "DevFeedback", targets: ["DevFeedback"])],
targets: [
.target(name: "DevFeedback"),
.testTarget(name: "DevFeedbackTests", dependencies: ["DevFeedback"])
]
)
74 changes: 74 additions & 0 deletions packages/swiftui-feedback/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# DevFeedback for SwiftUI

Pick a tagged view in a native Mac development build, describe the requested change, and export selected notes with stable target IDs and source locations. Porch Speech is the first integration. This prototype is separate from the browser extension and is not a published Swift package release.

## Add to an app

Requires macOS 14+, Swift tools 5.9, and a Debug build. Add this directory as a local Swift package dependency and link the **DevFeedback** product to the app target. For a reproducible host checkout, vendor this directory without `.build`/`.swiftpm`, preserve the license, and record the exact upstream repository commit. SwiftPM cannot fetch a nested package by repository URL; a dedicated package repository can follow after the integration proves useful.

```swift
import SwiftUI
#if DEBUG
import DevFeedback
#endif

// Inside the App's scene builder:
Window("My App", id: "main") {
#if DEBUG
StatusView().feedbackOverlay(appID: "example.app", screen: "status")
#else
StatusView()
#endif
}
.commands {
#if DEBUG
FeedbackCommands()
#endif
}

// At the meaningful control or section boundary:
Button("Save", action: save)
.feedbackTarget("profile.save", label: "Save profile")
```

The tag example assumes the package is imported. Hosts that guard the import in Release can provide a Release-only no-op tagging shim with lazy (`@autoclosure`) arguments, or conditionally compile tag calls. Verify the resulting distributable, including dynamic tag-key creation, rather than relying on the shim alone.

The idle overlay inserts no button, badge, or reserved spacing. Activate **Developer → Pick UI for Feedback** or **⌘⌥⇧F** while the app window is active. **Developer → Feedback History…** opens saved notes. Highlight outlines and Cancel appear only during an active pick. Targets report their actual layout bounds using anchor preferences; nested picking chooses the smallest registered bounds under the pointer. Add `.feedbackViewport()` to each `ScrollView` itself (outside its content) or other clipped container. Outlines and click hit-testing then use only the intersection with every enclosing viewport; offscreen targets are omitted from the visible count. Capture context retains the original bounds. Container tags preserve descendant tags. Register a row/card and its independently discussable mode label, timestamp, text body, and actions; a lone container tag cannot provide granular feedback. Repeated components need distinct non-sensitive instance IDs. Labels should be static developer text, never values from a transcript, document, or form. The default `#fileID` and `#line` identify the tagging call, not a guaranteed permanent source location.

The screen parameter may change with navigation: new captures use the current screen while saved records and open drafts preserve their original screen. History is shared within the app ID. Install an overlay separately on any sheet needing capture. Release builds compile both public modifiers as inlinable no-ops with lazy metadata arguments, omit the Developer menu items, and exclude the panel, store, and record implementation. The Release test confirms metadata-producing expressions are not evaluated. Host and dependency must both use Debug; a host-only flag does not enable the Release package.

## Test the workflow

1. With the app window active, press **⌘⌥⇧F** or choose **Developer → Pick UI for Feedback**. Orange outlines show registered targets. Click a control; its normal action should not execute.
2. Write a requested change and optional acceptance checks. **Save & pick next** returns to the picker; **Save** returns to History. Cancel picking with Escape or Cancel.
3. Close and reopen the panel with an unsaved draft, then navigate to a different app section. The draft should stay attached to its original target. Save or explicitly discard before another pick.
4. Edit a saved note. Its original target, capture time, bounds, appearance, and app/build stay unchanged.
5. Select records and **Review export**. Copy Markdown or save JSON. Confirm unselected notes are excluded. Notes themselves can contain private information.
6. **Show in Finder** reveals the exact local history JSON. Moving it to Trash clears stored history; reopening the feedback panel reloads disk state. An in-progress draft remains in memory until discarded or saved. Files already exported and clipboard copies are independent.

## Storage and export

History lives under the host's Application Support directory in `DevFeedback/<hex-encoded-app-ID>/history.json`. Saves are atomic, limited to 500 records with 16,000 characters per request/acceptance field. Unsupported or corrupt history is preserved and reported rather than reset. Opening the panel refreshes disk state; same-process main-thread window saves merge the current file. Simultaneous writes from separate app processes are not supported.

The package stores only static registered metadata, window-local bounds, screen, appearance, app/build version, notes, and acceptance checks. It does not inspect rendered text, record audio, or capture screenshots. It adds no network, Accessibility, microphone, or Screen Recording access. Sandboxed hosts need user-selected read/write file access for the save dialog; unsandboxed development apps need no additional entitlements.

JSON has `schemaVersion: 1`, `source: "swiftui-dev-feedback"`, and `records`. Each record has `id`, `createdAt`, `appID`, `screen`, `target` (`id`, `label`, `file`, `line`), `bounds`, `appVersion`, `build`, `appearance`, `note`, and `acceptance`. Dates use Foundation Codable's seconds since 2001-01-01 UTC. The selected snapshot is captured before the save dialog opens.

Agents can read this JSON or the Markdown with ordinary file tools. Browser MCP import compatibility, screenshots, native menu/title-bar picking, untagged-view discovery, and iOS are outside this first slice. Bounds and source hints describe capture time; agents must resolve them against current source. Rendered UI coverage still requires manual verification.

## Agent companion

The repository includes a [Codex plugin](../../plugins/swiftui-feedback/README.md) with integration/tag-maintenance instructions and a duplicate literal-ID checker. It is packaged source for local testing, not a published marketplace listing. Installing the agent plugin and linking the app library are separate steps.

## Checks

```sh
swift test --package-path packages/swiftui-feedback
swift test -c release --package-path packages/swiftui-feedback
```

Tests include an actual SwiftUI hosting/rendering regression for nested parent/child registrations, plus persistence, selected-only export, edits preserving context, failed writes, corrupt/future history, field limits, same-process windows, and Finder deletion. The host integration must also exercise the real picker, panel, and export dialog.

## Distribution gate

Development installation and a distributable are distinct products. Use Xcode **Release** for Archive/export; never distribute the Debug app used for feedback. Check effective host and package compilation conditions: `DEBUG` must be absent. Verify the built app has no Developer feedback commands, picker, History panel/storage code, bundled DevFeedback framework/resources, source hints, or feedback-only tag markers. Inspect the actual linked executable and bundle, and fail packaging if markers remain. Importing a dependency in the project is not by itself proof that its runtime ships, nor is hiding a control proof of exclusion. Signing and notarization are separate host release requirements.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import SwiftUI

#if DEBUG
struct FeedbackSessionFocusKey: FocusedValueKey {
typealias Value = FeedbackSession
}

extension FocusedValues {
var devFeedbackSession: FeedbackSession? {
get { self[FeedbackSessionFocusKey.self] }
set { self[FeedbackSessionFocusKey.self] = newValue }
}
}
#endif

/// Add to the host scene's .commands builder in DEBUG builds.
/// Commands target the active window's overlay; no controls are inserted in the app layout.
@MainActor
public struct FeedbackCommands: Commands {
#if DEBUG
@FocusedValue(\.devFeedbackSession) private var session
#endif

public init() {}

public var body: some Commands {
#if DEBUG
CommandMenu("Developer") {
Button("Pick UI for Feedback") {
guard let session else { return }
if session.hasUnsavedChanges { session.showPanel() }
else { session.startPicking() }
}
.keyboardShortcut("f", modifiers: [.command, .option, .shift])
.disabled(session == nil)
Button("Feedback History…") { session?.showPanel() }
.disabled(session == nil)
}
#else
CommandGroup(after: .help) {}
#endif
}
}
140 changes: 140 additions & 0 deletions packages/swiftui-feedback/Sources/DevFeedback/FeedbackOverlay.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import SwiftUI
#if DEBUG
import AppKit

struct TargetAnchor: Identifiable {
let id = UUID()
let target: FeedbackTarget
let anchor: Anchor<CGRect>
var viewports: [Anchor<CGRect>] = []
}
struct TargetPreference: PreferenceKey {
static var defaultValue: [TargetAnchor] = []
static func reduce(value: inout [TargetAnchor], nextValue: () -> [TargetAnchor]) {
value.append(contentsOf: nextValue())
}
}

struct VisibleFeedbackTarget: Identifiable {
let id: UUID
let target: FeedbackTarget
let sourceBounds: CGRect
let visibleBounds: CGRect

init?(id: UUID, target: FeedbackTarget, bounds: CGRect, viewports: [CGRect]) {
let visible = viewports.reduce(bounds) { $0.intersection($1) }
guard !visible.isNull, !visible.isInfinite, visible.width > 0, visible.height > 0 else { return nil }
self.id = id
self.target = target
self.sourceBounds = bounds
self.visibleBounds = visible
}

static func pick(at point: CGPoint, from targets: [Self]) -> Self? {
targets.filter { $0.visibleBounds.contains(point) }
.min { $0.sourceBounds.width * $0.sourceBounds.height < $1.sourceBounds.width * $1.sourceBounds.height }
}
}

@MainActor
private struct FeedbackOverlay: ViewModifier {
@StateObject private var session: FeedbackSession
private let screen: String
@Environment(\.colorScheme) private var appearance

init(appID: String, screen: String) {
self.screen = screen
_session = StateObject(wrappedValue: FeedbackSession(appID: appID, screen: screen))
}

func body(content: Content) -> some View {
content.overlayPreferenceValue(TargetPreference.self) { targets in
GeometryReader { geometry in
let resolved = targets.compactMap { target in
VisibleFeedbackTarget(id: target.id, target: target.target, bounds: geometry[target.anchor],
viewports: target.viewports.map { geometry[$0] } + [CGRect(origin: .zero, size: geometry.size)])
}
let duplicates = Dictionary(grouping: resolved, by: { $0.target.id }).filter { $0.value.count > 1 }.count
ZStack(alignment: .topTrailing) {
if session.picking {
// One hit surface prevents underlying app actions and resolves nested targets by area.
Color.black.opacity(0.08).contentShape(Rectangle())
.gesture(SpatialTapGesture().onEnded { tap in
if let hit = VisibleFeedbackTarget.pick(at: tap.location, from: resolved) {
session.capture(hit.target, bounds: hit.sourceBounds, appearance: appearance == .dark ? "dark" : "light")
}
})
ForEach(resolved) { target in
let rect = target.visibleBounds
Rectangle().strokeBorder(.orange, lineWidth: 2)
.frame(width: rect.width, height: rect.height)
.position(x: rect.midX, y: rect.midY)
.allowsHitTesting(false)
}
}
HStack(spacing: 8) {
if session.picking {
Text("Pick a highlighted view · \(resolved.count) visible targets")
Button("Cancel") { session.picking = false; session.showPanel() }
.keyboardShortcut(.cancelAction)
}
if session.picking && duplicates > 0 {
Text("\(duplicates) duplicate IDs").foregroundStyle(.red)
.help("Give repeated instances distinct, non-sensitive feedback IDs.")
}
}
.font(.caption).padding(session.picking ? 8 : 0)
.background {
if session.picking { RoundedRectangle(cornerRadius: 8).fill(.regularMaterial) }
}
.padding(session.picking ? 6 : 0)
}
}
}
.focusedSceneValue(\.devFeedbackSession, session)
.onChange(of: screen) { _, value in session.updateScreen(value) }
}
}
#endif

public extension View {
/// Register a meaningful control or section. Use stable IDs and static labels, never user content.
/// Repeated components should append a non-sensitive instance key. The source is this call site.
#if DEBUG
func feedbackTarget(_ id: String, label: String? = nil, file: String = #fileID, line: UInt = #line) -> some View {
transformAnchorPreference(key: TargetPreference.self, value: .bounds) { targets, anchor in
targets.append(TargetAnchor(target: FeedbackTarget(id: id, label: label ?? id, file: file, line: line), anchor: anchor))
}
}
#else
@inlinable
func feedbackTarget(_ id: @autoclosure () -> String, label: @autoclosure () -> String? = nil,
file: String = #fileID, line: UInt = #line) -> Self { self }
#endif

/// Attach to each ScrollView or clipped container, outside its scrolling content.
/// Both outlines and hit-testing respect all enclosing feedback viewports.
#if DEBUG
func feedbackViewport() -> some View {
transformAnchorPreference(key: TargetPreference.self, value: .bounds) { targets, viewport in
for index in targets.indices { targets[index].viewports.append(viewport) }
}
}
#else
@inlinable
func feedbackViewport() -> Self { self }
#endif

/// Install once on each window's content, and separately on any sheet needing capture.
/// Add FeedbackCommands to the scene to activate capture from its Developer menu.
/// Idle views have no injected controls; Release builds return the original view.
#if DEBUG
@MainActor
func feedbackOverlay(appID: String, screen: String) -> some View {
modifier(FeedbackOverlay(appID: appID, screen: screen))
}
#else
@MainActor @inlinable
func feedbackOverlay(appID: @autoclosure () -> String, screen: @autoclosure () -> String) -> Self { self }
#endif
}
Loading
Loading