이탈 기록 첫 버전은 아빠가 돌아올 때마다 이유를 물었어. Comet에 갔다가 Voyager로 돌아오면 툴바 아래에 막대가 하나 떴지. 문구는 You went to Comet at 12:34. What was missing here?, 12시 34분에 Comet으로 갔던데 여기 뭐가 모자랐냐는 거야. 막대는 아빠가 닫을 때까지 버텼어. 게다가 페이지 위에 쌓인 막대들 사이에 끼어 있어서, 떠 있는 동안 페이지를 아래로 밀었지.
그 뒤에 벌어진 일도 기록이 다 적어뒀어. 전부 적는 계기가 쓸모 있는 게 바로 이런 때야. 16일 동안 질문은 444번 나왔고 대답은 한 번 받았어. 옛 동작이 왜 사라졌는지 읽지 않고는 아무도 그걸 되살리지 못하게, 이제 불변식 10번 본문에 그 숫자가 들어 있어.
444번에 대답 한 번은 Comet에 대해선 별말을 안 해줘. 질문에 대해선 아주 많은 걸 말해주지. 묻는 게 매번 사용자한테 뭔가를 치르게 하면 제일 싼 대응은 닫아버리는 거야. 그러면 계기는 결국 인내심을 재게 돼.
커밋 하나에 바뀐 것 셋
수정은 2026-10-22에 커밋 b2371a8로 들어왔어. 사용성 검토(할 일 #603)의 B1 줄이야. 세 가지를 한꺼번에 바꿨어.
진짜 브라우저만 쳐. 웹 주소와 웹 페이지를 둘 다 여는 앱이어야 해. 앞 레슨에서 본 그 규칙이야.
달력 하루에 한 번까지. 이탈은 여전히 전부 적혀. 아껴 쓰는 건 질문뿐이야.
막대 대신 토스트. 질문은 페이지 오른쪽 위 구석에 떠 있어. 페이지를 밀지 않고, 키 입력을 뺏지 않고, 알아서 사라져.
추적기 안의 하루 한 번
DepartureTracker는 부모 쪽의 그냥 값이야. AppKit은 하나도 안 들어 있어. Voyager가 앞으로 나오면 voyagerCameForward(at:calendar:)가 열려 있던 머무름을 닫고, 물어볼 이탈을 돌려주거나 아무것도 안 돌려줘. now와 같은 달력 날에 이미 질문을 보였으면 아무것도 안 돌려줘. 아니면 아직 아무도 안 물어본 이탈 중 가장 최근 걸 돌려줘.
이 규칙이 실제로 버티게 해주는 디테일이 셋 있어. 그중 하나엔 틈이 있고.
넘겨주고 나서 적어. 셸은 앞에 있는 창이 질문을 받아 갔을 때만 markAsked를 불러. 떠 있는 창이 없었으면 질문은 계속 빚으로 남아. 테스트는 추적기 쪽 절반을 확인해. 물어봤다고 적기 전까지는 같은 이탈이 계속 돌아와. 셸 쪽 절반은 주석이 약속하는 것보다 헐거워. 그 창에 이탈 토스트가 이미 떠 있으면 ask(about:)는 아무것도 안 하고 돌아가는데, 클로저는 그래도 보여줬다고 답해. 그러면 두 번째 이탈은 질문이 화면에 한 번도 안 떴는데 물어본 걸로 적혀. 첫 토스트가 자정을 넘겨서까지 떠 있어야 생기는 좁은 구석이긴 해. 포인터가 그 위에 올라가 있거나 이유를 반쯤 쳐둔 채로 말이야. 그래도 그 일이 생기면 기록엔 아무도 못 본 asked 줄이 남아.
하루는 다시 켜도 이어져.DepartureLog.read()가 파일의 가장 최근 asked 이벤트에서 lastAskedAt을 뽑고, 추적기는 거기서 시작해. Voyager를 껐다 켠다고 오늘 몫이 초기화되진 않아.
빈 대답은 대답이 아니야.reason(_:for:at:)은 공백을 다듬고, 빈 문자열이면 아무것도 안 돌려줘. 엔터를 잘못 눌러도 이유는 안 적혀. 토스트를 그냥 보내도 아무것도 안 적혀. 그 이탈은 '물어봤는데 이유는 없음'으로 기록에 남고, doctor가 그걸 세.
이건 일부러 한 거래야. 기록은 빠짐없고, 질문은 표본이야. 하루에 많아야 이탈 하나만, 그것도 기다리는 것 중 가장 최근 것만 물어. 끊임없이 물어서 사용자한테 무시하는 법을 가르치느니, 기록은 빠짐없이 하고 질문은 드물게 하는 게 나아.
토스트가 페이지를 못 미는 이유
"페이지를 절대 안 민다"는 조심해서 지키는 약속일 수도 있었어. Voyager에선 구조가 지켜. 그 구조는 창의 layout()이야. 콘텐츠 뷰는 bars라는 목록을 들고 있어. 탭 줄, 툴바, 북마크 막대, 알림 막대, 찾기 막대. 레이아웃은 그 목록을 위에서부터 돌면서, 보이는 막대마다 남은 높이에서 자기 높이를 떼 가. 페이지는 나머지를 받아. 토스트는 그 목록에 없어. 레이아웃 맨 마지막에 페이지 오른쪽 위 구석에 얹히고, 크기는 내용에 맞춰. 페이지 높이는 하나도 안 떼 가.
페이지가 튀던 문제는 그게 해결의 전부야. 옛 질문은 알림 막대에 살았고, 알림 막대는 막대니까 높이를 뗐어. 새 질문은 아무것도 안 떼는 뷰에 살아.
알림 막대는 아직 있어. 이제는 아빠가 대답할 때까지 남아 있어야 하는 것만 들고 있어. 프로세스가 끝나버린 페이지, 실패한 로드, 읽지 못한 세션, 저장할 로그인. 잠깐이면 되는 건 전부 토스트야.
키를 안 뺏고, 알아서 가
토스트는 포커스 없이 나타나. 그래서 아빠가 치던 건 계속 페이지로 들어가. 토스트 입력칸을 눌렀다가 토스트가 사라지면 키 입력은 페이지로 돌아가. 타이머는 아무도 그 위에 없을 때만 줄어. 포인터가 토스트 위에 머물거나 아빠가 거기 치고 있으면 시계가 멈추고, 이유를 쳐둔 토스트는 시간이 다 돼도 안 사라져. 이탈 질문은 12초, 방금 시작한 다운로드 알림은 5초 떠 있어. 창 하나엔 토스트가 많아야 셋이고, 가장 오래된 게 먼저 나가. 이탈 질문은 한 번에 하나만 떠. × 버튼 이름은 Not now, 지금은 말고라는 뜻이야.
사람을 배려하는 계측
모아 놓고 보면 이 정책은 다시 써먹을 수 있는 짧은 목록이 돼.
물어보든 안 물어보든 이벤트는 전부 적어.
드물게 묻고, 아직 생생할 때 가장 최근 일을 물어.
절대 막지 말고, 사용자가 읽는 걸 움직이지 말고, 입력을 뺏지 마.
침묵도 기록에 담기는 대답으로 쳐.
이 커밋이 잘한 게 하나 더 있어. 자기 보고 방식이야. 검증 칸에 빌드 성공과 이탈 테스트 19개 통과를 적고, 이어서 대놓고 썼어. 설치된 앱에서는 아직 안 돌려봤다고. 패리티 계약은 설치된 번들에서 나온 증거만 받아주니까, 그 한 문장 덕분에 이 수정은 증명도 되기 전에 증명된 걸로 둔갑하지 않았어.
Code
cwkVoyager, Sources/VoyagerCore/DepartureLog.swift: 달력 하루에 한 번, 보인 다음에만 적고, 빈 대답은 대답으로 안 쳐 (원문 그대로, 211-234행)·swift
/// Voyager came to the front. Returns the departure to ask about, if one
/// is waiting and nothing was asked yet that day; it is asked about once,
/// so the caller records `asked`. Asked on every return, the question came
/// 444 times in sixteen days and was answered once (2026-10-22): now it
/// comes at most once a day, and every departure is still in the log.
public mutating func voyagerCameForward(at now: Date = Date(), calendar: Calendar = .current) -> Departure? {
open = nil
if let lastAsked, calendar.isDate(lastAsked, inSameDayAs: now) { return nil }
return unasked
}
/// The question was shown.
public mutating func markAsked(_ id: String, at: Date) -> DepartureEvent? {
guard unasked?.id == id else { return nil }
unasked = nil
lastAsked = at
return .asked(id: id, at: at)
}
/// Dad's answer. An empty one is no answer.
public func reason(_ text: String, for id: String, at: Date) -> DepartureEvent? {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : .reason(id: id, at: at, text: trimmed)
}
cwkVoyager, Sources/VoyagerApp/BrowserWindowController.swift: 막대는 페이지 높이를 떼 가고, 토스트는 그 위에 얹혀서 하나도 안 떼 가 (원문 그대로, 1849-1882행)·swift
override func layout() {
super.layout()
var top = bounds.height
for bar in bars where !bar.isHidden {
let height: CGFloat
switch bar {
case let strip as TabStripView: height = strip.height
case let toolbar as ToolbarView: height = toolbar.height
case let bookmarks as BookmarksBarView: height = bookmarks.height
case let find as FindBarView: height = find.height
default: height = bar.fittingSize.height
}
top -= height
bar.frame = NSRect(x: 0, y: top, width: bounds.width, height: height)
}
let height = max(top, 0)
var pageWidth = bounds.width
if let side, !side.isHidden {
// The page keeps at least 400 points however wide Pippa was left.
let width = min(side.width, max(bounds.width - 400, PippaPanelView.minimumWidth))
pageWidth = max(bounds.width - width, 0)
side.frame = NSRect(x: pageWidth, y: 0, width: bounds.width - pageWidth, height: height)
}
page?.frame = NSRect(x: 0, y: 0, width: pageWidth, height: height)
if !status.isHidden {
status.sizeToFit()
status.frame = NSRect(x: 0, y: 0, width: min(status.frame.width, pageWidth * 0.8), height: status.frame.height + 2)
}
if let toasts, !toasts.isHidden {
let size = toasts.fittingSize
let width = min(size.width, max(pageWidth - 24, 0))
toasts.frame = NSRect(x: max(pageWidth - width - 12, 0), y: max(height - size.height - 10, 0), width: width, height: size.height)
}
}
cwkVoyager, Sources/VoyagerApp/ToastStack.swift: 토스트 시계는 아무도 그 위에 없을 때만 가 (원문 그대로, 103-113행)·swift
.onHover { hovering = $0 }
.task(id: toast.id) {
// Its time runs only while nobody is on it.
var left = toast.seconds
while left > 0 || hovering || typing || !reason.isEmpty {
try? await Task.sleep(for: .milliseconds(250))
if Task.isCancelled { return }
if !hovering && !typing { left -= 0.25 }
}
onDismiss(toast)
}
네가 만드는 소프트웨어에서 사용자를 끊고 들어오는 질문, 설문, 확인창 하나를 찾아. 먼저 재봐. 얼마나 자주 뜨고, 얼마나 자주 진짜 대답을 받는지. 이어서 이 레슨의 네 규칙으로 다시 설계해. 묻든 안 묻든 이벤트는 전부 적기, 기간마다 많아야 한 번 가장 최근 일만 묻기, 절대 막지도 콘텐츠를 움직이지도 않기, 닫은 것도 기록에 담기는 대답으로 치기. 아껴 묻는 부분은 마지막으로 물은 시각, 지금, 달력을 받는 순수 함수로 짜고, 자정을 넘나드는 경우로 시험해봐.
Hint
아직 대답률을 못 재면 재는 것부터 넣어. Voyager가 "444번 묻고 1번 대답"을 안 건 기록이 묻는 것까지 전부 적었기 때문이야. 레이아웃 쪽은 끼어드는 뷰가 콘텐츠 크기를 정하는 쌓기 안에 들어 있는지 봐. 거기 있으면 아무리 조심스럽게 애니메이션을 줘도 콘텐츠는 움직여.
Progress
Progress is local-only — sign in to sync across devices.