import AppKit import Foundation import Quartz import UniformTypeIdentifiers /// **The preview's whole appearance** — a `BoardOutline` turned into one self-contained HTML /// document plus the images it references. /// /// ### Why the styling values are resolved here and not in the walk /// /// `BoardOutline` carries `background`, `icon` and `iconColor` **as written** — a palette name, a /// hex, an SF Symbol name — exactly as `BoardModel` does, because "an unrecognized value renders as /// the default and the bytes stay as written" is a *rendering* rule and this file is the renderer /// (Palette.swift ▸ "Lenient, never an error"; ItemSymbol.swift ▸ the same for symbols). Resolution /// goes through `Palette` itself rather than through a second copy of the twelve-and-twelve table, /// so a board tinted `smokey-ocean` is the same colour in the preview as it is in the app. /// /// ### Why the board tint is a wash and not a band /// /// A board's colour is chosen to sit *behind a whole window* of app chrome, where the app picks /// readable ink for it against the window backdrop (`ContrastMath` ▸ `BoardTextInk`). A preview has /// no such machinery and should not grow one for a header strip, so the tint is applied at low alpha /// under the title and at full strength only as a lane's edge accent — narrow marks that carry no /// text. Contrast is then never in question, in either appearance, for any of the 16.7 million /// values a hand-written hex can be. enum BoardPreviewPage { /// The size Quick Look draws its loading state at, and its opening guess at the panel. Only a /// hint: HTML has no intrinsic size, so the panel is the user's to resize and the page reflows. static let contentSizeHint = CGSize(width: 820, height: 560) /// The board's default symbol when it carries no `icon` — `ItemSymbol.board`'s value /// (03-board-ui.md § Styling ▸ Capabilities: "`icon`: SF Symbol per item with per-level /// defaults"). /// /// Restated rather than shared: `ItemSymbol` reaches `StyleLevel` for its per-level lookup, which /// reaches the live store, which has no business inside a preview extension. One string is the /// cheaper coupling, and it is the board's default — the only one of the three this file can ever /// need. private static let defaultSymbol = "rectangle.split.3x1" /// The mark's two bakings. A PNG has one colour and a preview has two appearances, so the page /// carries both and lets CSS choose — see `attachments(for:)`. private static let lightMarkID = "board-mark-light" private static let darkMarkID = "board-mark-dark" // MARK: - The page static func html(for outline: BoardOutline) -> Data { var out = "\n\n\n\n" out += "\n" out += "\(escape(outline.title))\n" out += "\n\n\n" out += "
\n" out += header(for: outline) out += lanes(of: outline) out += "
\n\n\n" return Data(out.utf8) } private static func header(for outline: BoardOutline) -> String { var out = "
\n" out += "\"\"" out += "\"\"\n" out += "
\n" out += "

\(escape(outline.title))

\n" out += "

\(escape(laneCountPhrase(for: outline)))

\n" out += "
\n
\n" return out } /// "3 lanes", "1 lane", "No lanes yet" — and "100+ lanes" for the board whose count the walk /// stopped taking (`BoardOutline.laneCountIsCapped`), because a number that is not the number is /// worse than an honest floor. private static func laneCountPhrase(for outline: BoardOutline) -> String { guard outline.laneCount > 0 else { return "No lanes yet" } let count = outline.laneCountIsCapped ? "\(outline.laneCount)+" : "\(outline.laneCount)" return "\(count) \(outline.laneCount == 1 && !outline.laneCountIsCapped ? "lane" : "lanes")" } private static func lanes(of outline: BoardOutline) -> String { // A board with nothing in it, and a folder that is not a board at all, reach here the same // way and say so once — the header's "No lanes yet" is the count, this is the invitation. guard outline.laneCount > 0 else { return "

This board has no lanes yet.

\n" } var out = "
\n" for lane in outline.lanes { let accent = cssColor(lane.background).map { " style=\"--accent: \($0)\"" } ?? "" out += "
\n" out += "

