Skip to content

Commit 98f0987

Browse files
authored
Merge pull request #316 from charliek/feature/plan-023-badge
fix(iced): pin the notification dots to the Mac accent #007aff (#311)
2 parents bc70fc6 + ac6c114 commit 98f0987

5 files changed

Lines changed: 367 additions & 38 deletions

File tree

crates/roost-iced/src/chrome.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@ pub const ACTIVE_AGENT: Color = Color::from_rgb8(0x3a, 0x3a, 0x3a);
5757
pub const TEXT: Color = Color::from_rgb8(0xf2, 0xf2, 0xf2);
5858
pub const MUTED_TEXT: Color = Color::from_rgb8(0xa0, 0xa4, 0xb0);
5959
pub const NOTIFICATION: Color = Color::from_rgb8(0x4e, 0x9a, 0xf1);
60+
/// Both notification dots — the tab-pill badge and the sidebar
61+
/// project-row dot. Pinned to the Mac's `NSColor.controlAccentColor`
62+
/// (#007aff), which both Mac surfaces use (`App.swift:4772`, `:5207`)
63+
/// and which GTK hardcodes for its tab badge
64+
/// (`crates/roost-linux/src/resources/style.css:277-288`) rather than
65+
/// tracking the desktop accent — on COSMIC `@accent_bg_color` renders
66+
/// teal. Deliberately separate from `NOTIFICATION`: only the dots have a
67+
/// cited reference value (#311).
68+
pub const NOTIFICATION_BADGE: Color = Color::from_rgb8(0x00, 0x7a, 0xff);
6069
pub const DRAGGED_PILL: Color = Color::from_rgba8(0x55, 0x68, 0x7b, 0.65);
6170
pub const PALETTE_SURFACE: Color = Color::from_rgb8(0x2d, 0x2d, 0x33);
6271
pub const PALETTE_SELECTION: Color = Color::from_rgb8(0x48, 0x48, 0x4e);
@@ -114,7 +123,7 @@ pub fn tab_pill(active: bool, dragging: bool) -> impl Fn(&Theme) -> container::S
114123

115124
pub fn badge(_: &Theme) -> container::Style {
116125
container::Style::default()
117-
.background(NOTIFICATION)
126+
.background(NOTIFICATION_BADGE)
118127
.border(Border::default().rounded(NOTIFICATION_DOT_SIZE / 2.0))
119128
}
120129

@@ -455,4 +464,30 @@ mod tests {
455464
assert_eq!(style.border.color, ERROR_TEXT.scale_alpha(0.55));
456465
assert_eq!(style.border.width, 1.0);
457466
}
467+
468+
#[test]
469+
fn notification_dots_pin_the_mac_accent_and_leave_the_generic_accent_alone() {
470+
// Literals, not the constants themselves: comparing a constant to
471+
// itself passes for any value and would not catch a re-flip.
472+
assert_eq!(NOTIFICATION_BADGE, Color::from_rgb8(0x00, 0x7a, 0xff));
473+
assert_eq!(NOTIFICATION, Color::from_rgb8(0x4e, 0x9a, 0xf1));
474+
475+
// Wiring, not color: this one is tautological on its own (it would
476+
// hold for any value of the constant). It exists to catch `badge()`
477+
// being repointed at a *different* constant; the literals above are
478+
// what pin the value.
479+
let theme = Theme::Dark;
480+
assert_eq!(
481+
badge(&theme).background,
482+
Some(Background::Color(NOTIFICATION_BADGE)),
483+
"both notification dots render the Mac accent"
484+
);
485+
486+
// The generic accent keeps its other uses — nothing in #311's scope
487+
// touches the rename affordances.
488+
let focused =
489+
inline_rename_input(&theme, text_input::Status::Focused { is_hovered: false });
490+
assert_eq!(focused.border.color, NOTIFICATION);
491+
assert_eq!(focused.selection, NOTIFICATION.scale_alpha(0.65));
492+
}
458493
}

