aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/specs/google-keep-emacs-integration-spec.org220
-rw-r--r--init.el2
-rw-r--r--modules/calendar-sync.el10
-rw-r--r--modules/dirvish-config.el9
-rw-r--r--modules/google-keep-config.el210
-rw-r--r--modules/ledger-config.el40
-rw-r--r--modules/prog-general.el10
-rw-r--r--modules/ui-navigation.el10
-rw-r--r--modules/user-constants.el6
-rwxr-xr-xscripts/google-keep/keep-bridge.py92
-rw-r--r--scripts/google-keep/test_keep_bridge.py152
-rw-r--r--scripts/theme-studio/app-core.js20
-rw-r--r--scripts/theme-studio/app.js240
-rw-r--r--scripts/theme-studio/browser-gates.js19
-rw-r--r--scripts/theme-studio/controls.js209
-rw-r--r--scripts/theme-studio/face_coverage.py8
-rw-r--r--scripts/theme-studio/generate.py40
-rw-r--r--scripts/theme-studio/palette-generator-core.js3
-rw-r--r--scripts/theme-studio/styles.css7
-rw-r--r--scripts/theme-studio/test-locate.mjs27
-rw-r--r--scripts/theme-studio/test_generate.py33
-rw-r--r--scripts/theme-studio/theme-studio.html88
-rw-r--r--tests/test-calendar-sync--apply-single-exception.el37
-rw-r--r--tests/test-dirvish-config-wallpaper-program.el4
-rw-r--r--tests/test-google-keep-config.el142
-rw-r--r--tests/test-prog-lsp.el66
-rw-r--r--todo.org66
27 files changed, 1407 insertions, 363 deletions
diff --git a/docs/specs/google-keep-emacs-integration-spec.org b/docs/specs/google-keep-emacs-integration-spec.org
new file mode 100644
index 000000000..376522ab4
--- /dev/null
+++ b/docs/specs/google-keep-emacs-integration-spec.org
@@ -0,0 +1,220 @@
+#+TITLE: Google Keep <-> Emacs integration — Spec
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-06-24
+#+TODO: TODO | DONE SUPERSEDED CANCELLED
+
+* Metadata
+| Status | v1 implemented (Phases 1-3); live setup pending; v2 next |
+|----------+------------------------------------------------------------|
+| Owner | Craig |
+|----------+------------------------------------------------------------|
+| Reviewer | Codex (spec-review) |
+|----------+------------------------------------------------------------|
+| Related | [[file:../../todo.org][todo.org: google-keep in-editor integration]] |
+
+* Problem / Context
+
+Craig keeps quick notes in Google Keep but works almost entirely in Emacs. Today, reading or acting on a Keep note means leaving Emacs for the phone or the web app — a context switch for content that wants to live next to his org files. He wants Keep notes native to Emacs: browsable, searchable, greppable, and editable, with a path to publish the result as a standalone package.
+
+Two hard constraints shape every choice:
+
+1. *No official API.* Google Keep has no public API. Every live client (gkeepapi and the tools built on it) reverse-engineers the private mobile endpoint. That layer is fragile: it breaks when Google changes auth, and it needs a Google master token, not a password.
+2. *The existing MCP is agent-only.* Craig already has a google-keep MCP with full read/write (create/update/find/labels/archive/list-items). But MCP tools are invoked by the *agent* (Claude), not from elisp — there is no elisp MCP client. So the in-editor integration cannot reuse the MCP as its data path; it needs its own.
+
+Researched 2026-06-24: there is no in-editor Emacs Google Keep package on MELPA or GitHub — only KeepToOrg, a one-shot Takeout-HTML-to-org importer (unmaintained). So this is a build, not an adopt.
+
+The closest prior art in this config is =calendar-sync.el=: an existing engine that fetches external data and writes generated org files under =data/=. This spec follows its conventions (generated file under =data/=, atomic temp-then-rename writes, async =make-process= + sentinel, =auth-source= via the house helper) rather than reinventing them.
+
+* Goals and Non-Goals
+** Goals
+- Keep notes visible and usable inside Emacs without leaving the editor.
+- An org-native representation, so notes are searchable/greppable and reuse org machinery.
+- A structure that starts as glue in =.emacs.d= and can be extracted to a publishable package (the VAMP / pearl module-to-package pattern).
+- Read-write (create/edit notes from Emacs) as the immediate v2 increment — v1 ships read-only first, but the read path is built to carry write, so v2 is additive.
+** Non-Goals
+- Full bidirectional offline sync, conflict resolution, or real-time updates.
+- Faithful round-tripping of every Keep feature (list checkboxes, collaborators, drawings, images).
+- Reusing the MCP from elisp (infeasible — agent-only).
+** Scope tiers
+- v1: read-only. Fetch Keep notes through the gkeepapi bridge and render them as an org page (each note an org header). A manual refresh command. Auth via auth-source. Graceful degradation when the bridge or credentials are missing. The read path establishes a round-trip-ready data model and a stable per-note identity, because v2 builds on them.
+- v2 (immediate follow-on, not deferred): read-write — create a note from a region or capture, and edit a note back to Keep. Reuses v1's bridge, auth, data model, and note-identity; adds the inverse direction and a staleness check.
+- Out of scope: list/checkbox fidelity, collaborators, drawings/images, and any background or real-time sync.
+- Later: list/checkbox rendering, and extracting the core to a standalone package.
+
+* Design
+
+** For the user
+
+A command (=cj/keep-refresh=) pulls the current Keep notes and writes them into one generated org file (=data/keep.org= under =user-emacs-directory=, a constant in =user-constants.el= alongside =gcal-file= — a generated file, not a hand-authored one in =~/org/=). Each note becomes a top-level org heading: the title (or a derived title) as the heading text, the note body as the entry, and Keep metadata as properties — labels as org tags, plus =:KEEP_ID:=, =:PINNED:=, =:COLOR:=, =:ARCHIVED:=, =:UPDATED:= in a drawer. Pinned notes sort first; each header carries an org link back to the source note (=https://keep.google.com/#NOTE/<id>=). The file is plain org, so it is searchable with the agenda, greppable, and linkable. Opening it is just visiting the file; a keybinding and a dashboard entry make it one keystroke. v1 is read-only: editing the org file does not push back to Keep (a header line says so), so there is no accidental-mutation risk while the integration is young. The =:KEEP_ID:= and =:UPDATED:= on each header are what v2 later uses to target an update and detect a stale local copy.
+
+** For the implementer
+
+Three layers, cleanly separable so the core can later be a package:
+
+1. *Data bridge (Python).* A small script using gkeepapi: authenticate with a stored master token, fetch notes, emit JSON on stdout per the schema below. =id= is the gkeepapi server id (immutable across title/content edits — the safe v2 targeting key, never derived from content), and =updated= is the freshness anchor v2's staleness guard reads. This is the one place the unofficial API lives, isolated so a break is contained and swappable; the same bridge gains a write subcommand in v2. On failure it exits non-zero and prints a single reason token on stderr (=no-gkeepapi=, =no-token=, =auth-failed=, =network=) so the elisp sentinel can surface which piece broke.
+2. *Org renderer (elisp core).* Runs the bridge with =make-process= + a sentinel (async, so a slow or hung auth never blocks the interactive thread — the calendar-sync pattern), parses its JSON, and writes the org page via a temp file + atomic rename (never a partial =keep.org= under the user's eyes). Records =:KEEP_ID:=/=:UPDATED:= per note, labels as tags, pinned-first, archived notes tagged =:archived:=. =cj/keep-refresh= is the entry point. The master token is read with =cj/auth-source-secret-value= (system-lib.el, the house helper calendar-sync/slack/transcription use) against a documented =:host= entry. The JSON-to-org transform is the round-trip contract v2 inverts. This core takes its file path, auth host, and keymap injected from the glue layer — it never reaches into =user-constants= or binds keys itself, so it lifts out cleanly as a package.
+3. *Glue (=modules/google-keep-config.el=).* Supplies the core its =data/keep.org= path, the =:host=, a Keep keybinding prefix, the dashboard entry, and the =(require 'google-keep-config)= in =init.el=. A load-time =cj/executable-find-or-warn= for =python3= warns early when the interpreter is absent (the gkeepapi-missing and token-missing cases surface at refresh time via the bridge's stderr reason token).
+
+** Bridge JSON schema (the load-bearing seam)
+
+Both the v1 transform and the v2 inverse are written against this, so it is pinned here. On success the bridge prints a JSON array of note objects:
+
+#+begin_src js
+[ { "id": "<gkeepapi server id, string>",
+ "title": "<string, may be empty>",
+ "text": "<string; newlines are real \n inside the JSON string>",
+ "labels": ["<label>", ...], // [] when none
+ "pinned": true|false,
+ "archived": true|false,
+ "color": "<keep color name, e.g. \"WHITE\">",
+ "updated": "<ISO8601 UTC, e.g. 2026-06-25T04:12:00Z>" } ]
+#+end_src
+
+- =updated= is ISO8601 UTC — sortable, parseable by =parse-iso8601-time-string=, and readable in the drawer. v2's staleness compare depends on this exact, comparable form.
+- An empty Keep returns =[]= (a valid empty page, not an error).
+- =labels= is always an array (empty, never null). =text= newlines are real =\n= inside the JSON string (standard JSON string encoding).
+- Failure is not expressed in JSON: the bridge exits non-zero with one stderr reason token (above). The sentinel maps the token to a =display-warning=.
+
+* Alternatives Considered
+
+** A — Takeout import (one-shot HTML -> org)
+- Good, because no auth, fully offline, dead simple, and zero ongoing breakage risk.
+- Bad, because it is not live — Craig must manually export a Takeout archive, so notes are stale the moment they are imported, and it cannot write, so it is useless for the v2 write path.
+- Neutral, because it is the right tool for an archival snapshot, the wrong one for daily use. Deferred — not built in v1; a possible later no-auth archive importer if ever wanted.
+
+** B (chosen for the data path) — gkeepapi via a Python subprocess bridge
+- Good, because it is the only path that gives live notes and (in v2) write-back from inside Emacs, with the full note model.
+- Bad, because gkeepapi reverse-engineers a private API: it breaks on Google auth changes, needs Python plus a stored master token, and the bridge is glue Craig owns and maintains.
+- Neutral, because the fragility is isolated to one script; when it breaks, the renderer degrades to a warning.
+
+** C — Reuse the google-keep MCP from elisp
+- Good, because the MCP already has full read/write and is maintained outside the config.
+- Bad, because MCP tools are invoked by the agent, not elisp — there is no elisp MCP client, so this is infeasible for an in-editor feature.
+- Neutral, because the MCP stays the right tool for agent-driven Keep access; it just can't power an in-editor integration.
+
+** D — A local HTTP server wrapping gkeepapi
+- Good, because elisp would talk clean HTTP instead of spawning a subprocess each refresh.
+- Bad, because a long-running personal server is more infrastructure than a single-user note view warrants.
+- Neutral, because it is a heavier variant of B; revisit only if subprocess latency ever bites.
+
+* Decisions [5/5]
+
+** DONE Presentation shape: org page of headers
+- Decision: We will render notes as one *org page*, each note a top-level header (title + body + metadata properties, labels as tags). Confirmed by Craig 2026-06-25.
+- Consequences: easier — reuses org search/agenda/grep/links, nothing bespoke to render, and read-only is safe. Harder — a large Keep collection makes a long file (mitigated by pinned-first sort and org folding).
+
+** DONE Direction: read-only v1, read-write v2 (immediate)
+- Decision: We will ship v1 read-only (fetch + render + refresh), then move directly into v2 read-write (create/edit back to Keep). v2 is the immediate next increment, not a deferred someday. The read path must establish a round-trip-ready data model and a stable per-note identity up front, so v2 is additive rather than a rewrite. Confirmed by Craig 2026-06-25.
+- Consequences: easier — v1 ships the visible value fast on a path write reuses wholesale, and a parse bug can't corrupt real Keep data while the model is being proven. Harder — the read path carries design weight it wouldn't if write were truly far off (note-identity and the freshness field have to be right in v1), and the write work — note targeting, staleness/overwrite handling — still has to be built right after.
+
+** DONE Data path: gkeepapi subprocess bridge (Takeout deferred)
+- Decision: We will use a small Python gkeepapi bridge that emits JSON as the sole data path; it powers read in v1 and gains a write subcommand in v2. A failure degrades to a clear warning. The Takeout-import path is deferred (read-only and stale, so it can't serve v2). The MCP is not in the data path. Confirmed by Craig 2026-06-25.
+- Consequences: easier — one live path that serves both read and write, fragility isolated to one swappable script. Harder — a Python + gkeepapi dependency and a maintained bridge; the unofficial API can break and needs a master token, with no second read path as a safety net (the warning is the degradation).
+
+** DONE Auth: master token in authinfo.gpg via auth-source
+- Decision: We will store the master token in =authinfo.gpg= and read it via =auth-source= (with =cj/auth-source-secret-value=, the house helper), and document the one-time master-token retrieval. Confirmed by Craig 2026-06-25.
+- Consequences: easier — consistent with existing credential handling, no plaintext secret, the daemon's auth-source cache applies. Harder — the one-time token retrieval is a manual setup step, and a revoked/expired token surfaces as an auth failure the renderer must report cleanly.
+
+** DONE Structure: google-keep-config.el glue + extractable core
+- Decision: We will build the integration as =modules/google-keep-config.el= (the =.emacs.d= glue: paths, keys, dashboard, auth wiring) plus a self-contained core (the bridge runner + org renderer) written so the core can later move to a standalone =keep.el=-style package, mirroring the VAMP / pearl migration. The core takes paths/keys/host injected from the glue — it never reaches into =user-constants= or binds keys itself. Confirmed by Craig 2026-06-25.
+- Consequences: easier — usable immediately in =.emacs.d=, with a clean seam for later extraction. Harder — the discipline of keeping the core free of =.emacs.d=-specific assumptions from the start.
+
+* Review findings [8/8]
+** DONE Generated-file path + atomic write (round 1, non-blocking)
+- Finding: the draft put the file at =~/org/keep.org= (hand-authored org area) with no atomic write, diverging from calendar-sync (generated org under =data/=, temp-then-rename). Risk: a partial write leaves a truncated file the user is viewing, and a generated file in =~/org/= invites edits that get overwritten.
+- Resolution: path is =data/keep.org= as a =user-constants.el= constant; the renderer writes via temp file + atomic rename.
+
+** DONE Async subprocess via make-process + sentinel (round 1, non-blocking)
+- Finding: the draft said "run as a subprocess" without committing to async; gkeepapi can hang on auth churn, and a synchronous =call-process= would freeze Emacs.
+- Resolution: specified =make-process= + sentinel (the calendar-sync pattern); the warning-on-failure degradation lives in the sentinel.
+
+** DONE Name the auth-source house helper (round 1, non-blocking)
+- Finding: the draft read the token via "auth-source" generically; the house helper is =cj/auth-source-secret-value= (system-lib.el).
+- Resolution: spec now calls it out with a documented =:host= entry.
+
+** DONE Name the missing-tool helper (round 1, non-blocking)
+- Finding: the draft promised a display-warning for a missing tool but didn't cite =cj/executable-find-or-warn=, and didn't separate the gkeepapi-missing vs token-missing failure modes.
+- Resolution: glue uses =cj/executable-find-or-warn= for =python3=; the bridge emits distinct stderr reason tokens the sentinel surfaces.
+
+** DONE Pin the bridge JSON schema (round 1, BLOCKING — cleared)
+- Finding: Phase 1 listed JSON fields but left the =updated= format, label/newline serialization, empty-result, and error envelope unstated — the round-trip contract both v1 and v2's staleness guard depend on.
+- Resolution: added the "Bridge JSON schema" subsection — ISO8601-UTC =updated=, always-array =labels=, standard JSON newline encoding, =[]= for empty, and exit-nonzero + stderr-token for failure.
+
+** DONE v2 staleness guard re-fetches before write (round 1, non-blocking)
+- Finding: v2 must not trust the possibly-stale =:UPDATED:= in the generated file; a phone edit between refresh and edit-back would be clobbered.
+- Resolution: Phase 4 states the write path re-fetches the target note's current =updated= from Keep and compares before overwriting, prompting on mismatch.
+
+** DONE Note KEEP_ID is the immutable server id (round 1, non-blocking)
+- Finding: "stable" id asserted but not anchored.
+- Resolution: spec states =:KEEP_ID:= is the gkeepapi server id, immutable across edits, never derived from content.
+
+** DONE Small enhancements: source link + archived handling (round 1, non-blocking, optional)
+- Finding: a back-link to the Keep web note and archived-note handling were free given the data already carried.
+- Resolution: each header links to =keep.google.com/#NOTE/<id>=; archived notes are tagged =:archived:= (the JSON carries =archived=).
+
+* Implementation phases
+
+** Phase 1 — Data bridge
+A Python script (gkeepapi) that authenticates with the stored master token and prints the JSON array per the Bridge JSON schema on stdout (=id= = immutable server id, =updated= = ISO8601 UTC). On failure: exit non-zero with one stderr reason token. Standalone and testable from the shell with a fixture; no Emacs yet. Tree stays working (new files only).
+
+** Phase 2 — Org renderer + refresh
+The elisp core + =modules/google-keep-config.el=: run the bridge with =make-process= + a sentinel, parse the JSON, and write =data/keep.org= via temp file + atomic rename (heading + body + property drawer per note, recording =:KEEP_ID:= and =:UPDATED:=, labels as tags, archived tagged =:archived:=, pinned-first, a source back-link per header). =cj/keep-refresh= regenerates it; auth via =cj/auth-source-secret-value=. A header line marks the file a read-only view. The sentinel maps a bridge failure (stderr token) to a =display-warning= naming the missing piece — never errors at load.
+
+** Phase 3 — Access UX + un-orphan
+Keybindings (a Keep prefix), a dashboard entry, a load-time =cj/executable-find-or-warn= for =python3=, and the =(require 'google-keep-config)= in =init.el=. Optional: a dedicated read-only major mode for the buffer.
+
+** Phase 4 (v2) — read-write
+The immediate next increment after v1 lands. A write subcommand on the bridge (gkeepapi create/update), and an elisp path that targets a note by =:KEEP_ID:= and, before overwriting, re-fetches that note's current =updated= from Keep and compares it against the drawer's =:UPDATED:= (a staleness guard — abort/prompt on mismatch so a phone edit since the last refresh isn't clobbered). Entry points to create a note from a region/capture and edit one back. Specced in detail once v1's read model is proven on real notes; listed here so Phases 1-3 don't paint into a read-only corner.
+
+(Later, not specced here, logged to todo.org: list/checkbox rendering; extract the core to a package.)
+
+* Acceptance criteria
+- [ ] =cj/keep-refresh= fetches the current Keep notes and writes =data/keep.org= via temp + atomic rename, one header per note with title/body/labels/metadata and a source back-link.
+- [ ] Pinned notes sort to the top; labels render as org tags; archived notes are tagged =:archived:=; Keep id/color/pinned/archived/updated land in a property drawer.
+- [ ] Each note header carries an immutable =:KEEP_ID:= (gkeepapi server id) and an ISO8601 =:UPDATED:= (the v2 targeting + staleness anchors).
+- [ ] The bridge runs async (=make-process= + sentinel); a slow/hung auth does not block Emacs.
+- [ ] The master token is read via =cj/auth-source-secret-value= from =authinfo.gpg=; no secret is hardcoded.
+- [ ] A missing python3 / gkeepapi / token (distinct bridge stderr tokens) produces a clear =display-warning=, not a load error or a crash; an empty Keep yields an empty page, not an error.
+- [ ] =make validate-modules= + launch smoke clean with =google-keep-config= required.
+
+* Readiness dimensions
+- Data model & ownership: Keep is the source of truth; =data/keep.org= is a generated read-only view (v1). Each note maps to one org header keyed by the immutable =:KEEP_ID:=; the bridge JSON (pinned schema) is the contract between Python and elisp, and the JSON-to-org transform is the round-trip contract v2 inverts.
+- Errors, empty states & failure: auth failure, a broken gkeepapi, missing Python/token, or zero notes each degrade to a warning (named by the bridge's stderr token) or an empty page, never a crash. The unofficial API breaking is expected, not exceptional.
+- Security & privacy: the master token lives in =authinfo.gpg= (gpg-encrypted), read via =cj/auth-source-secret-value=; note content lands in a local generated org file under =data/=. No secret in the repo. The token grants broad Google access — documented as a risk.
+- Observability: the warning path names which piece is missing (python3 / gkeepapi / token / auth) from the bridge's stderr token. The generated page's header shows the last refresh time.
+- Performance & scale: one async subprocess per manual refresh over N notes (tens to low hundreds); trivial. No background polling in v1.
+- Reuse & lost opportunities: follows calendar-sync conventions (generated org under =data/=, atomic temp+rename, =make-process= + sentinel) and reuses =cj/auth-source-secret-value=, =cj/executable-find-or-warn=, org rendering, and the dashboard. gkeepapi supplies the API client, so no endpoint code is written here.
+- Architecture fit & weak points: three layers (Python bridge / elisp core / glue) with the fragile API isolated in layer 1 and the core kept free of =.emacs.d= specifics for extraction. Weak point: gkeepapi maintenance and Google auth churn — mitigated by isolation, graceful degradation, and a swappable bridge.
+- Config surface: the =data/keep.org= path constant, the auth-source =:host= entry, and a keybinding prefix. No tuning knobs in v1.
+- Documentation plan: a setup note (one-time master-token retrieval, =pip install gkeepapi=, the authinfo entry) and the module commentary. No user-migration doc (personal config).
+- Dev tooling: the bridge is shell-testable with a JSON fixture; the core gets ERT over the JSON-to-org transform (against the pinned schema); =make validate-modules= + launch smoke for the module.
+- Rollout, compatibility & rollback: additive — a new module + a require. Rollback = drop the require and delete =data/keep.org=. No existing behavior changes.
+- External APIs & deps: gkeepapi (PyPI) and the unofficial Google Keep mobile endpoint it wraps — the single load-bearing external dependency and the central risk. Python 3 on PATH. A Google master token.
+
+* Risks, Rabbit Holes, and Drawbacks
+- The central risk is gkeepapi breaking when Google changes auth or the private endpoint. It has a history of auth churn. With Takeout deferred there is no second read path, so the mitigations are: isolate gkeepapi in the bridge, degrade to a clear warning (named by stderr token), keep the bridge swappable, and never block Emacs load on it.
+- Credential risk: the master token grants broad account access. Keep it in =authinfo.gpg=, never the repo; document revocation.
+- v2 staleness: because write follows immediately, the edit-back path must re-fetch the note's current =updated= from Keep before writing, not trust the generated file's =:UPDATED:= (which is only as fresh as the last refresh). Carrying =:UPDATED:= correctly in v1 is what makes that guard possible.
+- Scope creep: the pull toward full bidirectional sync, list fidelity, and real-time. v1 is a read-only org view and v2 is targeted read-write; richness and sync stay later, gated behind those landing.
+
+* Review and iteration history
+** 2026-06-24 Wed @ 22:40:00 -0400 — Claude — author
+- What: initial draft.
+- Why: Craig asked to spec the google-keep in-editor integration before building. It spans a fragile external API, an auth-source credential, a Python/elisp bridge, and a module-to-package trajectory, with real trade-offs on shape, direction, and data path — worth pinning before code.
+- Artifacts: docs/specs/google-keep-emacs-integration-spec.org; the todo.org google-keep task.
+** 2026-06-25 Thu @ 00:40:00 -0400 — Claude — author
+- What: resolved all five decisions with Craig (one by one) and folded them in. Shape = org page (popup dropped, a separate later discussion). Direction = read-only v1 then read-write v2 immediately, so the read path now carries a round-trip-ready model + stable note-identity. Data path = gkeepapi sole bridge, Takeout deferred. Auth = authinfo.gpg via auth-source. Structure = extractable core + glue. Added Phase 4 (v2) and the v2-staleness risk.
+- Why: decisions resolved, so the spec could go to spec-review.
+- Artifacts: this spec.
+** 2026-06-25 Thu @ 00:55:00 -0400 — spec-review (round 1) + author response
+- What: independent review against the readiness gate and Phase 4 dimensions, reading calendar-sync.el as the analog. Eight findings (one blocking: the bridge JSON schema; seven non-blocking: data/ path + atomic write, async make-process, the two house helpers, v2 re-fetch, KEEP_ID server id, source-link + archived). Craig accepted all; folded in here. Added the Bridge JSON schema subsection, the calendar-sync-convention reuse, and the Review findings section.
+- Why: clears the blocker and the readiness gaps; rubric moves to Ready.
+- Artifacts: this spec (Review findings [8/8], Bridge JSON schema).
+** 2026-06-25 Thu @ 01:05:00 -0400 — spec-review (round 2) — reviewer
+- What: fresh readiness pass on the revised spec. Verified the round-1 resolutions are coherent (data/keep.org consistent throughout, the JSON schema is the single referenced contract, the async/sentinel + stderr-token story lines up across Design/Phase 2/acceptance). Confirmed cj/auth-source-secret-value (host &optional user) exists and is buildable as written, and that the data/keep.org constant matches user-constants.el's gcal-file form. No new findings.
+- Why: confirms the spec is implementation-ready after two rounds.
+- Artifacts: this spec. Verdict: Ready.
+** 2026-06-25 Thu @ 01:30:00 -0400 — Claude — implementer
+- What: built v1 (Phases 1-3) in a no-approvals speedrun. Phase 1: the gkeepapi bridge (scripts/google-keep/keep-bridge.py) emitting the pinned JSON schema, degrading with a stderr reason token; 12 Python tests. Phase 2: the elisp pure core (parse/tag/heading/render, extractable) + cj/keep-refresh (async make-process + sentinel, atomic temp-then-rename to keep-file, stderr-token to display-warning); 15 ERT tests. Phase 3: keep-file constant in user-constants.el, C-c k prefix (refresh/open), executable warning, required in init.el. validate-modules clean; no-token path degrades without error.
+- Why: spec was Ready and Craig green-lit a no-approvals build of v1.
+- Artifacts: scripts/google-keep/keep-bridge.py + test; modules/google-keep-config.el + tests/test-google-keep-config.el; user-constants.el; init.el. Remaining: the one-time gkeepapi + master-token setup (a VERIFY under todo.org Manual testing), then v2.
diff --git a/init.el b/init.el
index 16f019839..2fa34ab4c 100644
--- a/init.el
+++ b/init.el
@@ -120,6 +120,7 @@
(require 'prog-webdev)
(require 'prog-json)
(require 'prog-yaml)
+(require 'ledger-config) ;; plain-text accounting (ledger format)
;; ---------------------------------- Org Mode ---------------------------------
@@ -146,6 +147,7 @@
;; ------------------------- Personal Workflow Related -------------------------
(require 'calendar-sync) ;; sync calendars, must come after org-agenda
+(require 'google-keep-config) ;; google keep notes as a read-only org page
(require 'reconcile-open-repos) ;; review dirty repositories and reconcile
(require 'local-repository) ;; local repository for easy config portability
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el
index 8d7552d3e..7ed14921f 100644
--- a/modules/calendar-sync.el
+++ b/modules/calendar-sync.el
@@ -540,7 +540,15 @@ Compares year, month, day, hour, minute."
(plist-put result :location (plist-get exception :location)))
;; Pass through new fields if exception overrides them
(when (plist-get exception :attendees)
- (plist-put result :attendees (plist-get exception :attendees)))
+ (plist-put result :attendees (plist-get exception :attendees))
+ ;; Re-derive the user's status from the overridden attendees so a
+ ;; singly-declined occurrence drops its inherited series "accepted"
+ ;; (otherwise `calendar-sync--filter-declined' can't drop it). Leave the
+ ;; inherited status when the override doesn't name the user.
+ (let ((status (calendar-sync--find-user-status
+ (plist-get exception :attendees) calendar-sync-user-emails)))
+ (when status
+ (plist-put result :status status))))
(when (plist-get exception :organizer)
(plist-put result :organizer (plist-get exception :organizer)))
(when (plist-get exception :url)
diff --git a/modules/dirvish-config.el b/modules/dirvish-config.el
index f33e8cf74..c4c5f1aae 100644
--- a/modules/dirvish-config.el
+++ b/modules/dirvish-config.el
@@ -400,18 +400,19 @@ regardless of what file or subdirectory the point is on."
"Return the (PROGRAM PRE-FILE-ARG...) list for setting wallpaper under ENV.
ENV is a display-server symbol: `x11' picks feh with --bg-fill, `wayland'
-picks swww with the img subcommand. Any other value returns nil so the
-caller can surface an \"unknown display server\" error.
+picks the `set-wallpaper' script (on PATH from dotfiles; it wraps the awww
+backend and persists the choice to waypaper's config). Any other value
+returns nil so the caller can surface an \"unknown display server\" error.
Pure helper used by `cj/set-wallpaper'."
(pcase env
('x11 '("feh" "--bg-fill"))
- ('wayland '("swww" "img"))
+ ('wayland '("set-wallpaper"))
(_ nil)))
(defun cj/set-wallpaper ()
"Set the image at point as the desktop wallpaper.
-Uses feh on X11, swww on Wayland."
+Uses feh on X11, the `set-wallpaper' script on Wayland."
(interactive)
(let* ((raw (dired-file-name-at-point))
(file (and raw (expand-file-name raw)))
diff --git a/modules/google-keep-config.el b/modules/google-keep-config.el
new file mode 100644
index 000000000..1738fa6e0
--- /dev/null
+++ b/modules/google-keep-config.el
@@ -0,0 +1,210 @@
+;;; google-keep-config.el --- Google Keep -> org integration -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;; A read-only view of Google Keep notes as an org page. `cj/keep-refresh'
+;; runs a Python gkeepapi bridge (scripts/google-keep/keep-bridge.py), parses
+;; its JSON, and regenerates `keep-file' with one org header per note. Editing
+;; the file does NOT sync back to Keep -- that is v2.
+;;
+;; The pure JSON-to-org core (the cj/keep--render* / --note-* helpers) is kept
+;; free of .emacs.d specifics so it can later extract to a standalone package;
+;; the IO layer and this module supply paths, auth, and keys.
+;;
+;; One-time setup: install the client (pip install gkeepapi), obtain a Google
+;; master token, set `cj/keep-email', and store the token in authinfo.gpg as
+;; machine google-keep login <you@gmail.com> password <master-token>
+;; See docs/specs/google-keep-emacs-integration-spec.org.
+
+;;; Code:
+
+(require 'json)
+(require 'subr-x)
+(require 'system-lib) ;; cj/auth-source-secret-value, cj/executable-find-or-warn
+(require 'user-constants) ;; keep-file
+
+;; ------------------------------ Configuration --------------------------------
+
+(defgroup cj/keep nil
+ "Google Keep to org integration."
+ :group 'applications
+ :prefix "cj/keep-")
+
+(defcustom cj/keep-email nil
+ "Google account email for the Keep bridge, also the authinfo login.
+Unset until the one-time setup is done; `cj/keep-refresh' warns when nil."
+ :type '(choice (const :tag "Unset" nil) string)
+ :group 'cj/keep)
+
+(defcustom cj/keep-auth-host "google-keep"
+ "The authinfo.gpg machine entry holding the Keep master token."
+ :type 'string
+ :group 'cj/keep)
+
+(defcustom cj/keep-python "python3"
+ "Python interpreter used to run the Keep bridge."
+ :type 'string
+ :group 'cj/keep)
+
+(defvar cj/keep--bridge-script
+ (expand-file-name "scripts/google-keep/keep-bridge.py" user-emacs-directory)
+ "Path to the gkeepapi bridge script.")
+
+(defconst cj/keep--web-base "https://keep.google.com/#NOTE/"
+ "Base URL for a Keep note back-link.")
+
+;; --------------------------- Pure core: JSON -> org --------------------------
+;; These take plain data and return strings -- no IO, no .emacs.d paths -- so
+;; they unit-test directly and lift out to a package unchanged.
+
+(defun cj/keep--parse-json (json-string)
+ "Parse the bridge JSON-STRING into a list of note alists."
+ (json-parse-string json-string
+ :object-type 'alist :array-type 'list
+ :false-object nil :null-object nil))
+
+(defun cj/keep--label-to-tag (label)
+ "Sanitize LABEL into a valid org tag (alphanumerics / _ / @ / # / %)."
+ (replace-regexp-in-string "[^[:alnum:]_@#%]" "_" label))
+
+(defun cj/keep--note-tags (note)
+ "Return the trailing org-tag string for NOTE (labels + archived), or \"\"."
+ (let ((tags (append (mapcar #'cj/keep--label-to-tag (alist-get 'labels note))
+ (and (alist-get 'archived note) '("archived")))))
+ (if tags (concat " :" (string-join tags ":") ":") "")))
+
+(defun cj/keep--note-heading (note)
+ "Render NOTE (an alist) as one org subtree string."
+ (let* ((id (alist-get 'id note))
+ (title (alist-get 'title note))
+ (text (alist-get 'text note))
+ (heading (if (and title (> (length title) 0)) title "(untitled)")))
+ (concat
+ "* " heading (cj/keep--note-tags note) "\n"
+ ":PROPERTIES:\n"
+ ":KEEP_ID: " (or id "") "\n"
+ ":PINNED: " (if (alist-get 'pinned note) "t" "nil") "\n"
+ ":COLOR: " (or (alist-get 'color note) "") "\n"
+ ":ARCHIVED: " (if (alist-get 'archived note) "t" "nil") "\n"
+ ":UPDATED: " (or (alist-get 'updated note) "") "\n"
+ ":END:\n"
+ (if (and id (> (length id) 0))
+ (concat "[[" cj/keep--web-base id "][open in Keep]]\n")
+ "")
+ "\n"
+ (if (and text (> (length text) 0)) (concat text "\n") ""))))
+
+(defun cj/keep--sort-pinned-first (notes)
+ "Return NOTES with pinned ones first, original order otherwise preserved."
+ (let (pinned rest)
+ (dolist (n notes)
+ (if (alist-get 'pinned n) (push n pinned) (push n rest)))
+ (append (nreverse pinned) (nreverse rest))))
+
+(defun cj/keep--render (notes &optional generated-at)
+ "Render NOTES (a list of alists) into the full org page string.
+GENERATED-AT is an optional last-refresh timestamp string for the header."
+ (concat
+ "# Generated by cj/keep-refresh -- read-only view; edits here do NOT sync to Keep.\n"
+ "#+TITLE: Google Keep\n"
+ (if generated-at (concat "# Last refresh: " generated-at "\n") "")
+ "\n"
+ (mapconcat #'cj/keep--note-heading (cj/keep--sort-pinned-first notes) "")))
+
+;; ------------------------------- IO: run + write -----------------------------
+
+(defun cj/keep--write-atomically (content file)
+ "Write CONTENT to FILE via a temp file in FILE's directory + atomic rename."
+ (let ((tmp (make-temp-file
+ (expand-file-name (concat "." (file-name-nondirectory file) ".")
+ (file-name-directory file))
+ nil nil content)))
+ (rename-file tmp file t)))
+
+(defun cj/keep--warn (token)
+ "Surface a Keep bridge failure TOKEN as a `display-warning'."
+ (display-warning
+ 'cj/keep
+ (pcase token
+ ("no-gkeepapi" "Keep bridge: gkeepapi is not installed (pip install gkeepapi).")
+ ("no-token" "Keep bridge: no master token in authinfo.gpg, or `cj/keep-email' is unset.")
+ ("auth-failed" "Keep bridge: Google rejected the credentials (token expired or revoked?).")
+ ("network" "Keep bridge: network error reaching Google Keep.")
+ (_ (format "Keep bridge failed: %s" (if (string-empty-p token) "unknown error" token))))
+ :error))
+
+(defun cj/keep--write-notes (json)
+ "Parse bridge JSON, render, and write `keep-file' atomically.
+Returns the note count."
+ (let* ((notes (cj/keep--parse-json json))
+ (org (cj/keep--render notes (format-time-string "%Y-%m-%d %H:%M"))))
+ (cj/keep--write-atomically org keep-file)
+ (length notes)))
+
+;;;###autoload
+(defun cj/keep-refresh ()
+ "Fetch Google Keep notes and regenerate `keep-file' (a read-only view)."
+ (interactive)
+ (let ((token (and cj/keep-email
+ (cj/auth-source-secret-value cj/keep-auth-host cj/keep-email))))
+ (cond
+ ((not (file-exists-p cj/keep--bridge-script))
+ (user-error "Keep bridge script not found: %s" cj/keep--bridge-script))
+ ((or (not cj/keep-email) (not token))
+ (cj/keep--warn "no-token"))
+ (t
+ (let* ((out (generate-new-buffer " *keep-bridge-out*"))
+ (err (generate-new-buffer " *keep-bridge-err*"))
+ (process-environment
+ (append (list (concat "KEEP_EMAIL=" cj/keep-email)
+ (concat "KEEP_MASTER_TOKEN=" token))
+ process-environment)))
+ (message "Keep: fetching...")
+ (make-process
+ :name "keep-bridge"
+ :buffer out
+ :stderr err
+ :command (list cj/keep-python cj/keep--bridge-script)
+ :sentinel
+ (lambda (proc _event)
+ (when (memq (process-status proc) '(exit signal))
+ (unwind-protect
+ (if (and (eq (process-status proc) 'exit)
+ (= (process-exit-status proc) 0))
+ (let ((n (cj/keep--write-notes
+ (with-current-buffer out (buffer-string)))))
+ (message "Keep: wrote %d notes to %s" n keep-file))
+ (cj/keep--warn
+ (string-trim (if (buffer-live-p err)
+ (with-current-buffer err (buffer-string))
+ ""))))
+ (when (buffer-live-p out) (kill-buffer out))
+ (when (buffer-live-p err) (kill-buffer err)))))))))))
+
+;;;###autoload
+(defun cj/keep-open ()
+ "Open the generated Keep org file, offering to refresh when it's absent."
+ (interactive)
+ (if (file-exists-p keep-file)
+ (find-file keep-file)
+ (if (y-or-n-p "Keep file doesn't exist yet. Refresh now? ")
+ (cj/keep-refresh)
+ (message "Run M-x cj/keep-refresh to generate it"))))
+
+;; --------------------------------- Glue / keys -------------------------------
+
+(defvar cj/keep-prefix-map
+ (let ((map (make-sparse-keymap)))
+ (define-key map "r" #'cj/keep-refresh)
+ (define-key map "o" #'cj/keep-open)
+ map)
+ "Prefix keymap for Google Keep commands (bound to \\=`C-c k').")
+
+(keymap-global-set "C-c k" cj/keep-prefix-map)
+
+;; Warn at load if the interpreter is missing; gkeepapi/token failures surface
+;; at refresh time via the bridge's stderr reason token.
+(cj/executable-find-or-warn cj/keep-python "Google Keep bridge" 'google-keep-config)
+
+(provide 'google-keep-config)
+;;; google-keep-config.el ends here
diff --git a/modules/ledger-config.el b/modules/ledger-config.el
index 5b2712b57..018601043 100644
--- a/modules/ledger-config.el
+++ b/modules/ledger-config.el
@@ -2,15 +2,25 @@
;; author Craig Jennings <c@cjennings.net>
;;; Commentary:
+;; Editing support for ledger-format plain-text accounting files: ledger-mode,
+;; flycheck linting, company completion, clean-on-save, and a small report set.
+;; The reports and reconcile shell out to the `ledger' CLI; a load-time check
+;; warns when it is missing rather than letting a report fail cryptically.
;;; Code:
;; ------------------------------- Declarations --------------------------------
(declare-function ledger-mode-clean-buffer "ledger-mode")
+(declare-function cj/executable-find-or-warn "system-lib")
(defvar ledger-mode-map)
(defvar company-backends)
+(defcustom cj/ledger-clean-on-save t
+ "When non-nil, tidy a ledger buffer with `ledger-mode-clean-buffer' before save."
+ :type 'boolean
+ :group 'ledger)
+
;; -------------------------------- Ledger Mode --------------------------------
;; edit files in ledger format
@@ -19,36 +29,38 @@
"\\.ledger\\'"
"\\.journal\\'")
:preface
- (defun cj/ledger-save ()
- "Automatically clean the ledger buffer at each save."
- (interactive)
- (save-excursion
- (when (buffer-modified-p)
- (with-demoted-errors "Error cleaning ledger buffer: %S"
- (ledger-mode-clean-buffer))
- (save-buffer))))
- :bind
- (:map ledger-mode-map
- ("C-x C-s" . cj/ledger-save))
+ (defun cj/ledger--clean-before-save ()
+ "Tidy the ledger buffer before save when `cj/ledger-clean-on-save' is set.
+Errors are demoted so a malformed buffer still saves."
+ (when cj/ledger-clean-on-save
+ (with-demoted-errors "Error cleaning ledger buffer: %S"
+ (ledger-mode-clean-buffer))))
+ (defun cj/ledger--enable-clean-on-save ()
+ "Install the clean-on-save hook buffer-locally so it fires on every save path."
+ (add-hook 'before-save-hook #'cj/ledger--clean-before-save nil t))
+ :hook (ledger-mode . cj/ledger--enable-clean-on-save)
:custom
(ledger-clear-whole-transactions t)
(ledger-reconcile-default-commodity "$")
(ledger-report-use-header-line nil)
+ (ledger-highlight-xact-under-point t)
(ledger-reports
'(("bal" "%(binary) --strict -f %(ledger-file) bal")
("bal this month" "%(binary) --strict -f %(ledger-file) bal -p %(month) -S amount")
("bal this year" "%(binary) --strict -f %(ledger-file) bal -p 'this year'")
("net worth" "%(binary) --strict -f %(ledger-file) bal Assets Liabilities")
- ("account" "%(binary) --strict -f %(ledger-file) reg %(account)"))))
+ ("account" "%(binary) --strict -f %(ledger-file) reg %(account)")))
+ :config
+ (cj/executable-find-or-warn "ledger" 'ledger-mode))
;; ------------------------------ Flycheck Ledger ------------------------------
-;; syntax and unbalanced transaction linting
+;; syntax and unbalanced-transaction linting
(use-package flycheck-ledger
:after ledger-mode)
;; ------------------------------- Company Ledger ------------------------------
-;; autocompletion for ledger
+;; account/payee autocompletion for ledger
(use-package company-ledger
:after (company ledger-mode)
diff --git a/modules/prog-general.el b/modules/prog-general.el
index 99b3cbfab..8e317413c 100644
--- a/modules/prog-general.el
+++ b/modules/prog-general.el
@@ -290,6 +290,16 @@ seeded by `cj/deadgrep--initial-term'. Shared tail of the deadgrep commands."
(with-eval-after-load 'dired
(keymap-set dired-mode-map "G" #'cj/deadgrep-here))
+;; ------------------------------------ wgrep ----------------------------------
+;; Make a grep buffer editable, then write the edits back across files -- turns
+;; a consult-grep / embark-export result into a project-wide find-and-replace.
+;; In a grep buffer: C-c C-p to start editing, C-c C-c to apply.
+
+(use-package wgrep
+ :custom
+ (wgrep-auto-save-buffer t) ;; save the touched files when applying
+ (wgrep-change-readonly-file t)) ;; let edits flow into read-only buffers
+
;; ---------------------------------- Snippets ---------------------------------
;; reusable code and text
diff --git a/modules/ui-navigation.el b/modules/ui-navigation.el
index c099e0834..cb0fc5697 100644
--- a/modules/ui-navigation.el
+++ b/modules/ui-navigation.el
@@ -283,5 +283,15 @@ With numeric prefix ARG, re-open the ARGth most-recently-killed file
:config
(winner-mode 1))
+;; ------------------------------- Cursor Jump (avy) ---------------------------
+;; Jump anywhere visible by typing a few of the target's characters, then the
+;; decision-tree key avy overlays. Fills the in-buffer motion gap that windmove
+;; (windows) and isearch (text) leave.
+
+(use-package avy
+ :bind (("C-:" . avy-goto-char-timer) ;; type chars, pause, jump to a match
+ ("M-g w" . avy-goto-word-1)
+ ("M-g l" . avy-goto-line)))
+
(provide 'ui-navigation)
;;; ui-navigation.el ends here
diff --git a/modules/user-constants.el b/modules/user-constants.el
index b392212ed..570b142fb 100644
--- a/modules/user-constants.el
+++ b/modules/user-constants.el
@@ -167,6 +167,12 @@ Proton Calendar.")
Stored in .emacs.d/data/ so each machine syncs independently from
Google Calendar.")
+(defvar keep-file (expand-file-name "data/keep.org" user-emacs-directory)
+ "The location of the generated org file containing Google Keep notes.
+A read-only view regenerated by `cj/keep-refresh'; edits here do not
+sync back to Keep. Stored in .emacs.d/data/ so each machine syncs
+independently.")
+
(defvar reference-file (expand-file-name "reference.org" org-dir)
"The location of the org file containing reference information.")
diff --git a/scripts/google-keep/keep-bridge.py b/scripts/google-keep/keep-bridge.py
new file mode 100755
index 000000000..ef1fdd75a
--- /dev/null
+++ b/scripts/google-keep/keep-bridge.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+"""keep-bridge -- fetch Google Keep notes via gkeepapi and emit JSON.
+
+The one place the unofficial Google Keep API lives, isolated so a break is
+contained and the elisp renderer talks only to this script's JSON contract.
+See docs/specs/google-keep-emacs-integration-spec.org (Bridge JSON schema).
+
+Reads two environment variables (set by the elisp caller, which pulls the
+token from authinfo.gpg via auth-source):
+
+ KEEP_EMAIL the Google account email
+ KEEP_MASTER_TOKEN the gkeepapi master token
+
+On success: prints a JSON array of note objects on stdout, exits 0. An empty
+Keep prints "[]". On failure: exits non-zero with one reason token on stderr,
+which the elisp sentinel maps to a display-warning:
+
+ no-gkeepapi gkeepapi is not importable
+ no-token KEEP_MASTER_TOKEN or KEEP_EMAIL is unset
+ auth-failed gkeepapi rejected the credentials
+ network a network/other error reaching Keep
+"""
+
+import json
+import os
+import sys
+from datetime import timezone
+from typing import NoReturn
+
+
+def iso8601_utc(dt):
+ """Format DT (a datetime) as ISO8601 UTC with a trailing Z, or None."""
+ if dt is None:
+ return None
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def color_name(color):
+ """Return the Keep color as a plain string from a gkeepapi enum or a string."""
+ return getattr(color, "value", None) or getattr(color, "name", None) or str(color)
+
+
+def note_to_dict(note):
+ """Shape one gkeepapi note (or a duck-typed stand-in) into the schema dict."""
+ return {
+ "id": note.id,
+ "title": note.title or "",
+ "text": note.text or "",
+ "labels": [label.name for label in note.labels.all()],
+ "pinned": bool(note.pinned),
+ "archived": bool(note.archived),
+ "color": color_name(note.color),
+ "updated": iso8601_utc(note.timestamps.updated),
+ }
+
+
+def notes_to_json(notes):
+ """Serialize an iterable of NOTES to the schema JSON string."""
+ return json.dumps([note_to_dict(n) for n in notes], ensure_ascii=False)
+
+
+def _fail(token) -> NoReturn:
+ sys.stderr.write(token + "\n")
+ sys.exit(1)
+
+
+def main():
+ try:
+ import gkeepapi # type: ignore[import] # optional runtime dep
+ except ImportError:
+ _fail("no-gkeepapi")
+ email = os.environ.get("KEEP_EMAIL")
+ token = os.environ.get("KEEP_MASTER_TOKEN")
+ if not email or not token:
+ _fail("no-token")
+ keep = gkeepapi.Keep()
+ try:
+ keep.resume(email, token)
+ except Exception as exc: # gkeepapi raises LoginException on bad credentials
+ _fail("auth-failed" if type(exc).__name__ == "LoginException" else "network")
+ try:
+ keep.sync()
+ notes = list(keep.all())
+ except Exception:
+ _fail("network")
+ sys.stdout.write(notes_to_json(notes))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/google-keep/test_keep_bridge.py b/scripts/google-keep/test_keep_bridge.py
new file mode 100644
index 000000000..a24132744
--- /dev/null
+++ b/scripts/google-keep/test_keep_bridge.py
@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+"""Tests for keep-bridge's pure shaping helpers + its failure degradation.
+
+The gkeepapi auth/fetch path is the IO boundary and is exercised live once the
+token is configured; here we test the JSON-shaping logic (the round-trip
+contract the elisp side reads) with duck-typed stand-ins, plus a subprocess
+smoke test that the script degrades with a reason token rather than crashing.
+
+Run: python3 -m unittest test_keep_bridge (from scripts/google-keep/)
+"""
+
+import importlib.util
+import os
+import subprocess
+import sys
+import unittest
+from datetime import datetime, timezone, timedelta
+
+_HERE = os.path.dirname(os.path.abspath(__file__))
+_BRIDGE = os.path.join(_HERE, "keep-bridge.py")
+
+_spec = importlib.util.spec_from_file_location("keep_bridge", _BRIDGE)
+assert _spec and _spec.loader
+kb = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(kb)
+
+
+# --- duck-typed stand-ins for a gkeepapi note ---------------------------------
+
+class FakeLabel:
+ def __init__(self, name):
+ self.name = name
+
+
+class FakeLabels:
+ def __init__(self, names):
+ self._labels = [FakeLabel(n) for n in names]
+
+ def all(self):
+ return self._labels
+
+
+class FakeTimestamps:
+ def __init__(self, updated):
+ self.updated = updated
+
+
+class FakeColor:
+ def __init__(self, value):
+ self.value = value
+
+
+class FakeNote:
+ def __init__(self, id="n1", title: object = "T", text: object = "B", labels=(),
+ pinned=False, archived=False, color: object = "WHITE", updated=None):
+ self.id = id
+ self.title = title
+ self.text = text
+ self.labels = FakeLabels(labels)
+ self.pinned = pinned
+ self.archived = archived
+ self.color = color
+ self.timestamps = FakeTimestamps(updated)
+
+
+class TestIso8601Utc(unittest.TestCase):
+ def test_normal_naive_datetime_treated_as_utc(self):
+ self.assertEqual(kb.iso8601_utc(datetime(2026, 6, 25, 4, 12, 0)),
+ "2026-06-25T04:12:00Z")
+
+ def test_normal_aware_datetime_converted_to_utc(self):
+ est = timezone(timedelta(hours=-5))
+ self.assertEqual(kb.iso8601_utc(datetime(2026, 6, 24, 23, 12, 0, tzinfo=est)),
+ "2026-06-25T04:12:00Z")
+
+ def test_boundary_none_returns_none(self):
+ self.assertIsNone(kb.iso8601_utc(None))
+
+
+class TestColorName(unittest.TestCase):
+ def test_normal_enum_with_value(self):
+ self.assertEqual(kb.color_name(FakeColor("RED")), "RED")
+
+ def test_normal_plain_string(self):
+ self.assertEqual(kb.color_name("WHITE"), "WHITE")
+
+ def test_boundary_name_only_object(self):
+ class C:
+ name = "BLUE"
+ self.assertEqual(kb.color_name(C()), "BLUE")
+
+
+class TestNoteToDict(unittest.TestCase):
+ def test_normal_full_note(self):
+ note = FakeNote(id="abc", title="Groceries", text="milk\neggs",
+ labels=("shopping", "home"), pinned=True, archived=False,
+ color=FakeColor("YELLOW"),
+ updated=datetime(2026, 6, 25, 4, 0, 0, tzinfo=timezone.utc))
+ self.assertEqual(kb.note_to_dict(note), {
+ "id": "abc",
+ "title": "Groceries",
+ "text": "milk\neggs",
+ "labels": ["shopping", "home"],
+ "pinned": True,
+ "archived": False,
+ "color": "YELLOW",
+ "updated": "2026-06-25T04:00:00Z",
+ })
+
+ def test_boundary_empty_title_and_no_labels(self):
+ note = FakeNote(title="", labels=(), color="WHITE",
+ updated=datetime(2026, 1, 1, tzinfo=timezone.utc))
+ d = kb.note_to_dict(note)
+ self.assertEqual(d["title"], "")
+ self.assertEqual(d["labels"], [])
+
+ def test_boundary_none_title_text_coerced_to_empty(self):
+ note = FakeNote(title=None, text=None, color="WHITE",
+ updated=datetime(2026, 1, 1, tzinfo=timezone.utc))
+ d = kb.note_to_dict(note)
+ self.assertEqual(d["title"], "")
+ self.assertEqual(d["text"], "")
+
+
+class TestNotesToJson(unittest.TestCase):
+ def test_normal_array_of_notes(self):
+ import json
+ notes = [FakeNote(id="a", updated=datetime(2026, 1, 1, tzinfo=timezone.utc)),
+ FakeNote(id="b", updated=datetime(2026, 1, 2, tzinfo=timezone.utc))]
+ parsed = json.loads(kb.notes_to_json(notes))
+ self.assertEqual([n["id"] for n in parsed], ["a", "b"])
+
+ def test_boundary_empty_keep_is_empty_array(self):
+ self.assertEqual(kb.notes_to_json([]), "[]")
+
+
+class TestDegradation(unittest.TestCase):
+ def test_error_no_env_exits_nonzero_with_reason_token(self):
+ # With no KEEP_EMAIL/KEEP_MASTER_TOKEN the script must exit non-zero
+ # with a single reason token, never crash. The exact token depends on
+ # whether gkeepapi is installed in this environment.
+ env = {k: v for k, v in os.environ.items()
+ if k not in ("KEEP_EMAIL", "KEEP_MASTER_TOKEN")}
+ proc = subprocess.run([sys.executable, _BRIDGE], env=env,
+ capture_output=True, text=True)
+ self.assertNotEqual(proc.returncode, 0)
+ self.assertIn(proc.stderr.strip(), ("no-gkeepapi", "no-token"))
+ self.assertEqual(proc.stdout, "")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js
index 966010f4c..94b5d7ae8 100644
--- a/scripts/theme-studio/app-core.js
+++ b/scripts/theme-studio/app-core.js
@@ -652,15 +652,6 @@ function locateFaceMeta(owner,face,registry){
return e||{owner,face,unassigned:true};
}
-// The owner-aware membership check the preview gate calls: the entry's attributes
-// when (owner, face) is a known face of that owner, null when it isn't (a bad
-// owner is rejected). A known face with no non-default attributes returns {} --
-// still truthy, so membership reads cleanly off the result.
-function previewFaceAttrs(owner,face,registry){
- const e=registry&&registry[locateKey(owner,face)];
- return e?e.attrs:null;
-}
-
// Clickable predicate: an element is on-pane only when its owner is the pane being
// viewed. Recomputed from the current view at render time (never stored in the
// registry), since switching panes changes clickability but not ownership.
@@ -708,13 +699,4 @@ function formatLocateTitle(meta){
return parts.concat(locateAttrsList(meta.attrs)).join(', ');
}
-// The immediate-wayfinding info line shown in the preview-label area on hover:
-// "section > face — value" (effective fg, plus bg when set). An unassigned meta
-// reads "<face> — unassigned". Terser than the title; the title is the full record.
-function locateInfoLine(meta){
- if(!meta||meta.unassigned)return (meta&&meta.face?meta.face:'')+' — unassigned';
- const val=meta.value.fg+(meta.value.bg?' / '+meta.value.bg:'');
- return meta.section+' > '+meta.face+' — '+val;
-}
-
-export { nameToHex, migrateLegacyFace, cssWeight, faceDecoration, boxCss, faceCss, composeHoverTitle, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, paletteOptionList, galleryModel, appViewKeysSorted, faceBoxNonDefaults, overflowNonDefault, clampHeight, HEIGHT_MIN, HEIGHT_MAX, stepViewIndex, spanNeighborHex, slugify, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, usedPaletteHexes, paletteUsages, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet, buildLocateRegistry, locateFaceMeta, formatLocateTitle, previewFaceAttrs, isLocateOnPane, locateInfoLine };
+export { nameToHex, migrateLegacyFace, cssWeight, faceDecoration, boxCss, faceCss, composeHoverTitle, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, paletteOptionList, galleryModel, appViewKeysSorted, faceBoxNonDefaults, overflowNonDefault, clampHeight, HEIGHT_MIN, HEIGHT_MAX, stepViewIndex, spanNeighborHex, slugify, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, usedPaletteHexes, paletteUsages, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet, buildLocateRegistry, locateFaceMeta, formatLocateTitle, isLocateOnPane };
diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js
index b50315981..ce1480ffb 100644
--- a/scripts/theme-studio/app.js
+++ b/scripts/theme-studio/app.js
@@ -68,213 +68,7 @@ function renderCode(){
cp.onclick=(e)=>{const s=e.target.closest('[data-k]');if(s)flashAssign(s.dataset.k);};
buildMockFrame();
}
-// Custom color dropdown: a real swatch + name + hex per row, since native
-// <option> background colors render unreliably on Linux Chrome. The popup is
-// fixed-positioned on <body> so a table's overflow can't clip it.
-let _ddPop=null;
-function closeColorDropdown(){if(_ddPop){_ddPop.remove();_ddPop=null;}}
-document.addEventListener('pointerdown',e=>{if(_ddPop&&!e.target.closest('.cdd')&&!e.target.closest('.cddpop'))closeColorDropdown();});
-function mkColorDropdown(options,cur,onPick,opts={}){
- const wrap=document.createElement('div');wrap.className='cstep';
- const left=document.createElement('button'),right=document.createElement('button');
- left.className='cstepbtn';right.className='cstepbtn';left.type=right.type='button';
- left.textContent='‹';right.textContent='›';left.title='move to next darker color in this column';right.title='move to next lighter color in this column';
- const t=document.createElement('div');t.className='cdd'+(opts.compact?' compact':'');t.tabIndex=0;
- const nameOf=h=>{const o=options.find(p=>p[0]===h);return o?o[1]:(h||'none');};
- const displayHex=h=>h||(opts.defaultHex||'');
- const displayName=h=>h?nameOf(h):(opts.defaultName||nameOf(h));
- function step(dir){if(wrap.dataset.locked==='1')return;const next=spanNeighborHex(cur,PALETTE,groundPair(),dir);if(!next)return;cur=next;paint();onPick(next);}
- function paintStepButtons(){
- const locked=wrap.dataset.locked==='1';
- left.disabled=locked||!spanNeighborHex(cur,PALETTE,groundPair(),-1);
- right.disabled=locked||!spanNeighborHex(cur,PALETTE,groundPair(),1);
- }
- function paint(){const shown=displayHex(cur),nm=displayName(cur),ttl=cur?(nm+' '+cur):(nm+(shown?' -> '+shown:''));t.style.background=shown||'#161412';t.style.color=shown?textOn(shown):'#b4b1a2';t.dataset.val=cur||'';t.title=ttl;t.classList.toggle('is-default',!cur);t.classList.toggle('gone',!!cur&&nameOf(cur)==='(gone)');
- t.innerHTML=opts.compact?`<span class="cddsw" style="background:${shown||'transparent'}"></span>`:`<span class="cddsw" style="background:${shown||'transparent'}"></span>${esc(nm)}`;paintStepButtons();}
- paint();
- left.onclick=e=>{e.stopPropagation();step(-1);};
- right.onclick=e=>{e.stopPropagation();step(1);};
- t.onclick=(e)=>{e.stopPropagation();if(wrap.dataset.locked==='1')return;if(_ddPop){closeColorDropdown();return;}
- // 2D gallery: a grid of swatches in the palette-panel shape (ground strip,
- // then one row per family) instead of a long vertical list. galleryModel is
- // the shared pure layout (app-core.js).
- const pop=document.createElement('div');pop.className='cddpop cddgrid';
- const model=galleryModel(cur,PALETTE,groundPair());
- const pick=(hex)=>{cur=hex;paint();closeColorDropdown();onPick(hex);};
- const head=document.createElement('div');head.className='cddghead';
- const def=document.createElement('button');def.type='button';
- def.className='cddgdef'+(model.default.selected?' sel':'');
- def.textContent=opts.defaultName||'default';def.title='clear — use the default';
- def.onclick=(ev)=>{ev.stopPropagation();pick('');};head.appendChild(def);
- if(model.gone){const g=document.createElement('span');g.className='cddgc gone sel';
- g.style.background=model.gone.hex;g.title='(gone) '+model.gone.hex;head.appendChild(g);
- const gl=document.createElement('span');gl.className='cddglbl';gl.textContent='(gone) '+model.gone.hex;head.appendChild(gl);}
- pop.appendChild(head);
- for(const row of model.rows){const rr=document.createElement('div');rr.className='cddgrow';
- for(const c of row.cells){const sw=document.createElement('button');sw.type='button';
- sw.className='cddgc'+(c.selected?' sel':'');sw.style.background=c.hex;
- sw.dataset.hex=c.hex;sw.dataset.name=c.name;sw.title=c.name+' '+c.hex;
- sw.onclick=(ev)=>{ev.stopPropagation();pick(c.hex);};rr.appendChild(sw);}
- pop.appendChild(rr);}
- document.body.appendChild(pop);const r=t.getBoundingClientRect();
- pop.style.left=r.left+'px';pop.style.minWidth=r.width+'px';
- pop.style.top=(r.bottom+2)+'px';
- const ph=pop.getBoundingClientRect().height;
- if(r.bottom+ph>window.innerHeight-6)pop.style.top=Math.max(6,r.top-ph-2)+'px';
- const pr=pop.getBoundingClientRect();
- if(pr.right>window.innerWidth-6)pop.style.left=Math.max(6,window.innerWidth-6-pr.width)+'px';
- _ddPop=pop;};
- t.setValue=h=>{cur=h;paint();};
- wrap.setValue=h=>{cur=h;paint();};
- wrap.syncLocked=paintStepButtons;
- wrap.appendChild(left);wrap.appendChild(t);wrap.appendChild(right);paintStepButtons();
- return wrap;}
-// Standard option list for a swatch dropdown: a "default" entry, then the
-// palette in the same ground/column order as the palette panel. If cur is set
-// but no longer in the palette, surface it as a "(gone)" entry so the row still
-// shows what it points at. Shared by all three tiers.
-function ddList(cur){return paletteOptionList(cur,PALETTE,groundPair());}
-// Shared lock toggle for any table row. lockKey is namespaced per tier (bare
-// syntax kind, 'ui:'+face, 'pkg:'+app+':'+face). els are the row's editable
-// controls — native selects/buttons/inputs are disabled; the custom swatch
-// dropdown (a div) gets data-locked so its onclick refuses to open.
-function mkLockCell(lockKey,els){
- const td=document.createElement('td');td.style.textAlign='center';
- const lk=document.createElement('button');lk.className='lockbtn';
- function paint(){const on=LOCKED.has(lockKey);lk.textContent=on?'🔒':'🔓';lk.classList.toggle('on',on);
- lk.title=on?'locked — click to unlock':'click to lock this decision';
- (els||[]).forEach(el=>{if(!el)return;
- if(el.tagName==='SELECT'||el.tagName==='BUTTON'||el.tagName==='INPUT')el.disabled=on;
- else{el.dataset.locked=on?'1':'';el.classList.toggle('locked',on);if(el.syncLocked)el.syncLocked();}});}
- lk.onclick=()=>{LOCKED.has(lockKey)?LOCKED.delete(lockKey):LOCKED.add(lockKey);paint();updateLockToggles();};
- paint();td.appendChild(lk);return td;}
-// The in-row style controls, shared by the syntax / UI / package tables: a weight
-// selector, a slant selector, and box-like underline and strike controls. Each
-// edit mutates the face object and calls onChange to repaint. Returns the control
-// elements so the caller lays them out and hands them to mkLockCell.
-const WEIGHT_OPTS=[['light','light'],['normal','normal'],['medium','medium'],['semibold','semibold'],['bold','bold'],['heavy','heavy']];
-const SLANT_OPTS=[['normal','normal'],['italic','italic'],['oblique','oblique']];
-// A compact custom dropdown for an enum attribute (weight / slant), themed like
-// the color dropdown. The trigger shows the current value drawn in its own weight
-// or slant; the popup lists each option drawn with the attribute applied, so the
-// choice previews itself. opts.styleFor(value) returns the preview style props
-// ({fontWeight} / {fontStyle}); opts.placeholder is the unset-state label.
-function mkEnumDropdown(options,get,set,opts={}){
- const t=document.createElement('div');t.className='cdd enumdd';t.tabIndex=0;
- const styleFor=opts.styleFor||(()=>({}));
- const labelOf=v=>{const o=options.find(p=>p[0]===v);return o?o[1]:'';};
- function applyPreview(el,v){el.style.fontWeight='';el.style.fontStyle='';const s=styleFor(v);if(s.fontWeight)el.style.fontWeight=s.fontWeight;if(s.fontStyle)el.style.fontStyle=s.fontStyle;}
- function paint(){const v=get()||'';t.dataset.val=v;t.classList.toggle('is-default',!v);
- t.textContent=v?labelOf(v):(opts.placeholder||'set');applyPreview(t,v);t.title=opts.title||'';}
- paint();
- t.onclick=(e)=>{e.stopPropagation();if(t.dataset.locked==='1')return;if(_ddPop){closeColorDropdown();return;}
- const pop=document.createElement('div');pop.className='cddpop enumpop';const cur=get()||'';
- const pick=v=>{set(v||null);paint();closeColorDropdown();};
- const def=document.createElement('button');def.type='button';
- def.className='enumopt enumdef'+(cur===''?' sel':'');def.textContent='default';
- def.title='clear — use the default';def.onclick=ev=>{ev.stopPropagation();pick('');};pop.appendChild(def);
- for(const [v,label] of options){const b=document.createElement('button');b.type='button';
- b.className='enumopt'+(v===cur?' sel':'');b.textContent=label;applyPreview(b,v);
- b.onclick=ev=>{ev.stopPropagation();pick(v);};pop.appendChild(b);}
- document.body.appendChild(pop);const r=t.getBoundingClientRect();
- pop.style.left=r.left+'px';pop.style.minWidth=r.width+'px';pop.style.top=(r.bottom+2)+'px';
- const ph=pop.getBoundingClientRect().height;
- if(r.bottom+ph>window.innerHeight-6)pop.style.top=Math.max(6,r.top-ph-2)+'px';
- _ddPop=pop;};
- t.setValue=()=>paint();t.syncLocked=()=>paint();
- return t;}
-// Underline control: none / line / wave glyph buttons plus a color swatch shown
-// while a style is active. Mirrors mkBoxControl; get()/set() read and write the
-// underline object ({style,color}) or null.
-function mkLineStyleControl(states,get,set,opts={}){const wrap=document.createElement('div');wrap.className='boxctl';
- const cluster=document.createElement('div');cluster.className='boxcluster';const btns={};
- states.forEach(([v,title,glyph])=>{const b=document.createElement('button');b.className='boxbtn';b.dataset.style=v;b.textContent=glyph;b.title=title;
- b.onclick=()=>{const cur=get();set(v?(opts.toState?opts.toState(v,cur):Object.assign({color:(cur&&cur.color)||null},opts.styled?{style:v}:{})):null);paint();};
- cluster.appendChild(b);btns[v]=b;});
- const dd=mkColorDropdown(ddList((get()&&get().color)||''),(get()&&get().color)||'',h=>{const cur=get();if(!cur)return;set(Object.assign({},cur,{color:h||null}));paint();},{compact:true,defaultHex:opts.defaultHex});
- function paint(){const cur=get(),active=opts.styled?(cur&&cur.style?cur.style:''):(cur?'on':'');
- for(const v in btns)btns[v].classList.toggle('on',v===active);
- dd.style.display=active?'':'none';dd.setValue(cur&&cur.color?cur.color:'');
- const locked=wrap.dataset.locked==='1';for(const v in btns)btns[v].disabled=locked;
- const ddoff=locked||!active;dd.dataset.locked=ddoff?'1':'';dd.classList.toggle('locked',ddoff);if(dd.syncLocked)dd.syncLocked();}
- wrap.syncLocked=()=>paint();wrap.append(cluster,dd);paint();return wrap;}
-function mkUnderlineControl(get,set,opts={}){
- return mkLineStyleControl([['','no underline',''],['line','underline','_'],['wave','wavy underline','~']],get,set,Object.assign({styled:true},opts));}
-function mkStrikeControl(get,set,opts={}){
- return mkLineStyleControl([['','no strike',''],['on','strike-through','S']],get,set,Object.assign({styled:false},opts));}
-// In-row style controls: weight + slant selectors and a strike control. The
-// underline control lives in the per-row expander (it carries the wave/color
-// detail), keeping the row compact.
-function mkStyleControls(face,onChange,opts={}){
- const w=mkEnumDropdown(WEIGHT_OPTS,()=>face.weight,v=>{face.weight=v;onChange();},{placeholder:'weight',title:'font weight',styleFor:v=>({fontWeight:cssWeight(v)})});
- const s=mkEnumDropdown(SLANT_OPTS,()=>face.slant,v=>{face.slant=v;onChange();},{placeholder:'slant',title:'font slant',styleFor:v=>({fontStyle:v||'normal'})});
- const k=mkStrikeControl(()=>face.strike,v=>{face.strike=v;onChange();},opts);
- return [w,s,k];}
-function mkOverlineControl(get,set,opts={}){
- return mkLineStyleControl([['','no overline',''],['on','overline','O']],get,set,Object.assign({styled:false},opts));}
-function mkCheck(get,set){const c=document.createElement('input');c.type='checkbox';c.className='detailcheck';c.checked=!!get();c.onchange=()=>set(c.checked);return c;}
-// The per-row attribute editor revealed by the expander: distant-fg, family,
-// overline, inverse, extend, and (for ui/syntax, where inherit/height have no
-// inline column) inherit + height. Each control mutates FACE and calls onChange.
-// Returns the element plus the interactive controls so the row's lock cell can
-// disable them. opts.inheritOptions and opts.showInheritHeight gate the last two.
-// Hover help for each expander field, so the detail labels explain themselves the
-// way the table-header labels do. Keyed by the label text passed to add().
-const DETAIL_HOVERS={
- 'distant fg':'foreground swapped in when the text sits on a background too close to its own color to read (Emacs :distant-foreground)',
- 'family':'font family for this face; blank inherits the default (Emacs :family)',
- 'underline':'underline style and color (Emacs :underline)',
- 'overline':'a line drawn above the text (Emacs :overline)',
- 'inverse':'swap the foreground and background (Emacs :inverse-video)',
- 'extend':'extend the background past the end of the line to the window edge (Emacs :extend)',
- 'inherit':'base face this one inherits unset attributes from (Emacs :inherit)',
- 'height':'text size as a scaling factor of the inherited height, 0.1 to 2.0 (Emacs :height)'
-};
-function mkDetailEditor(face,onChange,opts={}){
- const wrap=document.createElement('div');wrap.className='detailedit';const locks=[];
- const add=(label,el)=>{const g=document.createElement('label');g.className='detailfield';g.title=DETAIL_HOVERS[label]||'';const s=document.createElement('span');s.textContent=label;g.append(s,el);wrap.appendChild(g);locks.push(el);};
- const df=mkColorDropdown(ddList(face['distant-fg']||''),face['distant-fg']||'',h=>{face['distant-fg']=h||null;onChange();},{compact:true,defaultHex:opts.defaultHex});
- add('distant fg',df);
- const fam=document.createElement('input');fam.type='text';fam.className='detailinput';fam.placeholder='font family';fam.value=face.family||'';fam.onchange=()=>{face.family=fam.value.trim()||null;onChange();};
- add('family',fam);
- add('underline',mkUnderlineControl(()=>face.underline,v=>{face.underline=v;onChange();},opts));
- add('overline',mkOverlineControl(()=>face.overline,v=>{face.overline=v;onChange();},opts));
- add('inverse',mkCheck(()=>face.inverse,v=>{face.inverse=v;onChange();}));
- add('extend',mkCheck(()=>face.extend,v=>{face.extend=v;onChange();}));
- if(opts.showInheritHeight){
- const isel=document.createElement('select');isel.className='chip detailsel';
- (opts.inheritOptions||['']).forEach(o=>{const op=document.createElement('option');op.value=o;op.textContent=o||'— none —';isel.appendChild(op);});
- isel.value=face.inherit||'';isel.onchange=()=>{face.inherit=isel.value||null;onChange();};add('inherit',isel);
- const hin=document.createElement('input');hin.type='number';hin.min=''+HEIGHT_MIN;hin.max=''+HEIGHT_MAX;hin.step='0.05';hin.className='hstep';hin.value=face.height||1;hin.onchange=()=>{const raw=hin.value,h=clampHeight(raw);face.height=h;hin.value=h==null?1:h;if(h!=null&&parseFloat(raw)!==h)notify('height clamped to '+h+' (allowed '+HEIGHT_MIN+'–'+HEIGHT_MAX+')',false);onChange();};add('height',hin);
- }
- return {el:wrap,locks};}
-// Wire a per-row expander: a toggle button plus a hidden detail row (colspan
-// across the table) holding mkDetailEditor. The caller drops the button into a
-// cell, adds the returned locks to the row's lock cell, and inserts detailRow
-// right after the main row.
-// Which rows have their detail expanded, keyed by the row's element/face key.
-// Held outside the DOM so a table rebuild (a package edit rebuilds the whole
-// table) re-opens the rows that were open, instead of collapsing them under the
-// user — editing a value in an open expander must not close it.
-let EXPANDED=new Set();
-function mkExpander(face,colspan,onChange,opts={}){
- const detail=document.createElement('tr');detail.className='detailrow';detail.style.display='none';
- if(opts.expandKey&&EXPANDED.has(opts.expandKey))detail.style.display='';
- const btn=document.createElement('button');btn.className='exptoggle';
- // The disclosure triangle shows the row's state: ▶ collapsed, ▼ expanded.
- const setGlyph=()=>{const open=detail.style.display!=='none';btn.textContent=open?'▼':'▶';btn.classList.toggle('on',open);};
- // Flag the toggle when collapsed and at least one hidden attribute differs from
- // the default, so a non-default attribute is never invisible. ndCheck re-runs
- // after every edit (for tiers whose onChange does not rebuild the row).
- const ndCheck=opts.ndCheck||(()=>false);
- const refreshNd=()=>{const nd=ndCheck();btn.classList.toggle('exp-nd',nd);btn.title=nd?'more attributes (some differ from default)':'more attributes';};
- const wrapped=()=>{onChange();refreshNd();};
- const td=document.createElement('td');td.colSpan=colspan;const {el,locks}=mkDetailEditor(face,wrapped,opts);td.appendChild(el);detail.appendChild(td);
- btn.onclick=()=>{const willOpen=detail.style.display==='none';detail.style.display=willOpen?'':'none';
- if(opts.expandKey){willOpen?EXPANDED.add(opts.expandKey):EXPANDED.delete(opts.expandKey);}
- setGlyph();syncExpandAllBtns();};
- refreshNd();setGlyph();
- return {btn,detail,locks};}
+CONTROLS_J
// Expand/collapse every row in a table at once, then sync the per-row triangles.
function setAllExpanded(tableId,expand){
const tb=document.getElementById(tableId);if(!tb)return;
@@ -304,7 +98,7 @@ function clearUnlockedRows(items,keyFn,resetFn){
for(const it of items){const k=keyFn(it);if(k===null)continue;if(!LOCKED.has(k))resetFn(it);}
}
function rebuildColorTables(){
- buildTable();buildUITable();if(document.getElementById('pkgbody'))buildPkgTable();
+ buildTable();buildUITable();buildPkgTable();// buildPkgTable self-guards when #pkgbody is absent
}
function refreshPaletteState(opts={}){
renderPalette();rebuildColorTables();
@@ -595,7 +389,9 @@ function flashPkg(f){flashRow(document.querySelector(`#pkgbody tr[data-face="${f
function flashPkgPreview(f){const sp=document.querySelectorAll(`#pkgpreview [data-face="${f}"]`);if(sp.length){flashEls(sp);return;}const row=document.querySelector(`#pkgbody tr[data-face="${f}"]`);if(row)flashEl(row.querySelector('.cat'));}
function mockSpan(k,t){return `<span data-k="${k}" style="${syntaxStyle(k)}">${esc(t)}</span>`;}
function uiCss(o,fgv,bgv,opts={}){const fg=fgv===undefined?effFg(o.fg):fgv,bg=bgv===undefined?o.bg:bgv;return faceCss(o,fg,bg,{noBg:opts.noBg,boxBg:bg||MAP['bg']});}
-function syncMockHeight(){const t=document.getElementById('uitable'),m=document.getElementById('mockframe');if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';}
+// Size a preview pane to its faces table, minus the label bar above it. Shared by
+// the UI mock and the package preview, which differ only in their element IDs.
+function syncPaneHeight(tableId,paneId){const t=document.getElementById(tableId),m=document.getElementById(paneId);if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';}
function buildMockFrame(){
const fr=document.getElementById('mockframe');if(!fr)return;
rebuildLocateRegistry();
@@ -720,9 +516,9 @@ function onViewChange(){const s=document.getElementById('viewsel');const v=(s&&s
const show=(id,on)=>{const e=document.getElementById(id);if(e)e.style.display=on?'':'none';};
show('view-code',v==='@code');show('view-ui',v==='@ui');show('view-pkg',v[0]!=='@');
if(v==='@code')renderCode();
- else if(v==='@ui'){buildUITable();buildMockFrame();syncMockHeight();}
+ else if(v==='@ui'){buildUITable();buildMockFrame();syncPaneHeight('uitable','mockframe');}
else pkgChanged();}
-function pkgChanged(){buildPkgTable();buildPkgPreview();syncPkgHeight();}
+function pkgChanged(){buildPkgTable();buildPkgPreview();syncPaneHeight('pkgtable','pkgpreview');}
function buildPkgTable(){
const app=curApp(),tb=document.getElementById('pkgbody');if(!tb)return;tb.innerHTML='';
const flt=(document.getElementById('pkgfilter').value||'').trim().toLowerCase();
@@ -832,7 +628,6 @@ function buildPkgPreview(){
// no separate info line.
}
function resetApp(){const app=curApp();for(const [face,,d] of APPS[app].faces)if(!LOCKED.has('pkg:'+app+':'+face))PKGMAP[app][face]=seedFace(d);pkgChanged();notify('reset editable '+app+' faces to package defaults',false);}
-function syncPkgHeight(){const t=document.getElementById('pkgtable'),m=document.getElementById('pkgpreview');if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';}
// --- worst-case readout for the covered overlay faces (spec Phase 4) ---------
// Default WCAG target for the worst-case verdict (AA). AAA is selectable.
let WORST_TARGET=4.5;
@@ -875,7 +670,7 @@ function repaintCovered(){COVERED_FACES.forEach(f=>{if(UIMAP[f]&&document.getEle
function paintUI(face){const pv=document.getElementById('uiprev-'+face);if(!pv)return;const o=UIMAP[face];pv.style.color=effFg(o.fg);pv.style.background=effBg(o.bg);pv.style.fontWeight=cssWeight(o.weight);pv.style.fontStyle=o.slant||'normal';pv.style.textDecoration=(o.underline?'underline ':'')+(o.strike?'line-through':'')||'none';pv.style.boxShadow=boxCss(o.box,effBg(o.bg));
const report=coveredContrastReport(face);
pv.title='';
- const cr=document.getElementById('uicr-'+face);if(cr){cr.title='';if(report!==null){if(report.empty){cr.title='this overlay has no syntax foreground set yet';cr.innerHTML='<span title="this overlay has no syntax foreground set yet">no fg set</span>';}else{const title=failureTitle(report)||'all covered text clears '+WORST_TARGET.toFixed(1);cr.title=title;cr.innerHTML=`<span style="color:${ratingColor(report.worst.ratio)}" title="${esc(title)}">${report.worst.ratio.toFixed(1)}</span>`;}}else{const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}}}
+ const cr=document.getElementById('uicr-'+face);if(cr){cr.title='';const wc=worstCellHtml(face);if(wc!==null){cr.title=report.empty?'this overlay has no syntax foreground set yet':(failureTitle(report)||'all covered text clears '+WORST_TARGET.toFixed(1));cr.innerHTML=wc;}else{const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}}}
function buildUITable(){
const tb=document.getElementById('uibody');tb.innerHTML='';
for(const [face,label,ex] of UI_FACES){
@@ -884,16 +679,23 @@ function buildUITable(){
exp.detail.dataset.detailFor=face;
const c0=document.createElement('td');c0.className='cat';c0.title=composeHoverTitle(FACE_DOCS[face],c0.title);c0.appendChild(exp.btn);
const c0lbl=document.createElement('span');c0lbl.textContent=' '+label;c0lbl.style.cursor='pointer';c0lbl.title='flash this face in the live preview';c0lbl.onclick=()=>flashUiPreview(face);c0.appendChild(c0lbl);
+ // Emacs draws the cursor as a rectangle: its fg colors the glyph sitting on
+ // it and its bg is the cursor color, but weight/slant/underline/strike and
+ // box are no-ops on it. Show only fg+bg for the cursor row; mute the rest.
+ const cursorOnly=(face==='cursor');
+ const naCell=t=>{const s=document.createElement('span');s.textContent='—';s.style.opacity='0.4';s.title=t;return s;};
const fgSel=uiSelect(face,'fg'),bgSel=uiSelect(face,'bg');
const cF=document.createElement('td');cF.appendChild(fgSel);
const cB=document.createElement('td');cB.appendChild(bgSel);
const cS=document.createElement('td');
- const stCtls=mkStyleControls(UIMAP[face],()=>{paintUI(face);buildMockFrame();},{defaultHex:effFg(UIMAP[face].fg)});
- const uiCluster=document.createElement('div');uiCluster.className='stylecluster';stCtls.forEach(c=>uiCluster.appendChild(c));cS.appendChild(uiCluster);
+ const stCtls=cursorOnly?[]:mkStyleControls(UIMAP[face],()=>{paintUI(face);buildMockFrame();},{defaultHex:effFg(UIMAP[face].fg)});
+ if(cursorOnly){cS.appendChild(naCell('Emacs ignores weight/slant/underline/strike on the cursor face'));}
+ else{const uiCluster=document.createElement('div');uiCluster.className='stylecluster';stCtls.forEach(c=>uiCluster.appendChild(c));cS.appendChild(uiCluster);}
const cC=document.createElement('td');cC.id='uicr-'+face;cC.style.whiteSpace='nowrap';cC.style.fontSize='10pt';
const cP=document.createElement('td');cP.className='ex';cP.id='uiprev-'+face;cP.textContent=ex;cP.style.padding='4px 10px';cP.style.borderRadius='4px';
- const cX=document.createElement('td');const boxCtl=mkBoxControl(()=>UIMAP[face].box,b=>{UIMAP[face].box=b;paintUI(face);buildMockFrame();},{compact:true});cX.appendChild(boxCtl);
- const cL=mkLockCell('ui:'+face,[fgSel,bgSel,...stCtls,boxCtl,...exp.locks]);
+ const cX=document.createElement('td');const boxCtl=cursorOnly?null:mkBoxControl(()=>UIMAP[face].box,b=>{UIMAP[face].box=b;paintUI(face);buildMockFrame();},{compact:true});
+ if(cursorOnly){cX.appendChild(naCell('Emacs ignores the box attribute on the cursor face'));}else{cX.appendChild(boxCtl);}
+ const cL=mkLockCell('ui:'+face,cursorOnly?[fgSel,bgSel,...exp.locks]:[fgSel,bgSel,...stCtls,boxCtl,...exp.locks]);
tr.appendChild(cL);tr.appendChild(c0);tr.appendChild(cF);tr.appendChild(cB);tr.appendChild(cS);tr.appendChild(cX);tr.appendChild(cC);tr.appendChild(cP);tb.appendChild(tr);tb.appendChild(exp.detail);paintUI(face);
}
applyTableSort('uibody');
@@ -918,9 +720,9 @@ function initApp(){
paletteShowFull=false; // open collapsed to base colors; the arrow expands the spans
buildLangSel();buildViewSel();renderPalette();rebuildColorTables();renderCode();applyGround();
initGeneratorControls();
- updateTitle();initPicker();buildPkgPreview();syncMockHeight();syncPkgHeight();
+ updateTitle();initPicker();buildPkgPreview();syncPaneHeight('uitable','mockframe');syncPaneHeight('pkgtable','pkgpreview');
onViewChange();
}
initApp();
-addEventListener('resize',()=>{syncMockHeight();syncPkgHeight();});
+addEventListener('resize',()=>{syncPaneHeight('uitable','mockframe');syncPaneHeight('pkgtable','pkgpreview');});
BROWSER_GATES_J
diff --git a/scripts/theme-studio/browser-gates.js b/scripts/theme-studio/browser-gates.js
index 0bc6b2fbd..fcdfaff00 100644
--- a/scripts/theme-studio/browser-gates.js
+++ b/scripts/theme-studio/browser-gates.js
@@ -221,6 +221,21 @@ if(location.hash==='#mocktest')gate('mocktest',A=>withSavedState(['UIMAP','PKGMA
pickEnum(pkgWeight(),'heavy');
A(PKGMAP[app][face].weight==='heavy'&&PKGMAP[app][face].source==='user','pkg weight dropdown writes the model and marks the face edited');
}));
+// Cursor-row gate (open with #cursorrowtest): the cursor face honors only fg
+// (the glyph on it) and bg (the cursor color); weight/slant/underline/strike and
+// box are no-ops, so the row mutes them to a dash while non-cursor rows keep them.
+if(location.hash==='#cursorrowtest')gate('cursorrowtest',A=>{
+ buildUITable();
+ const rows=[...document.querySelectorAll('#uibody tr')];
+ const cur=rows.find(r=>r.dataset.face==='cursor');
+ A(!!cur,'cursor row present');
+ A(!!cur.cells[2].querySelector('.cdd'),'cursor keeps the fg swatch');
+ A(!!cur.cells[3].querySelector('.cdd'),'cursor keeps the bg swatch');
+ A(!cur.cells[4].querySelector('.enumdd')&&cur.cells[4].textContent.includes('—'),'cursor mutes the style controls');
+ A(cur.cells[5].textContent.includes('—'),'cursor mutes the box control');
+ const ml=rows.find(r=>r.dataset.face==='mode-line');
+ A(!!ml.cells[4].querySelector('.enumdd'),'non-cursor rows keep the style controls');
+});
// Palette-generator gate (open with #generatortest): previewing is non-mutating,
// clicking a generated tile loads the existing selector, adding creates a normal
// singleton base column, and appending a preview column commits all span members
@@ -937,7 +952,7 @@ if(location.hash==='#pickertest')gate('pickertest',A=>{
// four radio buttons (none / line / pressed / raised); the color swatch shows
// only while a box style is active.
if(location.hash==='#boxtest')gate('boxtest',A=>{
- LOCKED.clear();const f=UI_FACES[0][0];const saveBox=UIMAP[f].box;
+ LOCKED.clear();const f=UI_FACES.map(x=>x[0]).find(x=>x!=='cursor');const saveBox=UIMAP[f].box; // cursor has no box control by design
UIMAP[f].box=null;buildUITable();
const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[5];
A(!!cell.querySelector('.boxcluster'),'box-cluster-present');
@@ -956,7 +971,7 @@ if(location.hash==='#boxtest')gate('boxtest',A=>{
// Style-cluster gate (open with #styletest): the style cell holds a weight
// selector, a slant selector, and box-like underline and strike controls.
if(location.hash==='#styletest')gate('styletest',A=>{
- buildUITable();const f=UI_FACES[0][0];
+ buildUITable();const f=UI_FACES.map(x=>x[0]).find(x=>x!=='cursor'); // cursor row has no style cluster by design
const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[4];
const cluster=cell.querySelector('.stylecluster');
A(!!cluster,'style-cluster-present');
diff --git a/scripts/theme-studio/controls.js b/scripts/theme-studio/controls.js
new file mode 100644
index 000000000..e98a69a5c
--- /dev/null
+++ b/scripts/theme-studio/controls.js
@@ -0,0 +1,209 @@
+// controls.js -- the custom dropdown / detail-editor / expander control
+// factories, extracted from app.js for navigability. Inlined raw at the
+// CONTROLS_J token: these are hoisting function declarations plus the
+// dropdown popup state, so the token's position preserves execution order.
+// Custom color dropdown: a real swatch + name + hex per row, since native
+// <option> background colors render unreliably on Linux Chrome. The popup is
+// fixed-positioned on <body> so a table's overflow can't clip it.
+let _ddPop=null;
+function closeColorDropdown(){if(_ddPop){_ddPop.remove();_ddPop=null;}}
+document.addEventListener('pointerdown',e=>{if(_ddPop&&!e.target.closest('.cdd')&&!e.target.closest('.cddpop'))closeColorDropdown();});
+function mkColorDropdown(options,cur,onPick,opts={}){
+ const wrap=document.createElement('div');wrap.className='cstep';
+ const left=document.createElement('button'),right=document.createElement('button');
+ left.className='cstepbtn';right.className='cstepbtn';left.type=right.type='button';
+ left.textContent='‹';right.textContent='›';left.title='move to next darker color in this column';right.title='move to next lighter color in this column';
+ const t=document.createElement('div');t.className='cdd'+(opts.compact?' compact':'');t.tabIndex=0;
+ const nameOf=h=>{const o=options.find(p=>p[0]===h);return o?o[1]:(h||'none');};
+ function step(dir){if(wrap.dataset.locked==='1')return;const next=spanNeighborHex(cur,PALETTE,groundPair(),dir);if(!next)return;cur=next;paint();onPick(next);}
+ function paintStepButtons(){
+ const locked=wrap.dataset.locked==='1';
+ left.disabled=locked||!spanNeighborHex(cur,PALETTE,groundPair(),-1);
+ right.disabled=locked||!spanNeighborHex(cur,PALETTE,groundPair(),1);
+ }
+ function paint(){const shown=cur||(opts.defaultHex||''),nm=cur?nameOf(cur):(opts.defaultName||nameOf(cur)),ttl=cur?(nm+' '+cur):(nm+(shown?' -> '+shown:''));t.style.background=shown||'#161412';t.style.color=shown?textOn(shown):'#b4b1a2';t.dataset.val=cur||'';t.title=ttl;t.classList.toggle('is-default',!cur);t.classList.toggle('gone',!!cur&&nameOf(cur)==='(gone)');
+ t.innerHTML=opts.compact?`<span class="cddsw" style="background:${shown||'transparent'}"></span>`:`<span class="cddsw" style="background:${shown||'transparent'}"></span>${esc(nm)}`;paintStepButtons();}
+ paint();
+ left.onclick=e=>{e.stopPropagation();step(-1);};
+ right.onclick=e=>{e.stopPropagation();step(1);};
+ t.onclick=(e)=>{e.stopPropagation();if(wrap.dataset.locked==='1')return;if(_ddPop){closeColorDropdown();return;}
+ // 2D gallery: a grid of swatches in the palette-panel shape (ground strip,
+ // then one row per family) instead of a long vertical list. galleryModel is
+ // the shared pure layout (app-core.js).
+ const pop=document.createElement('div');pop.className='cddpop cddgrid';
+ const model=galleryModel(cur,PALETTE,groundPair());
+ const pick=(hex)=>{cur=hex;paint();closeColorDropdown();onPick(hex);};
+ const head=document.createElement('div');head.className='cddghead';
+ const def=document.createElement('button');def.type='button';
+ def.className='cddgdef'+(model.default.selected?' sel':'');
+ def.textContent=opts.defaultName||'default';def.title='clear — use the default';
+ def.onclick=(ev)=>{ev.stopPropagation();pick('');};head.appendChild(def);
+ if(model.gone){const g=document.createElement('span');g.className='cddgc gone sel';
+ g.style.background=model.gone.hex;g.title='(gone) '+model.gone.hex;head.appendChild(g);
+ const gl=document.createElement('span');gl.className='cddglbl';gl.textContent='(gone) '+model.gone.hex;head.appendChild(gl);}
+ pop.appendChild(head);
+ for(const row of model.rows){const rr=document.createElement('div');rr.className='cddgrow';
+ for(const c of row.cells){const sw=document.createElement('button');sw.type='button';
+ sw.className='cddgc'+(c.selected?' sel':'');sw.style.background=c.hex;
+ sw.dataset.hex=c.hex;sw.dataset.name=c.name;sw.title=c.name+' '+c.hex;
+ sw.onclick=(ev)=>{ev.stopPropagation();pick(c.hex);};rr.appendChild(sw);}
+ pop.appendChild(rr);}
+ document.body.appendChild(pop);const r=t.getBoundingClientRect();
+ pop.style.left=r.left+'px';pop.style.minWidth=r.width+'px';
+ pop.style.top=(r.bottom+2)+'px';
+ const ph=pop.getBoundingClientRect().height;
+ if(r.bottom+ph>window.innerHeight-6)pop.style.top=Math.max(6,r.top-ph-2)+'px';
+ const pr=pop.getBoundingClientRect();
+ if(pr.right>window.innerWidth-6)pop.style.left=Math.max(6,window.innerWidth-6-pr.width)+'px';
+ _ddPop=pop;};
+ t.setValue=h=>{cur=h;paint();};
+ wrap.setValue=h=>{cur=h;paint();};
+ wrap.syncLocked=paintStepButtons;
+ wrap.appendChild(left);wrap.appendChild(t);wrap.appendChild(right);paintStepButtons();
+ return wrap;}
+// Standard option list for a swatch dropdown: a "default" entry, then the
+// palette in the same ground/column order as the palette panel. If cur is set
+// but no longer in the palette, surface it as a "(gone)" entry so the row still
+// shows what it points at. Shared by all three tiers.
+function ddList(cur){return paletteOptionList(cur,PALETTE,groundPair());}
+// Shared lock toggle for any table row. lockKey is namespaced per tier (bare
+// syntax kind, 'ui:'+face, 'pkg:'+app+':'+face). els are the row's editable
+// controls — native selects/buttons/inputs are disabled; the custom swatch
+// dropdown (a div) gets data-locked so its onclick refuses to open.
+function mkLockCell(lockKey,els){
+ const td=document.createElement('td');td.style.textAlign='center';
+ const lk=document.createElement('button');lk.className='lockbtn';
+ function paint(){const on=LOCKED.has(lockKey);lk.textContent=on?'🔒':'🔓';lk.classList.toggle('on',on);
+ lk.title=on?'locked — click to unlock':'click to lock this decision';
+ (els||[]).forEach(el=>{if(!el)return;
+ if(el.tagName==='SELECT'||el.tagName==='BUTTON'||el.tagName==='INPUT')el.disabled=on;
+ else{el.dataset.locked=on?'1':'';el.classList.toggle('locked',on);if(el.syncLocked)el.syncLocked();}});}
+ lk.onclick=()=>{LOCKED.has(lockKey)?LOCKED.delete(lockKey):LOCKED.add(lockKey);paint();updateLockToggles();};
+ paint();td.appendChild(lk);return td;}
+// The in-row style controls, shared by the syntax / UI / package tables: a weight
+// selector, a slant selector, and box-like underline and strike controls. Each
+// edit mutates the face object and calls onChange to repaint. Returns the control
+// elements so the caller lays them out and hands them to mkLockCell.
+const WEIGHT_OPTS=[['light','light'],['normal','normal'],['medium','medium'],['semibold','semibold'],['bold','bold'],['heavy','heavy']];
+const SLANT_OPTS=[['normal','normal'],['italic','italic'],['oblique','oblique']];
+// A compact custom dropdown for an enum attribute (weight / slant), themed like
+// the color dropdown. The trigger shows the current value drawn in its own weight
+// or slant; the popup lists each option drawn with the attribute applied, so the
+// choice previews itself. opts.styleFor(value) returns the preview style props
+// ({fontWeight} / {fontStyle}); opts.placeholder is the unset-state label.
+function mkEnumDropdown(options,get,set,opts={}){
+ const t=document.createElement('div');t.className='cdd enumdd';t.tabIndex=0;
+ const styleFor=opts.styleFor||(()=>({}));
+ const labelOf=v=>{const o=options.find(p=>p[0]===v);return o?o[1]:'';};
+ function applyPreview(el,v){el.style.fontWeight='';el.style.fontStyle='';const s=styleFor(v);if(s.fontWeight)el.style.fontWeight=s.fontWeight;if(s.fontStyle)el.style.fontStyle=s.fontStyle;}
+ function paint(){const v=get()||'';t.dataset.val=v;t.classList.toggle('is-default',!v);
+ t.textContent=v?labelOf(v):(opts.placeholder||'set');applyPreview(t,v);t.title=opts.title||'';}
+ paint();
+ t.onclick=(e)=>{e.stopPropagation();if(t.dataset.locked==='1')return;if(_ddPop){closeColorDropdown();return;}
+ const pop=document.createElement('div');pop.className='cddpop enumpop';const cur=get()||'';
+ const pick=v=>{set(v||null);paint();closeColorDropdown();};
+ const def=document.createElement('button');def.type='button';
+ def.className='enumopt enumdef'+(cur===''?' sel':'');def.textContent='default';
+ def.title='clear — use the default';def.onclick=ev=>{ev.stopPropagation();pick('');};pop.appendChild(def);
+ for(const [v,label] of options){const b=document.createElement('button');b.type='button';
+ b.className='enumopt'+(v===cur?' sel':'');b.textContent=label;applyPreview(b,v);
+ b.onclick=ev=>{ev.stopPropagation();pick(v);};pop.appendChild(b);}
+ document.body.appendChild(pop);const r=t.getBoundingClientRect();
+ pop.style.left=r.left+'px';pop.style.minWidth=r.width+'px';pop.style.top=(r.bottom+2)+'px';
+ const ph=pop.getBoundingClientRect().height;
+ if(r.bottom+ph>window.innerHeight-6)pop.style.top=Math.max(6,r.top-ph-2)+'px';
+ _ddPop=pop;};
+ t.setValue=()=>paint();t.syncLocked=()=>paint();
+ return t;}
+// Underline control: none / line / wave glyph buttons plus a color swatch shown
+// while a style is active. Mirrors mkBoxControl; get()/set() read and write the
+// underline object ({style,color}) or null.
+function mkLineStyleControl(states,get,set,opts={}){const wrap=document.createElement('div');wrap.className='boxctl';
+ const cluster=document.createElement('div');cluster.className='boxcluster';const btns={};
+ states.forEach(([v,title,glyph])=>{const b=document.createElement('button');b.className='boxbtn';b.dataset.style=v;b.textContent=glyph;b.title=title;
+ b.onclick=()=>{const cur=get();set(v?(opts.toState?opts.toState(v,cur):Object.assign({color:(cur&&cur.color)||null},opts.styled?{style:v}:{})):null);paint();};
+ cluster.appendChild(b);btns[v]=b;});
+ const dd=mkColorDropdown(ddList((get()&&get().color)||''),(get()&&get().color)||'',h=>{const cur=get();if(!cur)return;set(Object.assign({},cur,{color:h||null}));paint();},{compact:true,defaultHex:opts.defaultHex});
+ function paint(){const cur=get(),active=opts.styled?(cur&&cur.style?cur.style:''):(cur?'on':'');
+ for(const v in btns)btns[v].classList.toggle('on',v===active);
+ dd.style.display=active?'':'none';dd.setValue(cur&&cur.color?cur.color:'');
+ const locked=wrap.dataset.locked==='1';for(const v in btns)btns[v].disabled=locked;
+ const ddoff=locked||!active;dd.dataset.locked=ddoff?'1':'';dd.classList.toggle('locked',ddoff);if(dd.syncLocked)dd.syncLocked();}
+ wrap.syncLocked=()=>paint();wrap.append(cluster,dd);paint();return wrap;}
+function mkUnderlineControl(get,set,opts={}){
+ return mkLineStyleControl([['','no underline',''],['line','underline','_'],['wave','wavy underline','~']],get,set,Object.assign({styled:true},opts));}
+function mkStrikeControl(get,set,opts={}){
+ return mkLineStyleControl([['','no strike',''],['on','strike-through','S']],get,set,Object.assign({styled:false},opts));}
+// In-row style controls: weight + slant selectors and a strike control. The
+// underline control lives in the per-row expander (it carries the wave/color
+// detail), keeping the row compact.
+function mkStyleControls(face,onChange,opts={}){
+ const w=mkEnumDropdown(WEIGHT_OPTS,()=>face.weight,v=>{face.weight=v;onChange();},{placeholder:'weight',title:'font weight',styleFor:v=>({fontWeight:cssWeight(v)})});
+ const s=mkEnumDropdown(SLANT_OPTS,()=>face.slant,v=>{face.slant=v;onChange();},{placeholder:'slant',title:'font slant',styleFor:v=>({fontStyle:v||'normal'})});
+ const k=mkStrikeControl(()=>face.strike,v=>{face.strike=v;onChange();},opts);
+ return [w,s,k];}
+function mkOverlineControl(get,set,opts={}){
+ return mkLineStyleControl([['','no overline',''],['on','overline','O']],get,set,Object.assign({styled:false},opts));}
+function mkCheck(get,set){const c=document.createElement('input');c.type='checkbox';c.className='detailcheck';c.checked=!!get();c.onchange=()=>set(c.checked);return c;}
+// The per-row attribute editor revealed by the expander: distant-fg, family,
+// overline, inverse, extend, and (for ui/syntax, where inherit/height have no
+// inline column) inherit + height. Each control mutates FACE and calls onChange.
+// Returns the element plus the interactive controls so the row's lock cell can
+// disable them. opts.inheritOptions and opts.showInheritHeight gate the last two.
+// Hover help for each expander field, so the detail labels explain themselves the
+// way the table-header labels do. Keyed by the label text passed to add().
+const DETAIL_HOVERS={
+ 'distant fg':'foreground swapped in when the text sits on a background too close to its own color to read (Emacs :distant-foreground)',
+ 'family':'font family for this face; blank inherits the default (Emacs :family)',
+ 'underline':'underline style and color (Emacs :underline)',
+ 'overline':'a line drawn above the text (Emacs :overline)',
+ 'inverse':'swap the foreground and background (Emacs :inverse-video)',
+ 'extend':'extend the background past the end of the line to the window edge (Emacs :extend)',
+ 'inherit':'base face this one inherits unset attributes from (Emacs :inherit)',
+ 'height':'text size as a scaling factor of the inherited height, 0.1 to 2.0 (Emacs :height)'
+};
+function mkDetailEditor(face,onChange,opts={}){
+ const wrap=document.createElement('div');wrap.className='detailedit';const locks=[];
+ const add=(label,el)=>{const g=document.createElement('label');g.className='detailfield';g.title=DETAIL_HOVERS[label]||'';const s=document.createElement('span');s.textContent=label;g.append(s,el);wrap.appendChild(g);locks.push(el);};
+ const df=mkColorDropdown(ddList(face['distant-fg']||''),face['distant-fg']||'',h=>{face['distant-fg']=h||null;onChange();},{compact:true,defaultHex:opts.defaultHex});
+ add('distant fg',df);
+ const fam=document.createElement('input');fam.type='text';fam.className='detailinput';fam.placeholder='font family';fam.value=face.family||'';fam.onchange=()=>{face.family=fam.value.trim()||null;onChange();};
+ add('family',fam);
+ add('underline',mkUnderlineControl(()=>face.underline,v=>{face.underline=v;onChange();},opts));
+ add('overline',mkOverlineControl(()=>face.overline,v=>{face.overline=v;onChange();},opts));
+ add('inverse',mkCheck(()=>face.inverse,v=>{face.inverse=v;onChange();}));
+ add('extend',mkCheck(()=>face.extend,v=>{face.extend=v;onChange();}));
+ if(opts.showInheritHeight){
+ const isel=document.createElement('select');isel.className='chip detailsel';
+ (opts.inheritOptions||['']).forEach(o=>{const op=document.createElement('option');op.value=o;op.textContent=o||'— none —';isel.appendChild(op);});
+ isel.value=face.inherit||'';isel.onchange=()=>{face.inherit=isel.value||null;onChange();};add('inherit',isel);
+ const hin=document.createElement('input');hin.type='number';hin.min=''+HEIGHT_MIN;hin.max=''+HEIGHT_MAX;hin.step='0.05';hin.className='hstep';hin.value=face.height||1;hin.onchange=()=>{const raw=hin.value,h=clampHeight(raw);face.height=h;hin.value=h==null?1:h;if(h!=null&&parseFloat(raw)!==h)notify('height clamped to '+h+' (allowed '+HEIGHT_MIN+'–'+HEIGHT_MAX+')',false);onChange();};add('height',hin);
+ }
+ return {el:wrap,locks};}
+// Wire a per-row expander: a toggle button plus a hidden detail row (colspan
+// across the table) holding mkDetailEditor. The caller drops the button into a
+// cell, adds the returned locks to the row's lock cell, and inserts detailRow
+// right after the main row.
+// Which rows have their detail expanded, keyed by the row's element/face key.
+// Held outside the DOM so a table rebuild (a package edit rebuilds the whole
+// table) re-opens the rows that were open, instead of collapsing them under the
+// user — editing a value in an open expander must not close it.
+let EXPANDED=new Set();
+function mkExpander(face,colspan,onChange,opts={}){
+ const detail=document.createElement('tr');detail.className='detailrow';detail.style.display='none';
+ if(opts.expandKey&&EXPANDED.has(opts.expandKey))detail.style.display='';
+ const btn=document.createElement('button');btn.className='exptoggle';
+ // The disclosure triangle shows the row's state: ▶ collapsed, ▼ expanded.
+ const setGlyph=()=>{const open=detail.style.display!=='none';btn.textContent=open?'▼':'▶';btn.classList.toggle('on',open);};
+ // Flag the toggle when collapsed and at least one hidden attribute differs from
+ // the default, so a non-default attribute is never invisible. ndCheck re-runs
+ // after every edit (for tiers whose onChange does not rebuild the row).
+ const ndCheck=opts.ndCheck||(()=>false);
+ const refreshNd=()=>{const nd=ndCheck();btn.classList.toggle('exp-nd',nd);btn.title=nd?'more attributes (some differ from default)':'more attributes';};
+ const wrapped=()=>{onChange();refreshNd();};
+ const td=document.createElement('td');td.colSpan=colspan;const {el,locks}=mkDetailEditor(face,wrapped,opts);td.appendChild(el);detail.appendChild(td);
+ btn.onclick=()=>{const willOpen=detail.style.display==='none';detail.style.display=willOpen?'':'none';
+ if(opts.expandKey){willOpen?EXPANDED.add(opts.expandKey):EXPANDED.delete(opts.expandKey);}
+ setGlyph();syncExpandAllBtns();};
+ refreshNd();setGlyph();
+ return {btn,detail,locks};}
diff --git a/scripts/theme-studio/face_coverage.py b/scripts/theme-studio/face_coverage.py
index c6200e05c..57b44815a 100644
--- a/scripts/theme-studio/face_coverage.py
+++ b/scripts/theme-studio/face_coverage.py
@@ -179,12 +179,12 @@ def classify(name, items, src, pkgfaces):
if name == 'emacs-core':
return 'core'
c = collections.Counter(bucket_of_source(src.get(f, '')) for f in items)
- loaded = c['elpa'] + c['builtin'] + c['user'] + c['other']
- if loaded == 0:
+ elpa, builtin, user, other = c['elpa'], c['builtin'], c['user'], c['other']
+ if elpa + builtin + user + other == 0:
return 'package' if any(f in pkgfaces for f in items) else 'general'
- if c['elpa'] >= max(c['builtin'], c['user'], c['other']):
+ if elpa >= max(builtin, user, other):
return 'package'
- if c['other'] > c['builtin'] and c['other'] >= c['elpa']:
+ if other > builtin and other >= elpa:
return 'package'
return 'general'
diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py
index 09c25d804..797fcc28e 100644
--- a/scripts/theme-studio/generate.py
+++ b/scripts/theme-studio/generate.py
@@ -15,6 +15,22 @@ def read_json(name):
NERD_ICONS_LEGEND_FIELDS = ("key", "label", "face", "category", "glyph")
NERD_ICONS_GALLERY_GLYPH_FIELDS = ("glyph", "name")
+_NO_ARTIFACT = object() # distinguishes absent/malformed from a file that parsed to null
+
+def _load_nerd_icons_artifact(path, kind, tail):
+ """Open and JSON-parse the nerd-icons artifact at PATH. Return the parsed value,
+ or _NO_ARTIFACT (with a KIND/TAIL-labeled warning) when absent or malformed.
+ Shared skeleton for the legend and gallery loaders."""
+ if not os.path.exists(path):
+ print(f"WARNING: nerd-icons {kind} absent ({path}); {tail}")
+ return _NO_ARTIFACT
+ try:
+ with open(path) as src:
+ return json.load(src)
+ except (json.JSONDecodeError, OSError) as exc:
+ print(f"WARNING: nerd-icons {kind} malformed ({path}: {exc}); {tail}")
+ return _NO_ARTIFACT
+
def load_nerd_icons_legend(path=None):
"""Return the nerd-icons legend rows, or None when the artifact is unusable.
@@ -27,14 +43,8 @@ def load_nerd_icons_legend(path=None):
file, which lands here as None.
"""
path = path or os.path.join(HERE, "nerd-icons-legend.json")
- if not os.path.exists(path):
- print(f"WARNING: nerd-icons legend absent ({path}); generic nerd-icons app")
- return None
- try:
- with open(path) as src:
- data = json.load(src)
- except (json.JSONDecodeError, OSError) as exc:
- print(f"WARNING: nerd-icons legend malformed ({path}: {exc}); generic nerd-icons app")
+ data = _load_nerd_icons_artifact(path, "legend", "generic nerd-icons app")
+ if data is _NO_ARTIFACT:
return None
rows = data.get("legend") if isinstance(data, dict) else data
if not isinstance(rows, list) or not rows:
@@ -59,14 +69,8 @@ def load_nerd_icons_gallery(path=None):
the legend data still loads. Never raises.
"""
path = path or os.path.join(HERE, "nerd-icons-legend.json")
- if not os.path.exists(path):
- print(f"WARNING: nerd-icons gallery absent ({path}); legend without gallery")
- return None
- try:
- with open(path) as src:
- data = json.load(src)
- except (json.JSONDecodeError, OSError) as exc:
- print(f"WARNING: nerd-icons gallery malformed ({path}: {exc}); legend without gallery")
+ data = _load_nerd_icons_artifact(path, "gallery", "legend without gallery")
+ if data is _NO_ARTIFACT:
return None
groups = data.get("gallery") if isinstance(data, dict) else None
if not isinstance(groups, list) or not groups:
@@ -129,6 +133,9 @@ if os.path.exists(os.path.join(HERE,_FONT_WOFF2)):
STYLES=STYLES.replace('url("%s")'%_FONT_WOFF2,
'url("data:font/woff2;base64,%s")'%_FONT_B64)
APP_BODY=read_text('app.js')
+# Custom dropdown / detail-editor / expander factories, split from app.js for
+# navigability and spliced in at the CONTROLS_J token. Raw (no imports/exports).
+CONTROLS_BODY=read_text('controls.js')
# Bespoke per-package preview renderers, spliced into the page <script> via the
# PREVIEWS_J token in app.js. No imports/exports, so read raw.
PREVIEWS_BODY=read_text('previews.js')
@@ -381,6 +388,7 @@ def _build():
def fill_data(s):
return (s.replace("COLORMATH_J",COLORMATH_BODY)
.replace("APP_CORE_J",APP_CORE_BODY)
+ .replace("CONTROLS_J",CONTROLS_BODY)
.replace("PREVIEWS_J",PREVIEWS_BODY)
.replace("APP_UTIL_J",APP_UTIL_BODY)
.replace("PALETTE_GENERATOR_CORE_J",PALETTE_GENERATOR_CORE_BODY)
diff --git a/scripts/theme-studio/palette-generator-core.js b/scripts/theme-studio/palette-generator-core.js
index 6ad2bf44f..033fff373 100644
--- a/scripts/theme-studio/palette-generator-core.js
+++ b/scripts/theme-studio/palette-generator-core.js
@@ -50,8 +50,7 @@ function generatorHues(baseHue,scheme,count,rng){
const offsets=[0,120,240,30,150,270,60,180,300,90,210,330];
return offsets.slice(0,n).map(o=>(b+o)%360);
}
- if(scheme==='manual')return Array.from({length:n},(_,i)=>(b+(i*360)/n)%360);
- return Array.from({length:n},(_,i)=>(b+(i*360)/n)%360);
+ return Array.from({length:n},(_,i)=>(b+(i*360)/n)%360); // even spread (manual/default/unknown)
}
function generatorChroma(mode){
return mode==='subdued'?0.055:mode==='vivid'?0.13:0.085;
diff --git a/scripts/theme-studio/styles.css b/scripts/theme-studio/styles.css
index a32dbafd1..0d13f423c 100644
--- a/scripts/theme-studio/styles.css
+++ b/scripts/theme-studio/styles.css
@@ -39,8 +39,11 @@
select.navsel,select.navsel option{background:#1f1c19;color:#e8bd30}
/* Prev/next arrows flanking the view dropdown: step the selection without reopening it.
Scoped under .pkgbar to outweigh the generic `.pkgbar button` rule above. */
- .pkgbar .viewnav{appearance:none;border:1px solid #00000060;border-radius:5px;background:#1f1c19;color:#e8bd30;font:bold 16px monospace;width:26px;height:30px;padding:0;margin:0;cursor:pointer;vertical-align:middle}
- .pkgbar .viewnav:hover{border-color:#e8bd30}
+ .pkgbar .viewnav,.langbar .viewnav{appearance:none;border:1px solid #00000060;border-radius:5px;background:#1f1c19;color:#e8bd30;font:bold 16px monospace;width:26px;height:30px;padding:0;margin:0;cursor:pointer;vertical-align:middle}
+ .pkgbar .viewnav:hover,.langbar .viewnav:hover{border-color:#e8bd30}
+ /* Disabled nav (a single-pane preview): keep the gold, dim it, don't look clickable. */
+ .viewnav:disabled{opacity:0.5;cursor:default}
+ select.navsel:disabled{opacity:0.5}
/* Non-default marker: a small gold corner flag on a per-face setting cell whose
value differs from the face's default. The size box looks identical default
or not, so the flag is the only at-a-glance cue that a value was changed. */
diff --git a/scripts/theme-studio/test-locate.mjs b/scripts/theme-studio/test-locate.mjs
index faac7f916..09d15b8bc 100644
--- a/scripts/theme-studio/test-locate.mjs
+++ b/scripts/theme-studio/test-locate.mjs
@@ -8,7 +8,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
- buildLocateRegistry, locateFaceMeta, formatLocateTitle, previewFaceAttrs, isLocateOnPane, locateInfoLine,
+ buildLocateRegistry, locateFaceMeta, formatLocateTitle, isLocateOnPane,
} from './app-core.js';
// A constructed model: two package apps that BOTH own a face literally named
@@ -142,31 +142,6 @@ test('formatLocateTitle: Error — an unassigned meta reads "unassigned"', () =>
assert.equal(formatLocateTitle(locateFaceMeta('org-faces', 'ghost', reg)), 'ghost, unassigned');
});
-// --- locateInfoLine: "section > face — value" -------------------------------
-
-test('locateInfoLine: Normal — section > face — value (fg only, then fg / bg)', () => {
- const reg = buildLocateRegistry(APPS, PKGMAP, UIMAP, MAP);
- assert.equal(locateInfoLine(locateFaceMeta('org-faces', 'org-todo', reg)), 'org-faces > org-todo — #cc3333');
- const pkgmap = { app: { face: { fg: '#aabbcc', bg: '#223344', inherit: null, source: 'user' } } };
- const apps = { app: { label: 'App', faces: [['face', 'F', {}]] } };
- const reg2 = buildLocateRegistry(apps, pkgmap, {}, MAP);
- assert.equal(locateInfoLine(locateFaceMeta('app', 'face', reg2)), 'App > face — #aabbcc / #223344');
-});
-
-test('locateInfoLine: Error — an unassigned meta reads "<face> — unassigned"', () => {
- const reg = buildLocateRegistry(APPS, PKGMAP, UIMAP, MAP);
- assert.equal(locateInfoLine(locateFaceMeta('org-faces', 'ghost', reg)), 'ghost — unassigned');
-});
-
-// --- previewFaceAttrs: owner-aware validation -------------------------------
-
-test('previewFaceAttrs: Normal — a known owner/face validates; a bad owner is rejected', () => {
- const reg = buildLocateRegistry(APPS, PKGMAP, UIMAP, MAP);
- assert.ok(previewFaceAttrs('org-faces', 'org-todo', reg), 'known face validates');
- assert.equal(previewFaceAttrs('org-mode', 'minibuffer-prompt', reg), null, 'a UI face under a package owner is rejected');
- assert.equal(previewFaceAttrs('nope', 'org-todo', reg), null, 'an unknown owner is rejected');
-});
-
// --- lifecycle + perf -------------------------------------------------------
test('buildLocateRegistry: lifecycle — a rebuild after an edit reflects the new value', () => {
diff --git a/scripts/theme-studio/test_generate.py b/scripts/theme-studio/test_generate.py
index bc0e87815..3bc78bdf8 100644
--- a/scripts/theme-studio/test_generate.py
+++ b/scripts/theme-studio/test_generate.py
@@ -18,6 +18,39 @@ from collections import Counter, defaultdict
from contextlib import redirect_stdout
import generate # importable without side effects: the file write is __main__-guarded
+import face_coverage
+from unittest import mock
+
+
+class ClassifyBucket(unittest.TestCase):
+ """Characterization of face_coverage.classify's core/general/package decision,
+ locking each branch before the named-locals rewrite. bucket_of_source is mocked
+ to identity, so the src dict maps each face straight to its bucket name."""
+
+ def _classify(self, src, pkgfaces=(), name="x"):
+ with mock.patch.object(face_coverage, "bucket_of_source", lambda s: s):
+ return face_coverage.classify(name, list(src), src, set(pkgfaces))
+
+ def test_emacs_core_short_circuits_to_core(self):
+ self.assertEqual(face_coverage.classify("emacs-core", [], {}, set()), "core")
+
+ def test_nothing_loaded_with_a_package_face_is_package(self):
+ self.assertEqual(self._classify({"a": "unloaded", "b": "unloaded"}, pkgfaces={"b"}), "package")
+
+ def test_nothing_loaded_without_a_package_face_is_general(self):
+ self.assertEqual(self._classify({"a": "unloaded"}), "general")
+
+ def test_elpa_plurality_is_package(self):
+ self.assertEqual(self._classify({"a": "elpa", "b": "elpa", "c": "builtin"}), "package")
+
+ def test_elpa_tied_with_builtin_is_package(self):
+ self.assertEqual(self._classify({"a": "elpa", "b": "builtin"}), "package")
+
+ def test_other_beats_builtin_and_ties_elpa_is_package(self):
+ self.assertEqual(self._classify({"a": "other", "b": "other", "c": "elpa", "d": "builtin"}), "package")
+
+ def test_builtin_plurality_is_general(self):
+ self.assertEqual(self._classify({"a": "builtin", "b": "builtin", "c": "elpa"}), "general")
from app_inventory import face_rows
from default_faces import DefaultFaces, changed_summary
from face_specs import face_spec, package_face_spec, ui_face_spec
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index 7c077610d..7f5727cef 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -41,8 +41,11 @@
select.navsel,select.navsel option{background:#1f1c19;color:#e8bd30}
/* Prev/next arrows flanking the view dropdown: step the selection without reopening it.
Scoped under .pkgbar to outweigh the generic `.pkgbar button` rule above. */
- .pkgbar .viewnav{appearance:none;border:1px solid #00000060;border-radius:5px;background:#1f1c19;color:#e8bd30;font:bold 16px monospace;width:26px;height:30px;padding:0;margin:0;cursor:pointer;vertical-align:middle}
- .pkgbar .viewnav:hover{border-color:#e8bd30}
+ .pkgbar .viewnav,.langbar .viewnav{appearance:none;border:1px solid #00000060;border-radius:5px;background:#1f1c19;color:#e8bd30;font:bold 16px monospace;width:26px;height:30px;padding:0;margin:0;cursor:pointer;vertical-align:middle}
+ .pkgbar .viewnav:hover,.langbar .viewnav:hover{border-color:#e8bd30}
+ /* Disabled nav (a single-pane preview): keep the gold, dim it, don't look clickable. */
+ .viewnav:disabled{opacity:0.5;cursor:default}
+ select.navsel:disabled{opacity:0.5}
/* Non-default marker: a small gold corner flag on a per-face setting cell whose
value differs from the face's default. The size box looks identical default
or not, so the flag is the only at-a-glance cue that a value was changed. */
@@ -1206,15 +1209,6 @@ function locateFaceMeta(owner,face,registry){
return e||{owner,face,unassigned:true};
}
-// The owner-aware membership check the preview gate calls: the entry's attributes
-// when (owner, face) is a known face of that owner, null when it isn't (a bad
-// owner is rejected). A known face with no non-default attributes returns {} --
-// still truthy, so membership reads cleanly off the result.
-function previewFaceAttrs(owner,face,registry){
- const e=registry&&registry[locateKey(owner,face)];
- return e?e.attrs:null;
-}
-
// Clickable predicate: an element is on-pane only when its owner is the pane being
// viewed. Recomputed from the current view at render time (never stored in the
// registry), since switching panes changes clickability but not ownership.
@@ -1261,15 +1255,6 @@ function formatLocateTitle(meta){
}
return parts.concat(locateAttrsList(meta.attrs)).join(', ');
}
-
-// The immediate-wayfinding info line shown in the preview-label area on hover:
-// "section > face — value" (effective fg, plus bg when set). An unassigned meta
-// reads "<face> — unassigned". Terser than the title; the title is the full record.
-function locateInfoLine(meta){
- if(!meta||meta.unassigned)return (meta&&meta.face?meta.face:'')+' — unassigned';
- const val=meta.value.fg+(meta.value.bg?' / '+meta.value.bg:'');
- return meta.section+' > '+meta.face+' — '+val;
-}
// Pure color/UI-boundary helpers (normHex, ratingColor, textOn), inlined from
// app-util.js. textOn uses rl from the colormath core above.
// Pure color/UI-boundary helpers: hex-input parsing, the contrast-rating status
@@ -1349,8 +1334,7 @@ function generatorHues(baseHue,scheme,count,rng){
const offsets=[0,120,240,30,150,270,60,180,300,90,210,330];
return offsets.slice(0,n).map(o=>(b+o)%360);
}
- if(scheme==='manual')return Array.from({length:n},(_,i)=>(b+(i*360)/n)%360);
- return Array.from({length:n},(_,i)=>(b+(i*360)/n)%360);
+ return Array.from({length:n},(_,i)=>(b+(i*360)/n)%360); // even spread (manual/default/unknown)
}
function generatorChroma(mode){
return mode==='subdued'?0.055:mode==='vivid'?0.13:0.085;
@@ -1735,6 +1719,10 @@ function renderCode(){
cp.onclick=(e)=>{const s=e.target.closest('[data-k]');if(s)flashAssign(s.dataset.k);};
buildMockFrame();
}
+// controls.js -- the custom dropdown / detail-editor / expander control
+// factories, extracted from app.js for navigability. Inlined raw at the
+// CONTROLS_J token: these are hoisting function declarations plus the
+// dropdown popup state, so the token's position preserves execution order.
// Custom color dropdown: a real swatch + name + hex per row, since native
// <option> background colors render unreliably on Linux Chrome. The popup is
// fixed-positioned on <body> so a table's overflow can't clip it.
@@ -1748,15 +1736,13 @@ function mkColorDropdown(options,cur,onPick,opts={}){
left.textContent='‹';right.textContent='›';left.title='move to next darker color in this column';right.title='move to next lighter color in this column';
const t=document.createElement('div');t.className='cdd'+(opts.compact?' compact':'');t.tabIndex=0;
const nameOf=h=>{const o=options.find(p=>p[0]===h);return o?o[1]:(h||'none');};
- const displayHex=h=>h||(opts.defaultHex||'');
- const displayName=h=>h?nameOf(h):(opts.defaultName||nameOf(h));
function step(dir){if(wrap.dataset.locked==='1')return;const next=spanNeighborHex(cur,PALETTE,groundPair(),dir);if(!next)return;cur=next;paint();onPick(next);}
function paintStepButtons(){
const locked=wrap.dataset.locked==='1';
left.disabled=locked||!spanNeighborHex(cur,PALETTE,groundPair(),-1);
right.disabled=locked||!spanNeighborHex(cur,PALETTE,groundPair(),1);
}
- function paint(){const shown=displayHex(cur),nm=displayName(cur),ttl=cur?(nm+' '+cur):(nm+(shown?' -> '+shown:''));t.style.background=shown||'#161412';t.style.color=shown?textOn(shown):'#b4b1a2';t.dataset.val=cur||'';t.title=ttl;t.classList.toggle('is-default',!cur);t.classList.toggle('gone',!!cur&&nameOf(cur)==='(gone)');
+ function paint(){const shown=cur||(opts.defaultHex||''),nm=cur?nameOf(cur):(opts.defaultName||nameOf(cur)),ttl=cur?(nm+' '+cur):(nm+(shown?' -> '+shown:''));t.style.background=shown||'#161412';t.style.color=shown?textOn(shown):'#b4b1a2';t.dataset.val=cur||'';t.title=ttl;t.classList.toggle('is-default',!cur);t.classList.toggle('gone',!!cur&&nameOf(cur)==='(gone)');
t.innerHTML=opts.compact?`<span class="cddsw" style="background:${shown||'transparent'}"></span>`:`<span class="cddsw" style="background:${shown||'transparent'}"></span>${esc(nm)}`;paintStepButtons();}
paint();
left.onclick=e=>{e.stopPropagation();step(-1);};
@@ -1942,6 +1928,7 @@ function mkExpander(face,colspan,onChange,opts={}){
setGlyph();syncExpandAllBtns();};
refreshNd();setGlyph();
return {btn,detail,locks};}
+
// Expand/collapse every row in a table at once, then sync the per-row triangles.
function setAllExpanded(tableId,expand){
const tb=document.getElementById(tableId);if(!tb)return;
@@ -1971,7 +1958,7 @@ function clearUnlockedRows(items,keyFn,resetFn){
for(const it of items){const k=keyFn(it);if(k===null)continue;if(!LOCKED.has(k))resetFn(it);}
}
function rebuildColorTables(){
- buildTable();buildUITable();if(document.getElementById('pkgbody'))buildPkgTable();
+ buildTable();buildUITable();buildPkgTable();// buildPkgTable self-guards when #pkgbody is absent
}
function refreshPaletteState(opts={}){
renderPalette();rebuildColorTables();
@@ -2513,7 +2500,9 @@ function flashPkg(f){flashRow(document.querySelector(`#pkgbody tr[data-face="${f
function flashPkgPreview(f){const sp=document.querySelectorAll(`#pkgpreview [data-face="${f}"]`);if(sp.length){flashEls(sp);return;}const row=document.querySelector(`#pkgbody tr[data-face="${f}"]`);if(row)flashEl(row.querySelector('.cat'));}
function mockSpan(k,t){return `<span data-k="${k}" style="${syntaxStyle(k)}">${esc(t)}</span>`;}
function uiCss(o,fgv,bgv,opts={}){const fg=fgv===undefined?effFg(o.fg):fgv,bg=bgv===undefined?o.bg:bgv;return faceCss(o,fg,bg,{noBg:opts.noBg,boxBg:bg||MAP['bg']});}
-function syncMockHeight(){const t=document.getElementById('uitable'),m=document.getElementById('mockframe');if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';}
+// Size a preview pane to its faces table, minus the label bar above it. Shared by
+// the UI mock and the package preview, which differ only in their element IDs.
+function syncPaneHeight(tableId,paneId){const t=document.getElementById(tableId),m=document.getElementById(paneId);if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';}
function buildMockFrame(){
const fr=document.getElementById('mockframe');if(!fr)return;
rebuildLocateRegistry();
@@ -2638,9 +2627,9 @@ function onViewChange(){const s=document.getElementById('viewsel');const v=(s&&s
const show=(id,on)=>{const e=document.getElementById(id);if(e)e.style.display=on?'':'none';};
show('view-code',v==='@code');show('view-ui',v==='@ui');show('view-pkg',v[0]!=='@');
if(v==='@code')renderCode();
- else if(v==='@ui'){buildUITable();buildMockFrame();syncMockHeight();}
+ else if(v==='@ui'){buildUITable();buildMockFrame();syncPaneHeight('uitable','mockframe');}
else pkgChanged();}
-function pkgChanged(){buildPkgTable();buildPkgPreview();syncPkgHeight();}
+function pkgChanged(){buildPkgTable();buildPkgPreview();syncPaneHeight('pkgtable','pkgpreview');}
function buildPkgTable(){
const app=curApp(),tb=document.getElementById('pkgbody');if(!tb)return;tb.innerHTML='';
const flt=(document.getElementById('pkgfilter').value||'').trim().toLowerCase();
@@ -3278,7 +3267,6 @@ function buildPkgPreview(){
// no separate info line.
}
function resetApp(){const app=curApp();for(const [face,,d] of APPS[app].faces)if(!LOCKED.has('pkg:'+app+':'+face))PKGMAP[app][face]=seedFace(d);pkgChanged();notify('reset editable '+app+' faces to package defaults',false);}
-function syncPkgHeight(){const t=document.getElementById('pkgtable'),m=document.getElementById('pkgpreview');if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';}
// --- worst-case readout for the covered overlay faces (spec Phase 4) ---------
// Default WCAG target for the worst-case verdict (AA). AAA is selectable.
let WORST_TARGET=4.5;
@@ -3321,7 +3309,7 @@ function repaintCovered(){COVERED_FACES.forEach(f=>{if(UIMAP[f]&&document.getEle
function paintUI(face){const pv=document.getElementById('uiprev-'+face);if(!pv)return;const o=UIMAP[face];pv.style.color=effFg(o.fg);pv.style.background=effBg(o.bg);pv.style.fontWeight=cssWeight(o.weight);pv.style.fontStyle=o.slant||'normal';pv.style.textDecoration=(o.underline?'underline ':'')+(o.strike?'line-through':'')||'none';pv.style.boxShadow=boxCss(o.box,effBg(o.bg));
const report=coveredContrastReport(face);
pv.title='';
- const cr=document.getElementById('uicr-'+face);if(cr){cr.title='';if(report!==null){if(report.empty){cr.title='this overlay has no syntax foreground set yet';cr.innerHTML='<span title="this overlay has no syntax foreground set yet">no fg set</span>';}else{const title=failureTitle(report)||'all covered text clears '+WORST_TARGET.toFixed(1);cr.title=title;cr.innerHTML=`<span style="color:${ratingColor(report.worst.ratio)}" title="${esc(title)}">${report.worst.ratio.toFixed(1)}</span>`;}}else{const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}}}
+ const cr=document.getElementById('uicr-'+face);if(cr){cr.title='';const wc=worstCellHtml(face);if(wc!==null){cr.title=report.empty?'this overlay has no syntax foreground set yet':(failureTitle(report)||'all covered text clears '+WORST_TARGET.toFixed(1));cr.innerHTML=wc;}else{const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}}}
function buildUITable(){
const tb=document.getElementById('uibody');tb.innerHTML='';
for(const [face,label,ex] of UI_FACES){
@@ -3330,16 +3318,23 @@ function buildUITable(){
exp.detail.dataset.detailFor=face;
const c0=document.createElement('td');c0.className='cat';c0.title=composeHoverTitle(FACE_DOCS[face],c0.title);c0.appendChild(exp.btn);
const c0lbl=document.createElement('span');c0lbl.textContent=' '+label;c0lbl.style.cursor='pointer';c0lbl.title='flash this face in the live preview';c0lbl.onclick=()=>flashUiPreview(face);c0.appendChild(c0lbl);
+ // Emacs draws the cursor as a rectangle: its fg colors the glyph sitting on
+ // it and its bg is the cursor color, but weight/slant/underline/strike and
+ // box are no-ops on it. Show only fg+bg for the cursor row; mute the rest.
+ const cursorOnly=(face==='cursor');
+ const naCell=t=>{const s=document.createElement('span');s.textContent='—';s.style.opacity='0.4';s.title=t;return s;};
const fgSel=uiSelect(face,'fg'),bgSel=uiSelect(face,'bg');
const cF=document.createElement('td');cF.appendChild(fgSel);
const cB=document.createElement('td');cB.appendChild(bgSel);
const cS=document.createElement('td');
- const stCtls=mkStyleControls(UIMAP[face],()=>{paintUI(face);buildMockFrame();},{defaultHex:effFg(UIMAP[face].fg)});
- const uiCluster=document.createElement('div');uiCluster.className='stylecluster';stCtls.forEach(c=>uiCluster.appendChild(c));cS.appendChild(uiCluster);
+ const stCtls=cursorOnly?[]:mkStyleControls(UIMAP[face],()=>{paintUI(face);buildMockFrame();},{defaultHex:effFg(UIMAP[face].fg)});
+ if(cursorOnly){cS.appendChild(naCell('Emacs ignores weight/slant/underline/strike on the cursor face'));}
+ else{const uiCluster=document.createElement('div');uiCluster.className='stylecluster';stCtls.forEach(c=>uiCluster.appendChild(c));cS.appendChild(uiCluster);}
const cC=document.createElement('td');cC.id='uicr-'+face;cC.style.whiteSpace='nowrap';cC.style.fontSize='10pt';
const cP=document.createElement('td');cP.className='ex';cP.id='uiprev-'+face;cP.textContent=ex;cP.style.padding='4px 10px';cP.style.borderRadius='4px';
- const cX=document.createElement('td');const boxCtl=mkBoxControl(()=>UIMAP[face].box,b=>{UIMAP[face].box=b;paintUI(face);buildMockFrame();},{compact:true});cX.appendChild(boxCtl);
- const cL=mkLockCell('ui:'+face,[fgSel,bgSel,...stCtls,boxCtl,...exp.locks]);
+ const cX=document.createElement('td');const boxCtl=cursorOnly?null:mkBoxControl(()=>UIMAP[face].box,b=>{UIMAP[face].box=b;paintUI(face);buildMockFrame();},{compact:true});
+ if(cursorOnly){cX.appendChild(naCell('Emacs ignores the box attribute on the cursor face'));}else{cX.appendChild(boxCtl);}
+ const cL=mkLockCell('ui:'+face,cursorOnly?[fgSel,bgSel,...exp.locks]:[fgSel,bgSel,...stCtls,boxCtl,...exp.locks]);
tr.appendChild(cL);tr.appendChild(c0);tr.appendChild(cF);tr.appendChild(cB);tr.appendChild(cS);tr.appendChild(cX);tr.appendChild(cC);tr.appendChild(cP);tb.appendChild(tr);tb.appendChild(exp.detail);paintUI(face);
}
applyTableSort('uibody');
@@ -3364,11 +3359,11 @@ function initApp(){
paletteShowFull=false; // open collapsed to base colors; the arrow expands the spans
buildLangSel();buildViewSel();renderPalette();rebuildColorTables();renderCode();applyGround();
initGeneratorControls();
- updateTitle();initPicker();buildPkgPreview();syncMockHeight();syncPkgHeight();
+ updateTitle();initPicker();buildPkgPreview();syncPaneHeight('uitable','mockframe');syncPaneHeight('pkgtable','pkgpreview');
onViewChange();
}
initApp();
-addEventListener('resize',()=>{syncMockHeight();syncPkgHeight();});
+addEventListener('resize',()=>{syncPaneHeight('uitable','mockframe');syncPaneHeight('pkgtable','pkgpreview');});
// Shared gate harness. Each call site keeps its literal location.hash==='#NAMEtest'
// check (run-tests.sh greps it); gate() owns the ok/notes/A setup and the verdict
// postamble. Note format standardized to ' fails=note1,note2'.
@@ -3592,6 +3587,21 @@ if(location.hash==='#mocktest')gate('mocktest',A=>withSavedState(['UIMAP','PKGMA
pickEnum(pkgWeight(),'heavy');
A(PKGMAP[app][face].weight==='heavy'&&PKGMAP[app][face].source==='user','pkg weight dropdown writes the model and marks the face edited');
}));
+// Cursor-row gate (open with #cursorrowtest): the cursor face honors only fg
+// (the glyph on it) and bg (the cursor color); weight/slant/underline/strike and
+// box are no-ops, so the row mutes them to a dash while non-cursor rows keep them.
+if(location.hash==='#cursorrowtest')gate('cursorrowtest',A=>{
+ buildUITable();
+ const rows=[...document.querySelectorAll('#uibody tr')];
+ const cur=rows.find(r=>r.dataset.face==='cursor');
+ A(!!cur,'cursor row present');
+ A(!!cur.cells[2].querySelector('.cdd'),'cursor keeps the fg swatch');
+ A(!!cur.cells[3].querySelector('.cdd'),'cursor keeps the bg swatch');
+ A(!cur.cells[4].querySelector('.enumdd')&&cur.cells[4].textContent.includes('—'),'cursor mutes the style controls');
+ A(cur.cells[5].textContent.includes('—'),'cursor mutes the box control');
+ const ml=rows.find(r=>r.dataset.face==='mode-line');
+ A(!!ml.cells[4].querySelector('.enumdd'),'non-cursor rows keep the style controls');
+});
// Palette-generator gate (open with #generatortest): previewing is non-mutating,
// clicking a generated tile loads the existing selector, adding creates a normal
// singleton base column, and appending a preview column commits all span members
@@ -4308,7 +4318,7 @@ if(location.hash==='#pickertest')gate('pickertest',A=>{
// four radio buttons (none / line / pressed / raised); the color swatch shows
// only while a box style is active.
if(location.hash==='#boxtest')gate('boxtest',A=>{
- LOCKED.clear();const f=UI_FACES[0][0];const saveBox=UIMAP[f].box;
+ LOCKED.clear();const f=UI_FACES.map(x=>x[0]).find(x=>x!=='cursor');const saveBox=UIMAP[f].box; // cursor has no box control by design
UIMAP[f].box=null;buildUITable();
const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[5];
A(!!cell.querySelector('.boxcluster'),'box-cluster-present');
@@ -4327,7 +4337,7 @@ if(location.hash==='#boxtest')gate('boxtest',A=>{
// Style-cluster gate (open with #styletest): the style cell holds a weight
// selector, a slant selector, and box-like underline and strike controls.
if(location.hash==='#styletest')gate('styletest',A=>{
- buildUITable();const f=UI_FACES[0][0];
+ buildUITable();const f=UI_FACES.map(x=>x[0]).find(x=>x!=='cursor'); // cursor row has no style cluster by design
const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[4];
const cluster=cell.querySelector('.stylecluster');
A(!!cluster,'style-cluster-present');
diff --git a/tests/test-calendar-sync--apply-single-exception.el b/tests/test-calendar-sync--apply-single-exception.el
index 3d2342708..f23104d98 100644
--- a/tests/test-calendar-sync--apply-single-exception.el
+++ b/tests/test-calendar-sync--apply-single-exception.el
@@ -105,5 +105,42 @@
(plist-get (calendar-sync--apply-single-exception occ exc)
:url)))))
+;;; Status re-derivation from overridden attendees (chime handoff 2026-06-24)
+
+(ert-deftest test-calendar-sync--apply-single-exception-declined-occurrence-rederives-status ()
+ "Normal: a declined single occurrence re-derives :status from the override attendees."
+ (let ((calendar-sync-user-emails '("craig@example.com"))
+ (occ (list :start '(2026 6 24 16 0) :status "accepted" :uid "abc"))
+ (exc (list :start '(2026 6 24 16 0)
+ :attendees (list (list :email "craig@example.com" :partstat "DECLINED")))))
+ (should (equal "declined"
+ (plist-get (calendar-sync--apply-single-exception occ exc) :status)))))
+
+(ert-deftest test-calendar-sync--apply-single-exception-no-attendee-override-keeps-status ()
+ "Boundary: an exception with no attendee block leaves the inherited :status intact."
+ (let ((calendar-sync-user-emails '("craig@example.com"))
+ (occ (list :start '(2026 6 24 16 0) :status "accepted" :uid "abc"))
+ (exc (list :start '(2026 6 24 16 0) :summary "Moved")))
+ (should (equal "accepted"
+ (plist-get (calendar-sync--apply-single-exception occ exc) :status)))))
+
+(ert-deftest test-calendar-sync--apply-single-exception-accepted-override-stays-accepted ()
+ "Normal: an accepted attendee override keeps :status accepted."
+ (let ((calendar-sync-user-emails '("craig@example.com"))
+ (occ (list :start '(2026 6 24 16 0) :status "accepted" :uid "abc"))
+ (exc (list :start '(2026 6 24 16 0)
+ :attendees (list (list :email "craig@example.com" :partstat "ACCEPTED")))))
+ (should (equal "accepted"
+ (plist-get (calendar-sync--apply-single-exception occ exc) :status)))))
+
+(ert-deftest test-calendar-sync--apply-single-exception-override-without-user-keeps-status ()
+ "Boundary: override attendees that don't include the user leave :status intact."
+ (let ((calendar-sync-user-emails '("craig@example.com"))
+ (occ (list :start '(2026 6 24 16 0) :status "accepted" :uid "abc"))
+ (exc (list :start '(2026 6 24 16 0)
+ :attendees (list (list :email "someone@else.com" :partstat "DECLINED")))))
+ (should (equal "accepted"
+ (plist-get (calendar-sync--apply-single-exception occ exc) :status)))))
+
(provide 'test-calendar-sync--apply-single-exception)
;;; test-calendar-sync--apply-single-exception.el ends here
diff --git a/tests/test-dirvish-config-wallpaper-program.el b/tests/test-dirvish-config-wallpaper-program.el
index 556c13100..41d2ad8b2 100644
--- a/tests/test-dirvish-config-wallpaper-program.el
+++ b/tests/test-dirvish-config-wallpaper-program.el
@@ -28,9 +28,9 @@
'("feh" "--bg-fill"))))
(ert-deftest test-cj--wallpaper-program-for-wayland ()
- "Normal: wayland dispatches to swww with the img subcommand."
+ "Normal: wayland dispatches to the set-wallpaper script (awww backend + waypaper persist)."
(should (equal (cj/--wallpaper-program-for 'wayland)
- '("swww" "img"))))
+ '("set-wallpaper"))))
(ert-deftest test-cj--wallpaper-program-for-unknown-returns-nil ()
"Boundary: an unknown environment returns nil so the wrapper can fall back."
diff --git a/tests/test-google-keep-config.el b/tests/test-google-keep-config.el
new file mode 100644
index 000000000..690355506
--- /dev/null
+++ b/tests/test-google-keep-config.el
@@ -0,0 +1,142 @@
+;;; test-google-keep-config.el --- Tests for google-keep-config -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the pure JSON-to-org core of google-keep-config.el (the part that
+;; later extracts to a package) plus the parse-render-write chain. The bridge
+;; subprocess + auth are the IO boundary, exercised live once the token is set.
+
+;;; Code:
+
+(require 'ert)
+(require 'google-keep-config)
+
+(defun test-google-keep--note (&rest overrides)
+ "Build a note alist (parse-shaped) with OVERRIDES merged in."
+ (let ((base (list (cons 'id "abc")
+ (cons 'title "Groceries")
+ (cons 'text "milk\neggs")
+ (cons 'labels '("shopping" "home"))
+ (cons 'pinned nil)
+ (cons 'archived nil)
+ (cons 'color "WHITE")
+ (cons 'updated "2026-06-25T04:00:00Z"))))
+ (dolist (pair overrides base)
+ (setf (alist-get (car pair) base) (cdr pair)))))
+
+;;; cj/keep--parse-json
+
+(ert-deftest test-google-keep-parse-json-array ()
+ "Normal: a JSON array parses to a list of note alists."
+ (let ((notes (cj/keep--parse-json
+ "[{\"id\":\"a\",\"title\":\"T\",\"labels\":[\"x\"],\"pinned\":true}]")))
+ (should (= 1 (length notes)))
+ (should (equal "a" (alist-get 'id (car notes))))
+ (should (equal '("x") (alist-get 'labels (car notes))))
+ (should (eq t (alist-get 'pinned (car notes))))))
+
+(ert-deftest test-google-keep-parse-json-empty ()
+ "Boundary: an empty Keep ([]) parses to an empty list."
+ (should (null (cj/keep--parse-json "[]"))))
+
+;;; cj/keep--label-to-tag
+
+(ert-deftest test-google-keep-label-to-tag-plain ()
+ "Normal: an alphanumeric label is unchanged."
+ (should (equal "shopping" (cj/keep--label-to-tag "shopping"))))
+
+(ert-deftest test-google-keep-label-to-tag-sanitizes ()
+ "Boundary: spaces and punctuation become underscores (valid org tag chars)."
+ (should (equal "to_do_list_" (cj/keep--label-to-tag "to do/list!"))))
+
+;;; cj/keep--note-tags
+
+(ert-deftest test-google-keep-note-tags-labels ()
+ "Normal: labels render as a trailing org-tag string."
+ (should (equal " :shopping:home:" (cj/keep--note-tags (test-google-keep--note)))))
+
+(ert-deftest test-google-keep-note-tags-archived ()
+ "Normal: an archived note gains the archived tag."
+ (should (equal " :shopping:home:archived:"
+ (cj/keep--note-tags (test-google-keep--note (cons 'archived t))))))
+
+(ert-deftest test-google-keep-note-tags-none ()
+ "Boundary: no labels and not archived yields an empty tag string."
+ (should (equal "" (cj/keep--note-tags
+ (test-google-keep--note (cons 'labels nil))))))
+
+;;; cj/keep--note-heading
+
+(ert-deftest test-google-keep-note-heading-full ()
+ "Normal: a full note renders heading, properties, link, and body."
+ (let ((s (cj/keep--note-heading (test-google-keep--note))))
+ (should (string-match-p "\\`\\* Groceries :shopping:home:\n" s))
+ (should (string-match-p ":KEEP_ID: abc\n" s))
+ (should (string-match-p ":UPDATED: 2026-06-25T04:00:00Z\n" s))
+ (should (string-match-p "\\[\\[https://keep.google.com/#NOTE/abc\\]\\[open in Keep\\]\\]" s))
+ (should (string-match-p "milk\neggs\n" s))))
+
+(ert-deftest test-google-keep-note-heading-untitled ()
+ "Boundary: an empty title falls back to (untitled)."
+ (let ((s (cj/keep--note-heading (test-google-keep--note (cons 'title "")))))
+ (should (string-match-p "\\`\\* (untitled)" s))))
+
+(ert-deftest test-google-keep-note-heading-empty-text ()
+ "Boundary: an empty body emits no trailing text block."
+ (let ((s (cj/keep--note-heading
+ (test-google-keep--note (cons 'text "") (cons 'labels nil)))))
+ (should-not (string-match-p "open in Keep\\]\\]\n.+[^\n]" s))))
+
+;;; cj/keep--sort-pinned-first
+
+(ert-deftest test-google-keep-sort-pinned-first ()
+ "Normal: pinned notes come first, order otherwise preserved."
+ (let* ((a (test-google-keep--note (cons 'id "a") (cons 'pinned nil)))
+ (b (test-google-keep--note (cons 'id "b") (cons 'pinned t)))
+ (c (test-google-keep--note (cons 'id "c") (cons 'pinned nil)))
+ (sorted (cj/keep--sort-pinned-first (list a b c))))
+ (should (equal '("b" "a" "c") (mapcar (lambda (n) (alist-get 'id n)) sorted)))))
+
+;;; cj/keep--render
+
+(ert-deftest test-google-keep-render-header-and-notes ()
+ "Normal: the page carries the read-only header and a heading per note."
+ (let ((s (cj/keep--render (list (test-google-keep--note)) "2026-06-25 04:00")))
+ (should (string-match-p "read-only view" s))
+ (should (string-match-p "Last refresh: 2026-06-25 04:00" s))
+ (should (string-match-p "^\\* Groceries" s))))
+
+(ert-deftest test-google-keep-render-empty ()
+ "Boundary: no notes still produces a valid header-only page."
+ (let ((s (cj/keep--render nil)))
+ (should (string-match-p "#\\+TITLE: Google Keep" s))
+ (should-not (string-match-p "^\\* " s))))
+
+;;; cj/keep--write-atomically + the parse-render-write chain
+
+(ert-deftest test-google-keep-write-atomically ()
+ "Normal: content lands in the target file via temp + rename."
+ (let* ((dir (make-temp-file "keep-test-" t))
+ (file (expand-file-name "keep.org" dir)))
+ (unwind-protect
+ (progn
+ (cj/keep--write-atomically "hello\n" file)
+ (should (equal "hello\n"
+ (with-temp-buffer (insert-file-contents file)
+ (buffer-string)))))
+ (delete-directory dir t))))
+
+(ert-deftest test-google-keep-write-notes-chain ()
+ "Normal: JSON in, a rendered org file out, with the note count returned."
+ (let* ((dir (make-temp-file "keep-test-" t))
+ (keep-file (expand-file-name "keep.org" dir)))
+ (unwind-protect
+ (let ((n (cj/keep--write-notes
+ "[{\"id\":\"a\",\"title\":\"One\",\"labels\":[],\"pinned\":false,\"archived\":false,\"color\":\"WHITE\",\"updated\":\"2026-06-25T04:00:00Z\"}]")))
+ (should (= 1 n))
+ (should (string-match-p "^\\* One"
+ (with-temp-buffer (insert-file-contents keep-file)
+ (buffer-string)))))
+ (delete-directory dir t))))
+
+(provide 'test-google-keep-config)
+;;; test-google-keep-config.el ends here
diff --git a/tests/test-prog-lsp.el b/tests/test-prog-lsp.el
new file mode 100644
index 000000000..7e38111d0
--- /dev/null
+++ b/tests/test-prog-lsp.el
@@ -0,0 +1,66 @@
+;;; test-prog-lsp.el --- Startup smoke test for LSP config resolution -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; A narrow smoke test of prog-lsp.el, the central LSP module. It pins the
+;; invariants that should hold the moment the config loads, before any server
+;; starts: lsp-enable-remote stays nil (so TRAMP files don't auto-start a slow
+;; LSP), the file-watch-ignore defaults live in one idempotent place, the eldoc
+;; provider is stripped from the global hook, and a mode never accrues a
+;; duplicate lsp-deferred entry. The generic :config defaults are deferred to
+;; lsp-mode's own load (see the make-test no-package-initialize note in
+;; CLAUDE.md), so this tests the top-level :init and helper surface, which runs.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'use-package)
+(require 'prog-lsp)
+
+;; lsp-mode's defcustom isn't loaded under make test, and prog-lsp's bare
+;; `(defvar lsp-file-watch-ignored-directories)' only marks it special within
+;; that file's unit. Declare it special here too so the `let' bindings below
+;; bind dynamically (the helper reads it through the symbol via add-to-list).
+(defvar lsp-file-watch-ignored-directories nil)
+
+(ert-deftest test-prog-lsp-enable-remote-nil ()
+ "Normal: lsp-enable-remote is nil so LSP never auto-starts on TRAMP files."
+ (should (boundp 'lsp-enable-remote))
+ (should (null lsp-enable-remote)))
+
+(ert-deftest test-prog-lsp-file-watch-adds-extras ()
+ "Normal: the build/cache ignore patterns get appended to lsp's watch-ignore list."
+ (let ((lsp-file-watch-ignored-directories '("[/\\\\]\\.git\\'")))
+ (cj/lsp--add-file-watch-ignored-extras)
+ (dolist (pattern cj/lsp-file-watch-ignored-extras)
+ (should (member pattern lsp-file-watch-ignored-directories)))
+ (should (member "[/\\\\]\\.git\\'" lsp-file-watch-ignored-directories))))
+
+(ert-deftest test-prog-lsp-file-watch-idempotent ()
+ "Boundary: adding the extras twice leaves each pattern present exactly once."
+ (let ((lsp-file-watch-ignored-directories '()))
+ (cj/lsp--add-file-watch-ignored-extras)
+ (cj/lsp--add-file-watch-ignored-extras)
+ (dolist (pattern cj/lsp-file-watch-ignored-extras)
+ (should (= 1 (cl-count pattern lsp-file-watch-ignored-directories
+ :test #'equal))))))
+
+(ert-deftest test-prog-lsp-eldoc-provider-removed-globally ()
+ "Normal: the global eldoc provider is stripped so lsp can't reattach it."
+ (let ((eldoc-documentation-functions
+ (list #'lsp-eldoc-function #'ignore)))
+ (cj/lsp--remove-eldoc-provider-global)
+ (should-not (memq 'lsp-eldoc-function eldoc-documentation-functions))
+ (should (memq 'ignore eldoc-documentation-functions))))
+
+(ert-deftest test-prog-lsp-no-duplicate-mode-hook ()
+ "Boundary: a mode prog-lsp wires never holds more than one lsp-deferred entry.
+prog-lsp and the per-language modules both add lsp-deferred for some modes;
+add-hook dedups identical symbols, and this pins that invariant so a future
+non-symbol (lambda) addition that breaks it gets caught."
+ (dolist (hook '(c-mode-hook python-mode-hook go-ts-mode-hook))
+ (when (boundp hook)
+ (should (>= 1 (cl-count 'lsp-deferred (symbol-value hook)))))))
+
+(provide 'test-prog-lsp)
+;;; test-prog-lsp.el ends here
diff --git a/todo.org b/todo.org
index 11dfcb18d..c64ada672 100644
--- a/todo.org
+++ b/todo.org
@@ -55,8 +55,40 @@ Tags are additive. For example, a small wrong-behavior fix can be
=:bug:quick:=, and a feature that requires internal restructuring can be
=:feature:refactor:=.
* Emacs Open Work
+** TODO [#B] eww User-Agent advice may not inject under Emacs 30 :bug:
+The UA-injection advice =my-eww--inject-user-agent= (=modules/eww-config.el:47=) gates on =(derived-mode-p 'eww-mode)= and is an =:around= on =url-retrieve= / =url-retrieve-synchronously=. Its tests (=tests/test-eww-config-user-agent-advice.el=) fail under =make test=: =test-eww-ua-injected-in-eww-buffer= (:21) and =test-eww-ua-replaces-existing-and-keeps-other-headers= (:42) both see no injected UA, because =(derived-mode-p 'eww-mode)= returns nil in the test's temp buffer where the mode is set with a bare =(setq major-mode 'eww-mode)=. Two possibilities: (a) test-only artifact — a bare setq doesn't establish the mode the way =derived-mode-p= now resolves it, so the test should set the mode properly (call =eww-mode=, or set =derived-mode-parent=); or (b) a real Emacs-30 =derived-mode-p= change that means the advice no longer fires in real eww buffers either, so the desktop User-Agent isn't actually sent. Check (b) first: in a live eww page, =M-: (derived-mode-p 'eww-mode)= — if nil, the gate is broken in production (fix to =(eq major-mode 'eww-mode)= or =provided-mode-derived-p=). If only the test is wrong, fix its mode setup. Pre-existing (eww-config + test unchanged this session); surfaced 2026-06-25 running the full suite during the google-keep work. The third test (=test-eww-ua-not-injected-outside-eww=) passes.
+** TODO [#B] first f12 doesn't toggle the term window :bug:solo:
+The first =f12= of a session flashes the terminal open and immediately closes it, as if the toggle fired on then off; a second =f12= then works. Seen across two separate sessions. From the roam inbox 2026-06-24.
+** TODO [#B] F12 pops EAT instead of ghostel :feature:studio:
+Switch the F12 terminal toggle from ghostel/ghostty to EAT (emulator-for-terminals, pure elisp). The draw: EAT renders entirely in elisp, so its whole palette is real Emacs faces (=eat-term-color-0= .. =eat-term-color-15=, foreground/background, cursor), which makes it fully themeable from the theme — and a fun theme-studio coverage target. Steps: install =eat=; wire F12 to pop/toggle an EAT terminal (mind the =ghostel-keymap-exceptions= + rebuild gotcha if any ghostel F-key wiring lingers; the new path is plain Emacs keymaps); theme the =eat-term-color-*= faces (candidate to surface in theme-studio). Tradeoff to accept knowingly (themeability research 2026-06-24): ghostel is actually the most live-themeable — it has an =enable-theme-functions= resync hook and a dedicated default fg/bg face, whereas EAT needs a buffer reload to pick up a theme change and exposes no default fg/bg defcustom. So this trades ghostel's automatic theme-resync for EAT's pure-elisp face control. Spawned from the terminal-themeability comparison.
+** TODO [#C] org-capture popup leaks f12 / f10 / f11 / ai-term keys :bug:
+While the org-capture popup is open, the global F-keys (the =f12= term, =f10= / =f11=, the ai-term family) still fire and pop a terminal over the capture. Disable those keys for the duration of the capture popup if there's a clean way. Research first and report; if it's too invasive, defer or cancel rather than force it. From the roam inbox 2026-06-24.
+** CANCELLED [#C] dirvish image previews missing in the pictures dir :bug:
+CLOSED: [2026-06-25 Thu]
+Craig couldn't reproduce — image previews render fine in dirvish now. Cancelled.
+** DONE [#B] calendar-sync: a declined single occurrence keeps :STATUS: accepted :bug:solo:
+CLOSED: [2026-06-25 Thu]
+A recurring event declined for just one occurrence synced out with =:STATUS: accepted= (chime then faithfully showed it). Root cause (diagnosed by a chime session, 2026-06-24): =calendar-sync--apply-single-exception= merged the override's =:attendees= but never re-derived =:status=, so the occurrence kept the series master's accepted status, and =calendar-sync--filter-declined= (which keys off =:status=) didn't drop it. Fix (TDD): in =apply-single-exception=, when overriding =:attendees=, re-derive =:status= via =calendar-sync--find-user-status= against =calendar-sync-user-emails=. Four new tests in =test-calendar-sync--apply-single-exception.el= (declined → "declined"; no-attendee override → inherited intact; accepted override → accepted; override without the user → inherited intact); recurrence + find-user-status + integration suites unchanged. Live-reloaded; a manual re-sync ran clean. The specific 2026-06-24 Arusyak occurrence is past now (its RECURRENCE-ID override aged out of the feed), so the live confirmation lands on the next single-occurrence decline.
** PROJECT [#A] Manual testing and validation
Exercised once the phases above land.
+*** VERIFY Google Keep v1 live setup and first fetch
+What we're verifying: read-only v1 fetches real Keep notes and renders =data/keep.org= once the one-time gkeepapi + master-token setup is done. The code and 27 tests (12 Python + 15 ERT) are green; this is the live-credential step only Craig can run.
+- Install the client into the interpreter =cj/keep-python= uses: =pip install gkeepapi= (or pipx).
+- Obtain a Google master token (one-time, via gkeepapi's current login/gpsoauth flow against your account).
+- Set your email:
+#+begin_src emacs-lisp
+(setq cj/keep-email "you@gmail.com")
+#+end_src
+- Add the token to =~/.authinfo.gpg= (line: =machine google-keep login you@gmail.com password <master-token>=), then clear the auth cache so the daemon sees it:
+#+begin_src emacs-lisp
+(auth-source-forget-all-cached)
+#+end_src
+- Fetch (or press =C-c k r=):
+#+begin_src emacs-lisp
+(cj/keep-refresh)
+#+end_src
+- Open the result with =C-c k o=.
+Expected: =data/keep.org= lists your Keep notes, pinned first, each a header with title/body, labels as org tags, a property drawer (=:KEEP_ID:= / =:UPDATED:= / ...), and an "open in Keep" link. A missing piece (gkeepapi / token / auth) shows a clear =*Warnings*= message naming it, not a crash.
*** TODO theme-studio preview-locate discoverability read
What we're verifying: the locate hover/flash actually feels discoverable in a live frame — the subjective read the deterministic gates can't make.
- Open theme-studio in Chrome (=make theme-studio-open=, or open theme-studio.html).
@@ -378,6 +410,16 @@ What we're verifying: in dirvish, d now duplicates the file at point (delete-to-
Expected: d duplicates; D names the exact targets and only deletes on yes; the files are gone with no trash copy. If sudo needs a password that shell-command can't supply, flag it — the delete may need to route through a tty instead.
** PROJECT [#A] Theme-Studio Open Work
Parent grouping the open theme-studio / theming issues; close each child independently.
+*** TODO [#C] ansi-color dropdown plain, reuse info in the hover :feature:studio:
+Drop the parenthetical from the "ansi color" assignment-view dropdown label so it reads plain, and move the explanation to the hover instead: name the packages that reuse these colors (vterm / eshell / compilation / ghostel) and verify they actually do before stating it. From the roam inbox 2026-06-24.
+*** 2026-06-24 Wed @ 21:53:39 -0400 Editable face-list color grouping cancelled — subsumed by the gallery
+The roam-inbox ask was to default-group the editable nerd-icons face table by color family. Craig's call (2026-06-24): the gallery preview already clusters the icons by hue, which covers the underlying "see colors grouped" need, so grouping the 34-row editable table too isn't worth it. Cancelled.
+*** 2026-06-24 Wed @ 18:09:26 -0400 theme-studio tier-1 simplifications landed
+Behavior-preserving simplifications from the four-agent refactor/simplify assessment, all test-verified (full suite green). Landed: syncMockHeight + syncPkgHeight merged into syncPaneHeight(tableId, paneId); the dead generatorHues "manual" branch deleted (identical to fallback); locateInfoLine removed (fn + export + test, orphaned this session); the redundant pkgbody guard dropped (buildPkgTable self-guards); displayHex/displayName closures inlined; paintUI now calls worstCellHtml; generate.py's two nerd-icons loaders share _load_nerd_icons_artifact (sentinel keeps the null-file edge exact); face_coverage.classify rewritten with named locals (with a new characterization test). Two agent findings were wrong and skipped on verification: LOCATE_REG is live (read by previewSpan), and normalizePaletteEntryCore doesn't exist (hallucinated). Skipped on judgment: a RELEASED_BOX constant (mutable-dict aliasing hazard, only ~10 sites) and inlining apply_hover_box_default (its why-docstring earns the named function). Open for Craig: previewFaceAttrs (app-core.js) is test-only with a stale "the gate calls it" docstring — confirm delete vs keep.
+*** 2026-06-24 Wed @ 21:53:39 -0400 app.js split — controls.js extracted, remaining splits declined
+The highest-value extraction landed (controls.js, see below). Craig's call (2026-06-24): stop there — the remaining clusters (picker, locate, io, tables) are diminishing navigability gain for more churn, so they're declined. The token-at-position pattern is proven and documented if any one is ever wanted.
+**** 2026-06-24 Wed @ 19:16:47 -0400 Extracted the control factories to controls.js
+Cut the contiguous dropdown / detail-editor / expander cluster (the custom color dropdown state + closeColorDropdown + mkColorDropdown through mkExpander, 205 lines) from app.js into controls.js, spliced back at a CONTROLS_J token via generate.py. app.js dropped 927 to 721 lines. The token sits at the exact extraction point, so the assembled page is byte-identical (just relocated source) — full suite green with no gate changes. mkBoxControl (a lone factory elsewhere in app.js) stayed put; it can join controls.js later.
*** TODO [#A] theme-studio: consistent assignment-view table columns :feature:studio:next:
All view-assignment tables should use one consistent column set and order, whatever view is selected: element name (sortable), lock, fg, bg, style, box (with a side expansion showing the selected color, as in UI faces), contrast, inheritance, size, preview text. No other columns at this design stage. When a view's elements can't take a given section, raise a signal and disable that section for that view; the disabled state is the visual cue. From the roam inbox 2026-06-16.
*** TODO [#B] Route hardcoded theme colors through the theme :refactor:
@@ -477,8 +519,8 @@ Package faces model =inherit= explicitly, but UI faces currently expose only fg/
The calibre package preview has no elements to theme in the search list, and coloring switches to the string color on mismatched quotes. Investigate, then record a diagnosis and solution in this task before fixing. From the roam inbox 2026-06-15.
*** TODO [#C] theme-studio: break org-mode preview into grouped subsections :feature:studio:
Rather than cramming all org-mode preview into one pane, split into groups so each element is shown in a common, context-rich environment. From the roam inbox.
-*** TODO [#C] theme-studio: converter drops :inherit on UI faces :bug:studio:
-build-theme.el's UI tier passes inherit=nil to --attrs, so a UI face that relies only on its inherit field (no explicit fg/bg) loses the inheritance in the generated theme, while the studio preview shows the inherited color via resolveUiAttr. The package tier already emits :inherit; the UI tier should match. Surfaced while diagnosing why mode-line-inactive looked off in Emacs versus the preview (that case had explicit colors and turned out to be a stale deploy, but the inherit gap is real for any inherit-only UI face).
+*** 2026-06-24 Wed @ 22:30:00 -0400 converter :inherit on UI faces — verified already correct, not reproducible
+The reported bug (build-theme.el's UI tier dropping :inherit for inherit-only UI faces) does not reproduce in the current code. uiFaceBlank carries an inherit field, exportObj dumps the full UIMAP (inherit included), and build-theme/--attrs reads it. Direct test: a theme.json with ui face {inherit: mode-line, fg: null, bg: null} fed to build-theme/--ui-face-specs emits ((mode-line-inactive ((t (:inherit mode-line))))) — the :inherit survives. Closed as already-fixed / stale.
*** TODO [#C] theme-studio: elfeed ignores theme assignments :studio:studio:
The preview shows theme colors, but elfeed itself renders all-white with no variation. Note: this may be the shr-rendered entry/article view (elfeed-show), where color often comes from the document rather than the theme — confirm whether the symptom is in the search list or the article view. From the roam inbox.
*** VERIFY [#C] theme-studio face-consistency check :feature:studio:next:
@@ -1874,13 +1916,8 @@ Pitfalls:
- Do not accidentally re-enable UI/doc/sideline behavior that was explicitly
disabled for performance.
-***** TODO [#B] Add a startup smoke test for LSP config resolution :quick:solo:
-
-Keep this narrow. A useful test can require the LSP-related modules with mocked
-=use-package= side effects and assert that:
-- generic defaults are set in one place,
-- no duplicate hook entries are installed for the same mode,
-- =lsp-enable-remote= remains nil.
+***** 2026-06-25 Thu @ 01:53:48 -0400 Added the LSP config-resolution smoke test
+=tests/test-prog-lsp.el= (5 ERT tests) pins prog-lsp's load-time invariants: =lsp-enable-remote= stays nil (no auto-start on TRAMP files), the file-watch-ignore defaults live in one idempotent helper (=cj/lsp--add-file-watch-ignored-extras=), the eldoc provider is stripped from the global hook, and no mode holds a duplicate =lsp-deferred= entry. Tests the top-level =:init= + helper surface rather than the =:config= defaults, which defer to lsp-mode's own load under =make test= (the no-package-initialize constraint). Hit and fixed the lexical-binding special-var trap on =lsp-file-watch-ignored-directories= in the test.
**** TODO [#B] Gate tree-sitter grammar auto-install behind an explicit policy
@@ -2906,11 +2943,12 @@ The step-to-next-agent family (s-F9 and friends) should cycle to a running ai-te
:END:
Allow creating an ai-term backed by any of Claude, Codex, or a local LLM via ollama, with the backend chosen seamlessly at the start of the session. ai-term currently assumes Claude; generalize the launch path so the agent backend is a selectable parameter and switching between them at session start is frictionless. Routed here from the rulesets roam-inbox item "multiple agent source improvements" (its bullet 3 asked to send emacs this note); the item's other bullets — naming the agent so non-Claude agents aren't called "Claude", and tightening workflow wording for Codex's more literal reading — stay with rulesets.
-** TODO [#C] Compare terminal themeability: EAT vs vterm vs ghostel :feature:solo:next:
+** DONE [#C] Compare terminal themeability: EAT vs vterm vs ghostel :feature:solo:next:
+CLOSED: [2026-06-25 Thu]
:PROPERTIES:
:LAST_REVIEWED: 2026-06-22
:END:
-Research how completely each of EAT, vterm, and ghostel can be themed — in particular how far theme studio can theme each terminal and what it leaves out. Produce a comparison document, then review it with an eye to whether ai-term should move off ghostel (current) to EAT or vterm. Connects to the chime/emacs-wttrin/pearl face-exposure theme-studio thread. From the roam inbox.
+Researched 2026-06-24. All three expose the 16 ANSI colors as Emacs faces (=eat-term-color-*=, =vterm-color-*=, =ghostel-color-*=, each inheriting =ansi-color-*= / =term-color-*=). Ghostel is the most live-themeable: it alone registers an =enable-theme-functions= resync hook (repaints live buffers on a theme change) and exposes a dedicated =ghostel-default= face for the terminal's default fg/bg. EAT (pure elisp, where the faces are the real render source) and vterm (native, faces read at render) both expose themeable palettes but need a buffer reload to pick up a theme switch and give less default-fg/bg control. Outcome: Craig is moving F12 to EAT anyway, for pure-elisp face control and the fun of theming it — see "F12 pops EAT instead of ghostel" above, which carries the resync tradeoff knowingly.
** VERIFY [#C] Remove unused system-power keybindings :refactor:quick:next:
:PROPERTIES:
@@ -3740,8 +3778,10 @@ These may override useful defaults - review and pick better bindings:
:END:
Display slack.el message and thread buffers in a dedicated popup window (side or bottom) and reuse that one window instead of spawning a new window per buffer. Likely a =display-buffer-alist= rule (or popper integration) in =modules/slack-config.el=.
-** TODO [#D] Evaluate google-keep Emacs package :quick:
-From the roam inbox. Look at the google-keep Emacs package — worth adding for in-editor Keep, or does the existing google-keep MCP cover it? Triage / shortlist, not a commitment.
+** DOING [#C] google-keep in-editor integration — build, module-to-package :feature:
+v1 (read-only) implemented and tested (Phases 1-3): the gkeepapi Python bridge (=scripts/google-keep/keep-bridge.py=, 12 tests), the elisp core + =cj/keep-refresh= renderer with atomic writes and async make-process (=modules/google-keep-config.el=, 15 ERT tests), un-orphaned under a =C-c k= prefix, graceful warning when gkeepapi/token/auth is missing. The pure JSON-to-org core is kept extractable per the spec. Live fetch needs the one-time gkeepapi + master-token setup — see "Google Keep v1 live setup and first fetch" under Manual testing and validation.
+Next: v2 (read-write — create/edit back to Keep, with a staleness guard) per the spec, the immediate follow-on once the live read is confirmed. Later: list/checkbox rendering, package extraction.
+Spec: [[file:docs/specs/google-keep-emacs-integration-spec.org][google-keep-emacs-integration-spec.org]] (Ready, 2 review rounds; all five decisions resolved 2026-06-25).
** TODO [#D] Theme Studio nerd-icons vNext follow-ups :feature:
Deferred from [[file:docs/specs/theme-studio-nerd-icons-colors-spec.org][theme-studio-nerd-icons-colors-spec.org]]: extend the legend to
buffer-mode and command/symbol categories if the file set proves insufficient;