import LChatCore import SwiftUI import UniformTypeIdentifiers // Native layout: a fixed server rail on the left, the selected instance's // NATIVE chat surface on the right. A plain HStack (not NavigationSplitView) // keeps the rail always visible — no collapse / "Hide Sidebar" toggle. The // title bar is hidden (LChatMacApp); the rail's top and the chat headers act // as the drag region (NativeChatView's window-drag monitor). struct RootView: View { @EnvironmentObject var app: MacAppState // Chats whose pane has been created. Once opened, a pane stays mounted // (just hidden) so switching back is instant — models stay parked, sockets live. @State private var activated: Set = [] // Drives the first-run "Sign in" ceremony (empty rail only). Settings owns // its own MacHomeSignIn; both read the same Keychain session. @StateObject private var firstRunSignIn = MacHomeSignIn() // The create-a-space takeover (the rail's + / File ▸ New Space, via // app.createSpaceTick). Non-nil = the instrument owns the window. @State private var createModel: CreateSpaceModel? // Spaces ▸ Add from link… (via app.addFromLinkTick). @State private var showAddFromLink = false // Esc-to-close for the takeover, installed only while it's up. A local // NSEvent monitor, not a SwiftUI key hook: the webview behind still holds // first responder, so .onKeyPress/.onExitCommand never fire here. @State private var createEscMonitor: Any? // The goes-to-eleven create flow. The WHOLE pipeline (provision → claim → // setup_complete) runs inside `create`, under "Building your space" — the // instance is persisted (and vault-pushed) before success is ever shown. private func makeCreateModel() -> CreateSpaceModel { CreateSpaceModel( create: { [weak app] in // Carry the name you already go by (your rail's first space). let screenName = await MainActor.run { app?.instancesInRailOrder().first(where: { !$0.isArchived })?.screenName ?? app?.instances.first?.screenName ?? "" } let inst = try await SpaceCreationFlow.run(screenName: screenName) await MainActor.run { app?.addInstance(inst) } return inst }, finish: { _, _ in createModel = nil }, cancel: { createModel = nil }) } var body: some View { HStack(spacing: 0) { Sidebar() .frame(width: 76) Divider() detail } .ignoresSafeArea() .coordinateSpace(.named(RailTooltip.space)) // The rail is narrow and clips, so the hover label is drawn here, over the // whole window, positioned to the right of the hovered icon. .overlayPreferenceValue(RailTooltipKey.self) { info in Group { if let info { RailTooltipLabel(text: info.text) .offset(x: 64, y: info.y - 13) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .allowsHitTesting(false) } // Create-a-space takes over the WHOLE window (Paul's call — the sheet // read small; the haze and dust want the full stage). Item-based // (model != nil), so there's no present-before-content race. .overlay { if let model = createModel { CreateSpaceView(model: model) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor)) .transition(.opacity) // Esc backs out exactly where the Cancel button would // (model.canCancel — never mid-create). The takeover owns // the key while it's up: even when cancelling is gated // off, Esc must not leak to the webview underneath. .onAppear { createEscMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in guard event.keyCode == 53 else { return event } // Esc model.requestCancel() return nil } } .onDisappear { if let createEscMonitor { NSEvent.removeMonitor(createEscMonitor) } createEscMonitor = nil } } } .animation(.easeOut(duration: 0.18), value: createModel == nil) .onChange(of: app.createSpaceTick) { _, _ in createModel = makeCreateModel() } // Spaces ▸ Add from link… — the native paste-a-link claim. .onChange(of: app.addFromLinkTick) { _, _ in showAddFromLink = true } // …and the browser handoff pre-fills the same sheet (onOpenURL's // elevenmessenger://add — the sheet is the confirming click). .onChange(of: app.pendingAddLink) { _, link in if link != nil { showAddFromLink = true } } .sheet(isPresented: $showAddFromLink) { AddFromLinkSheet() } // ⌘K quick-switch palette, drawn over the whole window (above the web feed). .overlay { if app.showPalette { CommandPalette() } } // The photo / video lightboxes, zooming out of their bubble over black. .overlay { if let item = app.lightbox { LightboxOverlay(item: item) { app.lightbox = nil } } } .overlay { if let item = app.videoLightbox { VideoLightboxOverlay(item: item) { app.videoLightbox = nil } } } .overlay { if let item = app.youtubeLightbox { YouTubeLightboxOverlay(item: item) { app.youtubeLightbox = nil } } } // Drop-to-attach for the WHOLE window. An AppKit catcher, not a // SwiftUI .onDrop: AppKit hands a drag to the deepest REGISTERED view // under the cursor, so the composer's NSTextView (whose default drop // pastes the file's PATH as text) and any parked pane's WKWebView beat // the hosting view's region wherever they overlapped it — the "drops // only catch in a strip above the composer" bug. Topmost in the view // tree, this catcher wins everywhere; clicks fall straight through it. // No hover overlay — the drag's + badge is affordance enough. .overlay { WindowDropZone( // The create takeover owns the window; a drop would stage // invisibly into the pane underneath it. canDrop: { createModel == nil && PaneDropRouter.shared.canDrop(over: selectedPaneKey(app)) }, perform: { PaneDropRouter.shared.deliver($0, over: selectedPaneKey(app)) }) } } private func nativeInstance(for chat: SavedChat) -> SavedInstance? { guard let origin = chat.origin?.absoluteString else { return nil } return app.instances.first { $0.origin == origin } } // A rail entry with no native identity: signing in again pulls the key // from the member's Eleven account; there is deliberately no web fallback. // Sign-in leads, but it is never the only rung — a one-rung ladder reads // as "the app is broken", so the pane always offers the add-device link // and re-invite paths too, and every failure leaves them in reach // (accounts-and-recovery.md, phase 0). Removal stays as the destructive // escape hatch. private func lockedPane(_ chat: SavedChat) -> some View { ContentUnavailableView { Label(app.serverName(chat), systemImage: "lock") } description: { Text("This space can't be unlocked on this Mac yet. Sign in with Eleven to bring its key back, link it from another device, or ask a member for a fresh invite.") } actions: { if firstRunSignIn.phase.busy { ProgressView().controlSize(.small) } else { Button("Sign In with Eleven…") { Task { await firstRunSignIn.signIn(into: app) } } .buttonStyle(.borderedProminent) } Button("Link from Your iPhone…") { app.requestAddFromLink(hint: "On your iPhone (or anywhere you're already in this space), open the space menu, choose Add a device, and bring the link here — it signs this Mac in as you, with your history.") } Button("Ask for a New Invite…") { app.requestAddFromLink(hint: "Any member of this space can send you a fresh invite link — add it here. You'll rejoin as a new member.") } Button("Remove from This Mac", role: .destructive) { app.remove(chat) } if case .failed(let msg) = firstRunSignIn.phase { Text(msg).font(.footnote).foregroundStyle(.red) .frame(maxWidth: 420) } else if firstRunSignIn.phase.signedIn { // Signed in, yet this pane still renders: the account didn't // hold a key for this space — say so instead of dead-ending. Text("You're signed in, but that account doesn't hold a key for this space. Link it from a device that's already in, or ask a member for a fresh invite.") .font(.footnote).foregroundStyle(.secondary) .frame(maxWidth: 420) } } } // Every opened instance stays alive in the ZStack; only the selected one is // visible and interactive. A newly-selected instance is added to `activated`, // so its webview is created once and then cached (its WebSocket stays live in // the background, so switching back shows an up-to-date feed immediately). private var detail: some View { ZStack { ForEach(app.chats) { chat in if activated.contains(chat.id) { Group { // Native chat is the ONLY surface — the full-frame // webview (and its paste-a-link join) is gone: the app // is entered by signing in with Eleven, identities ride // the vault. A chat without one is an out-of-sync // remnant and says so, natively. if let inst = nativeInstance(for: chat) { NativeChatView(instance: inst, isActive: chat.id == app.selection) .id(inst.id) } else { lockedPane(chat) } } .opacity(chat.id == app.selection ? 1 : 0) .allowsHitTesting(chat.id == app.selection) } } if app.chats.isEmpty { // Fresh Mac: lead with Sign in (restore your spaces), the // invite link as the secondary path — iOS's first-run screen. MacFirstRun(model: firstRunSignIn) } else if app.selected == nil { ContentUnavailableView( "No space selected", systemImage: "bubble.left.and.bubble.right", description: Text("Pick a space from the rail.") ) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { if let s = app.selection { activated.insert(s) } } .onChange(of: app.selection) { _, s in if let s { activated.insert(s) } } // SAFETY NET only — the window-wide AppKit catcher (WindowDropZone, // end of body) claims drags first everywhere it is consulted; this // SwiftUI region keeps the pre-catcher behaviour alive for any drag // path that never reaches it. Still ONE region, never per-pane: the // keep-alive ZStack means overlapping regions, and drop targeting // picks the topmost one regardless of opacity or allowsHitTesting. .onDrop(of: paneDroppableTypes, delegate: PaneDropDelegate(app: app)) } } // The drop's pane, resolved at DRAG time from the live selection — never from // view-lifecycle events (an isActive-flip claim scheme lost the owner to a // coalesced onChange; see PaneDropRouter). The key mirrors nativeInstance(for:): // a chat routes to the feed registered under its instance's origin. private func selectedPaneKey(_ app: MacAppState) -> String? { guard let sel = app.selection, let chat = app.chats.first(where: { $0.id == sel }) else { return nil } return chat.origin?.absoluteString } // What drop-to-attach accepts: real files, promised files (how most apps vend // drags — screenshots, Photos), and raw image/movie data. private let paneDroppableTypes: [UTType] = { var types: [UTType] = [.fileURL, .image, .movie] types += NSFilePromiseReceiver.readableDraggedTypes.compactMap { UTType($0) } return types }() private struct PaneDropDelegate: DropDelegate { let app: MacAppState // Consulted per drag session (unlike the registered type list), so the // + badge tracks the live selection: no registered feed → the drag bounces. func validateDrop(info: DropInfo) -> Bool { PaneDropRouter.shared.canDrop(over: selectedPaneKey(app)) } func performDrop(info: DropInfo) -> Bool { // The drag pasteboard directly (DropInfo never exposes it) — the same // payload the catcher delivers, so the router speaks one language. PaneDropRouter.shared.deliver(NSPasteboard(name: .drag), over: selectedPaneKey(app)) } } // The invisible whole-window drag destination. Its NSView registers for the // droppable types and, sitting above every other view, is what AppKit's // deepest-registered-view search finds first — the NSTextView and WKWebView // interceptors underneath never see the drag. hitTest returns nil so ordinary // mouse events keep falling through to the real UI (drag-destination search // walks registered frames, not the hitTest override). private struct WindowDropZone: NSViewRepresentable { var canDrop: () -> Bool var perform: (NSPasteboard) -> Bool func makeNSView(context: Context) -> DropCatcherView { DropCatcherView() } func updateNSView(_ view: DropCatcherView, context: Context) { view.canDrop = canDrop view.perform = perform } } private final class DropCatcherView: NSView { var canDrop: () -> Bool = { false } var perform: (NSPasteboard) -> Bool = { _ in false } override init(frame frameRect: NSRect) { super.init(frame: frameRect) var types: [NSPasteboard.PasteboardType] = [.fileURL, .png, .tiff] types += NSFilePromiseReceiver.readableDraggedTypes.map { NSPasteboard.PasteboardType($0) } registerForDraggedTypes(types) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func hitTest(_ point: NSPoint) -> NSView? { nil } override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { // A drag that STARTED here (an image pulled out of a bubble, a rail // reorder) is leaving, not arriving — never a drop-to-attach. guard sender.draggingSource == nil, canDrop() else { return [] } return .copy } override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { perform(sender.draggingPasteboard) } } // Discord-style rail tooltip: appears instantly on hover, in a fixed position to // the right of the hovered icon (unlike the native ~1s-delay tooltip near the // cursor). Icons publish their name + vertical midpoint via a preference; RootView // reads it and draws the label in an overlay so it isn't clipped by the rail. enum RailTooltip { static let space = "rail" } struct RailTooltipInfo: Equatable { var text: String var y: CGFloat } struct RailTooltipKey: PreferenceKey { static let defaultValue: RailTooltipInfo? = nil static func reduce(value: inout RailTooltipInfo?, nextValue: () -> RailTooltipInfo?) { value = value ?? nextValue() } } struct RailTooltipLabel: View { let text: String private let fill = Color(white: 0.14) var body: some View { HStack(spacing: 0) { LeftArrow() .fill(fill) .frame(width: 6, height: 11) Text(text) .font(.callout.weight(.semibold)) .foregroundStyle(.white) .lineLimit(1) .padding(.horizontal, 10) .padding(.vertical, 5) .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(fill)) } .shadow(color: .black.opacity(0.28), radius: 6, y: 1) .fixedSize() } } // A small left-pointing triangle for the tooltip — apex toward the icon. struct LeftArrow: Shape { func path(in rect: CGRect) -> Path { var p = Path() p.move(to: CGPoint(x: rect.maxX, y: rect.minY)) p.addLine(to: CGPoint(x: rect.minX, y: rect.midY)) p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) p.closeSubpath() return p } } // What the web layer offers the palette (window.lchatPaletteItems in shell.js): // the current server's chats plus the actions available to this member right now. private struct PaletteChat: Decodable, Identifiable { let id: String let name: String let glyph: String? // set for an app-thread (To-do, …); nil = person/group let active: Bool } private struct PaletteWebItems: Decodable { var chats: [PaletteChat] } // One flat, keyboard-navigable list mixing all four row kinds. private enum PaletteRow: Identifiable { case chat(PaletteChat) case server(SavedChat) var id: String { switch self { case .chat(let c): return "chat:" + c.id case .server(let s): return "server:" + s.id } } } // ⌘K command palette: jump to any chat on the current server, run an action // (new chat, invite someone, theme, …), or switch to another server. Servers are // native state; the chats and actions come from the web layer over the // lchatPaletteItems / lchatPaletteRun bridge (shell.js) — labels and behaviour // keep one source of truth there, native only draws rows and posts the pick // back. Drawn as an overlay (like the rail tooltip) so it sits above the // WKWebView feed; type to filter, ↑/↓ to move, ↩ to open, esc to dismiss. struct CommandPalette: View { @EnvironmentObject var app: MacAppState @State private var query = "" @State private var index = 0 @State private var web = PaletteWebItems(chats: []) @FocusState private var focused: Bool // Recency rank for a server (0 = most recently opened); unopened sort last. private func recencyRank(_ chat: SavedChat) -> Int { app.recent.firstIndex(of: chat.id) ?? Int.max } // Other servers, phrased as a switch ("Switch to Paul's team") — the one // you're on is represented by its chats above, so it isn't offered again. // With no query: most-recently-opened first, then any never-opened in saved // order. With a query: fuzzy subsequence match on the row label or host, best // score first, ties broken by recency — so "cfc" (or "switch") finds // "Switch to Campbell family chat", and among comparable matches the one // used most recently wins. private func serverMatches(_ q: String) -> [SavedChat] { let ranked = app.chats.filter { $0.id != app.selection }.enumerated().map { (chat: $0.element, order: $0.offset, recency: recencyRank($0.element)) } if q.isEmpty { return ranked .sorted { $0.recency != $1.recency ? $0.recency < $1.recency : $0.order < $1.order } .map(\.chat) } return ranked .compactMap { r -> (chat: SavedChat, score: Int, order: Int, recency: Int)? in let scores = [fuzzyScore(q, "Switch to " + app.serverName(r.chat)), fuzzyScore(q, r.chat.origin?.host ?? "")].compactMap { $0 } guard let best = scores.max() else { return nil } return (r.chat, best, r.order, r.recency) } .sorted { if $0.score != $1.score { return $0.score > $1.score } if $0.recency != $1.recency { return $0.recency < $1.recency } return $0.order < $1.order } .map(\.chat) } // The full list, in section order: chats on this server (sorted // pinned-then-recent by the publisher, capped when idle so servers stay in // reach), then other servers. private var rows: [PaletteRow] { let q = query.filter { !$0.isWhitespace } var out: [PaletteRow] = [] if q.isEmpty { out += web.chats.prefix(6).map { .chat($0) } } else { out += web.chats .compactMap { c in fuzzyScore(q, c.name).map { (chat: c, score: $0) } } .sorted { $0.score > $1.score } .map { .chat($0.chat) } } out += serverMatches(q).map { .server($0) } return out } // Query chars must appear in `text` in order (case-insensitive). Word-boundary // and contiguous matches score higher; nil when it isn't a subsequence at all. private func fuzzyScore(_ query: String, _ text: String) -> Int? { let q = Array(query.lowercased()) let t = Array(text.lowercased()) var qi = 0, score = 0, prevMatch = -2 var prevChar: Character? for (ti, ch) in t.enumerated() { if qi < q.count, ch == q[qi] { if prevChar == nil || !(prevChar!.isLetter || prevChar!.isNumber) { score += 10 } if ti == prevMatch + 1 { score += 5 } score += 1 prevMatch = ti qi += 1 } prevChar = ch } return qi == q.count ? score : nil } var body: some View { let inits = app.initials() let rows = self.rows return ZStack(alignment: .top) { Color.black.opacity(0.2).ignoresSafeArea() .onTapGesture { close() } VStack(spacing: 0) { // Search field: leading magnifying glass + a big, quiet placeholder. HStack(spacing: 12) { Image(systemName: "magnifyingglass") .font(.system(size: 18, weight: .medium)) .foregroundStyle(.secondary) TextField("Jump to…", text: $query) .textFieldStyle(.plain) .font(.system(size: 21)) .focused($focused) .onSubmit(activate) } .padding(.horizontal, 20) .padding(.vertical, 17) Divider().opacity(0.5) ScrollViewReader { proxy in ScrollView { VStack(spacing: 4) { ForEach(Array(rows.enumerated()), id: \.element.id) { i, row in paletteRow(row, inits: inits, highlighted: i == index) .id(row.id) } } .padding(8) } .frame(maxHeight: 340) // Keep the keyboard highlight in view as ↑/↓ walk the list. An // explicit directional anchor (not nil, which macOS sometimes // ignores): moving down pins the row to the bottom edge, moving // up to the top — the ScrollView clamps, so nothing moves until // the highlight actually reaches an edge. .onChange(of: index) { old, new in if new < rows.count { proxy.scrollTo(rows[new].id, anchor: new > old ? .bottom : .top) } } } Divider().opacity(0.5) // Footer hint bar, à la TinyStart's status line. HStack(spacing: 16) { hint("↩", "Open") hint("↑ ↓", "Navigate") hint("esc", "Dismiss") Spacer() } .padding(.horizontal, 20) .padding(.vertical, 9) } .frame(width: 600) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 20, style: .continuous).strokeBorder(.white.opacity(0.12))) .shadow(color: .black.opacity(0.35), radius: 30, y: 12) .padding(.top, 80) } .onAppear { index = 0 loadWebItems() DispatchQueue.main.async { focused = true } } // While the palette is up it owns the keyboard. If anything else grabs // first responder (switching away and back re-focuses the web composer; // see ChatFeedWebView.focusComposer, which also guards on showPalette), // take it straight back — and re-assert on app re-activation too, since // a focus lost to another app comes back to whoever asks. .onChange(of: focused) { _, isFocused in if !isFocused && app.showPalette { DispatchQueue.main.async { focused = true } } } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in DispatchQueue.main.async { focused = true } } .onChange(of: query) { _, _ in index = 0 } .onKeyPress(.downArrow) { index = min(index + 1, rows.count - 1); return .handled } .onKeyPress(.upArrow) { index = max(index - 1, 0); return .handled } .onExitCommand { close() } } // Ask the selected instance's surface what it can offer. The native // takeover publishes its threads on MacAppState (no webview to ask); the // webview path asks lchatPaletteItems. Fails soft either way: the palette // still switches servers — just no chat rows. private func loadWebItems() { // The active NativeChatView publishes its threads on MacAppState — // the sole chat source now the webview bridge is gone. web = PaletteWebItems(chats: app.nativeChats.map { PaletteChat(id: $0.id, name: $0.title, glyph: $0.glyph, active: false) }) } private func activate() { let rows = self.rows guard index < rows.count else { return } pick(rows[index]) } private func pick(_ row: PaletteRow) { switch row { case .chat(let c): app.nativeOpenThread = c.id // consumed by the active NativeChatView close() case .server(let s): app.selection = s.id close() } } private func close() { app.showPalette = false; query = "" } @ViewBuilder private func paletteRow(_ row: PaletteRow, inits: [String: String], highlighted: Bool) -> some View { switch row { case .chat(let c): chatRow(c, highlighted: highlighted) case .server(let s): serverRow(s, initial: inits[s.id] ?? "?", highlighted: highlighted) } } // A chat on the current server: initials badge coloured by thread id (or the // app's glyph for an app-thread), accent dot on the one that's open. @ViewBuilder private func chatRow(_ c: PaletteChat, highlighted: Bool) -> some View { rowShell(highlighted: highlighted, action: { pick(.chat(c)) }) { statusDot(current: c.active) // The native surface knows each chat's real (decrypted) photo and // which app a thread belongs to — show the photo, or the app's // identity mark (docs/icons.md); glyph/initials remain the webview // path's fallback. let native = app.nativeChats.first(where: { $0.id == c.id }) if let appId = native?.appId, AppIdentity.symbol(for: appId) != nil || AppIdentity.mark(for: appId) != nil { NativeChatView.ThreadAvatar(title: c.name, source: nil, size: 38, glyph: native?.glyph, symbol: AppIdentity.symbol(for: appId), mark: AppIdentity.mark(for: appId)) } else if let avatar = native?.avatar { NativeChatView.ThreadAvatar(title: c.name, source: avatar, size: 38) } else { Text(c.glyph ?? chatInitials(c.name)) .font(.system(size: 16, weight: .semibold)) .lineLimit(1).minimumScaleFactor(0.6) .foregroundStyle(c.glyph == nil ? .white : .primary) .frame(width: 38, height: 38) .background(RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(c.glyph == nil ? Color(hue: app.hue(for: c.id), saturation: 0.55, brightness: 0.78) : Color.secondary.opacity(0.18))) } Text(c.name).font(.system(size: 16, weight: .semibold)) } } // Another server, TinyStart-style: colour badge with the instance initial, // "Switch to " + host. @ViewBuilder private func serverRow(_ chat: SavedChat, initial: String, highlighted: Bool) -> some View { rowShell(highlighted: highlighted, action: { pick(.server(chat)) }) { statusDot(current: false) Text(initial) .font(.system(size: 16, weight: .semibold)) .lineLimit(1).minimumScaleFactor(0.6) .foregroundStyle(.white) .frame(width: 38, height: 38) .background(RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Color(hue: app.hue(for: chat.id), saturation: 0.55, brightness: 0.78))) VStack(alignment: .leading, spacing: 1) { Text("Switch to \(app.serverName(chat))").font(.system(size: 16, weight: .semibold)) if let host = chat.origin?.host, host != app.serverName(chat) { Text(host).font(.system(size: 12)).foregroundStyle(.secondary) } } } } // Chat-name initials, mirroring MacAppState.initials(): one letter for a // single-word name, first letters of the first two words otherwise. private func chatInitials(_ name: String) -> String { let ws = name.split { !($0.isLetter || $0.isNumber) } func head(_ w: Substring?) -> String? { w?.first.map { String($0).uppercased() } } guard let first = head(ws.first) else { return "?" } return ws.count >= 2 ? first + (head(ws[1]) ?? "") : first } private func actionSymbol(_ id: String) -> String { switch id { case "new-chat": return "square.and.pencil" case "new-group": return "person.2" case "invite": return "person.badge.plus" case "apps": return "square.grid.2x2" case "add-device": return "laptopcomputer.and.iphone" case "theme": return "paintpalette" case "edit-profile": return "person.crop.circle" case "leave": return "rectangle.portrait.and.arrow.right" default: return "bolt" } } // Shared row chrome: the highlight background, trailing ↩, padding + button. @ViewBuilder private func rowShell(highlighted: Bool, action: @escaping () -> Void, @ViewBuilder content: () -> Content) -> some View { Button(action: action) { HStack(spacing: 12) { content() Spacer(minLength: 8) Image(systemName: "return") .font(.system(size: 14, weight: .medium)) .foregroundStyle(Color.accentColor) .opacity(highlighted ? 1 : 0) } .padding(.horizontal, 12) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(highlighted ? Color.accentColor.opacity(0.22) : .clear)) .contentShape(Rectangle()) } .buttonStyle(.plain) } private func statusDot(current: Bool) -> some View { Circle() .fill(current ? Color.accentColor : Color.secondary.opacity(0.35)) .frame(width: 6, height: 6) } @ViewBuilder private func hint(_ key: String, _ label: String) -> some View { HStack(spacing: 5) { Text(key) .font(.system(size: 11, weight: .semibold)) .padding(.horizontal, 5).padding(.vertical, 2) .background(RoundedRectangle(cornerRadius: 4).fill(.secondary.opacity(0.18))) Text(label).font(.system(size: 11)).foregroundStyle(.secondary) } } }