From 1868d788f929fab9e8a47a323e9e54c5d67c0ba8 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 12 Jun 2026 15:44:25 -0400 Subject: [PATCH] fix: editor usability and visual polish from UI scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rule name is now an inline text field at the top of the editor; the pencil/rename button and alert are gone (it was unclear what the edit button did). Names are sanitized on commit: trimmed, falling back to the kind's default when emptied - Day-of-week toggles fill the full row width with equal cells and >= 44pt tap targets - 'Add Rule' commit button sits on a bar-material bottom inset so form content no longer collides with it while scrolling (found via computer-use scan; verified clean in a follow-up scan) - Tests: 95 passing — new specs for the inline rename flow, day-toggle geometry, and name sanitization; three tests now scroll to reach bottom-of-form controls, matching real user behavior - Spec §6 updated for the editor changes Co-Authored-By: Claude --- Severed/Models/RuleDraft.swift | 9 ++++ .../Views/Components/DayOfWeekPicker.swift | 12 ++--- Severed/Views/Rules/RuleEditorView.swift | 44 +++++++++---------- SeveredTests/RuleModelTests.swift | 14 ++++++ SeveredUITests/RuleCreationUITests.swift | 35 ++++++++++++--- SeveredUITests/RuleManagementUITests.swift | 6 +++ docs/RULES_FEATURE_SPEC.md | 2 +- 7 files changed, 86 insertions(+), 36 deletions(-) diff --git a/Severed/Models/RuleDraft.swift b/Severed/Models/RuleDraft.swift index cee7f8e..2517768 100644 --- a/Severed/Models/RuleDraft.swift +++ b/Severed/Models/RuleDraft.swift @@ -83,6 +83,15 @@ struct RuleDraft: Hashable { return rule } + /// Trims the name and falls back to the kind's default when it is empty, + /// so a cleared name field can never produce an unnamed rule. + func sanitized() -> RuleDraft { + var copy = self + let trimmed = name.trimmingCharacters(in: .whitespaces) + copy.name = trimmed.isEmpty ? kind.defaultRuleName : trimmed + return copy + } + var schedule: RuleSchedule { RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days) } diff --git a/Severed/Views/Components/DayOfWeekPicker.swift b/Severed/Views/Components/DayOfWeekPicker.swift index 8d1cb88..3f32b1b 100644 --- a/Severed/Views/Components/DayOfWeekPicker.swift +++ b/Severed/Views/Components/DayOfWeekPicker.swift @@ -12,12 +12,11 @@ struct DayOfWeekPicker: View { @Binding var days: Set var body: some View { - HStack(spacing: 8) { + HStack(spacing: 0) { ForEach(Weekday.displayOrder, id: \.self) { day in dayToggle(day) } } - .padding(.vertical, 4) } private func dayToggle(_ day: Weekday) -> some View { @@ -31,13 +30,16 @@ struct DayOfWeekPicker: View { } label: { Text(day.shortLabel) .font(.subheadline.weight(.semibold)) - .foregroundStyle(isOn ? Color.white : .secondary) - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) + .foregroundStyle(isOn ? Color.white : Color.secondary) + .frame(width: 38, height: 38) .background( isOn ? AnyShapeStyle(.tint) : AnyShapeStyle(Color(.tertiarySystemFill)), in: Circle() ) + // Each cell takes an equal share of the row and at least a + // 44pt-tall hit area, so the whole strip is comfortably tappable. + .frame(maxWidth: .infinity, minHeight: 44) + .contentShape(Rectangle()) } .buttonStyle(.borderless) .accessibilityIdentifier("dayToggle-\(day.rawValue)") diff --git a/Severed/Views/Rules/RuleEditorView.swift b/Severed/Views/Rules/RuleEditorView.swift index d63a03d..6a82575 100644 --- a/Severed/Views/Rules/RuleEditorView.swift +++ b/Severed/Views/Rules/RuleEditorView.swift @@ -21,12 +21,11 @@ struct RuleEditorView: View { var onToggleEnabled: (() -> Void)? var onDelete: (() -> Void)? - @State private var showingRename = false @State private var showingAppPicker = false - @State private var renameText = "" var body: some View { Form { + nameSection sections if case .edit(let isEnabled) = mode { Section { @@ -46,22 +45,15 @@ struct RuleEditorView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { - Text(draft.name) + Text(draft.sanitized().name) .font(.headline) .lineLimit(1) .accessibilityIdentifier("ruleEditorTitle") } - ToolbarItem(placement: .topBarTrailing) { - Button("Rename", systemImage: "pencil") { - renameText = draft.name - showingRename = true - } - .accessibilityIdentifier("renameButton") - } if case .edit = mode { ToolbarItem(placement: .confirmationAction) { Button("Done") { - onCommit(draft) + onCommit(draft.sanitized()) } .accessibilityIdentifier("doneButton") } @@ -70,28 +62,22 @@ struct RuleEditorView: View { .safeAreaInset(edge: .bottom) { if mode == .create { Button { - onCommit(draft) + onCommit(draft.sanitized()) } label: { Text("Add Rule") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) - .padding(.horizontal) - .padding(.bottom, 8) .accessibilityIdentifier("commitRuleButton") + .padding(.horizontal) + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + // Bar material so scrolling form content doesn't collide + // with the floating button. + .background(.bar) } } - .alert("Rule Name", isPresented: $showingRename) { - TextField("Name", text: $renameText) - Button("OK") { - let trimmed = renameText.trimmingCharacters(in: .whitespaces) - if !trimmed.isEmpty { - draft.name = trimmed - } - } - Button("Cancel", role: .cancel) {} - } .sheet(isPresented: $showingAppPicker) { AppSelectionSheet(draft: $draft) } @@ -99,6 +85,16 @@ struct RuleEditorView: View { // MARK: - Sections + private var nameSection: some View { + Section { + TextField("Rule Name", text: $draft.name) + .submitLabel(.done) + .accessibilityIdentifier("ruleNameField") + } header: { + Text("Name").textCase(nil) + } + } + @ViewBuilder private var sections: some View { switch draft.kind { diff --git a/SeveredTests/RuleModelTests.swift b/SeveredTests/RuleModelTests.swift index 60ac1fe..421de1b 100644 --- a/SeveredTests/RuleModelTests.swift +++ b/SeveredTests/RuleModelTests.swift @@ -110,6 +110,20 @@ struct RuleDraftTests { #expect(rule.hardMode) } + @Test("Sanitizing trims whitespace and falls back to the kind default") + func sanitizedName() { + var draft = RuleDraft(kind: .schedule) + draft.name = " Deep Work " + #expect(draft.sanitized().name == "Deep Work") + + draft.name = " " + #expect(draft.sanitized().name == "In the Zone") + + var limitDraft = RuleDraft(kind: .timeLimit) + limitDraft.name = "" + #expect(limitDraft.sanitized().name == "Time Keeper") + } + @Test("Preset drafts copy the preset's schedule") func presetDraft() throws { let preset = try #require( diff --git a/SeveredUITests/RuleCreationUITests.swift b/SeveredUITests/RuleCreationUITests.swift index af99776..a5d271e 100644 --- a/SeveredUITests/RuleCreationUITests.swift +++ b/SeveredUITests/RuleCreationUITests.swift @@ -44,19 +44,38 @@ final class RuleCreationUITests: XCTestCase { app.buttons["newRuleButton"].waitToAppear().tap() app.buttons["ruleKind-schedule"].waitToAppear().tap() - app.buttons["renameButton"].waitToAppear().tap() - let nameField = app.textFields.firstMatch.waitToAppear() - nameField.tap() - // Clear the prefilled name, then type the new one. + // The rule name is an inline text field at the top of the editor — + // no separate edit/rename button. + XCTAssertFalse(app.buttons["renameButton"].exists) + let nameField = app.textFields["ruleNameField"].waitToAppear() + // Tap at the trailing edge so the cursor lands after the last character. + nameField.coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.5)).tap() let deletions = String(repeating: XCUIKeyboardKey.delete.rawValue, count: 24) - nameField.typeText(deletions + "My Focus") - app.buttons["OK"].tap() + nameField.typeText(deletions + "My Focus\n") XCTAssertEqual(app.staticTexts["ruleEditorTitle"].label, "My Focus") app.buttons["commitRuleButton"].waitToAppear().tap() app.buttons["ruleCard-My Focus"].waitToAppear() } + func testDayTogglesFillRowAndHaveLargeTapTargets() throws { + let app = XCUIApplication.launchSevered() + app.buttons["newRuleButton"].waitToAppear().tap() + app.buttons["ruleKind-schedule"].waitToAppear().tap() + + let first = app.buttons["dayToggle-1"].waitToAppear() + let last = app.buttons["dayToggle-7"].waitToAppear() + let span = last.frame.maxX - first.frame.minX + XCTAssertGreaterThan( + span, app.frame.width * 0.75, + "Day toggles should span the full row width, got \(span) of \(app.frame.width)" + ) + XCTAssertGreaterThanOrEqual( + first.frame.height, 44, + "Day toggle tap target should be at least 44pt tall" + ) + } + func testDayTogglesUpdateSummary() throws { let app = XCUIApplication.launchSevered() app.buttons["newRuleButton"].waitToAppear().tap() @@ -90,6 +109,10 @@ final class RuleCreationUITests: XCTestCase { app.buttons["newRuleButton"].waitToAppear().tap() app.buttons["ruleKind-schedule"].waitToAppear().tap() + // The toggle lives at the bottom of the form; scroll it clear of the + // commit bar before tapping. + app.staticTexts["ruleEditorTitle"].waitToAppear() + app.swipeUp() app.switches["adultContentToggle"].waitToAppear().tap() app.buttons["commitRuleButton"].waitToAppear().tap() diff --git a/SeveredUITests/RuleManagementUITests.swift b/SeveredUITests/RuleManagementUITests.swift index 93de54d..54c2a7f 100644 --- a/SeveredUITests/RuleManagementUITests.swift +++ b/SeveredUITests/RuleManagementUITests.swift @@ -47,6 +47,9 @@ final class RuleManagementUITests: XCTestCase { app.buttons["ruleCard-Sleep"].waitToAppear().tap() app.buttons["editRuleButton"].waitToAppear().tap() + // The disable/delete rows sit at the bottom of the form. + app.staticTexts["ruleEditorTitle"].waitToAppear() + app.swipeUp() app.buttons["toggleEnabledButton"].waitToAppear().tap() // The detail caption now reports the rule as disabled. @@ -63,6 +66,9 @@ final class RuleManagementUITests: XCTestCase { app.buttons["ruleCard-Sleep"].waitToAppear().tap() app.buttons["editRuleButton"].waitToAppear().tap() + // The disable/delete rows sit at the bottom of the form. + app.staticTexts["ruleEditorTitle"].waitToAppear() + app.swipeUp() app.buttons["deleteRuleButton"].waitToAppear().tap() app.buttons["newRuleButton"].waitToAppear() diff --git a/docs/RULES_FEATURE_SPEC.md b/docs/RULES_FEATURE_SPEC.md index c1098d0..bb1566d 100644 --- a/docs/RULES_FEATURE_SPEC.md +++ b/docs/RULES_FEATURE_SPEC.md @@ -344,7 +344,7 @@ reference for *what* the feature does; presentation now maps as follows: | Apps home | `NavigationStack` + `List`; "Blocked Apps" and "Rules" sections; **rules are list rows** (kind icon, name, block summary, trailing live status — green when active); "+" toolbar button | | Rule detail | Sheet with inline nav title (name + "Schedule, 6h left" caption), `LabeledContent` rows, "Edit Rule" row pushes the editor; hard-locked rules show a lock row instead | | New Rule | `List` with a "Rule Type" section and preset sections as plain rows; editor pushed via `navigationDestination(item:)` | -| Rule editor | Native `Form`: `DatePicker` rows, day-circle row with the summary in the section header, toggle rows with footers, stepper rows. Create commits with a prominent **"Add Rule"** button (replaces Hold to Commit); edit uses toolbar **Done** plus red Disable/Delete rows | +| Rule editor | Native `Form`: an inline **Name text field** at the top (no separate rename button; empty names fall back to the kind default), `DatePicker` rows, full-width day-circle row (≥44pt tap targets) with the summary in the section header, toggle rows with footers, stepper rows. Create commits with a prominent **"Add Rule"** button on a bar-material inset (replaces Hold to Commit); edit uses toolbar **Done** plus red Disable/Delete rows | | Onboarding / app picker | System styling, `.borderedProminent` buttons, default color scheme (no forced dark, default accent) | Dropped custom components: `Theme`, `HoldToCommitButton`, `RuleCardView`,