\(escape(lane.title ?? "Untitled"))" out += "\(escape(cardCountPhrase(for: lane)))

\n" if lane.cardTitles.isEmpty { out += "

Empty

\n" } else { out += "\n" } if lane.hiddenCardCount > 0 { out += "

+\(lane.hiddenCardCount) more

\n" } out += "
\n" } out += "
\n" if outline.hiddenLaneCount > 0 { let suffix = outline.laneCountIsCapped ? "+" : "" out += "

+\(outline.hiddenLaneCount)\(suffix) more lanes

\n" } return out } /// A lane's badge: its exact card count, or the floor the count stopped at /// (`LaneOutline.cardCountIsCapped`). private static func cardCountPhrase(for lane: LaneOutline) -> String { lane.cardCountIsCapped ? "\(lane.cardCount)+" : "\(lane.cardCount)" } // MARK: - Style private static func stylesheet(for outline: BoardOutline) -> String { let wash = cssColor(outline.background, alpha: 0.16) ?? "transparent" let rule = cssColor(outline.background, alpha: 0.55) ?? "var(--hairline)" return """ :root { --ink: #1d1d1f; --ink-quiet: #6e6e73; --page: #ffffff; --plate: #f5f5f7; --hairline: rgba(0, 0, 0, 0.12); --wash: \(wash); --rule: \(rule); --accent: var(--hairline); } @media (prefers-color-scheme: dark) { :root { --ink: #f5f5f7; --ink-quiet: #98989d; --page: #1e1e1e; --plate: #2a2a2c; --hairline: rgba(255, 255, 255, 0.16); } } * { box-sizing: border-box; } body { margin: 0; background: var(--page); color: var(--ink); font: 13px/1.45 -apple-system, "SF Pro Text", "Helvetica Neue", sans-serif; -webkit-font-smoothing: antialiased; } main { padding: 20px 22px 26px; } header.board { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 10px; background: var(--wash); border-bottom: 2px solid var(--rule); /* The wash and the rule are both the board's own colour, so a board tinted the same shade as the page it is previewed on paints nothing at all — a `#1E1E1E` board in the dark appearance. This hairline is what keeps the block a block regardless: the tint is the board's, the edge is the page's. */ box-shadow: inset 0 0 0 1px var(--hairline); } .mark { width: 34px; height: 34px; flex: none; object-fit: contain; } .light-only { display: block; } .dark-only { display: none; } @media (prefers-color-scheme: dark) { .light-only { display: none; } .dark-only { display: block; } } .board-text { min-width: 0; } h1 { margin: 0; font-size: 20px; font-weight: 600; letter-spacing: -0.01em; overflow-wrap: anywhere; } .meta { margin: 2px 0 0; color: var(--ink-quiet); font-size: 12px; } .lanes { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 18px; align-items: flex-start; } .lane { flex: 1 1 190px; min-width: 170px; max-width: 300px; padding: 10px 12px 10px 13px; border-radius: 8px; background: var(--plate); border-left: 3px solid var(--accent); } .lane h2 { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin: 0 0 8px; font-size: 13px; font-weight: 600; } .lane-title { overflow-wrap: anywhere; } .count { flex: none; color: var(--ink-quiet); font-variant-numeric: tabular-nums; font-weight: 400; } .lane ul { margin: 0; padding: 0; list-style: none; } .lane li { padding: 4px 0; border-top: 1px solid var(--hairline); overflow-wrap: anywhere; } .lane li:first-child { border-top: none; padding-top: 0; } .more, .empty-lane { margin: 6px 0 0; color: var(--ink-quiet); font-size: 12px; } .more-lanes, .empty { margin: 14px 0 0; color: var(--ink-quiet); font-size: 12px; } """ } // MARK: - Colour /// A stored styling value as a CSS colour — `Palette`'s resolution (a palette name or a /// `#RRGGBB[AA]` hex), re-emitted as `rgba()` so the value's own alpha and the caller's can be /// combined. `nil` for every shape `Palette` declines to read, which every call site treats as /// "there is no colour" rather than as an error. private static func cssColor(_ value: String?, alpha: Double = 1) -> String? { guard let value, let color = Palette.nsColor(for: value)?.usingColorSpace(.sRGB) else { return nil } let channel = { (component: CGFloat) in Int((component * 255).rounded()) } let combined = Double(color.alphaComponent) * alpha return "rgba(\(channel(color.redComponent)), \(channel(color.greenComponent)), " + "\(channel(color.blueComponent)), \(String(format: "%.3f", combined)))" } // MARK: - The mark /// The board's symbol, baked twice: once in the light appearance's ink and once in the dark /// one's, so a page rendered in either reads correctly. A board that names its own `iconColor` /// gets that colour in both — a chosen tint is not an appearance-dependent value. /// /// Both entries are always present, even when the symbol will not resolve: an attachment /// dictionary missing a `cid:` the page references is a broken-image glyph, and this way a bad /// `icon:` degrades to the *default* symbol exactly as it does in the app. static func attachments(for outline: BoardOutline) -> [String: QLPreviewReplyAttachment] { let symbol = outline.icon.flatMap(resolvedSymbol) ?? defaultSymbol let chosen = outline.iconColor.flatMap(Palette.nsColor(for:)) var attachments: [String: QLPreviewReplyAttachment] = [:] let inks: [(id: String, fallback: NSColor)] = [ (lightMarkID, NSColor(srgbRed: 0.11, green: 0.11, blue: 0.12, alpha: 1)), (darkMarkID, NSColor(srgbRed: 0.96, green: 0.96, blue: 0.97, alpha: 1)), ] for ink in inks { guard let png = markPNG(symbol: symbol, tint: chosen ?? ink.fallback) else { continue } attachments[ink.id] = QLPreviewReplyAttachment(data: png, contentType: .png) } return attachments } /// `name` if the running system can draw it as an SF Symbol — `ItemSymbol.exists(_:)`'s rule and /// its reasoning ("`NSImage(systemSymbolName:)` is the only honest test"). private static func resolvedSymbol(_ name: String) -> String? { guard !name.isEmpty, NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil else { return nil } return name } /// One symbol as tinted PNG bytes. /// /// Drawn at twice the size the page displays it at (34 CSS pixels), which is the whole reason for /// the point size below: a PNG has no vector fallback, and a Retina panel would show a 34-pixel /// image soft. `nil` for a symbol the system declines to draw or an image that will not encode — /// the page then simply shows no mark, which is `Palette`'s "there is no colour, so show none" /// one medium over. private static func markPNG(symbol: String, tint: NSColor) -> Data? { let configuration = NSImage.SymbolConfiguration(pointSize: 68, weight: .regular) guard let image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)? .withSymbolConfiguration(configuration) else { return nil } // `sourceAtop` over the drawn glyph rather than a palette configuration: it flattens a // multicolour symbol to the one tint, which is what the app's own `foregroundStyle` does to // it anyway, and it cannot come out black the way a template image can. let tinted = NSImage(size: image.size, flipped: false) { rect in image.draw(in: rect) tint.setFill() rect.fill(using: .sourceAtop) return true } guard let tiff = tinted.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff) else { return nil } return bitmap.representation(using: .png, properties: [:]) } // MARK: - Escaping /// Every character that could end an attribute, open a tag or start an entity — the page is /// assembled from board text that is entirely the user's, so nothing reaches the document without /// passing through here. private static func escape(_ text: String) -> String { var escaped = "" escaped.reserveCapacity(text.count) for character in text { switch character { case "&": escaped += "&" case "<": escaped += "<" case ">": escaped += ">" case "\"": escaped += """ case "'": escaped += "'" default: escaped.append(character) } } return escaped } }