docs: remove references to the source app and recording

Prepare for open-sourcing: scrub the prior-art attribution from the spec,
agent guide, and code comments so the project documents its own behavior.

- Reword RULES_FEATURE_SPEC.md to describe OpenAppLock's rules feature
  directly (drop the screen-recording source line and "as observed"/
  "not demoed"/"as shown" framing)
- Replace "the reference app/UI/style/defaults" comments and test names
  with direct descriptions of the behavior
- No behavior change; comments, docs, and @Test display names only

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 22:23:14 -04:00
parent 32b1694e0a
commit d005ed4fb1
13 changed files with 52 additions and 56 deletions

View File

@@ -3,8 +3,8 @@
OpenAppLock is an iOS Screen Time app: recurring **rules** that block selected
apps (Schedule windows, Time Limits, Open Limits), with a **Hard Mode** that
makes an active block impossible to lift, edit, or delete until it ends. The
feature set is a clone of Opal's "Rules"; the presentation is bare native iOS
(List/Form/NavigationStack, default color scheme).
presentation is bare native iOS (List/Form/NavigationStack, default color
scheme).
## Repo layout
@@ -39,9 +39,9 @@ OpenAppLockShieldAction/ ShieldAction extension: Open press spends an open,
OpenAppLockTests/ Swift Testing unit suites (@MainActor — the app
target defaults to MainActor isolation)
OpenAppLockUITests/ XCUITest flows (see harness below)
docs/RULES_FEATURE_SPEC.md Feature spec derived from the Opal reference
recording; §6 maps it to the native presentation.
Review/update this BEFORE behavior changes.
docs/RULES_FEATURE_SPEC.md Feature spec for the rules behavior; §6 maps it
to the native presentation. Review/update this
BEFORE behavior changes.
docs/SWIFT_GUIDELINES.md Swift coding/testing/patterns/security standards
agents must follow on this project.
```
@@ -53,7 +53,7 @@ docs/SWIFT_GUIDELINES.md Swift coding/testing/patterns/security standards
*starts* on. `start == end` = 24h window.
- Status is always **derived** (`rule.status(at:calendar:)`), never stored:
`disabled / dormant / active(until:) / paused(until:) / upcoming(startsAt:)`.
Labels match the reference app ("6h left" rounds hours **up**).
Countdown labels round hours **up** (e.g. "6h left").
- **Hard Mode**: `RulePolicy` is the single gate — while a hard-mode rule is
actively blocking, canEdit/canDisable/canDelete/canUnblock are all false.
Soft rules can be "unblocked", which sets `pausedUntil` = window end (the

View File

@@ -33,7 +33,7 @@ enum RuleStatus: Equatable, Sendable {
}
}
/// Compact countdown matching the reference app: minutes under an hour,
/// Compact countdown: minutes under an hour,
/// hours (rounded up) under two days, then days.
static func countdown(from now: Date, to target: Date) -> String {
let minutes = max(1, Int(ceil(target.timeIntervalSince(now) / 60)))

View File

@@ -78,7 +78,7 @@ final class BlockingRule {
self.dayNumbers = days.map(\.rawValue).sorted()
self.pausedUntil = pausedUntil
self.createdAt = createdAt
// Raw per-kind columns start at the reference defaults, then the
// Raw per-kind columns start at the default values, then the
// configuration overwrites the ones that apply to its kind.
self.kindRaw = configuration.kind.rawValue
self.startMinutes = 9 * 60

View File

@@ -25,8 +25,8 @@ struct RuleDraft: Hashable {
var kind: RuleKind { configuration.kind }
/// A fresh draft for a new rule of the given kind, using the reference
/// app's defaults (95 weekdays schedule, 45m/day, 5 opens/day).
/// A fresh draft for a new rule of the given kind, using the default
/// values (95 weekdays schedule, 45m/day, 5 opens/day).
init(kind: RuleKind) {
self.name = kind.defaultRuleName
self.days = Weekday.weekdays

View File

@@ -5,8 +5,7 @@
import SwiftUI
/// A suggested schedule rule shown in the New Rule sheet's preset gallery,
/// mirroring the reference app's sections and timings.
/// A suggested schedule rule shown in the New Rule sheet's preset gallery.
struct RulePreset: Identifiable, Hashable, Sendable {
let id: String
let name: String
@@ -14,7 +13,7 @@ struct RulePreset: Identifiable, Hashable, Sendable {
let endMinutes: Int
let days: Set<Weekday>
let symbolName: String
/// Gradient stand-in for the reference app's photo backgrounds.
/// Gradient background shown behind each preset card.
let gradientTop: Color
let gradientBottom: Color

View File

@@ -18,7 +18,7 @@ struct RuleConfigurationTests {
#expect(RuleConfiguration.openLimit(OpenLimitConfig()).kind == .openLimit)
}
@Test("Defaults match the reference app's new-rule defaults")
@Test("Defaults match the documented new-rule defaults")
func defaults() {
let schedule = RuleConfiguration.default(for: .schedule).scheduleConfig
#expect(schedule?.startMinutes == 9 * 60)

View File

@@ -12,7 +12,7 @@ import Testing
@MainActor
@Suite("BlockingRule model & persistence")
struct RuleModelTests {
@Test("Defaults match the reference app's new-rule defaults")
@Test("Defaults match the documented new-rule defaults")
func defaults() {
let rule = BlockingRule(name: "In the Zone")
#expect(rule.kind == .schedule)

View File

@@ -11,7 +11,7 @@ import Testing
@MainActor
@Suite("Rule status derivation and labels")
struct RuleStatusTests {
/// 09:0017:00 weekdays schedule rule, like the video's "Work Time".
/// 09:0017:00 weekdays schedule rule (the "Work Time" preset).
func workTime(hardMode: Bool = false) -> BlockingRule {
BlockingRule(name: "Work Time", hardMode: hardMode)
}
@@ -71,16 +71,16 @@ struct RuleStatusTests {
}
}
@Test("Active label matches the reference style: hours round up")
@Test("Active label rounds hours up")
func activeLabel() {
// 11:28 17:00 is 5h32m; the reference app shows "6h left".
// 11:28 17:00 is 5h32m; rounds up to "6h left".
let status = workTime().status(at: date(2025, 1, 6, 11, 28), calendar: utc)
#expect(status.label(relativeTo: date(2025, 1, 6, 11, 28)) == "6h left")
}
@Test("Upcoming label matches the reference style")
@Test("Upcoming label formats hours until start")
func upcomingLabel() {
// Friday 11:28 Saturday 09:00 is 21h32m; the reference shows "Starts in 22h".
// Friday 11:28 Saturday 09:00 is 21h32m; rounds up to "Starts in 22h".
let weekend = BlockingRule(name: "Weekend Zen", days: Weekday.weekends)
let friday = date(2025, 1, 10, 11, 28)
#expect(weekend.status(at: friday, calendar: utc).label(relativeTo: friday) == "Starts in 22h")

View File

@@ -26,8 +26,8 @@ enum RuleConfiguration: Hashable, Sendable {
}
}
/// The default configuration for a brand-new rule of the given kind, using
/// the reference app's defaults (95 schedule, 45m/day, 5 opens/day).
/// The default configuration for a brand-new rule of the given kind
/// (95 schedule, 45m/day, 5 opens/day).
static func `default`(for kind: RuleKind) -> RuleConfiguration {
switch kind {
case .schedule: .schedule(ScheduleConfig())

View File

@@ -5,7 +5,7 @@
import Foundation
/// The three kinds of blocking rules, mirroring the reference app's "New Rule" sheet.
/// The three kinds of blocking rules offered on the "New Rule" sheet.
enum RuleKind: String, Codable, CaseIterable, Sendable {
/// Block selected apps during a recurring time window.
case schedule
@@ -38,7 +38,7 @@ enum RuleKind: String, Codable, CaseIterable, Sendable {
}
}
/// Default name given to a brand-new rule of this kind (Opal: "In the Zone", "Time Keeper").
/// Default name given to a brand-new rule of this kind (e.g. "In the Zone", "Time Keeper").
var defaultRuleName: String {
switch self {
case .schedule: "In the Zone"

View File

@@ -76,7 +76,7 @@ struct RuleSchedule: Hashable, Sendable {
}
extension RuleSchedule {
/// "09:00" style label for a minutes-from-midnight value, matching the reference UI.
/// "09:00" style label for a minutes-from-midnight value.
static func timeLabel(forMinutes minutes: Int) -> String {
let clamped = ((minutes % (24 * 60)) + 24 * 60) % (24 * 60)
return String(format: "%02d:%02d", clamped / 60, clamped % 60)

View File

@@ -19,7 +19,7 @@ enum Weekday: Int, CaseIterable, Codable, Hashable, Sendable {
static let weekends: Set<Weekday> = [.saturday, .sunday]
static let everyDay: Set<Weekday> = Set(Weekday.allCases)
/// Display order used by the day picker, matching the reference UI: S M T W T F S.
/// Display order used by the day picker: S M T W T F S.
static let displayOrder: [Weekday] = [
.sunday, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday,
]

View File

@@ -1,9 +1,8 @@
# OpenAppLock — "Rules" Feature Spec (derived from Opal screen recording)
# OpenAppLock — "Rules" Feature Spec
Source: `ScreenRecording_06-12-2026 11-26-19_1.MP4` (iPhone, 4:29). The rules feature
appears roughly between 1:003:00 of the recording, on Opal's "My Apps" tab.
This spec describes what was observed, then maps it onto an implementation plan
for OpenAppLock.
This spec describes OpenAppLock's recurring app-blocking **rules** feature: the
behavior the app implements, then how it maps onto the native iOS presentation
(see §6).
---
@@ -11,13 +10,13 @@ for OpenAppLock.
A **Rule** is a recurring, automated app-blocking policy. Unlike a one-off
block/timer session, a rule re-arms itself on a schedule. Three rule types are
offered (Opal's "New Rule" sheet):
offered, presented on the "New Rule" sheet:
| Type | Icon | Example shown | Semantics |
|------|------|---------------|-----------|
| **Schedule** | calendar grid | "e.g. 9-5, Daily" | Block selected apps during a daily time window on chosen days |
| **Time Limit** | hourglass | "e.g. 45m/day" | After N minutes of cumulative use of selected apps per day, block them until a reset point |
| **Open Limit** | padlock | "e.g. 5 opens/day" | After N opens of selected apps per day, block them (not demoed in video; inferred from card) |
| **Open Limit** | padlock | "e.g. 5 opens/day" | After N opens of selected apps per day, block them |
**Common attributes** — present for *every* rule kind:
@@ -28,7 +27,7 @@ offered (Opal's "New Rule" sheet):
independently of any rule. When editing a rule the user picks an existing
list or creates a new one; editing a list affects every rule that uses it.
Deleting a list detaches it from its rules (they fall back to "no apps").
- **Hard Mode** — boolean, PRO-gated in Opal; subtitle "No unblocks allowed". When off, the rule detail shows "Unblocks allowed: Yes"
- **Hard Mode** — boolean; subtitle "No unblocks allowed". When off, the rule detail shows "Unblocks allowed: Yes"
- **Enabled/disabled** — a rule can be disabled without deleting ("Disable Rule")
**Per-kind options** — each kind carries only the options that make sense for
@@ -87,10 +86,10 @@ Dark theme throughout (near-black background, very dark green tint).
2. **Blocked Apps** — section header; horizontal row of currently-blocked app
icons. Each icon has a lock badge overlay and a teal/green rounded-rect
outline; caption "Unblock" under the icon. Tapping unblocks (with friction
if hard mode — not demoed).
if hard mode).
*(OpenAppLock)* Time/Open Limit rules whose budget is spent for the day
also appear here, blocked until midnight.
3. **Usage** *(OpenAppLock addition — not in the Opal video)* — a section
3. **Usage** *(OpenAppLock addition)* — a section
showing live tracking for every enabled Time/Open Limit rule scheduled today
**that is not currently blocking**. Each row leads its subtitle with the rule
**type** so the kind is clear without relying on the icon:
@@ -175,8 +174,7 @@ Sections (each an inset rounded group with a small icon + caption header):
1. **📅 During this time**
- Rows `From` / `To` with right-aligned time + stepper chevrons (`09:00 ⌃⌄`).
- A dotted vertical line with ●/○ endpoints visually links From → To.
- Tapping a row expands an inline wheel time picker (24h, demoed changing
From 22:00 → 23:00).
- Tapping a row expands an inline wheel time picker (24h).
2. **On these days:** — trailing summary label ("Weekdays"/"Weekends"/custom);
row of 7 circular toggles `S M T W T F S`; selected = filled white circle
with black letter, unselected = dark circle.
@@ -188,8 +186,8 @@ Sections (each an inset rounded group with a small icon + caption header):
blocked" / "Only these apps are allowed" accordingly.
4. **Hard Mode** `⚡PRO` badge — subtitle "No unblocks allowed"; trailing
toggle.
5. **Block Adult Content** *(OpenAppLock addition — not in the Opal video;
**Schedule rules only**)* — subtitle "Filter adult websites while this rule
5. **Block Adult Content** *(OpenAppLock addition; **Schedule rules only**)* —
subtitle "Filter adult websites while this rule
is active"; trailing toggle. Maps to Screen Time's web-content filter
(`ManagedSettingsStore.webContent.blockedByFilter = .auto(...)`), applied
and cleared together with the rule's shield. Surfaces in the rule detail
@@ -217,7 +215,7 @@ Same chrome (back / title / rename). Sections:
### 3.6 Rule Editor — Open Limit type
Not demoed beyond its card. Spec by analogy: "When I open [apps]" /
Spec by analogy with the other editors: "When I open [apps]" /
"More than `N opens ⌃⌄` (Daily)" / day picker / "Then block until …" /
Hard Mode / Hold to Commit. *(No Block Adult Content toggle — Schedule-only.)*
@@ -243,11 +241,10 @@ Full-height sheet:
letting users add arbitrary domains.
- Footer: "**N Apps Selected**" caption + white pill "**Save**" (+ "Cancel").
> Implementation note: Opal ships its own app categorization. On iOS, third
> parties cannot enumerate installed apps; the system-sanctioned route is
> `FamilyActivityPicker` (FamilyControls), which provides its own
> category/app/website UI and returns opaque tokens. **v1 of OpenAppLock should
> embed `FamilyActivityPicker`** instead of cloning Opal's custom picker.
> Implementation note: On iOS, third parties cannot enumerate installed apps;
> the system-sanctioned route is `FamilyActivityPicker` (FamilyControls), which
> provides its own category/app/website UI and returns opaque tokens. **v1 of
> OpenAppLock embeds `FamilyActivityPicker`** rather than a custom app picker.
>
> **App Lists (OpenAppLock):** the selection itself lives on a reusable
> **App List** (`@Model AppList`: name + encoded `FamilyActivitySelection`).
@@ -305,11 +302,11 @@ Full-height sheet:
(which lists only rules whose budget is exhausted); it shows under "Usage"
with its remaining opens.
5. **Disable vs delete** — "Disable Rule" pauses scheduling but keeps the
rule (card presumably shows disabled state). No delete flow was shown;
add delete via swipe/long-press or a button in the editor.
rule (the card shows a disabled state). Delete is offered from the rule
editor's actions menu.
6. **Commit friction** — creating/committing a rule uses press-and-hold
("Hold to Commit"), echoing Opal's philosophy that *starting* a commitment
should be deliberate. Editing uses a plain "Done".
("Hold to Commit"), making the *start* of a commitment deliberate. Editing
uses a plain "Done".
7. **Live countdowns** — "Starts in 22h" / "6h left" update over time
(minute granularity is fine).
8. **Overlapping rules — strictest enforcement wins.** When several rules
@@ -503,20 +500,20 @@ Xh", "Xh left") is **derived**, not stored.
- All shared logic lives in `Shared/` (notably `LimitEnforcement`), unit
tested from the app test target.
### 5.6 Out of scope (seen in video, not part of "rules")
### 5.6 Out of scope (not part of "rules")
- Onboarding flow, paywall ("You know Opal works. Make it permanent."),
Home tab gem/score UI, Timer tab (one-off focus sessions, "Leave Early?"
friction screen), notification nudges ("Complete Your Setup").
- Onboarding flow, paywall, Home tab gem/score UI, Timer tab (one-off focus
sessions, "Leave Early?" friction screen), notification nudges ("Complete
Your Setup").
---
## 6. Native UI re-skin (current presentation)
OpenAppLock has since replaced the Opal-style custom presentation with the bare
OpenAppLock has since replaced its custom themed presentation with the bare
iOS design language, keeping the backend (models, logic, services), the
flows, and the accessibility identifiers intact. Sections 15 remain as the
reference for *what* the feature does; presentation now maps as follows.
spec for *what* the feature does; presentation now maps as follows.
After onboarding the app is a three-tab `TabView` (`MainTabView`), each tab its
own `NavigationStack`: