diff --git a/CHANGELOG.md b/CHANGELOG.md index e05e49e..140a4da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,21 @@ is the place for the shorter, human-facing story. ## [Unreleased] +### Changed + +- New app icon: the ring from the menu bar, drawn large on an ink-coloured + tile — green from 0 to 80 %, amber to 90 %, red closing the circle to 100 %, + in the same colours the panel uses. The old icon was three week bars, and + below 32 pt they merged into a green smudge; the ring survives down to 16 pt, + which is the size Spotlight and the "Open with" list show. Thresholds of the + icon are deliberately not the notification defaults (80 / 95): five per cent + of a circle is one pixel at that size, and red would not be visible at all. +- Notification screenshots in the README and the guide retaken — the banners + carry the app icon, so both showed the old one. The README now opens with the + icon as well (`docs/images/app-icon.png`). + +## [0.1.9] — 2026-08-16 + ### Added - Notifications when spend crosses a threshold you set: two thresholds per @@ -40,6 +55,24 @@ is the place for the shorter, human-facing story. - `Formatting.longDuration` — the same interval in words ("2 дня 4 часа", "1 час 12 минут"), with Russian pluralisation. The panel keeps the short form; a banner has room for the long one. + +### Notes + +- Notifications are on out of the box: a feature that stays silent until you + find its tab does not do the job it was added for. macOS asks for permission + once, on the first launch of the new version; if it is refused, the tab says + so and offers the only place the ban can be lifted — System Settings. A build + run straight from `swift run` has no notifications at all: macOS identifies an + app by its bundle, and there isn't one. +- Session notifications need the official source. The local estimate does not + compute the 5-hour percentage at all, so offline there is nothing to warn + about — and an expired session stays quiet rather than warning off a cached + number that reset hours ago. + +## [0.1.8] — 2026-08-15 + +### Added + - Model breakdown in the panel: clicking the percentage — on a day row or on the 5-hour session row — replaces the day rows with one row per model (Opus, Sonnet, Haiku), each with the same kind of bar showing its share of the @@ -63,16 +96,6 @@ is the place for the shorter, human-facing story. ### Notes -- Notifications are on out of the box: a feature that stays silent until you - find its tab does not do the job it was added for. macOS asks for permission - once, on the first launch of the new version; if it is refused, the tab says - so and offers the only place the ban can be lifted — System Settings. A build - run straight from `swift run` has no notifications at all: macOS identifies an - app by its bundle, and there isn't one. -- Session notifications need the official source. The local estimate does not - compute the 5-hour percentage at all, so offline there is nothing to warn - about — and an expired session stays quiet rather than warning off a cached - number that reset hours ago. - The breakdown is computed from local transcripts in `~/.claude/projects` and weighted by model prices, so it is an estimate — which the panel states plainly while the breakdown is on screen. The official `/api/oauth/usage` @@ -175,7 +198,9 @@ seven days of the week window, the 5-hour session, official numbers from `/api/oauth/usage` with a local estimate as fallback, settings window, MIT licence, CI, and a disk image built by tag for Apple Silicon. -[Unreleased]: https://github.com/Greem4/ClaudeWeek/compare/v0.1.7...HEAD +[Unreleased]: https://github.com/Greem4/ClaudeWeek/compare/v0.1.9...HEAD +[0.1.9]: https://github.com/Greem4/ClaudeWeek/compare/v0.1.8...v0.1.9 +[0.1.8]: https://github.com/Greem4/ClaudeWeek/compare/v0.1.7...v0.1.8 [0.1.7]: https://github.com/Greem4/ClaudeWeek/compare/v0.1.6...v0.1.7 [0.1.6]: https://github.com/Greem4/ClaudeWeek/compare/v0.1.5...v0.1.6 [0.1.5]: https://github.com/Greem4/ClaudeWeek/compare/v0.1.4...v0.1.5 diff --git a/README.md b/README.md index 67feee0..aee60c1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +

+ Иконка ClaudeWeek: кольцо недели +

+ # ClaudeWeek [![CI](https://github.com/Greem4/ClaudeWeek/actions/workflows/ci.yml/badge.svg)](https://github.com/Greem4/ClaudeWeek/actions/workflows/ci.yml) diff --git a/Sources/ClaudeWeekApp/AppIcon.swift b/Sources/ClaudeWeekApp/AppIcon.swift index dce09e0..2aa54d7 100644 --- a/Sources/ClaudeWeekApp/AppIcon.swift +++ b/Sources/ClaudeWeekApp/AppIcon.swift @@ -1,15 +1,30 @@ import AppKit -/// Иконка приложения — та же метафора, что и в панели: три полосы недели, -/// где зелёная упирается в янтарную. Рисуем кодом, потому что без Xcode -/// собрать ассет-каталог нечем. +/// Иконка приложения — то же кольцо, что висит в строке меню, только крупно: +/// шкала недельного лимита, разложенная по порогам. Рисуем кодом, потому что +/// без Xcode собрать ассет-каталог нечем. /// -/// Цвета берутся из родной палитры, тёмной её половины: иконка на плитке -/// Launchpad живёт своей жизнью и на тему системы не смотрит, но роли у полос +/// Цвета дуг берутся из родной палитры, тёмной её половины: иконка на плитке +/// Launchpad живёт своей жизнью и на тему системы не смотрит, но роли у цветов /// те же, что в панели, — и расходиться их оттенки не должны. enum AppIcon { private static let ink = Palette.system + /// Где зелёное сменяется янтарным и янтарное — красным. Числа не совпадают + /// с порогами уведомлений (80 и 95 по умолчанию) намеренно: на 16 pt пять + /// процентов круга вырождаются в одну точку, и красного в знаке попросту + /// не видно. Тридцать шесть градусов — минимум, который переживает + /// уменьшение до иконки в Spotlight. + private static let warningStart: CGFloat = 80 + private static let criticalStart: CGFloat = 90 + + /// Плитка. Своих цветов у неё в палитре нет: фон панели рассчитан на текст + /// поверх материала, а плитке нужен градиент, который читается на любом + /// рабочем столе. Холодный графит выбран под кольцо — тёплый серый рядом с + /// зелёным желтит. + private static let plateTop: UInt32 = 0x2A2E38 + private static let plateBottom: UInt32 = 0x111318 + /// Пары «имя файла в .iconset — сторона в пикселях». static let variants: [(name: String, pixels: Int)] = [ ("icon_16x16", 16), ("icon_16x16@2x", 32), @@ -48,68 +63,92 @@ enum AppIcon { private static func draw(side: CGFloat) -> NSImage { NSImage(size: NSSize(width: side, height: side), flipped: false) { _ in - // macOS сама не скругляет иконку — рисуем «сквиркл» вручную, - // с отступом по гайдлайну (примерно десятая часть стороны). - let inset = side * 0.08 - let plate = NSRect(x: inset, y: inset, width: side - inset * 2, height: side - inset * 2) - let corner = plate.width * 0.22 + let plate = plateRect(side: side) + fillPlate(plate) + drawRing(in: plate) + return true + } + } - NSColor(hex: ink.panelBackground.dark).setFill() - NSBezierPath(roundedRect: plate, xRadius: corner, yRadius: corner).fill() + /// Отступ по гайдлайну: плитка не занимает квадрат целиком. + private static func plateRect(side: CGFloat) -> NSRect { + let inset = side * 0.055 + return NSRect(x: inset, y: inset, width: side - inset * 2, height: side - inset * 2) + } - let barHeight = plate.height * 0.11 - let spacing = plate.height * 0.10 - let left = plate.minX + plate.width * 0.14 - let usable = plate.width * 0.72 - let radius = barHeight / 2 - let totalHeight = barHeight * 3 + spacing * 2 - var y = plate.midY + totalHeight / 2 - barHeight + private static func fillPlate(_ rect: NSRect) { + NSGraphicsContext.current?.saveGraphicsState() + defer { NSGraphicsContext.current?.restoreGraphicsState() } - // Сверху вниз: недобор, ровно по плану, перерасход. - let rows: [(fill: Double, plan: Double)] = [ - (0.45, 0.70), - (0.70, 0.70), - (0.95, 0.70), - ] + squircle(in: rect).addClip() + NSGradient(colors: [NSColor(hex: plateTop), NSColor(hex: plateBottom)])? + .draw(in: rect, angle: -90) + // Блик по верхней кромке: без него плитка выглядит наклейкой, а не + // иконкой рядом с системными. + NSGradient(colors: [NSColor(white: 1, alpha: 0.14), NSColor(white: 1, alpha: 0)])? + .draw( + in: NSRect( + x: rect.minX, y: rect.maxY - rect.height * 0.4, + width: rect.width, height: rect.height * 0.4 + ), + angle: -90 + ) + } - for row in rows { - NSColor(hex: ink.track.dark).setFill() - NSBezierPath( - roundedRect: NSRect(x: left, y: y, width: usable, height: barHeight), - xRadius: radius, yRadius: radius - ).fill() + /// Форма плитки macOS — суперэллипс, а не прямоугольник со скруглением: + /// у скруглённого угол ломается там, где дуга встречается с прямой, и + /// рядом с системными иконками это видно. + private static func squircle(in rect: NSRect, exponent: CGFloat = 5) -> NSBezierPath { + let path = NSBezierPath() + let a = rect.width / 2, b = rect.height / 2 + let steps = 720 + for step in 0...steps { + let t = CGFloat(step) / CGFloat(steps) * 2 * .pi + let cosT = cos(t), sinT = sin(t) + let point = NSPoint( + x: rect.midX + a * copysign(pow(abs(cosT), 2 / exponent), cosT), + y: rect.midY + b * copysign(pow(abs(sinT), 2 / exponent), sinT) + ) + if step == 0 { path.move(to: point) } else { path.line(to: point) } + } + path.close() + return path + } - NSColor(hex: ink.plan.dark).setFill() - NSBezierPath( - roundedRect: NSRect(x: left, y: y, width: usable * row.plan, height: barHeight), - xRadius: radius, yRadius: radius - ).fill() + private static func drawRing(in plate: NSRect) { + let center = NSPoint(x: plate.midX, y: plate.midY) + let radius = plate.width * 0.305 + let width = plate.width * 0.165 - NSColor(hex: ink.good.dark).setFill() - NSBezierPath( - roundedRect: NSRect( - x: left, y: y, - width: usable * min(row.fill, row.plan), height: barHeight - ), - xRadius: radius, yRadius: radius - ).fill() + // Соседние дуги перекрываются на полградуса: встык AppKit оставляет + // волосяную щель, и на мелком размере она читается как грязь. + let bleed: CGFloat = 0.5 + let warningAngle = warningStart * 3.6 + let criticalAngle = criticalStart * 3.6 - if row.fill > row.plan { - let gap = max(side * 0.012, 1) - let start = left + usable * row.plan + gap - NSColor(hex: ink.warning.dark).setFill() - NSBezierPath( - roundedRect: NSRect( - x: start, y: y, - width: max(left + usable * row.fill - start, 1), height: barHeight - ), - xRadius: radius, yRadius: radius - ).fill() - } + arc(center: center, radius: radius, width: width, + from: 90, sweep: warningAngle + bleed, color: ink.good.dark) + arc(center: center, radius: radius, width: width, + from: 90 - warningAngle, sweep: criticalAngle - warningAngle + bleed, + color: ink.warning.dark) + arc(center: center, radius: radius, width: width, + from: 90 - criticalAngle, sweep: 360 - criticalAngle, color: ink.critical.dark) + } - y -= barHeight + spacing - } - return true - } + /// 12 часов — верх круга; в системе координат AppKit это 90°, дальше по + /// часовой стрелке, как заполняется кольцо в строке меню. + private static func arc( + center: NSPoint, radius: CGFloat, width: CGFloat, + from: CGFloat, sweep: CGFloat, color: UInt32 + ) { + let path = NSBezierPath() + path.appendArc( + withCenter: center, radius: radius, + startAngle: from, endAngle: from - min(sweep, 359.9), clockwise: true + ) + path.lineWidth = width + path.lineCapStyle = .butt + NSColor(hex: color).setStroke() + path.stroke() } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 623cc2e..17c5c78 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -157,7 +157,7 @@ SwiftUI` — значит, расчёт просочился в UI или нао | `NotificationController.swift` | уведомления глазами системы: разрешение macOS, отправка баннеров, лог сказанного на диске | | `AlertArtwork.swift` | картинка баннера: дуга для сессии, красное число для недели | | `Screenshot.swift` | `--screenshot`: панель во всех темах, она же в разбивке по моделям, вкладка уведомлений, иконка в PNG | -| `AppIcon.swift` | `--icon`: `.iconset` для сборки бандла | +| `AppIcon.swift` | `--icon`: кольцо недели по порогам, `.iconset` для сборки бандла | | `CLI.swift` | `--json`, `--calibrate` и `--update` | ### Потоки данных diff --git a/docs/images/app-icon.png b/docs/images/app-icon.png new file mode 100644 index 0000000..0aaafbb Binary files /dev/null and b/docs/images/app-icon.png differ diff --git a/docs/images/notification-session-dark.png b/docs/images/notification-session-dark.png index 5e2a7b8..7b235ca 100644 Binary files a/docs/images/notification-session-dark.png and b/docs/images/notification-session-dark.png differ diff --git a/docs/images/notification-week-dark.png b/docs/images/notification-week-dark.png index 67ee2d9..44a4aa7 100644 Binary files a/docs/images/notification-week-dark.png and b/docs/images/notification-week-dark.png differ