diff --git a/Justfile b/Justfile index 9bbf3d5..a70f4fd 100644 --- a/Justfile +++ b/Justfile @@ -226,6 +226,24 @@ ladle: screenshots: cd client && node screenshots.mjs +# Rasterises client/public/icon.svg (the source of truth) into the PWA any + +# maskable PNGs, the apple-touch-icon, and packaging/macos/deckd.icns. Uses +# the e2e Chromium, so client/node_modules must be installed; the .icns step +# is macOS-only (iconutil) and is skipped elsewhere. +# +# Regenerate all app icons from the brand SVG +icons: + #!/usr/bin/env bash + set -euo pipefail + cd client && node render-icons.mjs + cd .. + if command -v iconutil >/dev/null 2>&1; then + iconutil -c icns build/deckd.iconset -o packaging/macos/deckd.icns + echo "Wrote packaging/macos/deckd.icns" + else + echo "iconutil not found (not macOS); skipped .icns, PNGs are up to date." + fi + # Run the full verification ladder (docs/ONBOARDING.md) in order: # typechecks first (cheap gates), then Python unit/integration, then # TypeScript compile, client unit tests, Playwright e2e, the daemon diff --git a/assets/deckd-logo-light.svg b/assets/deckd-logo-light.svg new file mode 100644 index 0000000..9cdfb24 --- /dev/null +++ b/assets/deckd-logo-light.svg @@ -0,0 +1,56 @@ + + deckd logo (light) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + deckd + + + CONTEXT-AWARE CONTROLLER + diff --git a/assets/deckd-logo.svg b/assets/deckd-logo.svg new file mode 100644 index 0000000..289b75b --- /dev/null +++ b/assets/deckd-logo.svg @@ -0,0 +1,60 @@ + + deckd logo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + deckd + + + + CONTEXT-AWARE CONTROLLER + diff --git a/assets/deckd-mark-2x2.png b/assets/deckd-mark-2x2.png new file mode 100644 index 0000000..ace1f8b Binary files /dev/null and b/assets/deckd-mark-2x2.png differ diff --git a/assets/deckd-mark-2x2.svg b/assets/deckd-mark-2x2.svg new file mode 100644 index 0000000..10651ff --- /dev/null +++ b/assets/deckd-mark-2x2.svg @@ -0,0 +1,37 @@ + + deckd mark 2x2 (top-right active) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/deckd-mark-3x2.png b/assets/deckd-mark-3x2.png new file mode 100644 index 0000000..bbdb297 Binary files /dev/null and b/assets/deckd-mark-3x2.png differ diff --git a/assets/deckd-mark-mono.svg b/assets/deckd-mark-mono.svg new file mode 100644 index 0000000..9cc91a2 --- /dev/null +++ b/assets/deckd-mark-mono.svg @@ -0,0 +1,13 @@ + + deckd menu-bar mark + + + + + + + + diff --git a/assets/deckd-mark.png b/assets/deckd-mark.png new file mode 100644 index 0000000..d6b7d87 Binary files /dev/null and b/assets/deckd-mark.png differ diff --git a/assets/deckd-mark.svg b/assets/deckd-mark.svg new file mode 100644 index 0000000..d37bacd --- /dev/null +++ b/assets/deckd-mark.svg @@ -0,0 +1,47 @@ + + deckd mark + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/index.html b/client/index.html index 3f5a7a1..e857e10 100644 --- a/client/index.html +++ b/client/index.html @@ -10,7 +10,8 @@ (root in dev, /deckd/ on Project Pages). --> - + + diff --git a/client/public/apple-touch-icon.png b/client/public/apple-touch-icon.png new file mode 100644 index 0000000..f066cec Binary files /dev/null and b/client/public/apple-touch-icon.png differ diff --git a/client/public/icon-192.png b/client/public/icon-192.png new file mode 100644 index 0000000..bc2011d Binary files /dev/null and b/client/public/icon-192.png differ diff --git a/client/public/icon-512.png b/client/public/icon-512.png new file mode 100644 index 0000000..7104993 Binary files /dev/null and b/client/public/icon-512.png differ diff --git a/client/public/icon-maskable-192.png b/client/public/icon-maskable-192.png new file mode 100644 index 0000000..31c28d9 Binary files /dev/null and b/client/public/icon-maskable-192.png differ diff --git a/client/public/icon-maskable-512.png b/client/public/icon-maskable-512.png new file mode 100644 index 0000000..0499884 Binary files /dev/null and b/client/public/icon-maskable-512.png differ diff --git a/client/public/icon-maskable.svg b/client/public/icon-maskable.svg new file mode 100644 index 0000000..5bb482c --- /dev/null +++ b/client/public/icon-maskable.svg @@ -0,0 +1,35 @@ + + deckd mark (maskable) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/icon.svg b/client/public/icon.svg index b286fa1..10651ff 100644 --- a/client/public/icon.svg +++ b/client/public/icon.svg @@ -1,12 +1,37 @@ - - - - - - - - - - + + deckd mark 2x2 (top-right active) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/manifest.json b/client/public/manifest.json index 7e2fea4..44919cd 100644 --- a/client/public/manifest.json +++ b/client/public/manifest.json @@ -10,10 +10,34 @@ "orientation": "any", "icons": [ { - "src": "/icon.svg", + "src": "icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", - "purpose": "any maskable" + "purpose": "any" } ] } diff --git a/client/render-icons.mjs b/client/render-icons.mjs new file mode 100644 index 0000000..335752e --- /dev/null +++ b/client/render-icons.mjs @@ -0,0 +1,114 @@ +import { chromium } from "playwright-core"; +import { mkdirSync, readFileSync, rmSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { findChromiumExe } from "./e2e/find-chromium.mjs"; + +/** + * Rasterise the deckd brand SVGs into every PNG the app needs (issue #165). + * + * The SVG in ``client/public/icon.svg`` is the source of truth. Browsers are + * happy to consume it directly for the favicon and the ``any`` manifest icon, + * but the maskable PWA icon, the iOS apple-touch-icon, and the macOS ``.icns`` + * all need real pixels. Rather than pull in an SVG rasteriser, we drive the + * Playwright Chromium that the e2e suite already installs. + * + * Outputs (all git-tracked): + * client/public/icon-192.png, icon-512.png (any) + * client/public/icon-maskable-192.png, -512.png (maskable) + * client/public/apple-touch-icon.png (maskable, 180, opaque) + * packaging/macos/deckd-menubar.png, @2x.png (menu-bar template) + * build/deckd.iconset/*.png (for iconutil → .icns) + * + * Run via ``just icons``. + */ +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(__dirname, ".."); +const PUBLIC = resolve(__dirname, "public"); +// iconutil insists the directory name ends in ``.iconset``. +const ICONSET = resolve(REPO, "build", "deckd.iconset"); +const MACOS = resolve(REPO, "packaging", "macos"); + +const ANY_SVG = resolve(PUBLIC, "icon.svg"); +const MASKABLE_SVG = resolve(PUBLIC, "icon-maskable.svg"); +// Menu-bar template: monochrome, 18pt canvas, bundled into deckd.app. +const MENUBAR_SVG = resolve(REPO, "assets", "deckd-mark-mono.svg"); + +// Apple masks the home-screen icon itself and dislikes transparency, so the +// opaque, full-bleed maskable art is the right source here too. +const ANY_TARGETS = [ + [ANY_SVG, "icon-192.png", 192], + [ANY_SVG, "icon-512.png", 512], + [MASKABLE_SVG, "icon-maskable-192.png", 192], + [MASKABLE_SVG, "icon-maskable-512.png", 512], + [MASKABLE_SVG, "apple-touch-icon.png", 180], +]; + +// Menu-bar status-item template. AppKit resolves the @2x sibling when the +// 1x file is loaded, so the point size (18) and pixel sizes (18/36) line up. +const MENUBAR_TARGETS = [ + ["deckd-menubar.png", 18], + ["deckd-menubar@2x.png", 36], +]; + +// Standard macOS .iconset layout: name → pixel size (the @2x entries repeat +// the size of the next rung, which is why e.g. 32 appears twice). +const ICNS_TARGETS = [ + ["icon_16x16.png", 16], + ["icon_16x16@2x.png", 32], + ["icon_32x32.png", 32], + ["icon_32x32@2x.png", 64], + ["icon_128x128.png", 128], + ["icon_128x128@2x.png", 256], + ["icon_256x256.png", 256], + ["icon_256x256@2x.png", 512], + ["icon_512x512.png", 512], + ["icon_512x512@2x.png", 1024], +]; + +function htmlFor(svgText) { + // Fill the viewport exactly; no body margin, no scrollbars. + return ` +${svgText}`; +} + +async function main() { + rmSync(ICONSET, { recursive: true, force: true }); + mkdirSync(ICONSET, { recursive: true }); + + const chromePath = findChromiumExe(); + const browser = await chromium.launch({ executablePath: chromePath, headless: true }); + + try { + for (const [svgPath, name, size] of ANY_TARGETS) { + await render(browser, svgPath, resolve(PUBLIC, name), size, name); + } + for (const [name, size] of ICNS_TARGETS) { + await render(browser, ANY_SVG, resolve(ICONSET, name), size, `deckd.iconset/${name}`); + } + for (const [name, size] of MENUBAR_TARGETS) { + await render(browser, MENUBAR_SVG, resolve(MACOS, name), size, `menubar/${name}`); + } + } finally { + await browser.close(); + } + + console.log(`Done — ${PUBLIC}, ${MACOS}, and ${ICONSET}`); +} + +async function render(browser, svgPath, outPath, size, label) { + const page = await browser.newPage({ + viewport: { width: size, height: size }, + deviceScaleFactor: 1, + }); + await page.setContent(htmlFor(readFileSync(svgPath, "utf8"))); + await page.screenshot({ path: outPath, omitBackground: true }); + await page.close(); + console.log(` ${label}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/daemon/deckd/macos_app.py b/daemon/deckd/macos_app.py index d9d8add..3085a41 100644 --- a/daemon/deckd/macos_app.py +++ b/daemon/deckd/macos_app.py @@ -59,6 +59,21 @@ def overlay_src(root: Path) -> Path: return root / "layouts.macos" +def menubar_icon_paths(root: Path) -> tuple[Path, Path] | None: + """The ``(1x, 2x)`` menu-bar template PNGs, or ``None`` if not bundled. + + Frozen builds put them at the bundle root (``root`` is ``sys._MEIPASS``); + a source checkout keeps them beside the spec in ``packaging/macos``. + ``packaging/macos/menubar.py`` loads both reps into one template image. + """ + candidates = (root, root / "packaging" / "macos") + for base in candidates: + one_x = base / "deckd-menubar.png" + if one_x.is_file(): + return one_x, base / "deckd-menubar@2x.png" + return None + + def app_support_dir() -> Path: """``~/Library/Application Support/deckd`` — the writable data dir.""" return Path.home() / "Library" / "Application Support" / APP_SUPPORT_DIRNAME diff --git a/packaging/macos/deckd-menubar.png b/packaging/macos/deckd-menubar.png new file mode 100644 index 0000000..d2c4474 Binary files /dev/null and b/packaging/macos/deckd-menubar.png differ diff --git a/packaging/macos/deckd-menubar@2x.png b/packaging/macos/deckd-menubar@2x.png new file mode 100644 index 0000000..8849bda Binary files /dev/null and b/packaging/macos/deckd-menubar@2x.png differ diff --git a/packaging/macos/deckd.icns b/packaging/macos/deckd.icns new file mode 100644 index 0000000..329f81f Binary files /dev/null and b/packaging/macos/deckd.icns differ diff --git a/packaging/macos/deckd.spec b/packaging/macos/deckd.spec index 8a9f7bc..19f93fa 100644 --- a/packaging/macos/deckd.spec +++ b/packaging/macos/deckd.spec @@ -22,15 +22,22 @@ for line in (ROOT / "pyproject.toml").read_text().splitlines(): version = line.split("=", 1)[1].strip().strip('"') break -# Optional custom icon: generate an .icns (e.g. from client/public/icon.svg) -# and point DECKD_ICON at it, otherwise the default PyInstaller icon is used. -icon = os.environ.get("DECKD_ICON") -if icon and not Path(icon).is_file(): - raise SystemExit(f"DECKD_ICON set but not found: {icon}") +# App icon: the committed ``deckd.icns`` (built from client/public/icon.svg by +# ``just icons``). ``DECKD_ICON`` overrides it; otherwise the default +# PyInstaller icon is used when the .icns is missing. +icon = os.environ.get("DECKD_ICON") or str(ROOT / "packaging/macos/deckd.icns") +if not Path(icon).is_file(): + if os.environ.get("DECKD_ICON"): + raise SystemExit(f"DECKD_ICON set but not found: {icon}") + icon = None datas = [ (str(ROOT / "client/dist"), "web"), (str(ROOT / "layouts"), "layouts"), + # Menu-bar status-item template (monochrome, light/dark aware). Placed at + # the bundle root so ``resource_root() / "deckd-menubar.png"`` resolves. + (str(ROOT / "packaging/macos/deckd-menubar.png"), "."), + (str(ROOT / "packaging/macos/deckd-menubar@2x.png"), "."), ] if (ROOT / "layouts.macos").is_dir(): datas.append((str(ROOT / "layouts.macos"), "layouts.macos")) diff --git a/packaging/macos/menubar.py b/packaging/macos/menubar.py index f5e2859..3aa7dce 100644 --- a/packaging/macos/menubar.py +++ b/packaging/macos/menubar.py @@ -22,6 +22,7 @@ client_dist, default_log_file, layouts_src, + menubar_icon_paths, overlay_src, resource_root, seed_layouts, @@ -34,6 +35,34 @@ def _surface_url(port: int = DEFAULT_PORT) -> str: return f"http://127.0.0.1:{port}/" +def _menubar_image(): + """The brand mark as a menu-bar template image, or ``None`` if unbundled. + + Loads the bundled 1x/@2x template PNGs into one 18pt NSImage and marks it + a template, so macOS tints it to match the current menu-bar appearance. + """ + from AppKit import NSImage, NSImageRep + + paths = menubar_icon_paths(resource_root()) + if paths is None: + return None + image = NSImage.alloc().initWithSize_((18.0, 18.0)) + added = False + for path in paths: + if not path.is_file(): + continue + rep = NSImageRep.imageRepWithContentsOfFile_(str(path)) + if rep is None: + continue + rep.setSize_((18.0, 18.0)) + image.addRepresentation_(rep) + added = True + if not added: + return None + image.setTemplate_(True) + return image + + def _prepare() -> tuple[Path, Path, Path]: """Seed writable data, returning (layouts_dir, client_dist, log_file).""" root = resource_root() @@ -55,6 +84,7 @@ def main() -> None: from AppKit import ( NSApp, NSApplication, + NSImage, NSMenu, NSMenuItem, NSStatusBar, @@ -129,7 +159,22 @@ def add_item(menu, target, title, action, key=""): status_item = NSStatusBar.systemStatusBar().statusItemWithLength_( NSVariableStatusItemLength ) - status_item.button().setTitle_("deckd") + # The menu bar wants a monochrome template image, not the full-colour + # brand mark: the system tints a template for light/dark. Prefer the + # bundled brand mark, fall back to the matching SF Symbol (macOS 11+, + # we ship 12+), and finally to the text title. + button = status_item.button() + icon = _menubar_image() + if icon is None: + icon = NSImage.imageWithSystemSymbolName_accessibilityDescription_( + "square.grid.2x2", "deckd" + ) + if icon is not None: + icon.setTemplate_(True) + if icon is not None: + button.setImage_(icon) + else: + button.setTitle_("deckd") menu = NSMenu.alloc().init() add_item(menu, target, "Open deckd surface", "openSurface:") diff --git a/tests/test_app.py b/tests/test_app.py index 1bdc6ea..55b1083 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -30,6 +30,28 @@ def test_resource_root_frozen(monkeypatch, tmp_path: Path) -> None: assert macos_app.resource_root() == tmp_path +def test_menubar_icon_paths_frozen_layout(tmp_path: Path) -> None: + # Frozen: the spec drops the template PNGs at the bundle root. + _write(tmp_path / "deckd-menubar.png", "") + assert macos_app.menubar_icon_paths(tmp_path) == ( + tmp_path / "deckd-menubar.png", + tmp_path / "deckd-menubar@2x.png", + ) + + +def test_menubar_icon_paths_source_layout(tmp_path: Path) -> None: + # Source checkout: the PNGs sit beside the spec in packaging/macos. + _write(tmp_path / "packaging" / "macos" / "deckd-menubar.png", "") + assert macos_app.menubar_icon_paths(tmp_path) == ( + tmp_path / "packaging" / "macos" / "deckd-menubar.png", + tmp_path / "packaging" / "macos" / "deckd-menubar@2x.png", + ) + + +def test_menubar_icon_paths_absent(tmp_path: Path) -> None: + assert macos_app.menubar_icon_paths(tmp_path) is None + + def test_seed_layouts_first_run_then_noop(tmp_path: Path) -> None: src = tmp_path / "bundled" _write(src / "default.yaml", "id: default\n")