← Commit history
Cargo.lockadded+1325
@@ -0,0 +1,1325 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"++[[package]]+name = "adom-shotlog"+version = "0.4.1"+dependencies = [+ "axum",+ "base64",+ "chrono",+ "clap",+ "clap_complete",+ "image",+ "serde",+ "serde_json",+ "tokio",+ "tower-http",+]++[[package]]+name = "android_system_properties"+version = "0.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"+dependencies = [+ "libc",+]++[[package]]+name = "anstream"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"+dependencies = [+ "anstyle",+ "anstyle-parse",+ "anstyle-query",+ "anstyle-wincon",+ "colorchoice",+ "is_terminal_polyfill",+ "utf8parse",+]++[[package]]+name = "anstyle"+version = "1.0.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"++[[package]]+name = "anstyle-parse"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"+dependencies = [+ "utf8parse",+]++[[package]]+name = "anstyle-query"+version = "1.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"+dependencies = [+ "windows-sys",+]++[[package]]+name = "anstyle-wincon"+version = "3.0.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"+dependencies = [+ "anstyle",+ "once_cell_polyfill",+ "windows-sys",+]++[[package]]+name = "atomic-waker"+version = "1.1.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"++[[package]]+name = "autocfg"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"++[[package]]+name = "axum"+version = "0.8.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"+dependencies = [+ "axum-core",+ "axum-macros",+ "base64",+ "bytes",+ "form_urlencoded",+ "futures-util",+ "http",+ "http-body",+ "http-body-util",+ "hyper",+ "hyper-util",+ "itoa",+ "matchit",+ "memchr",+ "mime",+ "percent-encoding",+ "pin-project-lite",+ "serde_core",+ "serde_json",+ "serde_path_to_error",+ "serde_urlencoded",+ "sha1",+ "sync_wrapper",+ "tokio",+ "tokio-tungstenite",+ "tower",+ "tower-layer",+ "tower-service",+ "tracing",+]++[[package]]+name = "axum-core"+version = "0.5.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"+dependencies = [+ "bytes",+ "futures-core",+ "http",+ "http-body",+ "http-body-util",+ "mime",+ "pin-project-lite",+ "sync_wrapper",+ "tower-layer",+ "tower-service",+ "tracing",+]++[[package]]+name = "axum-macros"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "base64"+version = "0.22.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"++[[package]]+name = "bitflags"+version = "2.11.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"++[[package]]+name = "block-buffer"+version = "0.10.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"+dependencies = [+ "generic-array",+]++[[package]]+name = "bumpalo"+version = "3.20.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"++[[package]]+name = "bytemuck"+version = "1.25.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"++[[package]]+name = "byteorder-lite"+version = "0.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"++[[package]]+name = "bytes"+version = "1.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"++[[package]]+name = "cc"+version = "1.2.58"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"+dependencies = [+ "find-msvc-tools",+ "shlex",+]++[[package]]+name = "cfg-if"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"++[[package]]+name = "chrono"+version = "0.4.44"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"+dependencies = [+ "iana-time-zone",+ "js-sys",+ "num-traits",+ "serde",+ "wasm-bindgen",+ "windows-link",+]++[[package]]+name = "clap"+version = "4.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"+dependencies = [+ "clap_builder",+ "clap_derive",+]++[[package]]+name = "clap_builder"+version = "4.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"+dependencies = [+ "anstream",+ "anstyle",+ "clap_lex",+ "strsim",+]++[[package]]+name = "clap_complete"+version = "4.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb"+dependencies = [+ "clap",+]++[[package]]+name = "clap_derive"+version = "4.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"+dependencies = [+ "heck",+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "clap_lex"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"++[[package]]+name = "colorchoice"+version = "1.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"++[[package]]+name = "core-foundation-sys"+version = "0.8.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"++[[package]]+name = "cpufeatures"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"+dependencies = [+ "libc",+]++[[package]]+name = "crc32fast"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"+dependencies = [+ "cfg-if",+]++[[package]]+name = "crypto-common"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"+dependencies = [+ "generic-array",+ "typenum",+]++[[package]]+name = "data-encoding"+version = "2.10.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"++[[package]]+name = "digest"+version = "0.10.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"+dependencies = [+ "block-buffer",+ "crypto-common",+]++[[package]]+name = "errno"+version = "0.3.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"+dependencies = [+ "libc",+ "windows-sys",+]++[[package]]+name = "fdeflate"+version = "0.3.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"+dependencies = [+ "simd-adler32",+]++[[package]]+name = "find-msvc-tools"+version = "0.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"++[[package]]+name = "flate2"+version = "1.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"+dependencies = [+ "crc32fast",+ "miniz_oxide",+]++[[package]]+name = "form_urlencoded"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"+dependencies = [+ "percent-encoding",+]++[[package]]+name = "futures-channel"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"+dependencies = [+ "futures-core",+]++[[package]]+name = "futures-core"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"++[[package]]+name = "futures-sink"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"++[[package]]+name = "futures-task"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"++[[package]]+name = "futures-util"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"+dependencies = [+ "futures-core",+ "futures-sink",+ "futures-task",+ "pin-project-lite",+ "slab",+]++[[package]]+name = "generic-array"+version = "0.14.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"+dependencies = [+ "typenum",+ "version_check",+]++[[package]]+name = "getrandom"+version = "0.3.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"+dependencies = [+ "cfg-if",+ "libc",+ "r-efi",+ "wasip2",+]++[[package]]+name = "heck"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"++[[package]]+name = "http"+version = "1.4.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"+dependencies = [+ "bytes",+ "itoa",+]++[[package]]+name = "http-body"+version = "1.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"+dependencies = [+ "bytes",+ "http",+]++[[package]]+name = "http-body-util"+version = "0.1.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"+dependencies = [+ "bytes",+ "futures-core",+ "http",+ "http-body",+ "pin-project-lite",+]++[[package]]+name = "httparse"+version = "1.10.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"++[[package]]+name = "httpdate"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"++[[package]]+name = "hyper"+version = "1.8.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"+dependencies = [+ "atomic-waker",+ "bytes",+ "futures-channel",+ "futures-core",+ "http",+ "http-body",+ "httparse",+ "httpdate",+ "itoa",+ "pin-project-lite",+ "pin-utils",+ "smallvec",+ "tokio",+]++[[package]]+name = "hyper-util"+version = "0.1.20"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"+dependencies = [+ "bytes",+ "http",+ "http-body",+ "hyper",+ "pin-project-lite",+ "tokio",+ "tower-service",+]++[[package]]+name = "iana-time-zone"+version = "0.1.65"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"+dependencies = [+ "android_system_properties",+ "core-foundation-sys",+ "iana-time-zone-haiku",+ "js-sys",+ "log",+ "wasm-bindgen",+ "windows-core",+]++[[package]]+name = "iana-time-zone-haiku"+version = "0.1.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"+dependencies = [+ "cc",+]++[[package]]+name = "image"+version = "0.25.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"+dependencies = [+ "bytemuck",+ "byteorder-lite",+ "moxcms",+ "num-traits",+ "png",+ "zune-core",+ "zune-jpeg",+]++[[package]]+name = "is_terminal_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "js-sys"+version = "0.3.92"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"+dependencies = [+ "once_cell",+ "wasm-bindgen",+]++[[package]]+name = "libc"+version = "0.2.183"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"++[[package]]+name = "lock_api"+version = "0.4.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"+dependencies = [+ "scopeguard",+]++[[package]]+name = "log"+version = "0.4.29"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"++[[package]]+name = "matchit"+version = "0.8.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"++[[package]]+name = "memchr"+version = "2.8.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"++[[package]]+name = "mime"+version = "0.3.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"++[[package]]+name = "miniz_oxide"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]+name = "mio"+version = "1.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"+dependencies = [+ "libc",+ "wasi",+ "windows-sys",+]++[[package]]+name = "moxcms"+version = "0.8.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"+dependencies = [+ "num-traits",+ "pxfm",+]++[[package]]+name = "num-traits"+version = "0.2.19"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"+dependencies = [+ "autocfg",+]++[[package]]+name = "once_cell"+version = "1.21.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"++[[package]]+name = "once_cell_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"++[[package]]+name = "parking_lot"+version = "0.12.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"+dependencies = [+ "lock_api",+ "parking_lot_core",+]++[[package]]+name = "parking_lot_core"+version = "0.9.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"+dependencies = [+ "cfg-if",+ "libc",+ "redox_syscall",+ "smallvec",+ "windows-link",+]++[[package]]+name = "percent-encoding"+version = "2.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"++[[package]]+name = "pin-project-lite"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"++[[package]]+name = "pin-utils"+version = "0.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"++[[package]]+name = "png"+version = "0.18.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"+dependencies = [+ "bitflags",+ "crc32fast",+ "fdeflate",+ "flate2",+ "miniz_oxide",+]++[[package]]+name = "ppv-lite86"+version = "0.2.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"+dependencies = [+ "zerocopy",+]++[[package]]+name = "proc-macro2"+version = "1.0.106"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "pxfm"+version = "0.1.28"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"++[[package]]+name = "quote"+version = "1.0.45"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "r-efi"+version = "5.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"++[[package]]+name = "rand"+version = "0.9.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"+dependencies = [+ "rand_chacha",+ "rand_core",+]++[[package]]+name = "rand_chacha"+version = "0.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"+dependencies = [+ "ppv-lite86",+ "rand_core",+]++[[package]]+name = "rand_core"+version = "0.9.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"+dependencies = [+ "getrandom",+]++[[package]]+name = "redox_syscall"+version = "0.5.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"+dependencies = [+ "bitflags",+]++[[package]]+name = "rustversion"+version = "1.0.22"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"++[[package]]+name = "ryu"+version = "1.0.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"++[[package]]+name = "scopeguard"+version = "1.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"++[[package]]+name = "serde"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.149"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "serde_path_to_error"+version = "0.1.20"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"+dependencies = [+ "itoa",+ "serde",+ "serde_core",+]++[[package]]+name = "serde_urlencoded"+version = "0.7.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"+dependencies = [+ "form_urlencoded",+ "itoa",+ "ryu",+ "serde",+]++[[package]]+name = "sha1"+version = "0.10.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"+dependencies = [+ "cfg-if",+ "cpufeatures",+ "digest",+]++[[package]]+name = "shlex"+version = "1.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"++[[package]]+name = "signal-hook-registry"+version = "1.4.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"+dependencies = [+ "errno",+ "libc",+]++[[package]]+name = "simd-adler32"+version = "0.3.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"++[[package]]+name = "slab"+version = "0.4.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"++[[package]]+name = "smallvec"+version = "1.15.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"++[[package]]+name = "socket2"+version = "0.6.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"+dependencies = [+ "libc",+ "windows-sys",+]++[[package]]+name = "strsim"+version = "0.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"++[[package]]+name = "syn"+version = "2.0.117"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "sync_wrapper"+version = "1.0.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"++[[package]]+name = "thiserror"+version = "2.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"+dependencies = [+ "thiserror-impl",+]++[[package]]+name = "thiserror-impl"+version = "2.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "tokio"+version = "1.50.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"+dependencies = [+ "bytes",+ "libc",+ "mio",+ "parking_lot",+ "pin-project-lite",+ "signal-hook-registry",+ "socket2",+ "tokio-macros",+ "windows-sys",+]++[[package]]+name = "tokio-macros"+version = "2.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "tokio-tungstenite"+version = "0.28.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"+dependencies = [+ "futures-util",+ "log",+ "tokio",+ "tungstenite",+]++[[package]]+name = "tower"+version = "0.5.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"+dependencies = [+ "futures-core",+ "futures-util",+ "pin-project-lite",+ "sync_wrapper",+ "tokio",+ "tower-layer",+ "tower-service",+ "tracing",+]++[[package]]+name = "tower-http"+version = "0.6.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"+dependencies = [+ "bitflags",+ "bytes",+ "http",+ "pin-project-lite",+ "tower-layer",+ "tower-service",+]++[[package]]+name = "tower-layer"+version = "0.3.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"++[[package]]+name = "tower-service"+version = "0.3.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"++[[package]]+name = "tracing"+version = "0.1.44"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"+dependencies = [+ "log",+ "pin-project-lite",+ "tracing-core",+]++[[package]]+name = "tracing-core"+version = "0.1.36"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"+dependencies = [+ "once_cell",+]++[[package]]+name = "tungstenite"+version = "0.28.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"+dependencies = [+ "bytes",+ "data-encoding",+ "http",+ "httparse",+ "log",+ "rand",+ "sha1",+ "thiserror",+ "utf-8",+]++[[package]]+name = "typenum"+version = "1.19.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "utf-8"+version = "0.7.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"++[[package]]+name = "utf8parse"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"++[[package]]+name = "version_check"+version = "0.9.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"++[[package]]+name = "wasi"+version = "0.11.1+wasi-snapshot-preview1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"++[[package]]+name = "wasip2"+version = "1.0.2+wasi-0.2.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"+dependencies = [+ "wit-bindgen",+]++[[package]]+name = "wasm-bindgen"+version = "0.2.115"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"+dependencies = [+ "cfg-if",+ "once_cell",+ "rustversion",+ "wasm-bindgen-macro",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-macro"+version = "0.2.115"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"+dependencies = [+ "quote",+ "wasm-bindgen-macro-support",+]++[[package]]+name = "wasm-bindgen-macro-support"+version = "0.2.115"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"+dependencies = [+ "bumpalo",+ "proc-macro2",+ "quote",+ "syn",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-shared"+version = "0.2.115"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "windows-core"+version = "0.62.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"+dependencies = [+ "windows-implement",+ "windows-interface",+ "windows-link",+ "windows-result",+ "windows-strings",+]++[[package]]+name = "windows-implement"+version = "0.60.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "windows-interface"+version = "0.59.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "windows-link"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"++[[package]]+name = "windows-result"+version = "0.4.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"+dependencies = [+ "windows-link",+]++[[package]]+name = "windows-strings"+version = "0.5.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"+dependencies = [+ "windows-link",+]++[[package]]+name = "windows-sys"+version = "0.61.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"+dependencies = [+ "windows-link",+]++[[package]]+name = "wit-bindgen"+version = "0.51.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"++[[package]]+name = "zerocopy"+version = "0.8.47"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"+dependencies = [+ "zerocopy-derive",+]++[[package]]+name = "zerocopy-derive"+version = "0.8.47"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "zmij"+version = "1.0.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"++[[package]]+name = "zune-core"+version = "0.5.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"++[[package]]+name = "zune-jpeg"+version = "0.5.15"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"+dependencies = [+ "zune-core",+]
Cargo.tomladded+22
@@ -0,0 +1,22 @@+[package]+name = "adom-shotlog"+version = "0.4.1"+edition = "2021"+description = "Screenshot log viewer and CLI for Adom"++[dependencies]+axum = { version = "0.8", features = ["macros", "ws"] }+tokio = { version = "1", features = ["full"] }+serde = { version = "1", features = ["derive"] }+serde_json = "1"+clap = { version = "4", features = ["derive"] }+clap_complete = "4"+image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }+base64 = "0.22"+chrono = { version = "0.4", features = ["serde"] }+tower-http = { version = "0.6", features = ["cors"] }++[profile.release]+opt-level = "z"+lto = true+strip = true
VERSIONadded+1
@@ -0,0 +1 @@+0.4.1
bin/adom-shotlog
⋯ 1 unchanged line ⋯
build.shadded+17
@@ -0,0 +1,17 @@+#!/bin/bash+set -e+cd "$(dirname "$0")"++VERSION=$(cat VERSION | tr -d '[:space:]')+echo "[adom-shotlog] Building v${VERSION}..."++source "$HOME/.cargo/env" 2>/dev/null+cargo build --release 2>&1 | tail -1+sudo cp target/release/adom-shotlog /usr/local/bin/adom-shotlog+# Back-compat `shotlog` alias+sudo ln -sf /usr/local/bin/adom-shotlog /usr/local/bin/shotlog++echo "[adom-shotlog] Running install..."+adom-shotlog install++echo "[adom-shotlog] v${VERSION} built and installed (alias: shotlog)."
package.json+7−2
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.4.0",+  "version": "0.4.1",   "type": "app",   "title": "adom-shotlog",   "description": "See the shots your AI never showed you. Your AI screenshots your app while it tests but never surfaces them (it thinks in text) \u2014 adom-shotlog streams every shot it gathered into a live gallery you open in a webview, pup, your browser, or your phone. Now ingests Adom Desktop owned-popup arrays + coordMap via --meta.",@@ -17,7 +17,12 @@     "install.sh",     "uninstall.sh",     "bin/**",-    "screenshots/**"+    "screenshots/**",+    "src/**",+    "Cargo.toml",+    "Cargo.lock",+    "build.sh",+    "VERSION"   ],   "install": {     "binary_name": "adom-shotlog"
page.json+7−2
@@ -1,6 +1,6 @@ {   "slug": "adom-shotlog",-  "version": "0.4.0",+  "version": "0.4.1",   "type": "app",   "title": "adom-shotlog",   "description": "See the shots your AI never showed you. Your AI screenshots your app while it tests but never surfaces them (it thinks in text) — adom-shotlog streams every shot it gathered into a live gallery you open in a webview, pup, your browser, or your phone. Now ingests Adom Desktop owned-popup arrays + coordMap via --meta.",@@ -17,7 +17,12 @@     "install.sh",     "uninstall.sh",     "bin/**",-    "screenshots/**"+    "screenshots/**",+    "src/**",+    "Cargo.toml",+    "Cargo.lock",+    "build.sh",+    "VERSION"   ],   "install": {     "binary_name": "adom-shotlog"
src/describe.rsadded+49
@@ -0,0 +1,49 @@+use std::sync::Arc;+use crate::store::Store;++/// Spawn an async task to describe an image via claude CLI and update the entry.+pub fn describe_async(+    store: Arc<Store>,+    channel: String,+    entry_id: u64,+    image_path: String,+) {+    tokio::spawn(async move {+        match call_claude(&image_path).await {+            Ok(desc) => {+                store.update_description(&channel, entry_id, desc).await;+            }+            Err(e) => {+                eprintln!("[shotlog] Claude describe failed: {e}");+            }+        }+    });+}++async fn call_claude(image_path: &str) -> Result<String, String> {+    let prompt = "Describe this screenshot in one sentence. Be specific about what's shown. No preamble.";+    let cmd_str = format!("claude -p \"{}\" --model haiku --image {}", prompt, image_path);+    eprintln!("[shotlog] Running: {cmd_str}");++    let output = tokio::process::Command::new("claude")+        .args([+            "-p", prompt,+            "--model", "haiku",+            "--image", image_path,+        ])+        .output()+        .await+        .map_err(|e| format!("Failed to run claude CLI: {e}"))?;++    if !output.status.success() {+        let stderr = String::from_utf8_lossy(&output.stderr);+        return Err(format!("claude CLI failed: {stderr}"));+    }++    let desc = String::from_utf8_lossy(&output.stdout).trim().to_string();+    if desc.is_empty() {+        Err("Empty response from claude CLI".into())+    } else {+        Ok(desc)+    }+}
src/html/icon.svgadded+1
@@ -0,0 +1 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#e6edf3"><path d="M1 5h2v14H1V5zm4 0h2v14H5V5zm17 1v12a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h3.17L15 2h3l1.83 2H20a2 2 0 0 1 2 2zm-2.5 6.5A4.5 4.5 0 1 0 15 17a4.5 4.5 0 0 0 4.5-4.5zM17.5 12.5A2.5 2.5 0 1 1 15 10a2.5 2.5 0 0 1 2.5 2.5z"/></svg>
src/html/index.htmladded+473
@@ -0,0 +1,473 @@+<!doctype html>+<html lang="en" style="background:#0d1117">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">+<meta name="apple-mobile-web-app-capable" content="yes">+<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">+<link rel="icon" type="image/svg+xml" href="favicon.svg">+<title>adom-shotlog</title>+<style>+@font-face { font-family: 'Familjen Grotesk'; src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2'); font-weight: 700; font-display: swap; }+@font-face { font-family: 'Familjen Grotesk'; src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2'); font-weight: 600; font-display: swap; }+@font-face { font-family: 'Satoshi'; src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }+@font-face { font-family: 'Satoshi'; src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }+@font-face { font-family: 'JetBrains Mono'; src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }++:root {+  --bg: #0d1117; --surface: #161b22; --elevated: #1c2128; --overlay: #21262d;+  --border: #30363d; --border-muted: #21262d;+  --text: #e6edf3; --text2: #8b949e; --text3: #484f58;+  --accent: #00b8b0; --accent-bright: #00e6dc; --purple: #8C6BF7; --danger: #f85149;+}+*, *::before, *::after { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }+html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--text); font: 13px/1.4 'Satoshi', -apple-system, system-ui, sans-serif; }+body { display: flex; flex-direction: column; height: 100dvh; }+body.paste-active { outline: 2px solid var(--accent); outline-offset: -2px; }++/* Global tooltip — body-appended fixed (human-ui-patterns §1d) */+#global-tooltip {+  position: fixed; top: 0; left: 0; transform: translate(0,0);+  background: rgba(22,27,34,.97); backdrop-filter: blur(12px);+  border: 1px solid rgba(0,184,176,.25); border-radius: 7px; padding: 6px 10px;+  font: 12px/1.45 'Satoshi', sans-serif; color: var(--text); max-width: 300px;+  white-space: pre-wrap; box-shadow: 0 8px 28px rgba(0,0,0,.55);+  z-index: 99999; pointer-events: none; opacity: 0; display: none; transition: opacity .18s;+}+#global-tooltip.visible { display: block; } #global-tooltip.shown { opacity: 1; }++/* Header (adom-app-header, 44px) */+header {+  display: flex; align-items: center; gap: 10px; padding: 0 12px; height: 44px;+  flex-shrink: 0; background: var(--surface); border-bottom: 1px solid var(--border-muted);+  position: relative;+}+.brand { display: flex; align-items: center; gap: 7px; color: var(--accent);+  font-family: 'Familjen Grotesk', sans-serif; font-size: 13px; font-weight: 600;+  cursor: default; flex-shrink: 0; outline: none; }+.brand:hover .app-name { text-decoration: underline; text-underline-offset: 3px; }+.brand svg.logo { width: 15px; height: 15px; flex-shrink: 0; color: var(--text); } /* monochrome per brand */+.brand .sep { color: var(--text3); font-weight: 400; margin: 0 1px; }+.brand .app-subtitle { color: var(--text2); font-weight: 500; font-size: 12px; }+.vsep { width: 1px; height: 16px; background: var(--border); flex-shrink: 0; }+.subject { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; }+.subject-name { font-family: 'Familjen Grotesk', sans-serif; font-size: 13px; font-weight: 600;+  color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+.subject-meta { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 10px;+  white-space: nowrap; flex-shrink: 0; }+@media (max-width: 480px) { .subject-meta { display: none; } .brand .sep, .brand .app-subtitle { display: none; } }+.actions { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-shrink: 0; }+.hdr-btn { background: transparent; color: var(--text2); border: 1px solid var(--border);+  padding: 3px 8px; border-radius: 5px; font-size: 11px; cursor: pointer;+  font-family: 'Satoshi', sans-serif; flex-shrink: 0; transition: background .15s, color .15s; }+.hdr-btn:hover { background: var(--border); color: var(--text); }+.hdr-btn.danger { border-color: #f8514955; color: var(--danger); }+.hdr-btn.danger:hover { background: #f8514922; }+.ws-led { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; cursor: help; order: -1;+  background: var(--text3); transition: background .4s, box-shadow .4s; }+.ws-led.live { background: #2ea043; box-shadow: 0 0 7px rgba(46,160,67,.7); }++/* App-info popover */+.hover-popover { position: absolute; top: calc(100% + 6px); left: 12px;+  background: var(--elevated); border: 1px solid var(--border); border-radius: 8px;+  box-shadow: 0 8px 24px rgba(0,0,0,.5); padding: 11px 13px; min-width: 280px; max-width: 400px;+  font: 12px 'Satoshi', sans-serif; color: var(--text); z-index: 99999;+  opacity: 0; transform: translateY(-2px); pointer-events: none; transition: opacity 120ms, transform 120ms; }+.hover-popover.show { opacity: 1; transform: none; pointer-events: auto; }+.hover-popover .pop-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 13px; font-weight: 700; margin-bottom: 8px; }+.hover-popover .pop-row { display: flex; gap: 10px; padding: 2px 0; align-items: baseline; }+.hover-popover .pop-row .k { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 9px; width: 56px; flex-shrink: 0; }+.hover-popover .pop-row .v { color: var(--text); font-size: 11px; flex: 1; }+.hover-popover .pop-actions { margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--border-muted); }+.hover-popover .pop-link { color: var(--accent); text-decoration: none; font-family: 'Familjen Grotesk', sans-serif; font-size: 11px; font-weight: 600; }++/* Gallery */+.feed { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; -webkit-overflow-scrolling: touch; }+.empty { text-align: center; color: var(--text3); padding: 48px 20px; font-size: 13px; line-height: 1.6; }+.empty code { color: var(--accent); font-family: 'JetBrains Mono', monospace; }++.shot-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px;+  overflow: hidden; transition: border-color .15s; cursor: zoom-in;+  display: flex; flex-direction: column; flex-shrink: 0; } /* never compress in the flex-column feed */+.shot-card:hover, .shot-card:active { border-color: rgba(0,184,176,.5); }+.shot-card-desc { padding: 10px 13px 5px; font-family: 'Satoshi', sans-serif; font-size: 13px;+  font-weight: 500; color: var(--text); line-height: 1.4; max-height: 76px; overflow: hidden; flex-shrink: 0; }+.shot-card-desc.no-desc { color: var(--danger); font-size: 11px; font-weight: 600; max-height: none; }+.shot-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; padding: 3px 13px 7px;+  font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); }+.shot-card-meta .ts { color: var(--text2); }+.shot-card-meta .sep { color: var(--border); }+.shot-card-meta .dim-main { color: var(--text2); cursor: help; }+.shot-card-meta .dim-orig { cursor: help; }+.shot-card-meta .dim-missing { color: var(--danger); font-weight: 600; cursor: help; }+.shot-card-meta .src { font-style: italic; }+.shot-card-img { display: block; width: 100%; height: 220px; flex-shrink: 0; background: #111;+  object-fit: contain; object-position: center top; transition: opacity .15s; }+.shot-card:active .shot-card-img { opacity: .85; }+/* Popup-array badge + nerd info chip on a card */+.shot-card-meta .badge-pop { background: rgba(0,184,176,.16); color: var(--accent); border: 1px solid rgba(0,184,176,.4);+  border-radius: 999px; padding: 0 7px; font-weight: 600; cursor: help; }+.shot-card-meta .badge-nerd { color: var(--text3); cursor: help; border: 1px solid var(--border);+  border-radius: 4px; padding: 0 5px; }++/* Zoom modal — full card */+.modal { position: fixed; inset: 0; background: rgba(0,0,0,.88); display: none;+  align-items: flex-start; justify-content: center; z-index: 100; overflow-y: auto;+  padding: 16px; padding-top: max(16px, env(safe-area-inset-top)); }+.modal.open { display: flex; }+.modal-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;+  overflow: hidden; width: 100%; max-width: 820px; flex-shrink: 0; }+.modal-card .m-desc { padding: 14px 16px 6px; font: 500 15px/1.4 'Satoshi', sans-serif; color: var(--text); }+.modal-card .m-meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; padding: 4px 16px 10px;+  font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text3); }+.modal-card .m-meta .dim-main { color: var(--text2); } .modal-card .m-meta .dim-missing { color: var(--danger); font-weight: 600; }+.modal-card .m-meta .sep { color: var(--border); }+.modal-card img { display: block; width: 100%; object-fit: contain; background: #000; }+/* Popup array inside the modal */+.modal-card .m-popups { padding: 10px 16px 14px; }+.modal-card .m-popups .pop-h { font: 600 12px 'Familjen Grotesk', sans-serif; color: var(--text2);+  margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }+.modal-card .m-popups .pop-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }+.modal-card .m-popups .pop-cell { background: #000; border: 1px solid var(--border); border-radius: 8px;+  overflow: hidden; cursor: zoom-in; }+.modal-card .m-popups .pop-cell img { width: 100%; height: 110px; object-fit: contain; background: #111; }+.modal-card .m-popups .pop-cap { padding: 4px 6px; font-family: 'JetBrains Mono', monospace; font-size: 9px;+  color: var(--text3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+/* Nerd metadata panel — readable + copyable */+.modal-card .nerd { margin: 0 16px 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--elevated); }+.modal-card .nerd summary { cursor: pointer; padding: 8px 12px; font: 600 11px 'JetBrains Mono', monospace;+  color: var(--text2); list-style: none; display: flex; align-items: center; gap: 8px; }+.modal-card .nerd summary::-webkit-details-marker { display: none; }+.modal-card .nerd summary .copy { margin-left: auto; font-size: 10px; color: var(--accent); border: 1px solid rgba(0,184,176,.4);+  border-radius: 4px; padding: 1px 7px; }+.modal-card .nerd .body { padding: 0 12px 10px; }+.modal-card .nerd .row { display: flex; gap: 8px; font-family: 'JetBrains Mono', monospace; font-size: 10.5px;+  padding: 2px 0; border-top: 1px solid rgba(255,255,255,.04); }+.modal-card .nerd .row .k { color: var(--text3); min-width: 130px; } .modal-card .nerd .row .v { color: var(--text); user-select: all; word-break: break-all; }+.modal-card .nerd pre { margin: 8px 0 0; padding: 8px; background: #0c0f12; border-radius: 6px; max-height: 220px;+  overflow: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); user-select: all; white-space: pre-wrap; word-break: break-all; }++/* Toast */+.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);+  background: var(--elevated); color: var(--text); border: 1px solid var(--border);+  padding: 7px 13px; border-radius: 999px; font-size: 12px; opacity: 0; transition: opacity .18s;+  pointer-events: none; z-index: 200; white-space: nowrap; }+.toast.show { opacity: 1; }++/* Paste progress overlay */+.paste-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.7); display: none;+  align-items: center; justify-content: center; z-index: 150; }+.paste-overlay.active { display: flex; }+.paste-box { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;+  padding: 20px 24px; min-width: 260px; max-width: 420px; }+.paste-box .title { font-family: 'Familjen Grotesk', sans-serif; font-weight: 700; margin-bottom: 8px; }+.paste-box .detail { font-size: 12px; color: var(--text2); margin: 3px 0; }+.paste-box .val { color: var(--text); } .paste-box .filepath-result { font-family: 'JetBrains Mono', monospace;+  font-size: 10px; color: var(--text3); margin-top: 8px; word-break: break-all; }+</style>+</head>+<body tabindex="0">++<div id="global-tooltip"></div>++<header>+  <div class="brand" id="brandZone" data-tooltip-target="app-info" tabindex="0"+       data-tooltip="adom-shotlog — screenshot log&#10;Hover for app info">+    <svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">+      <path d="M1 5h2v14H1V5zm4 0h2v14H5V5zm17 1v12a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h3.17L15 2h3l1.83 2H20a2 2 0 0 1 2 2zm-2.5 6.5A4.5 4.5 0 1 0 15 17a4.5 4.5 0 0 0 4.5-4.5zM17.5 12.5A2.5 2.5 0 1 1 15 10a2.5 2.5 0 0 1 2.5 2.5z"/>+    </svg>+    <span class="app-name">adom-shotlog</span>+    <span class="sep">·</span>+    <span class="app-subtitle">screenshot log</span>+  </div>+  <div class="vsep"></div>+  <div class="subject">+    <span class="subject-name" id="channelName"+          data-tooltip="The channel — a screenshot log for one project or task.&#10;AI agents inject shots here; you watch them arrive live.">…</span>+    <span class="subject-meta" id="shotCount"></span>+  </div>+  <div class="actions">+    <button class="hdr-btn" id="toTopBtn" data-tooltip="Scroll to newest (top)">↑</button>+    <button class="hdr-btn danger" id="clearBtn" data-tooltip="Delete ALL screenshots in this channel (cannot be undone)">clear</button>+    <span class="ws-led" id="wsLed" data-tooltip="WebSocket: grey = disconnected, green = live.&#10;While green the server knows you're watching and&#10;won't force-open a duplicate tab on new shots."></span>+  </div>+  <div class="hover-popover" id="app-info" role="dialog">+    <div class="pop-title">adom-shotlog</div>+    <div class="pop-row"><span class="k">what</span><span class="v">Chronological screenshot log. AI agents inject shots as they work; you view them here, newest first, with descriptions and source resolution.</span></div>+    <div class="pop-row"><span class="k">inject</span><span class="v">adom-shotlog inject -c CH -d "why" FILE — auto-resizes &gt;1500px and records the original size</span></div>+    <div class="pop-row"><span class="k">paste</span><span class="v">Ctrl+V anywhere to paste a screenshot from the clipboard</span></div>+    <div class="pop-row"><span class="k">author</span><span class="v">Adom Inc · adom.inc</span></div>+    <div class="pop-actions"><a class="pop-link" href="https://wiki.adom.inc/adom/adom-shotlog" target="_blank" rel="noopener">Wiki ↗</a></div>+  </div>+</header>++<div class="feed" id="feed"><div class="empty">loading…</div></div>++<div class="toast" id="toast"></div>+<div class="modal" id="modal"><div class="modal-card" id="modalCard">+  <div class="m-desc" id="mDesc"></div><div class="m-meta" id="mMeta"></div><img id="mImg" alt="">+  <div class="m-popups" id="mPopups" style="display:none"></div>+  <div id="mNerd"></div>+</div></div>+<div class="paste-overlay" id="pasteOverlay"><div class="paste-box" id="pasteMsg"></div></div>++<script>+(function() {+  const $ = id => document.getElementById(id);+  const feed = $('feed'), toast = $('toast'), wsLed = $('wsLed');+  const modal = $('modal'), mImg = $('mImg'), mDesc = $('mDesc'), mMeta = $('mMeta');+  const mPopups = $('mPopups'), mNerd = $('mNerd');++  // Channel from URL path /log/<channel>/ (matches Rust route + base href)+  const m = location.pathname.match(/\/log\/([^/]+)\/?/);+  const channel = m ? decodeURIComponent(m[1]) : 'default';+  $('channelName').textContent = channel;+  document.title = 'shotlog · ' + channel;++  let entries = [];     // full list (ascending by id)+  let shotCount = 0;++  // ── Tooltip system (body-appended, 600ms, cursor-quadrant) ───────────────+  (function() {+    const tip = $('global-tooltip'); let cx=0, cy=0, timer=null;+    document.addEventListener('mousemove', e => { cx=e.clientX; cy=e.clientY; }, true);+    function show(el) {+      const t = el.getAttribute('data-tooltip'); if (!t) return;+      tip.textContent = t; tip.classList.add('visible');+      const tw=tip.offsetWidth, th=tip.offsetHeight, vw=innerWidth, vh=innerHeight, O=14;+      const c=[{x:cx+O,y:cy+O},{x:cx-tw-O,y:cy+O},{x:cx+O,y:cy-th-O},{x:cx-tw-O,y:cy-th-O}]+        .find(p=>p.x>=4&&p.y>=4&&p.x+tw<=vw-4&&p.y+th<=vh-4)||{x:cx+O,y:cy+O};+      tip.style.transform=`translate(${Math.max(4,c.x)}px,${Math.max(4,c.y)}px)`;+      requestAnimationFrame(()=>tip.classList.add('shown'));+    }+    function hide(){ if(timer){clearTimeout(timer);timer=null;} tip.classList.remove('shown');+      setTimeout(()=>{ if(!tip.classList.contains('shown')) tip.classList.remove('visible'); },180); }+    document.addEventListener('mouseover', e => { const el=e.target.closest('[data-tooltip]');+      if(el){ if(timer)clearTimeout(timer); timer=setTimeout(()=>show(el),600); } else hide(); }, true);+    document.addEventListener('mouseout', e => { if(!e.relatedTarget?.closest('[data-tooltip]')) hide(); }, true);+  })();++  // ── App-info popover ─────────────────────────────────────────────────────+  (function() {+    const trigger = $('brandZone'), target = $('app-info'); let st=null, ht=null;+    const show=()=>{clearTimeout(ht); if(target.classList.contains('show'))return; clearTimeout(st); st=setTimeout(()=>target.classList.add('show'),600);};+    const hide=()=>{clearTimeout(st); clearTimeout(ht); ht=setTimeout(()=>target.classList.remove('show'),220);};+    trigger.addEventListener('mouseenter',show); trigger.addEventListener('mouseleave',hide);+    target.addEventListener('mouseenter',show); target.addEventListener('mouseleave',hide);+    trigger.addEventListener('click',e=>{clearTimeout(st);target.classList.toggle('show');e.stopPropagation();});+    document.addEventListener('click',e=>{ if(!target.contains(e.target)&&!trigger.contains(e.target)) target.classList.remove('show'); });+  })();++  // ── Helpers ──────────────────────────────────────────────────────────────+  function showToast(msg){ toast.textContent=msg; toast.classList.add('show'); setTimeout(()=>toast.classList.remove('show'),1800); }+  function esc(s){ return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }+  function fmtTime(ts){ const d=new Date(ts), h=d.getHours(), mm=String(d.getMinutes()).padStart(2,'0');+    return (h%12||12)+':'+mm+(h<12?'am':'pm'); }+  function fmtDate(ts){ const d=new Date(ts), mo=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][d.getMonth()];+    return mo+' '+d.getDate()+', '+fmtTime(ts); }+  function imgUrl(e){ return 'shots/'+encodeURIComponent(channel)+'/'+encodeURIComponent(e.filename); }+  function popUrl(fn){ return 'shots/'+encodeURIComponent(channel)+'/'+encodeURIComponent(fn); }+  // Owned popups can be stored as a bare array (current) or wrapped — normalize to an array.+  function popList(e){ const p=e&&e.popups; return Array.isArray(p)?p:(p&&Array.isArray(p.popups)?p.popups:[]); }+  // Build a flat readable [key,value] view of the capture metadata for the nerd panel.+  function metaRows(e){+    const rows=[]; const m=e.meta||{}; const cm=m.coordMap||{};+    const push=(k,v)=>{ if(v!==undefined&&v!==null&&v!=='') rows.push([k,typeof v==='object'?JSON.stringify(v):String(v)]); };+    push('shotId', m.shotId);+    push('image size', (e.w&&e.h)?e.w+'×'+e.h:undefined);+    if(e.orig_w&&e.orig_h) push('orig capture', e.orig_w+'×'+e.orig_h);+    push('screenRect', cm.screenRect);+    push('dpiScale', cm.dpiScale!==undefined?cm.dpiScale:m.dpiScale);+    push('imageToScreen', cm.imageToScreen);+    push('screen resolution', cm.screenResolution||cm.screen||m.screenResolution);+    const np=m.ownedPopupCount!==undefined?m.ownedPopupCount:popList(e).length;+    push('owned popups', np||undefined);+    return rows;+  }++  function dimNodes(e, into) {+    if (!e.w || !e.h) return;+    const dm=document.createElement('span'); dm.className='dim-main'; dm.textContent=e.w+'×'+e.h;+    dm.setAttribute('data-tooltip','Stored size (after any resize) — what the AI sees when it reads the shot.');+    into.appendChild(dm);+    // Show the original only when it genuinely differs from the stored size.+    // (New injects always establish the original; equal/absent → show nothing.)+    if (e.orig_w && e.orig_h && (e.orig_w !== e.w || e.orig_h !== e.h)) {+      const sep=document.createElement('span'); sep.className='sep'; sep.textContent='·'; into.appendChild(sep);+      const o=document.createElement('span'); o.className='dim-orig'; o.textContent='orig '+e.orig_w+'×'+e.orig_h;+      o.setAttribute('data-tooltip','Original capture resolution before resize — confirms what was actually captured.');+      into.appendChild(o);+    }+  }++  function card(e) {+    const c=document.createElement('div'); c.className='shot-card'; c.dataset.id=e.id;+    c.addEventListener('click',()=>openModal(e));+    const desc=document.createElement('div');+    if (e.description) { desc.className='shot-card-desc'; desc.textContent=e.description; }+    else { desc.className='shot-card-desc no-desc'; desc.textContent='⚠ No description — inject with -d "what and why"'; }+    c.appendChild(desc);+    const meta=document.createElement('div'); meta.className='shot-card-meta';+    const ts=document.createElement('span'); ts.className='ts'; ts.textContent=fmtDate(e.timestamp);+    ts.setAttribute('data-tooltip', new Date(e.timestamp).toLocaleString()); meta.appendChild(ts);+    if (e.w&&e.h){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s); dimNodes(e,meta); }+    if (e.kb){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);+      const kb=document.createElement('span'); kb.textContent=e.kb+' KB'; meta.appendChild(kb); }+    if (e.source && e.source!=='cli'){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);+      const sr=document.createElement('span'); sr.className='src'; sr.textContent=e.source; meta.appendChild(sr); }+    const pops=popList(e);+    if (pops.length){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);+      const b=document.createElement('span'); b.className='badge-pop'; b.textContent='+'+pops.length+' popup'+(pops.length>1?'s':'');+      b.setAttribute('data-tooltip','This shot captured '+pops.length+' owned popup'+(pops.length>1?'s':'')+' (dropdowns / dialogs).&#10;Open the shot to view each one.');+      meta.appendChild(b); }+    if (e.meta){ const s=document.createElement('span'); s.className='sep'; s.textContent='·'; meta.appendChild(s);+      const n=document.createElement('span'); n.className='badge-nerd'; n.textContent='ⓘ meta';+      n.setAttribute('data-tooltip', metaRows(e).map(([k,v])=>k+': '+v).join('&#10;')||'capture metadata');+      meta.appendChild(n); }+    c.appendChild(meta);+    const img=document.createElement('img'); img.className='shot-card-img'; img.src=imgUrl(e); img.alt=e.description||e.filename;+    c.appendChild(img);+    return c;+  }++  function openModal(e) {+    mImg.src=imgUrl(e); mImg.alt=e.description||e.filename;+    mDesc.textContent=e.description||''; mDesc.style.display=e.description?'':'none';+    mMeta.innerHTML='';+    const add=(cls,txt,tip)=>{ const s=document.createElement('span'); if(cls)s.className=cls; s.textContent=txt; if(tip)s.setAttribute('data-tooltip',tip); mMeta.appendChild(s); };+    const sep=()=>add('sep','·');+    add('ts',fmtDate(e.timestamp));+    if(e.w&&e.h){ sep(); add('dim-main',e.w+'×'+e.h);+      if(e.orig_w&&e.orig_h&&(e.orig_w!==e.w||e.orig_h!==e.h)){ sep(); add('','orig '+e.orig_w+'×'+e.orig_h); } }+    if(e.kb){ sep(); add('',e.kb+' KB'); }+    if(e.source&&e.source!=='cli'){ sep(); add('',e.source); }++    // Owned-popup thumbnails — each opens full-size in its own modal entry.+    const pops=popList(e);+    mPopups.innerHTML='';+    if(pops.length){+      mPopups.style.display='';+      const h=document.createElement('div'); h.className='pop-h';+      h.textContent='Owned popups · '+pops.length+' captured';+      mPopups.appendChild(h);+      const grid=document.createElement('div'); grid.className='pop-grid';+      pops.forEach(p=>{+        const cell=document.createElement('div'); cell.className='pop-cell';+        const im=document.createElement('img'); im.src=popUrl(p.filename); im.alt=p.title||p.kind||'popup';+        im.loading='lazy';+        const cap=document.createElement('div'); cap.className='pop-cap';+        cap.textContent=(p.kind?p.kind+' · ':'')+(p.title||p.filename)+(p.w&&p.h?'  '+p.w+'×'+p.h:'');+        cell.appendChild(im); cell.appendChild(cap);+        cell.addEventListener('click', ev=>{ ev.stopPropagation();+          openModal({filename:p.filename, description:p.title||p.kind||'popup', w:p.w, h:p.h, kb:p.kb,+            timestamp:e.timestamp, source:'popup', meta:p.coordMap?{coordMap:p.coordMap,shotId:p.shotId}:null}); });+        grid.appendChild(cell);+      });+      mPopups.appendChild(grid);+    } else { mPopups.style.display='none'; }++    // Nerd metadata panel — readable rows + copyable raw JSON.+    mNerd.innerHTML='';+    const rows=metaRows(e);+    if(e.meta || rows.length){+      const raw=JSON.stringify({meta:e.meta||null, popups:pops.length?pops:undefined}, null, 2);+      const d=document.createElement('details'); d.className='nerd';+      const sum=document.createElement('summary'); sum.textContent='⌗ capture metadata';+      const cp=document.createElement('span'); cp.className='copy'; cp.textContent='copy JSON';+      cp.addEventListener('click', ev=>{ ev.preventDefault(); ev.stopPropagation();+        navigator.clipboard?.writeText(raw).then(()=>{ cp.textContent='copied ✓'; setTimeout(()=>cp.textContent='copy JSON',1200); }); });+      sum.appendChild(cp); d.appendChild(sum);+      const body=document.createElement('div'); body.className='body';+      rows.forEach(([k,v])=>{ const r=document.createElement('div'); r.className='row';+        const kk=document.createElement('span'); kk.className='k'; kk.textContent=k;+        const vv=document.createElement('span'); vv.className='v'; vv.textContent=v;+        r.appendChild(kk); r.appendChild(vv); body.appendChild(r); });+      const pre=document.createElement('pre'); pre.textContent=raw; body.appendChild(pre);+      d.appendChild(body); mNerd.appendChild(d);+    }++    modal.classList.add('open'); modal.scrollTop=0;+  }+  modal.addEventListener('click', e => { if(e.target===modal) modal.classList.remove('open'); });++  function renderAll() {+    feed.innerHTML='';+    if (!entries.length) {+      feed.innerHTML='<div class="empty">No screenshots yet in <b>'+esc(channel)+'</b>.<br><br>'++        'Inject one:<br><code>shotlog inject -c '+esc(channel)+' -d "what and why" FILE</code></div>';+      shotCount=0; updateCount(); return;+    }+    shotCount=entries.length;+    for (let i=entries.length-1;i>=0;i--) feed.appendChild(card(entries[i])); // newest first+    updateCount();+  }+  function updateCount(){ $('shotCount').textContent = shotCount? shotCount+(shotCount===1?' shot':' shots'):''; }++  // ── Load ───────────────────────────────────────────────────────────────+  fetch('api/shots?channel='+encodeURIComponent(channel)).then(r=>r.json()).then(data=>{+    entries = Array.isArray(data)?data:[]; renderAll(); feed.scrollTop=0;+  }).catch(e=>{ feed.innerHTML='<div class="empty">load failed: '+esc(e.message)+'</div>'; });++  // ── WebSocket live updates ({type:'entry', entry}) ───────────────────────+  function connectWs() {+    const proto = location.protocol==='https:'?'wss:':'ws:';+    const baseHref = document.querySelector('base')?.href.replace(location.origin,'') || '/';+    const ws = new WebSocket(`${proto}//${location.host}${baseHref}ws/${encodeURIComponent(channel)}`);+    ws.onopen = () => { wsLed.classList.add('live'); wsLed.setAttribute('data-tooltip','WebSocket live — the server knows you are watching.'); };+    ws.onmessage = ev => {+      try {+        const msg = JSON.parse(ev.data);+        if (msg.type==='entry' && msg.entry) {+          const e = msg.entry, existing = entries.find(x=>x.id===e.id);+          const atTop = feed.scrollTop < 60;+          if (existing) { Object.assign(existing,e); const old=feed.querySelector(`[data-id="${e.id}"]`); if(old) old.replaceWith(card(e)); }+          else { entries.push(e); const empty=feed.querySelector('.empty'); if(empty) feed.innerHTML='';+            feed.insertBefore(card(e), feed.firstChild); shotCount++; updateCount(); if(atTop) feed.scrollTop=0; }+        }+      } catch {}+    };+    ws.onclose = () => { wsLed.classList.remove('live');+      wsLed.setAttribute('data-tooltip','WebSocket disconnected — reconnecting…'); setTimeout(connectWs,3000); };+    ws.onerror = () => { try{ws.close();}catch{} };+  }+  connectWs();++  // ── Wiring ───────────────────────────────────────────────────────────────+  $('toTopBtn').addEventListener('click',()=>{ feed.scrollTop=0; });+  $('clearBtn').addEventListener('click', async ()=>{+    if(!confirm(`Delete ALL screenshots in "${channel}"? This cannot be undone.`)) return;+    entries=[]; renderAll();+    await fetch('api/clear-all?channel='+encodeURIComponent(channel),{method:'DELETE'}).catch(()=>{});+    showToast('cleared');+  });++  // ── Clipboard paste (Ctrl+V anywhere) ────────────────────────────────────+  const pasteOverlay=$('pasteOverlay'), pasteMsg=$('pasteMsg');+  document.body.addEventListener('click',()=>document.body.focus());+  document.body.classList.add('paste-active');+  document.addEventListener('paste', async e => {+    const items=e.clipboardData?.items; if(!items) return;+    for (const it of items) if (it.type.startsWith('image/')) {+      e.preventDefault(); const blob=it.getAsFile(); if(!blob) continue;+      pasteMsg.innerHTML='<div class="title">Processing paste…</div><div class="detail">'+Math.round(blob.size/1024)+' KB</div>';+      pasteOverlay.classList.add('active');+      try {+        const dataUrl = await new Promise((res,rej)=>{ const r=new FileReader(); r.onload=()=>res(r.result); r.onerror=rej; r.readAsDataURL(blob); });+        const res = await fetch('api/paste',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({channel,dataUrl})});+        const r = await res.json();+        if (r.ok) {+          let oi = (r.original_w&&r.original_h)?`<div class="detail">Original: <span class="val">${r.original_w}×${r.original_h}</span></div>`:'';+          pasteMsg.innerHTML=`<div class="title">Saved to log</div><div class="detail">File: <span class="val">${esc(r.name)}</span></div>`++            `<div class="detail">Size: <span class="val">${r.w}×${r.h}</span> · <span class="val">${r.kb} KB</span></div>`+oi;+          setTimeout(()=>pasteOverlay.classList.remove('active'),2500);+        } else { pasteMsg.innerHTML='<div class="title" style="color:#f85149">Paste failed</div>'; setTimeout(()=>pasteOverlay.classList.remove('active'),2000); }+      } catch(err){ pasteMsg.innerHTML='<div class="title" style="color:#f85149">Error: '+esc(err.message)+'</div>'; setTimeout(()=>pasteOverlay.classList.remove('active'),2000); }+      break;+    }+  });++  // Refresh relative timestamps periodically (cards use absolute, but keep ts tooltips fresh) — no-op for now+})();+</script>+</body>+</html>
src/image.rsadded+120
@@ -0,0 +1,120 @@+use base64::Engine;+use image::imageops::FilterType;+use image::ImageFormat;+use std::io::Cursor;++/// Read width and height from a PNG file's IHDR chunk (bytes 16-23).+/// Returns (0, 0) if the buffer is too short or not a valid PNG.+pub fn read_png_dimensions(bytes: &[u8]) -> (u32, u32) {+    if bytes.len() < 24 {+        return (0, 0);+    }+    // PNG signature check+    if &bytes[0..4] != b"\x89PNG" {+        return (0, 0);+    }+    let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);+    let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);+    (w, h)+}++/// Resize a PNG image if its width exceeds `max_width`.+/// Resize so neither dimension exceeds `max_edge`, preserving aspect ratio.+/// Returns the (possibly resized) PNG bytes and final (w, h). Caps BOTH width+/// and height — a tall narrow screenshot is constrained by height, not just width.+pub fn resize_png(bytes: Vec<u8>, max_edge: u32) -> Result<(Vec<u8>, u32, u32), String> {+    let (orig_w, orig_h) = read_png_dimensions(&bytes);++    if orig_w == 0 || (orig_w <= max_edge && orig_h <= max_edge) {+        return Ok((bytes, orig_w, orig_h));+    }++    let img = image::load_from_memory_with_format(&bytes, ImageFormat::Png)+        .map_err(|e| format!("Failed to decode PNG: {e}"))?;++    // Scale by whichever edge is over the limit (the larger ratio wins).+    let scale = (max_edge as f64 / orig_w as f64).min(max_edge as f64 / orig_h as f64);+    let new_w = (orig_w as f64 * scale).round().max(1.0) as u32;+    let new_h = (orig_h as f64 * scale).round().max(1.0) as u32;++    let resized = img.resize_exact(new_w, new_h, FilterType::Lanczos3);++    let mut buf = Cursor::new(Vec::new());+    resized+        .write_to(&mut buf, ImageFormat::Png)+        .map_err(|e| format!("Failed to encode resized PNG: {e}"))?;++    Ok((buf.into_inner(), new_w, new_h))+}++/// PNG metadata from the IHDR chunk.+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]+pub struct PngMeta {+    pub bit_depth: u8,+    pub color_type: String,+    pub channels: u8,+    pub raw_bytes: u64,+    pub file_bytes: u64,+    pub compression_ratio: f64,+    pub compression_pct: u8,+}++/// Read extended PNG metadata from IHDR chunk.+pub fn read_png_meta(bytes: &[u8]) -> Option<PngMeta> {+    if bytes.len() < 26 || &bytes[0..4] != b"\x89PNG" {+        return None;+    }+    let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);+    let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);+    let bit_depth = bytes[24];+    let color_type_byte = bytes[25];++    let (color_type, channels) = match color_type_byte {+        0 => ("Grayscale", 1u8),+        2 => ("RGB", 3),+        3 => ("Indexed", 1),+        4 => ("Grayscale+Alpha", 2),+        6 => ("RGBA", 4),+        _ => ("Unknown", 1),+    };++    let bytes_per_pixel = (channels as u64) * (bit_depth as u64) / 8;+    let raw = (w as u64) * (h as u64) * bytes_per_pixel.max(1);+    let file = bytes.len() as u64;+    let ratio = if file > 0 { raw as f64 / file as f64 } else { 1.0 };+    let pct = if raw > 0 { ((1.0 - (file as f64 / raw as f64)) * 100.0).max(0.0) as u8 } else { 0 };++    Some(PngMeta {+        bit_depth,+        color_type: color_type.to_string(),+        channels,+        raw_bytes: raw,+        file_bytes: file,+        compression_ratio: (ratio * 10.0).round() / 10.0,+        compression_pct: pct,+    })+}++/// Parse a data URL like `data:image/png;base64,iVBOR...` into (mime_type, raw_bytes).+pub fn parse_data_url(data_url: &str) -> Result<(String, Vec<u8>), String> {+    let rest = data_url+        .strip_prefix("data:")+        .ok_or("Not a data URL")?;++    let (meta, b64) = rest+        .split_once(",")+        .ok_or("No comma in data URL")?;++    let mime = meta+        .split(';')+        .next()+        .unwrap_or("application/octet-stream")+        .to_string();++    let bytes = base64::engine::general_purpose::STANDARD+        .decode(b64)+        .map_err(|e| format!("Base64 decode error: {e}"))?;++    Ok((mime, bytes))+}+
src/main.rsadded+585
@@ -0,0 +1,585 @@+mod describe;+mod image;+mod open;+mod server;+mod store;++use clap::{CommandFactory, Parser, Subcommand};+use std::path::PathBuf;+use std::sync::Arc;++fn ok(msg: &str) { println!("OK: {msg}"); }+fn err(msg: &str) { eprintln!("ERROR: {msg}"); }+fn hint(msg: &str) { eprintln!("Hint: {msg}"); }++const SKILL_MD: &str = include_str!("../SKILL.md");++#[derive(Parser)]+#[command(name = "adom-shotlog", version, about = "Screenshot log viewer and CLI")]+struct Cli {+    #[command(subcommand)]+    command: Commands,+}++#[derive(Subcommand)]+enum Commands {+    /// Start the shotlog HTTP server+    Serve {+        /// Port to listen on+        #[arg(short, long, default_value_t = 8820)]+        port: u16,+        /// Data directory for screenshot storage+        #[arg(short, long, default_value = "./screenshots/shotlog")]+        data_dir: PathBuf,+    },+    /// Inject a screenshot into the log (posts to running server).+    ///+    /// shotlog must always know the ORIGINAL capture resolution. Pass a full-res+    /// image (shotlog resizes >1500px itself and records the original), or for a+    /// pre-shrunk image declare the original via --orig-file / --orig-w+--orig-h,+    /// or assert it was never resized with --native. An inject that establishes+    /// none of these is REJECTED — so the viewer never has to show "orig missing".+    Inject {+        /// Channel name+        #[arg(short, long)]+        channel: String,+        /// Description of why this screenshot was taken (becomes the filename)+        #[arg(short, long)]+        desc: Option<String>,+        /// Source identifier (e.g., av_capture, pup_screenshot)+        #[arg(short, long, default_value = "cli")]+        source: String,+        /// Port of the running shotlog server+        #[arg(short, long, default_value_t = 8820)]+        port: u16,+        /// Path to the ORIGINAL (pre-shrink) image — shotlog reads its+        /// dimensions and records them. Use this when FILE is already resized.+        #[arg(long)]+        orig_file: Option<String>,+        /// Original width before your resize (alternative to --orig-file)+        #[arg(long)]+        orig_w: Option<u32>,+        /// Original height before your resize (alternative to --orig-file)+        #[arg(long)]+        orig_h: Option<u32>,+        /// Assert FILE is at original capture resolution (never resized). Use+        /// only when true — it records the original as the image's own size.+        #[arg(long)]+        native: bool,+        /// Path to an Adom Desktop screenshot-response JSON (from+        /// desktop_screenshot_window / _screen). shotlog reads coordMap + DPI ++        /// origWidth/Height + the screenshots[] OWNED POPUPS (by localSafePath)+        /// and stores them all on this entry: the popups become viewable+        /// thumbnails and the coordMap/screen metadata a hover panel.+        #[arg(long)]+        meta: Option<String>,+        /// PNG file path (use - for stdin)+        file: String,+    },+    /// Open a shotlog webview tab in the Adom workspace+    Open {+        /// Channel name+        #[arg(short, long)]+        channel: String,+        /// Display name for the tab+        #[arg(short, long)]+        name: Option<String>,+        /// MDI icon for the tab+        #[arg(short, long)]+        icon: Option<String>,+        /// Port of the running shotlog server+        #[arg(short, long, default_value_t = 8820)]+        port: u16,+    },+    /// Resize (if needed) and inject a screenshot (like inject but applies 1400px max-width)+    Paste {+        /// Channel name+        #[arg(short, long)]+        channel: String,+        /// Description (becomes the filename)+        #[arg(short, long)]+        desc: Option<String>,+        /// Source identifier+        #[arg(short, long, default_value = "cli")]+        source: String,+        /// Port of the running shotlog server+        #[arg(short, long, default_value_t = 8820)]+        port: u16,+        /// PNG file path (use - for stdin)+        file: String,+    },+    /// Resize an image to max 1400px wide with maximum PNG compression (for AI submission)+    Resize {+        /// Input PNG file+        file: String,+        /// Output file (default: overwrites input)+        #[arg(short, long)]+        output: Option<String>,+        /// Max width in pixels+        #[arg(short, long, default_value_t = 1400)]+        width: u32,+    },+    /// Check if the shotlog server is running+    Health {+        /// Port of the running shotlog server+        #[arg(short, long, default_value_t = 8820)]+        port: u16,+    },+    /// Install everything: skill, bash completions+    Install,+    /// Generate shell completions (bash, zsh, fish)+    Completions {+        /// Shell type+        shell: clap_complete::Shell,+    },+}++#[tokio::main]+async fn main() {+    let cli = Cli::parse();++    match cli.command {+        Commands::Serve { port, data_dir } => {+            run_server(port, data_dir).await;+        }+        Commands::Inject {+            channel, desc, source, port, orig_file, orig_w, orig_h, native, meta, file,+        } => {+            let desc = match desc {+                Some(d) if d.len() > 5 => d,+                Some(d) => {+                    err(&format!("Description too short: \"{d}\""));+                    hint("Write a specific description of what this screenshot shows and why you took it.");+                    hint("Example: --desc \"PCB board layout after routing power traces\"");+                    hint("The description becomes the filename, so make it meaningful.");+                    std::process::exit(1);+                }+                None => {+                    err("Missing --desc flag. Every screenshot needs a description.");+                    hint("The description becomes the filename and helps the user understand what they're looking at.");+                    hint("Example: shotlog inject -c my-project -d \"Homepage after nav redesign\" -s pup_screenshot file.png");+                    std::process::exit(1);+                }+            };++            // --meta: ingest an Adom Desktop screenshot-response JSON. Pulls the+            // coordMap/DPI/screen metadata, the original W×H (so the shot is never+            // "orig missing"), and the owned-popup array (reads each popup PNG by+            // its localSafePath and attaches it). All additive.+            let (cap_meta, popups, meta_ow, meta_oh) = match &meta {+                None => (None, None, None, None),+                Some(mp) => {+                    let raw = std::fs::read_to_string(mp).unwrap_or_else(|e| {+                        err(&format!("Failed to read --meta {mp}: {e}")); std::process::exit(1);+                    });+                    let ad: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|e| {+                        err(&format!("--meta {mp} is not valid JSON: {e}")); std::process::exit(1);+                    });+                    let cm = serde_json::json!({+                        "shotId": ad.get("shotId"), "coordMap": ad.get("coordMap"),+                        "ownedPopupCount": ad.get("ownedPopupCount"),+                        "width": ad.get("width"), "height": ad.get("height"),+                        "origWidth": ad.get("origWidth"), "origHeight": ad.get("origHeight"),+                        "note": ad.get("_popups_note"),+                    });+                    // Owned popups → read each PNG by localSafePath, base64 it.+                    let mut pops = Vec::new();+                    if let Some(arr) = ad.get("screenshots").and_then(|v| v.as_array()) {+                        for s in arr {+                            let path = s.get("localSafePath").or_else(|| s.get("localFullPath")).and_then(|v| v.as_str());+                            let Some(path) = path else { continue };+                            match std::fs::read(path) {+                                Ok(b) => {+                                    let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &b);+                                    pops.push(serde_json::json!({+                                        "image": format!("data:image/png;base64,{b64}"),+                                        "hwnd": s.get("hwnd"), "title": s.get("title"),+                                        "kind": s.get("kind"), "shotId": s.get("shotId"),+                                        "coordMap": s.get("coordMap"),+                                    }));+                                }+                                Err(e) => err(&format!("WARN: popup {path} unreadable ({e}) — skipped")),+                            }+                        }+                    }+                    let ow = ad.get("origWidth").and_then(|v| v.as_u64()).map(|n| n as u32);+                    let oh = ad.get("origHeight").and_then(|v| v.as_u64()).map(|n| n as u32);+                    (Some(cm), if pops.is_empty() { None } else { Some(pops) }, ow, oh)+                }+            };+            // --meta's origWidth/Height count as the declared original (so it+            // satisfies the orig-resolution requirement below).+            let (orig_w, orig_h) = (orig_w.or(meta_ow), orig_h.or(meta_oh));++            // Resolve the original dimensions. Two ways to supply them for a+            // pre-shrunk image: --orig-file (measure it) or --orig-w/--orig-h.+            let (orig_w, orig_h) = match (orig_file, orig_w, orig_h) {+                (Some(of), _, _) => {+                    let bytes = std::fs::read(&of).unwrap_or_else(|e| {+                        err(&format!("Failed to read --orig-file {of}: {e}"));+                        std::process::exit(1);+                    });+                    let (w, h) = image::read_png_dimensions(&bytes);+                    if w == 0 {+                        err(&format!("--orig-file {of} is not a readable PNG."));+                        std::process::exit(1);+                    }+                    (Some(w), Some(h))+                }+                (None, Some(w), Some(h)) => (Some(w), Some(h)),+                (None, None, None) => (None, None),+                (None, _, _) => {+                    err("Give BOTH --orig-w and --orig-h, or use --orig-file, or --native.");+                    hint("These record the original resolution of a pre-shrunk screenshot.");+                    std::process::exit(1);+                }+            };++            // Enforce: shotlog must always know the original resolution, so the+            // viewer never shows "orig missing".+            //   - explicit --orig-* already resolved above → trust it+            //   - stdin: can't peek the file → trust caller+            //   - otherwise read FILE's dims:+            //       --native            → record orig = the image's own size+            //       >1500px (any edge)  → leave None; the server resizes + records orig+            //       ≤1500px, nothing    → REJECT (forces the AI to declare the original)+            let (orig_w, orig_h) = if orig_w.is_some() || file == "-" {+                (orig_w, orig_h)+            } else {+                let bytes = std::fs::read(&file).unwrap_or_else(|e| {+                    err(&format!("Failed to read {file}: {e}"));+                    std::process::exit(1);+                });+                let (fw, fh) = image::read_png_dimensions(&bytes);+                if native {+                    if fw == 0 { (None, None) } else { (Some(fw), Some(fh)) }+                } else if fw != 0 && fw <= 1500 && fh <= 1500 {+                    err(&format!("Original resolution required — refusing to inject {fw}x{fh} blind."));+                    hint("Resizing before inject is fine and expected — oversized images crash Claude's");+                    hint("vision analysis, so most screenshot verbs return a shrunk copy. The original");+                    hint("size just has to travel WITH the shot. Provide it, ONE of:");+                    hint("  --orig-w <W> --orig-h <H>            (the original dimensions)");+                    hint("  --orig-file <the original image>     (shotlog measures it — keep the original around)");+                    hint("  --native                             (only if this image was NOT resized)");+                    hint("If your screenshot tool doesn't surface the original W×H yet, fix it to report");+                    hint("the pre-resize dimensions (and keep the full-res file available for --orig-file).");+                    std::process::exit(1);+                } else {+                    (None, None) // >1500 → server records the original on resize+                }+            };++            run_inject(&channel, &desc, &source, port, &file, false, orig_w, orig_h, cap_meta, popups);+        }+        Commands::Open {+            channel, name, icon, port,+        } => {+            open::open_webview(&channel, name.as_deref(), icon.as_deref(), port);+        }+        Commands::Paste {+            channel, desc, source, port, file,+        } => {+            let desc = match desc {+                Some(d) if d.len() > 5 => d,+                Some(d) => {+                    err(&format!("Description too short: \"{d}\""));+                    hint("Write a specific description. Example: --desc \"Schematic after adding decoupling caps\"");+                    std::process::exit(1);+                }+                None => {+                    err("Missing --desc flag. Every screenshot needs a description.");+                    hint("Example: shotlog paste -c my-project -d \"Full page screenshot resized for AI\" file.png");+                    std::process::exit(1);+                }+            };+            run_inject(&channel, &desc, &source, port, &file, true, None, None, None, None);+        }+        Commands::Resize { file, output, width } => {+            run_resize(&file, output.as_deref(), width);+        }+        Commands::Health { port } => {+            run_health(port);+        }+        Commands::Install => {+            run_install();+        }+        Commands::Completions { shell } => {+            let mut cmd = Cli::command();+            clap_complete::generate(shell, &mut cmd, "adom-shotlog", &mut std::io::stdout());+        }+    }+}++async fn run_server(port: u16, data_dir: PathBuf) {+    let store = store::Store::new(data_dir);+    store.recover().await;++    let channel_count = store.channel_names().await.len();+    ok(&format!("Recovered {channel_count} channel(s) from disk"));++    let state = Arc::new(server::AppState { store, port });+    let app = server::create_router(state);++    let addr = format!("0.0.0.0:{port}");+    ok(&format!("Listening on {addr}"));++    let listener = tokio::net::TcpListener::bind(&addr)+        .await+        .expect("Failed to bind port");+    axum::serve(listener, app)+        .await+        .expect("Server error");+}++#[allow(clippy::too_many_arguments)]+fn run_inject(+    channel: &str,+    desc: &str,+    source: &str,+    port: u16,+    file: &str,+    resize: bool,+    orig_w: Option<u32>,+    orig_h: Option<u32>,+    cap_meta: Option<serde_json::Value>,+    popups: Option<Vec<serde_json::Value>>,+) {+    let bytes = if file == "-" {+        use std::io::Read;+        let mut buf = Vec::new();+        std::io::stdin()+            .read_to_end(&mut buf)+            .expect("Failed to read stdin");+        buf+    } else {+        std::fs::read(file).unwrap_or_else(|e| {+            err(&format!("Failed to read {file}: {e}"));+            std::process::exit(1);+        })+    };++    let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes);+    let data_url = format!("data:image/png;base64,{b64}");++    let body = if resize {+        serde_json::json!({+            "channel": channel,+            "dataUrl": data_url,+            "description": desc,+        })+    } else {+        let mut b = serde_json::json!({+            "channel": channel,+            "image": data_url,+            "description": desc,+            "source": source,+        });+        if let (Some(ow), Some(oh)) = (orig_w, orig_h) {+            b["orig_w"] = serde_json::json!(ow);+            b["orig_h"] = serde_json::json!(oh);+        }+        if let Some(m) = cap_meta { b["meta"] = m; }+        if let Some(p) = popups { b["popups"] = serde_json::json!(p); }+        b+    };++    let endpoint = if resize { "/api/paste" } else { "/api/shots" };+    match http_post(port, endpoint, &body) {+        Ok(val) => {+            if val.get("ok").and_then(|v| v.as_bool()) == Some(true) {+                let w = val["w"].as_u64().unwrap_or(0);+                let h = val["h"].as_u64().unwrap_or(0);+                let ow = val["original_w"].as_u64();+                let oh = val["original_h"].as_u64();+                let dim_str = match (ow, oh) {+                    (Some(ow), Some(oh)) => format!("{w}x{h}, orig {ow}x{oh}"),+                    _ => format!("{w}x{h}"),+                };+                ok(&format!(+                    "Injected {} ({dim_str}, {} KB) into channel {channel}\n     Saved to: {}",+                    val["name"].as_str().unwrap_or("?"),+                    val["kb"],+                    val["path"].as_str().unwrap_or("?"),+                ));+                // shotlog resized a full-res image itself and recorded the original.+                if let (Some(ow), Some(oh)) = (ow, oh) {+                    if orig_w.is_none() {+                        hint(&format!("Auto-resized from {ow}x{oh} → {w}x{h}; original size recorded."));+                    }+                }+            } else {+                err(&format!("Server error: {}", serde_json::to_string(&val).unwrap_or_default()));+            }+        }+        Err(e) => {+            err(&e);+            hint("Is `shotlog serve` running? Start with: shotlog serve &");+        }+    }+}++fn run_resize(file: &str, output: Option<&str>, max_width: u32) {+    let bytes = std::fs::read(file).unwrap_or_else(|e| {+        err(&format!("Failed to read {file}: {e}"));+        std::process::exit(1);+    });++    let (orig_w, orig_h) = image::read_png_dimensions(&bytes);+    let orig_kb = bytes.len() / 1024;++    match image::resize_png(bytes, max_width) {+        Ok((resized, new_w, new_h)) => {+            let new_kb = resized.len() / 1024;+            let out_path = output.unwrap_or(file);+            std::fs::write(out_path, &resized).unwrap_or_else(|e| {+                err(&format!("Failed to write {out_path}: {e}"));+                std::process::exit(1);+            });++            if orig_w != new_w {+                ok(&format!(+                    "Resized {file}: {orig_w}x{orig_h} ({orig_kb} KB) -> {new_w}x{new_h} ({new_kb} KB) saved to {out_path}"+                ));+            } else {+                ok(&format!(+                    "Already within {max_width}px: {file} {orig_w}x{orig_h} ({new_kb} KB)"+                ));+            }+        }+        Err(e) => {+            err(&format!("Resize failed: {e}"));+            std::process::exit(1);+        }+    }+}++fn run_health(port: u16) {+    match http_get(port, "/health") {+        Ok(val) => {+            let channels = val["channels"].as_array().map(|a| a.len()).unwrap_or(0);+            ok(&format!(+                "Shotlog server running on port {port} with {channels} channel(s)"+            ));+        }+        Err(e) => {+            err(&format!("Cannot connect to shotlog on port {port}"));+            hint("Start with: shotlog serve &");+            eprintln!("{e}");+            std::process::exit(1);+        }+    }+}++fn run_install() {+    let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string());++    // 1. Install skill+    let skill_dir = format!("{home}/.claude/skills/adom-shotlog");+    let skill_file = format!("{skill_dir}/SKILL.md");+    std::fs::create_dir_all(&skill_dir).ok();+    match std::fs::write(&skill_file, SKILL_MD) {+        Ok(_) => ok(&format!("Skill installed to {skill_file}")),+        Err(e) => err(&format!("Failed to write skill: {e}")),+    }++    // 2. Install bash completions+    let comp_dir = format!("{home}/.local/share/bash-completion/completions");+    std::fs::create_dir_all(&comp_dir).ok();+    let comp_file = format!("{comp_dir}/adom-shotlog");+    let mut cmd = Cli::command();+    let mut buf = Vec::new();+    clap_complete::generate(clap_complete::Shell::Bash, &mut cmd, "adom-shotlog", &mut buf);+    match std::fs::write(&comp_file, &buf) {+        Ok(_) => ok(&format!("Bash completions installed to {comp_file}")),+        Err(e) => err(&format!("Failed to write completions: {e}")),+    }++    // 3. Add to .bashrc if not there+    let bashrc = format!("{home}/.bashrc");+    let bashrc_content = std::fs::read_to_string(&bashrc).unwrap_or_default();+    let marker = "# adom-shotlog completions";+    if !bashrc_content.contains(marker) {+        let line = format!("\n{marker}\ncommand -v adom-shotlog >/dev/null && eval \"$(adom-shotlog completions bash 2>/dev/null)\"\n");+        match std::fs::OpenOptions::new().append(true).open(&bashrc) {+            Ok(mut f) => {+                use std::io::Write;+                f.write_all(line.as_bytes()).ok();+                ok("Bash completions added to .bashrc");+            }+            Err(e) => err(&format!("Failed to update .bashrc: {e}")),+        }+    }++    // 4. Back-compat `shotlog` alias → symlink next to the adom-shotlog binary.+    if let Ok(exe) = std::env::current_exe() {+        if let Some(dir) = exe.parent() {+            let alias = dir.join("shotlog");+            if !alias.exists() {+                #[cfg(unix)]+                match std::os::unix::fs::symlink(&exe, &alias) {+                    Ok(_) => ok(&format!("`shotlog` alias linked to {}", alias.display())),+                    Err(e) => err(&format!("Could not create `shotlog` alias ({e}) — run: sudo ln -s {} {}", exe.display(), alias.display())),+                }+            }+        }+    }++    println!("\nInstall complete.");+}++// ── HTTP client (raw TCP) ───────────────────────────────────────++fn http_post(port: u16, endpoint: &str, body: &serde_json::Value) -> Result<serde_json::Value, String> {+    let body_str = body.to_string();+    let request = format!(+        "POST {endpoint} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body_str}",+        body_str.len(),+    );+    send_request(port, &request)+}++fn http_get(port: u16, endpoint: &str) -> Result<serde_json::Value, String> {+    let request = format!(+        "GET {endpoint} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"+    );+    send_request(port, &request)+}++fn send_request(port: u16, request: &str) -> Result<serde_json::Value, String> {+    use std::io::{Read, Write};++    let mut stream = std::net::TcpStream::connect(format!("127.0.0.1:{port}"))+        .map_err(|e| format!("Connection refused: {e}"))?;++    let timeout = std::time::Duration::from_secs(10);+    stream.set_read_timeout(Some(timeout)).ok();+    stream.set_write_timeout(Some(timeout)).ok();++    stream.write_all(request.as_bytes()).map_err(|e| e.to_string())?;+    let mut response = String::new();+    stream.read_to_string(&mut response).unwrap_or(0);++    if response.is_empty() {+        return Ok(serde_json::json!({ "ok": true }));+    }++    let body_start = match response.find("\r\n\r\n") {+        Some(pos) => pos,+        None => return Ok(serde_json::json!({ "ok": true })),+    };+    let raw_body = &response[body_start + 4..];++    // Handle chunked transfer encoding+    let body = if let Some(json_start) = raw_body.find('{') {+        let json_end = raw_body.rfind('}').unwrap_or(raw_body.len() - 1);+        &raw_body[json_start..=json_end]+    } else {+        raw_body.trim()+    };++    if body.is_empty() {+        return Ok(serde_json::json!({ "ok": true }));+    }++    serde_json::from_str(body).map_err(|e| format!("Invalid JSON: {e}"))+}
src/open.rsadded+109
@@ -0,0 +1,109 @@+use std::process::Command;++/// Open a webview tab in the Adom workspace for a shotlog channel.+pub fn open_webview(channel: &str, display_name: Option<&str>, icon: Option<&str>, port: u16) {+    let default_name = format!("Shots: {channel}");+    let name = display_name.unwrap_or(&default_name);+    let icon = icon.unwrap_or("mdi:camera-burst");++    // Build the proxy URL from VSCODE_PROXY_URI+    let proxy_uri = match std::env::var("VSCODE_PROXY_URI") {+        Ok(uri) => uri.replace("{{port}}", &port.to_string()),+        Err(_) => {+            eprintln!("VSCODE_PROXY_URI not set — cannot determine proxy URL.");+            eprintln!("Falling back to http://127.0.0.1:{port}");+            format!("http://127.0.0.1:{port}")+        }+    };++    let url = format!("{proxy_uri}log/{channel}/");++    // Get the workspace layout to find a leaf node+    let layout_output = Command::new("adom-cli")+        .args(["hydrogen", "workspace", "get"])+        .output();++    let leaf_id = match layout_output {+        Ok(output) => {+            let stdout = String::from_utf8_lossy(&output.stdout);+            find_leaf_id(&stdout)+        }+        Err(e) => {+            eprintln!("Failed to get workspace layout: {e}");+            None+        }+    };++    let Some(leaf_id) = leaf_id else {+        eprintln!("Could not find a leaf pane in workspace layout.");+        eprintln!("URL: {url}");+        return;+    };++    let initial_state = serde_json::json!({ "url": url }).to_string();++    let result = Command::new("adom-cli")+        .args([+            "hydrogen",+            "workspace",+            "add-tab",+            "--panel-id",+            &leaf_id,+            "--panel-type",+            "adom/a1b2c3d4-0031-4000-a000-000000000031",+            "--display-name",+            name,+            "--display-icon",+            icon,+            "--initial-state",+            &initial_state,+        ])+        .output();++    match result {+        Ok(output) => {+            if output.status.success() {+                println!("Opened shotlog viewer: {name}");+                println!("Channel: {channel}");+                println!("URL: {url}");+            } else {+                let stderr = String::from_utf8_lossy(&output.stderr);+                eprintln!("adom-cli add-tab failed: {stderr}");+            }+        }+        Err(e) => eprintln!("Failed to run adom-cli: {e}"),+    }+}++/// Walk the workspace JSON to find the first leaf node ID.+fn find_leaf_id(json_str: &str) -> Option<String> {+    let val: serde_json::Value = serde_json::from_str(json_str).ok()?;+    // The layout may be wrapped in a "root" key+    if let Some(root) = val.get("root") {+        find_leaf_recursive(root)+    } else {+        find_leaf_recursive(&val)+    }+}++fn find_leaf_recursive(val: &serde_json::Value) -> Option<String> {+    if let Some(node_type) = val.get("type").and_then(|t| t.as_str()) {+        if node_type == "leaf" {+            return val.get("id").and_then(|id| id.as_str()).map(|s| s.to_string());+        }+        if node_type == "split" {+            // Try second child first (usually the right/bottom pane where viewers go)+            if let Some(second) = val.get("second") {+                if let Some(id) = find_leaf_recursive(second) {+                    return Some(id);+                }+            }+            if let Some(first) = val.get("first") {+                if let Some(id) = find_leaf_recursive(first) {+                    return Some(id);+                }+            }+        }+    }+    None+}
src/server.rsadded+598
@@ -0,0 +1,598 @@+use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};+use axum::extract::{DefaultBodyLimit, Path, Query, State};+use axum::http::{header, HeaderValue, Method, StatusCode};+use axum::response::{Html, IntoResponse, Redirect, Response};+use axum::routing::{delete, get, post};+use axum::{Json, Router};+use serde::{Deserialize, Serialize};+use std::sync::Arc;+use tower_http::cors::CorsLayer;++use crate::describe;+use crate::image::{parse_data_url, read_png_dimensions, resize_png};+use crate::store::Store;++const INDEX_HTML: &str = include_str!("html/index.html");+const ICON_SVG: &str = include_str!("html/icon.svg");++/// Turn a description into a safe filename: lowercase, hyphens, max 60 chars+fn slugify_filename(desc: &str) -> String {+    let slug: String = desc+        .to_lowercase()+        .chars()+        .map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { ' ' })+        .collect::<String>()+        .split_whitespace()+        .collect::<Vec<_>>()+        .join("-");+    let slug = if slug.len() > 60 { &slug[..60] } else { &slug };+    let slug = slug.trim_end_matches('-');+    if slug.is_empty() {+        let ts = chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();+        format!("shot-{ts}.png")+    } else {+        format!("{slug}.png")+    }+}++/// Ensure a filename is unique in a directory by appending -2, -3, etc.+fn unique_filename(dir: &std::path::Path, filename: &str) -> String {+    if !dir.join(filename).exists() {+        return filename.to_string();+    }+    let stem = filename.trim_end_matches(".png");+    for i in 2..100 {+        let candidate = format!("{stem}-{i}.png");+        if !dir.join(&candidate).exists() {+            return candidate;+        }+    }+    // Fallback: add timestamp+    let ts = chrono::Utc::now().format("%H%M%S").to_string();+    format!("{stem}-{ts}.png")+}++pub struct AppState {+    pub store: Arc<Store>,+    pub port: u16,+}++pub fn create_router(state: Arc<AppState>) -> Router {+    let cors = CorsLayer::new()+        .allow_origin(tower_http::cors::Any)+        .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])+        .allow_headers([header::CONTENT_TYPE]);++    Router::new()+        .route("/", get(root_redirect))+        .route("/log/{channel}", get(log_redirect))+        .route("/log/{channel}/", get(serve_viewer))+        .route("/favicon.svg", get(serve_favicon))+        .route("/log/{channel}/favicon.svg", get(serve_favicon))+        .route("/api/shots", get(get_shots))+        .route("/api/shots", post(post_shots))+        .route("/api/viewer-status", get(viewer_status))+        .route("/prompt", get(serve_prompt))+        .route("/api/paste", post(post_paste))+        .route("/api/open", post(open_in_vscode))+        .route("/api/reveal", post(reveal_in_vscode))+        .route("/api/clear-log", delete(clear_log))+        .route("/api/clear-all", delete(clear_all))+        .route("/api/delete", delete(delete_entry))+        .route("/ws/{channel}", get(ws_handler))+        .route("/shots/{channel}/{filename}", get(serve_image))+        .route("/health", get(health))+        .layer(DefaultBodyLimit::max(50 * 1024 * 1024)) // 50MB+        .layer(cors)+        .with_state(state)+}++async fn root_redirect() -> Redirect {+    Redirect::temporary("/log/default/")+}++async fn log_redirect(Path(channel): Path<String>) -> Redirect {+    Redirect::permanent(&format!("/log/{channel}/"))+}++async fn serve_favicon() -> Response {+    let mut res = ICON_SVG.to_string().into_response();+    res.headers_mut().insert(+        header::CONTENT_TYPE,+        HeaderValue::from_static("image/svg+xml"),+    );+    res.headers_mut().insert(+        header::CACHE_CONTROL,+        HeaderValue::from_static("public, max-age=86400"),+    );+    res+}++async fn serve_viewer() -> Html<String> {+    let html = INDEX_HTML.replacen(+        "<head>",+        "<head><script>document.write('<base href=\"'+((location.pathname.match(/^\\/proxy\\/\\d+\\//)||['/'])[0])+'\">')</script>",+        1,+    );+    Html(html)+}++#[derive(Deserialize)]+struct ChannelQuery {+    channel: Option<String>,+}++async fn get_shots(+    State(state): State<Arc<AppState>>,+    Query(q): Query<ChannelQuery>,+) -> Json<Vec<crate::store::Entry>> {+    let channel = q.channel.unwrap_or_else(|| "default".into());+    let entries = state.store.get_entries(&channel).await;+    Json(entries)+}++async fn viewer_status(+    State(state): State<Arc<AppState>>,+    Query(q): Query<ChannelQuery>,+) -> Json<serde_json::Value> {+    let channel = q.channel.unwrap_or_else(|| "default".into());+    let viewers = state.store.viewer_count(&channel).await;+    Json(serde_json::json!({+        "channel": channel,+        "viewers": viewers,+        "has_viewers": viewers > 0,+    }))+}++/// Paste-prompt text served at /prompt — guidance for AI agents on how to inject.+async fn serve_prompt() -> impl IntoResponse {+    let body = "\+Use 'shotlog inject' for all screenshot logging.++shotlog ALWAYS records the original capture resolution. Resizing before inject is+fine and expected — oversized images crash Claude's vision analysis, so most+screenshot verbs return a shrunk copy. The original size just has to travel with+the shot.++If you inject a PRE-SHRUNK image (<=1500px), declare the original — shotlog+REJECTS the inject otherwise. One of:+  --orig-w <W> --orig-h <H>                    (the original dimensions)+  --orig-file <the original full-res image>    (shotlog measures it)+  --native                                     (only if it was NOT resized)++  shotlog inject -c CHANNEL -d \"what and why\" -s pup_screenshot --orig-w 1920 --orig-h 1080 SHRUNK.png++Building a screenshot tool? Have it (1) report the ORIGINAL width/height, and+(2) keep the full-res file available, so callers can pass --orig-w/--orig-h (or+--orig-file). A full-res image also works — shotlog resizes >1500px itself and+records the original.++The description becomes the filename -- say what you are looking at and why.+";+    ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], body)+}++/// Force-open the viewer for a channel when a shot lands with nobody watching.+/// In-process replacement for an external fs-watcher: called from the inject+/// path. Debounced per-channel so a burst of injects can't spawn a storm.+fn force_open_viewer(channel: &str, port: u16) {+    use std::sync::Mutex;+    use std::collections::HashMap;+    use std::time::Instant;+    static LAST: Mutex<Option<HashMap<String, Instant>>> = Mutex::new(None);++    let proxy_tmpl = match std::env::var("VSCODE_PROXY_URI") {+        Ok(v) => v,+        Err(_) => return, // not in an Adom container; nothing to open+    };++    {+        let mut guard = LAST.lock().unwrap();+        let map = guard.get_or_insert_with(HashMap::new);+        if let Some(t) = map.get(channel) {+            if t.elapsed().as_secs() < 60 {+                return; // cooldown — opened recently+            }+        }+        map.insert(channel.to_string(), Instant::now());+    }++    let base = proxy_tmpl.replace("{{port}}", &port.to_string());+    let base = base.trim_end_matches('/').to_string();+    let url = format!("{base}/log/{channel}/");+    let tab = format!("shotlog:{channel}");+    let icon = format!("data:image/svg+xml;base64,{}", base64::Engine::encode(&base64::engine::general_purpose::STANDARD, ICON_SVG));++    // Best-effort; don't block the inject response on it.+    std::thread::spawn(move || {+        let _ = std::process::Command::new("adom-cli")+            .args([+                "hydrogen", "webview", "open-or-refresh",+                "--name", &tab,+                "--url", &url,+                "--display-icon", &icon,+            ])+            .stdout(std::process::Stdio::null())+            .stderr(std::process::Stdio::null())+            .status();+    });+}++#[derive(Deserialize)]+struct InjectRequest {+    channel: String,+    image: String,+    description: String,+    source: Option<String>,+    filename: Option<String>,+    /// Caller-reported original dimensions (when the caller resized before sending).+    orig_w: Option<u32>,+    orig_h: Option<u32>,+    /// Free-form capture metadata (Adom Desktop coordMap / dpiScale / screenRect /+    /// screen resolution, shotId, ownedPopupCount, …). Stored verbatim on the entry.+    meta: Option<serde_json::Value>,+    /// Owned-popup screenshots. Each object carries an `image` data-URL plus any+    /// metadata (hwnd, title, kind, coordMap, shotId). The `image` is saved as its+    /// own PNG and replaced with `filename` in the stored array.+    popups: Option<Vec<serde_json::Value>>,+}++/// Images larger than this on any edge are auto-resized (Claude crashes >1500px).+const MAX_EDGE: u32 = 1500;+/// Target edge for the auto-resize.+const RESIZE_TO: u32 = 1400;++#[derive(Serialize)]+struct InjectResponse {+    ok: bool,+    id: u64,+    name: String,+    path: String,+    w: u32,+    h: u32,+    kb: u64,+    bytes: u64,+    #[serde(skip_serializing_if = "Option::is_none")]+    original_w: Option<u32>,+    #[serde(skip_serializing_if = "Option::is_none")]+    original_h: Option<u32>,+}++async fn post_shots(+    State(state): State<Arc<AppState>>,+    Json(req): Json<InjectRequest>,+) -> Result<Json<InjectResponse>, (StatusCode, String)> {+    let (_mime, raw_bytes) =+        parse_data_url(&req.image).map_err(|e| (StatusCode::BAD_REQUEST, e))?;++    let (incoming_w, incoming_h) = read_png_dimensions(&raw_bytes);++    // Auto-resize anything over the limit, recording the TRUE original. This is+    // how orig dims get captured at the source: shotlog owns the resize, so it+    // always knows what the image was before shrinking. No caller pre-resize needed.+    let (bytes, w, h, orig_w, orig_h) = if incoming_w > MAX_EDGE || incoming_h > MAX_EDGE {+        let (resized, nw, nh) =+            resize_png(raw_bytes, RESIZE_TO).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;+        (resized, nw, nh, Some(incoming_w), Some(incoming_h))+    } else {+        // Image already within limits. If the caller pre-resized externally and+        // reported the original via orig_w/orig_h, honor it; otherwise none.+        (raw_bytes, incoming_w, incoming_h, req.orig_w, req.orig_h)+    };++    let raw_name = req.filename.unwrap_or_else(|| slugify_filename(&req.description));+    let channel_dir = state.store.base_dir().join(&req.channel);+    let filename = unique_filename(&channel_dir, &raw_name);++    let source = req.source.unwrap_or_else(|| "ai".into());++    // Owned popups: save each popup's image as its own PNG next to the primary,+    // strip the data-URL from the stored object, and record its filename + dims.+    let popups_stored = if let Some(popups) = req.popups {+        let stem = filename.trim_end_matches(".png");+        let mut out = Vec::new();+        std::fs::create_dir_all(&channel_dir).ok();+        for (i, mut p) in popups.into_iter().enumerate() {+            let Some(img) = p.get("image").and_then(|v| v.as_str()).map(|s| s.to_string()) else { continue };+            let Ok((_m, pbytes)) = parse_data_url(&img) else { continue };+            let (pw, ph) = read_png_dimensions(&pbytes);+            let pname = format!("{stem}__popup{}.png", i + 1);+            std::fs::write(channel_dir.join(&pname), &pbytes).ok();+            if let Some(obj) = p.as_object_mut() {+                obj.remove("image");+                obj.insert("filename".into(), serde_json::json!(pname));+                obj.insert("w".into(), serde_json::json!(pw));+                obj.insert("h".into(), serde_json::json!(ph));+                obj.insert("kb".into(), serde_json::json!(pbytes.len() / 1024));+            }+            out.push(p);+        }+        if out.is_empty() { None } else { Some(serde_json::Value::Array(out)) }+    } else { None };++    let entry = state+        .store+        .add_entry(&req.channel, bytes, filename, req.description, source, w, h, orig_w, orig_h, req.meta, popups_stored)+        .await;++    // If nobody is watching this channel, force-open a viewer so the user+    // actually sees the shot instead of it landing invisibly in the log.+    if state.store.viewer_count(&req.channel).await == 0 {+        force_open_viewer(&req.channel, state.port);+    }++    Ok(Json(InjectResponse {+        ok: true,+        id: entry.id,+        name: entry.filename,+        path: entry.path,+        w: entry.w,+        h: entry.h,+        kb: entry.kb,+        bytes: entry.bytes,+        original_w: entry.orig_w,+        original_h: entry.orig_h,+    }))+}++#[derive(Deserialize)]+struct PasteRequest {+    channel: String,+    #[serde(rename = "dataUrl")]+    data_url: String,+    description: Option<String>,+}++async fn post_paste(+    State(state): State<Arc<AppState>>,+    Json(req): Json<PasteRequest>,+) -> Result<Json<InjectResponse>, (StatusCode, String)> {+    let (_mime, raw_bytes) =+        parse_data_url(&req.data_url).map_err(|e| (StatusCode::BAD_REQUEST, e))?;++    let (orig_w, orig_h) = read_png_dimensions(&raw_bytes);+    let orig_bytes = raw_bytes.len() as u64;++    let (bytes, w, h) =+        resize_png(raw_bytes, RESIZE_TO).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;++    let was_resized = w != orig_w || h != orig_h;++    let description = req.description.unwrap_or_else(|| {+        if was_resized {+            format!(+                "Clipboard paste (resized from {}x{}, {:.0} KB to {}x{}, {:.0} KB)",+                orig_w, orig_h, orig_bytes as f64 / 1024.0,+                w, h, bytes.len() as f64 / 1024.0,+            )+        } else {+            format!("Clipboard paste ({}x{}, {:.0} KB)", w, h, bytes.len() as f64 / 1024.0)+        }+    });++    let raw_name = slugify_filename(&description);+    let channel_dir = state.store.base_dir().join(&req.channel);+    let filename = unique_filename(&channel_dir, &raw_name);++    let entry = state+        .store+        .add_entry(+            &req.channel, bytes, filename, description, "clipboard".into(), w, h,+            if was_resized { Some(orig_w) } else { None },+            if was_resized { Some(orig_h) } else { None },+            None, None,+        )+        .await;+++    Ok(Json(InjectResponse {+        ok: true,+        id: entry.id,+        name: entry.filename,+        path: entry.path,+        w: entry.w,+        h: entry.h,+        kb: entry.kb,+        bytes: entry.bytes,+        original_w: if was_resized { Some(orig_w) } else { None },+        original_h: if was_resized { Some(orig_h) } else { None },+    }))+}++#[derive(Deserialize)]+struct OpenRequest {+    path: String,+}++async fn open_in_vscode(+    Json(req): Json<OpenRequest>,+) -> Json<serde_json::Value> {+    if req.path.contains("..") {+        return Json(serde_json::json!({ "ok": false, "error": "invalid path" }));+    }++    // Detect if path is a directory (reveal) or file (open)+    let is_dir = std::path::Path::new(&req.path).is_dir();+    let cmd = if is_dir { "reveal" } else { "open" };++    let result = std::process::Command::new("adom-vscode")+        .args([cmd, &req.path])+        .output()+        .or_else(|_| {+            std::process::Command::new(+                "/usr/lib/code-server/lib/vscode/bin/remote-cli/code-linux.sh",+            )+            .args(["--reuse-window", &req.path])+            .output()+        });++    match result {+        Ok(output) if output.status.success() => {+            Json(serde_json::json!({ "ok": true }))+        }+        Ok(output) => {+            let err = String::from_utf8_lossy(&output.stderr);+            Json(serde_json::json!({ "ok": false, "error": err.to_string() }))+        }+        Err(e) => {+            Json(serde_json::json!({ "ok": false, "error": e.to_string() }))+        }+    }+}++async fn reveal_in_vscode(+    Json(req): Json<OpenRequest>,+) -> Json<serde_json::Value> {+    if req.path.contains("..") {+        return Json(serde_json::json!({ "ok": false, "error": "invalid path" }));+    }++    // Ensure directory exists before revealing+    std::fs::create_dir_all(&req.path).ok();++    let result = std::process::Command::new("adom-vscode")+        .args(["reveal", &req.path])+        .output();++    match result {+        Ok(output) if output.status.success() => {+            Json(serde_json::json!({ "ok": true }))+        }+        Ok(output) => {+            let err = String::from_utf8_lossy(&output.stderr);+            Json(serde_json::json!({ "ok": false, "error": err.to_string() }))+        }+        Err(e) => {+            Json(serde_json::json!({ "ok": false, "error": e.to_string() }))+        }+    }+}++async fn clear_log(+    State(state): State<Arc<AppState>>,+    Query(q): Query<ChannelQuery>,+) -> Json<serde_json::Value> {+    let channel = q.channel.unwrap_or_else(|| "default".into());+    let count = state.store.clear_log(&channel).await;+    Json(serde_json::json!({ "ok": true, "cleared": count }))+}++async fn clear_all(+    State(state): State<Arc<AppState>>,+    Query(q): Query<ChannelQuery>,+) -> Json<serde_json::Value> {+    let channel = q.channel.unwrap_or_else(|| "default".into());+    let count = state.store.clear_all(&channel).await;+    Json(serde_json::json!({ "ok": true, "cleared": count, "files_deleted": true }))+}++#[derive(Deserialize)]+struct DeleteQuery {+    channel: Option<String>,+    filename: Option<String>,+}++async fn delete_entry(+    State(state): State<Arc<AppState>>,+    Query(q): Query<DeleteQuery>,+) -> Json<serde_json::Value> {+    let channel = q.channel.unwrap_or_else(|| "default".into());+    let filename = match q.filename {+        Some(f) => f,+        None => return Json(serde_json::json!({ "ok": false, "error": "missing filename" })),+    };++    // Delete the PNG and JSON sidecar from disk+    let channel_dir = state.store.base_dir().join(&channel);+    let png_path = channel_dir.join(&filename);+    let json_path = png_path.with_extension("json");+    std::fs::remove_file(&png_path).ok();+    std::fs::remove_file(&json_path).ok();++    // Remove from in-memory entries+    state.store.delete_entry(&channel, &filename).await;++    Json(serde_json::json!({ "ok": true, "deleted": filename }))+}++// WebSocket: client connects to /ws/<channel>, receives new entries as JSON text frames+async fn ws_handler(+    ws: WebSocketUpgrade,+    Path(channel): Path<String>,+    State(state): State<Arc<AppState>>,+) -> impl IntoResponse {+    ws.on_upgrade(move |socket| ws_connection(socket, channel, state))+}++async fn ws_connection(mut socket: WebSocket, channel: String, state: Arc<AppState>) {+    let mut rx = state.store.subscribe(&channel).await;++    loop {+        tokio::select! {+            result = rx.recv() => {+                match result {+                    Ok(entry) => {+                        let json = serde_json::json!({+                            "type": "entry",+                            "entry": entry,+                        });+                        if socket.send(Message::Text(json.to_string().into())).await.is_err() {+                            break; // client disconnected+                        }+                    }+                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,+                    Err(_) => break,+                }+            }+            msg = socket.recv() => {+                match msg {+                    Some(Ok(Message::Close(_))) | None => break,+                    _ => {} // ignore client messages+                }+            }+        }+    }+}++async fn serve_image(+    State(state): State<Arc<AppState>>,+    Path((channel, filename)): Path<(String, String)>,+) -> Response {+    if channel.contains("..") || filename.contains("..") {+        return (StatusCode::BAD_REQUEST, "Invalid path").into_response();+    }++    let file_path = state.store.base_dir().join(&channel).join(&filename);+    match tokio::fs::read(&file_path).await {+        Ok(bytes) => {+            let mut res = bytes.into_response();+            res.headers_mut().insert(+                header::CONTENT_TYPE,+                HeaderValue::from_static("image/png"),+            );+            res.headers_mut().insert(+                header::CACHE_CONTROL,+                HeaderValue::from_static("no-cache"),+            );+            res+        }+        Err(_) => (StatusCode::NOT_FOUND, "Image not found").into_response(),+    }+}++#[derive(Serialize)]+struct HealthResponse {+    ok: bool,+    port: u16,+    data_dir: String,+    channels: Vec<String>,+}++async fn health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {+    Json(HealthResponse {+        ok: true,+        port: state.port,+        data_dir: state.store.base_dir().to_string_lossy().to_string(),+        channels: state.store.channel_names().await,+    })+}
src/store.rsadded+362
@@ -0,0 +1,362 @@+use chrono::{DateTime, Utc};+use serde::{Deserialize, Serialize};+use std::collections::HashMap;+use std::fs;+use std::path::{Path, PathBuf};+use std::sync::Arc;+use tokio::sync::{broadcast, RwLock};++use crate::image::{read_png_dimensions, read_png_meta, PngMeta};++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Entry {+    pub id: u64,+    pub timestamp: DateTime<Utc>,+    pub filename: String,+    pub description: String,+    pub w: u32,+    pub h: u32,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub orig_w: Option<u32>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub orig_h: Option<u32>,+    pub bytes: u64,+    pub kb: u64,+    pub source: String,+    pub path: String,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub png_meta: Option<PngMeta>,+    /// Free-form capture metadata (e.g. Adom Desktop coordMap, dpiScale,+    /// screenRect, screen resolution). Additive — absent for plain injects.+    #[serde(skip_serializing_if = "Option::is_none")]+    pub meta: Option<serde_json::Value>,+    /// Owned-popup screenshots attached to this shot (autofill dropdowns,+    /// confirm bubbles, native dialogs). Each carries its own filename ++    /// coordMap. Additive — absent for plain single-image injects.+    #[serde(skip_serializing_if = "Option::is_none")]+    pub popups: Option<serde_json::Value>,+}++struct Channel {+    entries: Vec<Entry>,+    next_id: u64,+    tx: broadcast::Sender<Entry>,+}++const MAX_ENTRIES: usize = 200;++pub struct Store {+    channels: RwLock<HashMap<String, Channel>>,+    base_dir: PathBuf,+}++impl Store {+    pub fn new(base_dir: PathBuf) -> Arc<Self> {+        fs::create_dir_all(&base_dir).ok();+        // Canonicalize so all paths are absolute+        let base_dir = fs::canonicalize(&base_dir).unwrap_or(base_dir);+        let store = Arc::new(Self {+            channels: RwLock::new(HashMap::new()),+            base_dir,+        });+        store+    }++    /// Recover existing screenshots from disk on startup.+    pub async fn recover(&self) {+        let entries_dir = &self.base_dir;+        let Ok(read_dir) = fs::read_dir(entries_dir) else {+            return;+        };++        for dir_entry in read_dir.flatten() {+            if !dir_entry.path().is_dir() {+                continue;+            }+            let channel_name = dir_entry.file_name().to_string_lossy().to_string();+            let channel_dir = dir_entry.path();++            let mut files: Vec<(PathBuf, std::fs::Metadata)> = Vec::new();+            if let Ok(inner) = fs::read_dir(&channel_dir) {+                for f in inner.flatten() {+                    let p = f.path();+                    if p.extension().and_then(|e| e.to_str()) == Some("png") {+                        // Skip owned-popup attachment PNGs ({stem}__popupN.png) — they+                        // belong to a primary shot's sidecar, not a standalone entry.+                        if p.file_name().and_then(|n| n.to_str()).map(|n| n.contains("__popup")).unwrap_or(false) {+                            continue;+                        }+                        if let Ok(meta) = fs::metadata(&p) {+                            files.push((p, meta));+                        }+                    }+                }+            }++            // Sort by modification time ascending+            files.sort_by_key(|(_, m)| m.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH));++            let (tx, _) = broadcast::channel(64);+            let mut entries = Vec::new();+            let mut next_id = 1u64;++            for (path, meta) in &files {+                let bytes_raw = fs::read(path).unwrap_or_default();+                let (w, h) = read_png_dimensions(&bytes_raw);+                let file_bytes = meta.len();+                let filename = path+                    .file_name()+                    .unwrap_or_default()+                    .to_string_lossy()+                    .to_string();++                let modified: DateTime<Utc> = meta+                    .modified()+                    .unwrap_or(std::time::SystemTime::UNIX_EPOCH)+                    .into();++                // Read sidecar metadata if it exists+                let meta_path = path.with_extension("json");+                let (description, source, timestamp, orig_w, orig_h, cap_meta, popups) = if let Ok(json_str) = fs::read_to_string(&meta_path) {+                    if let Ok(v) = serde_json::from_str::<serde_json::Value>(&json_str) {+                        let desc = v["description"].as_str().unwrap_or("").to_string();+                        let src = v["source"].as_str().unwrap_or("recovered").to_string();+                        let ts = v["timestamp"].as_str()+                            .and_then(|s| s.parse::<DateTime<Utc>>().ok())+                            .unwrap_or(modified);+                        let ow = v["orig_w"].as_u64().map(|n| n as u32);+                        let oh = v["orig_h"].as_u64().map(|n| n as u32);+                        let cm = if v.get("meta").map(|m| !m.is_null()).unwrap_or(false) { Some(v["meta"].clone()) } else { None };+                        let pp = if v.get("popups").map(|p| !p.is_null()).unwrap_or(false) { Some(v["popups"].clone()) } else { None };+                        (desc, src, ts, ow, oh, cm, pp)+                    } else {+                        (String::new(), "recovered".into(), modified, None, None, None, None)+                    }+                } else {+                    (String::new(), "recovered".into(), modified, None, None, None, None)+                };++                let png_meta = read_png_meta(&bytes_raw);+                entries.push(Entry {+                    id: next_id,+                    timestamp,+                    filename: filename.clone(),+                    description,+                    w,+                    h,+                    orig_w,+                    orig_h,+                    bytes: file_bytes,+                    kb: file_bytes / 1024,+                    source,+                    path: path.to_string_lossy().to_string(),+                    png_meta,+                    meta: cap_meta,+                    popups,+                });+                next_id += 1;+            }++            // Keep only the last MAX_ENTRIES+            if entries.len() > MAX_ENTRIES {+                entries = entries.split_off(entries.len() - MAX_ENTRIES);+            }++            let mut channels = self.channels.write().await;+            channels.insert(+                channel_name,+                Channel {+                    entries,+                    next_id,+                    tx,+                },+            );+        }+    }++    /// Add a new screenshot entry. Saves to disk and broadcasts via SSE.+    #[allow(clippy::too_many_arguments)]+    pub async fn add_entry(+        &self,+        channel_name: &str,+        image_bytes: Vec<u8>,+        filename: String,+        description: String,+        source: String,+        w: u32,+        h: u32,+        orig_w: Option<u32>,+        orig_h: Option<u32>,+        meta: Option<serde_json::Value>,+        popups: Option<serde_json::Value>,+    ) -> Entry {+        let channel_dir = self.base_dir.join(channel_name);+        fs::create_dir_all(&channel_dir).ok();++        let file_path = channel_dir.join(&filename);+        fs::write(&file_path, &image_bytes).ok();++        let file_bytes = image_bytes.len() as u64;+        let png_meta = read_png_meta(&image_bytes);++        // Save metadata sidecar JSON+        let meta_path = file_path.with_extension("json");+        let mut meta_json = serde_json::json!({+            "description": description,+            "source": source,+            "w": w, "h": h,+            "bytes": file_bytes,+            "timestamp": Utc::now().to_rfc3339(),+        });+        if let (Some(ow), Some(oh)) = (orig_w, orig_h) {+            meta_json["orig_w"] = serde_json::json!(ow);+            meta_json["orig_h"] = serde_json::json!(oh);+        }+        if let Some(m) = &meta { meta_json["meta"] = m.clone(); }+        if let Some(p) = &popups { meta_json["popups"] = p.clone(); }+        fs::write(&meta_path, meta_json.to_string()).ok();++        let mut channels = self.channels.write().await;+        let ch = channels+            .entry(channel_name.to_string())+            .or_insert_with(|| {+                let (tx, _) = broadcast::channel(64);+                Channel {+                    entries: Vec::new(),+                    next_id: 1,+                    tx,+                }+            });++        let entry = Entry {+            id: ch.next_id,+            timestamp: Utc::now(),+            filename,+            description,+            w,+            h,+            orig_w,+            orig_h,+            bytes: file_bytes,+            kb: file_bytes / 1024,+            source,+            path: file_path.to_string_lossy().to_string(),+            png_meta,+            meta,+            popups,+        };++        ch.next_id += 1;+        ch.entries.push(entry.clone());+        if ch.entries.len() > MAX_ENTRIES {+            ch.entries.remove(0);+        }++        // Broadcast to SSE listeners (ignore errors — no receivers is fine)+        let _ = ch.tx.send(entry.clone());++        entry+    }++    /// Get all entries for a channel.+    pub async fn get_entries(&self, channel_name: &str) -> Vec<Entry> {+        let channels = self.channels.read().await;+        channels+            .get(channel_name)+            .map(|ch| ch.entries.clone())+            .unwrap_or_default()+    }++    /// Number of live viewers (WebSocket subscribers) for a channel.+    pub async fn viewer_count(&self, channel_name: &str) -> usize {+        let channels = self.channels.read().await;+        channels.get(channel_name).map(|ch| ch.tx.receiver_count()).unwrap_or(0)+    }++    /// Get a broadcast receiver for a channel's SSE stream.+    pub async fn subscribe(&self, channel_name: &str) -> broadcast::Receiver<Entry> {+        let mut channels = self.channels.write().await;+        let ch = channels+            .entry(channel_name.to_string())+            .or_insert_with(|| {+                let (tx, _) = broadcast::channel(64);+                Channel {+                    entries: Vec::new(),+                    next_id: 1,+                    tx,+                }+            });+        ch.tx.subscribe()+    }++    /// Update the description of an existing entry and broadcast the update.+    pub async fn update_description(&self, channel_name: &str, entry_id: u64, description: String) {+        let mut channels = self.channels.write().await;+        if let Some(ch) = channels.get_mut(channel_name) {+            if let Some(entry) = ch.entries.iter_mut().find(|e| e.id == entry_id) {+                entry.description = description.clone();++                // Update the sidecar JSON on disk+                let meta_path = std::path::Path::new(&entry.path).with_extension("json");+                if let Ok(json_str) = fs::read_to_string(&meta_path) {+                    if let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&json_str) {+                        v["description"] = serde_json::json!(description);+                        v["described_by"] = serde_json::json!("claude -p \"...\" --model haiku --image <path>");+                        v["described_at"] = serde_json::json!(chrono::Utc::now().to_rfc3339());+                        fs::write(&meta_path, v.to_string()).ok();+                    }+                }++                let _ = ch.tx.send(entry.clone());+            }+        }+    }++    /// Delete a single entry by filename.+    pub async fn delete_entry(&self, channel_name: &str, filename: &str) {+        let mut channels = self.channels.write().await;+        if let Some(ch) = channels.get_mut(channel_name) {+            ch.entries.retain(|e| e.filename != filename);+        }+    }++    /// Clear in-memory entries for a channel (log only, files stay on disk).+    pub async fn clear_log(&self, channel_name: &str) -> usize {+        let mut channels = self.channels.write().await;+        if let Some(ch) = channels.get_mut(channel_name) {+            let count = ch.entries.len();+            ch.entries.clear();+            count+        } else {+            0+        }+    }++    /// Clear entries AND delete all files for a channel.+    pub async fn clear_all(&self, channel_name: &str) -> usize {+        let count = self.clear_log(channel_name).await;+        let channel_dir = self.base_dir.join(channel_name);+        if channel_dir.is_dir() {+            if let Ok(entries) = fs::read_dir(&channel_dir) {+                for entry in entries.flatten() {+                    let p = entry.path();+                    if p.extension().and_then(|e| e.to_str()) == Some("png") {+                        fs::remove_file(&p).ok();+                    }+                }+            }+        }+        count+    }++    /// List all channel names.+    pub async fn channel_names(&self) -> Vec<String> {+        let channels = self.channels.read().await;+        channels.keys().cloned().collect()+    }++    /// Get the base directory path.+    pub fn base_dir(&self) -> &Path {+        &self.base_dir+    }+}