docs/development/iced-migration-roadmap.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -658,14 +658,19 @@ audited open set is:
658658
geometry, empty/loading/error states, remaining hover/focus states,
659659
offscreen-tab reveal, and confirm-overlay pointer modality; (b) **file/image drops**, upstream-blocked
660660
([#302]); (c) **terminal IME** — closed by engine slice E6 (plan 021,
661-
merged with this entry's edit); (d) one objective one-constant fix: iced's tab notification badge
662-
is `#4e9af1` where GTK deliberately hardcodes the Mac's `#007aff`
663-
found by the audit, filed as [#311].
661+
merged with this entry's edit); (d) fixed: the one-constant fix landed —
662+
`chrome::badge()` now renders a dedicated `NOTIFICATION_BADGE` (`#007aff`)
663+
instead of `#4e9af1`, pixel-verified by
664+
`test_tab_strip_pixels.py::test_notification_dots_paint_the_accent` — and
665+
it turned out to cover two dots, not one: the tab-pill badge and the
666+
sidebar project-row dot both style off `badge()` and both were corrected —
667+
found by the audit, filed and fixed as [#311].
664668
* The subjective P1s in (a) are Charlie-directed by design; M4's
665-
"no open P0/P1" criterion therefore reduces to: land E6, fix the badge
666-
constant, and either complete or explicitly waive the 3h items and the
667-
#302-blocked remainder for the beta — his call, flagged in the plan-021
668-
checklist. To be precise about waiver semantics: the criterion itself is
669+
"no open P0/P1" criterion reduces to: E6 landed (c) and the badge
670+
constant is fixed (d), so what remains is only to complete or explicitly
671+
waive the 3h items and the #302-blocked remainder for the beta — his
672+
call, flagged in the plan-021 checklist. To be precise about waiver
673+
semantics: the criterion itself is
669674
unchanged and this audit waives nothing — [#284]'s recommendation covers
670675
only the cross-toolkit CI-gate question, and any per-row waiver is an
671676
owner decision that must be recorded on the row in the inventory before

docs/development/iced-parity-inventory.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ product polish, and P2 is an optional native/toolkit refinement.
187187
| Sidebar collapse/resize | Both references expose a toolbar toggle. Swift persists a 160–400 pt user width; GTK uses a 160 pt minimum/default 220 pt `GtkPaned` without persisting a 400 pt cap | Resolved (plan 016): no in-window collapse affordance, matching the Mac — reopen via keybind/palette only, the ☰ button is removed. Dragging the grip past half the 160 pt floor (below 80 px, unclamped) collapses the sidebar and drops the live drag width without committing, so reopening restores the pre-drag committed width rather than the 160 pt floor. Recorded parity divergence: NSSplitView lets a drag continue past the floor and re-expand within the same gesture; here the grip leaves the tree at collapse, so drag-back-to-reopen within one gesture is impossible | closed | Resize: functional e2e + real-input grip segment (shipped, plan 011). Collapse: unit matrix (threshold crossing, no-Dragged-after-Collapse, released-event no-op, reopen-width invariant) plus capture (shipped, plan 016) |
188188
| Tab strip | About 24 pt pills in a compact band with 6 pt gaps and horizontal overflow | Closed for active/manual reachability: 24 pt dark pills in a 32 pt band (trued up from 34 pt in plan 016) with independent horizontal overflow | closed | Band/pill geometry assertions under both renderers; overflow test |
189189
| Tab status | Shared lifecycle dot at leading edge, white active label, muted inactive label | Audit-confirmed 2026-08-07: shared lifecycle derivation holds and the semantic color logic is unit-tested (`app.rs:3502-3529`), but no pixel test captures dot/label geometry — `test_tab_strip_pixels.py` pins only the #281 scrollbar band and the divider hairline | P1 → 3h | Add focused status-slot geometry/color capture; retain the semantic color unit test |
190-
| Tab close/badge | **Corrected 2026-08-07**: the shipped Mac shows × only on the *active* pill with no hover reveal (`App.swift:4747``closeButton.isHidden = !isActive`); the original "or hovered" framing was wrong. Inactive notification uses a blue trailing badge — GTK deliberately hardcodes Mac's `#007aff` (`style.css:277-286`) | Active exact-ID close implemented and covered (real-input `_direct_tab_close`: exact removal, survivor PTY, last-tab cascade; badge suppression + clear in `test_notifications.py`). Hover-close is a *product* decision, not a parity port → 3h. **Open finding: iced's badge is `#4e9af1` (`chrome.rs:59`) vs the references' `#007aff`** — undocumented divergence, filed as [#311](https://github.com/charliek/roost/issues/311) | P1 | Badge color one-constant fix + a badge color/position pixel assertion remain; close/cascade/clear coverage is done (audit runs 2026-08-07) |
190+
| Tab close/badge | **Corrected 2026-08-07**: the shipped Mac shows × only on the *active* pill with no hover reveal (`App.swift:4747` — `closeButton.isHidden = !isActive`); the original "or hovered" framing was wrong. Inactive notification uses a blue trailing badge — GTK deliberately hardcodes Mac's `#007aff` (`style.css:277-286`) | Active exact-ID close implemented and covered (real-input `_direct_tab_close`: exact removal, survivor PTY, last-tab cascade; badge suppression + clear in `test_notifications.py`). Hover-close is a *product* decision, not a parity port → 3h. **Closed: [#311](https://github.com/charliek/roost/issues/311) is fixed** — `chrome::badge()` now renders a dedicated `NOTIFICATION_BADGE` (`#007aff`) instead of `NOTIFICATION` (`#4e9af1`). The finding turned out to cover two surfaces, not one: `badge()` styles both the tab-pill badge and the sidebar project-row dot, and both have a Mac reference at `NSColor.controlAccentColor` (`App.swift:4772`, `:5207`), so both were corrected | P1 → 3h | Badge fix covered by `test_tab_strip_pixels.py::test_notification_dots_paint_the_accent` (color, size, and position for both the tab badge and the sidebar dot, mutation-verified); close/cascade/clear coverage is done (audit runs 2026-08-07) |
191191
| Tab rename | Inline rename through double-click or the configured command, with authoritative persistence | Closed: compact inline editor uses stable IDs, select-all focus, Enter commit, Escape/click-away cancel, and shared GTK/Iced trim/no-op policy | closed | X11 physical shortcut/double-click/Enter/Escape/click-away gate, zero PTY leakage, relaunch persistence, and named GTK/Iced captures |
192192
| Tab reorder | Pointer drag reorder with visible insertion feedback | Closed: stable-ID drag preview, insertion feedback, exact authoritative commit, cancellation, overflow, and relaunch persistence work under both renderers | closed | Bidirectional physical X11/Wayland input, outside-release/palette cancellation, zero PTY leakage, and named product captures |
193193
| New-tab affordance | Compact plus control following the pills | Closed (re-shaped by plan 016): the plus sits inside the scrolling strip 6px after the last pill and scrolls with overflow — Mac parity, where + is an arranged strip subview. Under overflow it scrolls offscreen (accepted; keybind/palette still create tabs) | closed | Real-input click computed from the last pill's rendered right edge opens one PTY-backed tab (plan 016 harness rework) |

tools/roosttest/test_sidebar_pixels.py

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -119,27 +119,45 @@ def _components(points: set[tuple[int, int]]) -> list[tuple[int, int, int, int]]
119119
return sorted(out)
120120

121121

122-
def _dot_blobs(shot, max_x: int) -> dict[str, list[tuple[int, int, int, int]]]:
123-
"""Solid, dot-sized blobs of each lifecycle colour within `max_x`."""
122+
def _blobs(
123+
shot,
124+
color: tuple[int, int, int],
125+
*,
126+
x0: int = 0,
127+
x1: int | None = None,
128+
y0: int = 0,
129+
y1: int | None = None,
130+
tol: int = 0,
131+
min_side: int = 1,
132+
) -> list[tuple[int, int, int, int]]:
133+
"""Connected components matching `color` within [x0,x1) x [y0,y1),
134+
filtered to those at least `min_side` on both axes. Returns
135+
(minx, miny, maxx, maxy) tuples, sorted."""
124136
width, height, bpp, px = shot
125-
max_x = max(0, min(max_x, width))
126-
matches: dict[str, set[tuple[int, int]]] = {name: set() for name in LIFECYCLE_COLORS}
127-
for y in range(height):
137+
x1 = width if x1 is None else min(x1, width)
138+
y1 = height if y1 is None else min(y1, height)
139+
x0 = max(0, min(x0, x1))
140+
y0 = max(0, min(y0, y1))
141+
tr, tg, tb = color
142+
points: set[tuple[int, int]] = set()
143+
for y in range(y0, y1):
128144
base = y * width * bpp
129-
for x in range(max_x):
145+
for x in range(x0, x1):
130146
o = base + x * bpp
131-
r, g, b = px[o], px[o + 1], px[o + 2]
132-
for name, (tr, tg, tb) in LIFECYCLE_COLORS.items():
133-
if abs(r - tr) <= COLOR_TOL and abs(g - tg) <= COLOR_TOL and abs(b - tb) <= COLOR_TOL:
134-
matches[name].add((x, y))
135-
break
147+
if abs(px[o] - tr) <= tol and abs(px[o + 1] - tg) <= tol and abs(px[o + 2] - tb) <= tol:
148+
points.add((x, y))
149+
return [
150+
c
151+
for c in _components(points)
152+
if (c[2] - c[0] + 1) >= min_side and (c[3] - c[1] + 1) >= min_side
153+
]
154+
155+
156+
def _dot_blobs(shot, max_x: int) -> dict[str, list[tuple[int, int, int, int]]]:
157+
"""Solid, dot-sized blobs of each lifecycle colour within `max_x`."""
136158
return {
137-
name: [
138-
c
139-
for c in _components(pts)
140-
if (c[2] - c[0] + 1) >= MIN_DOT_SIDE and (c[3] - c[1] + 1) >= MIN_DOT_SIDE
141-
]
142-
for name, pts in matches.items()
159+
name: _blobs(shot, color, x1=max_x, tol=COLOR_TOL, min_side=MIN_DOT_SIDE)
160+
for name, color in LIFECYCLE_COLORS.items()
143161
}
144162

145163

@@ -163,15 +181,7 @@ def _longest_vertical_run(shot, x: int, color: tuple[int, int, int]) -> int:
163181

164182

165183
def _color_components(shot, max_x: int, color: tuple[int, int, int]):
166-
width, height, bpp, px = shot
167-
points = set()
168-
for y in range(height):
169-
base = y * width * bpp
170-
for x in range(min(width, max_x)):
171-
offset = base + x * bpp
172-
if tuple(px[offset : offset + 3]) == color:
173-
points.add((x, y))
174-
return _components(points)
184+
return _blobs(shot, color, x1=max_x)
175185

176186

177187
def _capture(roost, path: Path):

0 commit comments

Comments
 (0)