Skip to content

Commit 90c8f0e

Browse files
authored
refactor(hig): tech-debt cleanup — ER diagram Dynamic Type, canvas toolbar, Connection Form titlebar (#994)
* refactor(er-diagram): scale node layout and Canvas font sizes with system text-size preference * refactor(er-diagram): canvas toolbar uses Capsule + thinMaterial (macOS Maps/Preview Markup pattern) * refactor(connection-form): move footer actions to native window toolbar (.confirmationAction/.cancellationAction/.destructiveAction) * docs(changelog): note HIG tech-debt cleanup * fix(connection-form): move Test inline, group Save buttons in confirmationAction so primary actions never collapse * feat(connection-form): allow window resize between 480 and 800pt wide * fix(connection-form): cap window max width at 720 to avoid grouped-form empty side gutters
1 parent 8fe8091 commit 90c8f0e

7 files changed

Lines changed: 119 additions & 110 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Changed
1111

12+
- HIG tech-debt cleanup: three deferred items from earlier phases. (1) ER diagram nodes now scale with the user's system text-size preference: `ERDiagramLayout.typeScale` derives a multiplier from `NSFont.preferredFont(forTextStyle: .body)`, applied to header height, column row height, node width, layout offsets, and Canvas font sizes. At Larger Accessibility Sizes the nodes grow proportionally instead of overflowing fixed pixel-pinned rows. (2) The ER diagram's floating canvas toolbar drops the `.regularMaterial` rounded-rectangle + manual drop shadow and adopts the macOS Maps / Preview Markup pattern: `Capsule()` shape with `.thinMaterial` plus a half-pixel `.quaternary` stroke. The system handles depth and active-window dimming. (3) Connection Form actions (Test, Delete, Cancel, Save, Save & Connect) move out of a custom HStack footer into the native window toolbar via `ToolbarItem` placements (`.navigation` for Test, `.destructiveAction` for Delete, `.cancellationAction` for Cancel, `.secondaryAction` for "Save Only", `.confirmationAction` for the primary action). The body wraps in `NavigationStack` so `.toolbar { }` attaches to the window titlebar, matching Mail compose, AIProviderDetailSheet, and macOS HIG sheet conventions.
1213
- HIG polish (phase 5): nine hero icons (empty / success / error / Pro feature gate states across Onboarding, Feedback, Export Success, Import Success, Import Error, Query Success, Main editor empty state) move from hardcoded `.system(size: 40-64)` to a Dynamic-Type-aware combination of `.font(.largeTitle).imageScale(.large).symbolRenderingMode(.hierarchical)`, so they scale with the user's accessibility text-size setting and gain the canonical SF Symbols depth treatment. Five `.plain`-button-as-link callsites (Fetch All in the status bar, Show All / Hide All in the column visibility popover, Skip in onboarding, Close in the feedback success state) move to `.buttonStyle(.link)` or `.borderless`. Spacing sweep across 8 files normalises 5pt-grid magic numbers (`spacing: 5`, `spacing: 14`, `padding(.horizontal, 5)`) onto Apple's 8pt grid (`spacing: 4`, `spacing: 12`, `padding(.horizontal, 6)`). The Connection Form's Cancel button gains `.keyboardShortcut(.cancelAction)` so Esc dismisses correctly.
1314
- HIG list & window cleanup (phase 4): three small refactors that align inline list affordances with the rest of the codebase. `SlowQueryListView` (the collapsible "Slow Queries" panel in the Server Dashboard) drops a hand-rolled `Button { chevron }` + `ScrollView` + `LazyVStack` and uses a real `DisclosureGroup` + `List`, so VoiceOver gets the disclosure semantics and keyboard handling for free, and the orange-on-red error pill in its header switches to the warning-triangle convention introduced in phase 3. `ColumnVisibilityPopover` (the column show/hide popover) replaces its `ScrollView` + `LazyVStack` with a native `List`, gaining row hover, separators, and the standard list keyboard-navigation it was missing; the "Show All" / "Hide All" buttons in its header switch from `.plain` + manual accent foreground to `.buttonStyle(.link)`. `JSONViewerWindowController` deletes its hand-coded UserDefaults size persistence and uses the `NSWindow.applyAutosaveName` helper that every other imperative window in the project already uses; window position is now remembered too, not just size.
1415
- HIG pattern refactors (phase 3): five callsites migrate off bespoke implementations onto native primitives. `ResultTabBar` (the `.plain`-button strip in the results pane) gains hover states, accent-tint selection, and a `.bar` material background that matches macOS Sequoia inline tab patterns. `ConnectionSwitcherPopover` drops its manual `selectedIndex: Int` plus per-row `isHighlighted` color flipping and uses native `List(selection:)` for keyboard navigation, focus chrome, and scroll-into-view. The Welcome window's `+` and "new group" buttons replace a custom `@State isHovering` background with `.buttonStyle(.bordered).controlSize(.large)`. Eight inline form validation banners (URL parsing, plugin install, pgpass permissions, jump-host test, license activation, etc.) move from raw `.foregroundStyle(.systemRed)` text to a semantic `Label("...", systemImage: "exclamationmark.triangle.fill").foregroundStyle(.systemOrange)` pattern matching Apple's form-warning convention. Four sheets (Create Database, Create Tag, Export Options, Pagination Settings popover) are rebuilt on `Form { Section { ... } }.formStyle(.grouped)` with `LabeledContent` rows instead of hand-laid `VStack` + caption-styled labels + `roundedBorder` TextFields.

TablePro/Models/ERDiagram/ERDiagramLayout.swift

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
1+
import AppKit
12
import Foundation
23
import os
34

45
/// Sugiyama-style layered layout for ER diagrams.
56
/// Produces node center positions from a graph of tables and FK edges.
67
enum ERDiagramLayout {
78
private static let logger = Logger(subsystem: "com.TablePro", category: "ERDiagramLayout")
8-
static let nodeWidth: CGFloat = 220
9+
10+
/// Multiplier derived from the user's system text-size preference.
11+
/// 1.0 at the default (~13pt body), grows with Larger Accessibility Sizes.
12+
static var typeScale: CGFloat {
13+
max(1.0, NSFont.preferredFont(forTextStyle: .body).pointSize / 13.0)
14+
}
15+
16+
static var nodeWidth: CGFloat { 220 * typeScale }
917
static let horizontalGap: CGFloat = 60
1018
static let verticalGap: CGFloat = 40
11-
static let headerHeight: CGFloat = 36
12-
static let columnRowHeight: CGFloat = 22
19+
static var headerHeight: CGFloat { 36 * typeScale }
20+
static var columnRowHeight: CGFloat { 22 * typeScale }
1321

1422
static func compute(
1523
graph: ERDiagramGraph

TablePro/TableProApp.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -661,11 +661,11 @@ struct TableProApp: App {
661661
restorable: false,
662662
fullScreenable: false,
663663
hideMiniaturizeButton: true,
664-
hideZoomButton: true
664+
hideZoomButton: false
665665
))
666666
}
667667
.windowResizability(.contentMinSize)
668-
.defaultSize(width: 640, height: 500)
668+
.defaultSize(width: 560, height: 560)
669669
.commandsRemoved()
670670

671671
Window("Integrations Activity", id: SceneId.integrationsActivity) {

TablePro/Views/Connection/ConnectionFormView+Footer.swift

Lines changed: 53 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -8,88 +8,72 @@
88
import SwiftUI
99
import TableProPluginKit
1010

11-
// MARK: - Footer
11+
// MARK: - Toolbar
1212

1313
extension ConnectionFormView {
14-
var footer: some View {
15-
VStack(alignment: .leading, spacing: 8) {
16-
HStack {
17-
// Test connection
18-
Button(action: testConnection) {
19-
HStack(spacing: 6) {
20-
if isTesting {
21-
ProgressView()
22-
.controlSize(.small)
23-
} else if testSucceeded {
24-
Image(systemName: "checkmark.circle.fill")
25-
.foregroundStyle(Color(nsColor: .systemGreen))
26-
} else {
27-
Image(systemName: "antenna.radiowaves.left.and.right")
28-
.foregroundStyle(.secondary)
14+
@ToolbarContentBuilder
15+
var connectionFormToolbar: some ToolbarContent {
16+
if !isNew {
17+
ToolbarItem(placement: .destructiveAction) {
18+
Button(String(localized: "Delete"), role: .destructive) {
19+
Task {
20+
let confirmed = await AlertHelper.confirmDestructive(
21+
title: String(localized: "Delete Connection"),
22+
message: String(localized: "Are you sure you want to delete this connection? This cannot be undone."),
23+
confirmButton: String(localized: "Delete"),
24+
window: NSApp.keyWindow
25+
)
26+
if confirmed {
27+
deleteConnection()
2928
}
30-
Text(testSucceeded ? String(localized: "Connected") : String(localized: "Test Connection"))
3129
}
3230
}
33-
.disabled(isTesting || isInstallingPlugin || !isValid)
34-
35-
if !isNew {
36-
Button("Delete", role: .destructive) {
37-
Task {
38-
let confirmed = await AlertHelper.confirmDestructive(
39-
title: String(localized: "Delete Connection"),
40-
message: String(localized: "Are you sure you want to delete this connection? This cannot be undone."),
41-
confirmButton: String(localized: "Delete"),
42-
window: NSApp.keyWindow
43-
)
44-
if confirmed {
45-
deleteConnection()
46-
}
47-
}
48-
}
49-
}
50-
51-
Spacer()
31+
}
32+
}
5233

53-
// Cancel
54-
Button("Cancel") {
55-
dismiss()
56-
}
34+
ToolbarItemGroup(placement: .confirmationAction) {
35+
Button(String(localized: "Cancel")) { dismiss() }
5736
.keyboardShortcut(.cancelAction)
5837

59-
if isNew {
60-
Button(String(localized: "Save")) {
61-
saveConnection(connect: false)
62-
}
38+
if isNew {
39+
Button(String(localized: "Save")) { saveConnection(connect: false) }
6340
.disabled(isInstallingPlugin || !isValid)
64-
}
41+
}
6542

66-
Button(isNew ? String(localized: "Save & Connect") : String(localized: "Save")) {
67-
saveConnection(connect: isNew)
68-
}
69-
.keyboardShortcut(.return)
70-
.buttonStyle(.borderedProminent)
71-
.disabled(isInstallingPlugin || !isValid)
43+
Button(isNew ? String(localized: "Save & Connect") : String(localized: "Save")) {
44+
saveConnection(connect: isNew)
7245
}
73-
.padding(.horizontal, 16)
74-
.padding(.vertical, 12)
46+
.keyboardShortcut(.defaultAction)
47+
.buttonStyle(.borderedProminent)
48+
.disabled(isInstallingPlugin || !isValid)
7549
}
76-
.background(Color(nsColor: .windowBackgroundColor))
77-
.onExitCommand {
78-
dismiss()
50+
}
51+
52+
// MARK: - Test Connection Strip
53+
54+
var testConnectionStrip: some View {
55+
HStack(spacing: 8) {
56+
Button(action: testConnection) {
57+
HStack(spacing: 6) {
58+
if isTesting {
59+
ProgressView().controlSize(.small)
60+
} else if testSucceeded {
61+
Image(systemName: "checkmark.circle.fill")
62+
.foregroundStyle(Color(nsColor: .systemGreen))
63+
} else {
64+
Image(systemName: "antenna.radiowaves.left.and.right")
65+
.foregroundStyle(.secondary)
66+
}
67+
Text(testSucceeded ? String(localized: "Connected") : String(localized: "Test Connection"))
68+
}
69+
}
70+
.controlSize(.small)
71+
.disabled(isTesting || isInstallingPlugin || !isValid)
72+
73+
Spacer()
7974
}
80-
.onChange(of: host) { _, _ in testSucceeded = false }
81-
.onChange(of: port) { _, _ in testSucceeded = false }
82-
.onChange(of: username) { _, _ in testSucceeded = false }
83-
.onChange(of: password) { _, _ in testSucceeded = false }
84-
.onChange(of: database) { _, _ in testSucceeded = false }
85-
.onChange(of: type) { _, _ in testSucceeded = false }
86-
.onChange(of: sshState.enabled) { _, _ in testSucceeded = false }
87-
.onChange(of: sshState.host) { _, _ in testSucceeded = false }
88-
.onChange(of: sshState.port) { _, _ in testSucceeded = false }
89-
.onChange(of: sshState.username) { _, _ in testSucceeded = false }
90-
.onChange(of: sshState.authMethod) { _, _ in testSucceeded = false }
91-
.onChange(of: sslMode) { _, _ in testSucceeded = false }
92-
.onChange(of: additionalFieldValues) { _, _ in testSucceeded = false }
75+
.padding(.horizontal, 16)
76+
.padding(.vertical, 8)
9377
}
9478

9579
// MARK: - Import from URL Sheet

TablePro/Views/Connection/ConnectionFormView.swift

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -152,39 +152,43 @@ struct ConnectionFormView: View {
152152
// MARK: - Body
153153

154154
var body: some View {
155-
VStack(spacing: 0) {
156-
// Tab picker
157-
Picker("", selection: $selectedTab) {
158-
ForEach(visibleTabs, id: \.rawValue) { tab in
159-
Text(tab.rawValue).tag(tab)
155+
NavigationStack {
156+
VStack(spacing: 0) {
157+
// Tab picker
158+
Picker("", selection: $selectedTab) {
159+
ForEach(visibleTabs, id: \.rawValue) { tab in
160+
Text(tab.rawValue).tag(tab)
161+
}
160162
}
161-
}
162-
.pickerStyle(.segmented)
163-
.labelsHidden()
164-
.padding(.horizontal, 20)
165-
.padding(.vertical, 8)
163+
.pickerStyle(.segmented)
164+
.labelsHidden()
165+
.padding(.horizontal, 20)
166+
.padding(.vertical, 8)
166167

167-
clipboardConnectionBannerView
168-
.animation(.easeInOut(duration: 0.18), value: clipboardCandidate)
168+
clipboardConnectionBannerView
169+
.animation(.easeInOut(duration: 0.18), value: clipboardCandidate)
169170

170-
// Tab form content
171-
tabForm
171+
// Tab form content
172+
tabForm
172173

173-
Divider()
174+
Divider()
174175

175-
footer
176+
testConnectionStrip
177+
}
178+
.navigationTitle(
179+
isNew ? String(localized: "New Connection") : String(localized: "Edit Connection")
180+
)
181+
.toolbar { connectionFormToolbar }
176182
}
177-
.frame(width: 480)
178-
.frame(minHeight: 520, idealHeight: 520)
179-
.navigationTitle(
180-
isNew ? String(localized: "New Connection") : String(localized: "Edit Connection")
181-
)
183+
.frame(minWidth: 480, idealWidth: 560, maxWidth: 720)
184+
.frame(minHeight: 520, idealHeight: 560)
182185
.onAppear {
183186
loadConnectionData()
184187
loadSSHConfig()
185188
detectClipboardConnectionStringIfNeeded()
186189
}
187190
.onChange(of: type) { _, newType in
191+
testSucceeded = false
188192
if hasLoadedData {
189193
port = String(newType.defaultPort)
190194
additionalFieldValues = [:]
@@ -200,6 +204,18 @@ struct ConnectionFormView: View {
200204
isInstallingPlugin = false
201205
pluginInstallError = nil
202206
}
207+
.onChange(of: host) { _, _ in testSucceeded = false }
208+
.onChange(of: port) { _, _ in testSucceeded = false }
209+
.onChange(of: username) { _, _ in testSucceeded = false }
210+
.onChange(of: password) { _, _ in testSucceeded = false }
211+
.onChange(of: database) { _, _ in testSucceeded = false }
212+
.onChange(of: sshState.enabled) { _, _ in testSucceeded = false }
213+
.onChange(of: sshState.host) { _, _ in testSucceeded = false }
214+
.onChange(of: sshState.port) { _, _ in testSucceeded = false }
215+
.onChange(of: sshState.username) { _, _ in testSucceeded = false }
216+
.onChange(of: sshState.authMethod) { _, _ in testSucceeded = false }
217+
.onChange(of: sslMode) { _, _ in testSucceeded = false }
218+
.onChange(of: additionalFieldValues) { _, _ in testSucceeded = false }
203219
.pluginInstallPrompt(connection: $pluginInstallConnection) { connection in
204220
connectAfterInstall(connection)
205221
}

TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import SwiftUI
22

33
/// Renders table nodes imperatively on a Canvas GraphicsContext.
44
enum ERDiagramNodeRenderer {
5-
private static let headerTextXOffset: CGFloat = 28
6-
private static let iconXOffset: CGFloat = 10
7-
private static let badgeXOffset: CGFloat = 14
8-
private static let columnNameXOffset: CGFloat = 24
9-
private static let typeRightMargin: CGFloat = 8
5+
private static var headerTextXOffset: CGFloat { 28 * ERDiagramLayout.typeScale }
6+
private static var iconXOffset: CGFloat { 10 * ERDiagramLayout.typeScale }
7+
private static var badgeXOffset: CGFloat { 14 * ERDiagramLayout.typeScale }
8+
private static var columnNameXOffset: CGFloat { 24 * ERDiagramLayout.typeScale }
9+
private static var typeRightMargin: CGFloat { 8 * ERDiagramLayout.typeScale }
1010
private static let maxTableNameChars = 24
1111
private static let maxTypeChars = 18
1212

@@ -16,6 +16,7 @@ enum ERDiagramNodeRenderer {
1616
rect: CGRect,
1717
isSelected: Bool
1818
) {
19+
let scale = ERDiagramLayout.typeScale
1920
let cornerRadius: CGFloat = 6
2021
let roundedRect = RoundedRectangle(cornerRadius: cornerRadius)
2122
let path = Path(roundedRect: rect, cornerRadius: cornerRadius)
@@ -43,7 +44,7 @@ enum ERDiagramNodeRenderer {
4344
? String(node.tableName.prefix(maxTableNameChars)) + "\u{2026}"
4445
: node.tableName
4546
let headerText = Text(displayName)
46-
.font(.system(size: 12, weight: .semibold, design: .monospaced))
47+
.font(.system(size: 12 * scale, weight: .semibold, design: .monospaced))
4748
context.draw(
4849
context.resolve(headerText),
4950
at: CGPoint(x: rect.minX + headerTextXOffset, y: rect.minY + headerHeight / 2),
@@ -52,7 +53,7 @@ enum ERDiagramNodeRenderer {
5253

5354
// Table icon
5455
let iconText = Text(Image(systemName: "tablecells"))
55-
.font(.system(size: 10))
56+
.font(.system(size: 10 * scale))
5657
.foregroundStyle(.secondary)
5758
context.draw(
5859
context.resolve(iconText),
@@ -76,15 +77,15 @@ enum ERDiagramNodeRenderer {
7677

7778
// PK/FK badge
7879
if col.isPrimaryKey {
79-
let badge = Text(Image(systemName: "key.fill")).font(.system(size: 8)).foregroundStyle(Color(nsColor: .systemYellow))
80+
let badge = Text(Image(systemName: "key.fill")).font(.system(size: 8 * scale)).foregroundStyle(Color(nsColor: .systemYellow))
8081
clipped.draw(clipped.resolve(badge), at: CGPoint(x: rect.minX + badgeXOffset, y: rowY), anchor: .center)
8182
} else if col.isForeignKey {
82-
let badge = Text(Image(systemName: "link")).font(.system(size: 8)).foregroundStyle(Color(nsColor: .systemBlue))
83+
let badge = Text(Image(systemName: "link")).font(.system(size: 8 * scale)).foregroundStyle(Color(nsColor: .systemBlue))
8384
clipped.draw(clipped.resolve(badge), at: CGPoint(x: rect.minX + badgeXOffset, y: rowY), anchor: .center)
8485
}
8586

8687
// Column name
87-
let nameText = Text(col.name).font(.system(size: 11, design: .monospaced))
88+
let nameText = Text(col.name).font(.system(size: 11 * scale, design: .monospaced))
8889
clipped.draw(
8990
clipped.resolve(nameText),
9091
at: CGPoint(x: rect.minX + columnNameXOffset, y: rowY),
@@ -96,7 +97,7 @@ enum ERDiagramNodeRenderer {
9697
? String(col.dataType.prefix(maxTypeChars)) + "\u{2026}"
9798
: col.dataType
9899
let typeText = Text(displayType)
99-
.font(.system(size: 10, design: .monospaced))
100+
.font(.system(size: 10 * scale, design: .monospaced))
100101
.foregroundStyle(.secondary)
101102
clipped.draw(
102103
clipped.resolve(typeText),

TablePro/Views/ERDiagram/ERDiagramToolbar.swift

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,9 @@ struct ERDiagramToolbar: View {
7171
.accessibilityLabel(String(localized: "Export as PNG"))
7272
}
7373
.padding(.horizontal, 12)
74-
.padding(.vertical, 8)
75-
.background(.regularMaterial)
76-
.clipShape(RoundedRectangle(cornerRadius: 8))
77-
.shadow(color: .black.opacity(0.1), radius: 4, y: 2)
74+
.padding(.vertical, 6)
75+
.background(.thinMaterial, in: Capsule())
76+
.overlay(Capsule().strokeBorder(.quaternary, lineWidth: 0.5))
7877
.padding(12)
7978
}
8079
}

0 commit comments

Comments
 (0)