60 Commits
Author SHA1 Message Date
malkuthroandClaude Opus 5 090eb460eb fix(snapshot): boot drift check no longer false-positives on unsaved edits (#278)
* fix(snapshot): boot drift check no longer false-positives on unsaved edits (#276)

The #161 boot-time drift guard flagged any canonical difference between
the tracked snapshot file and the live /userdata+picks state — which is
also what ordinary "kept working after the last named save" looks like,
so nearly every session booted into a false "drifted" warning and
periodic autosaves were misrouted to _unsaved_autosave/.

detectBootDrift() is now three-way: a mismatch counts as drift only when
the live state's fingerprint also matches the persisted last-named-save
baseline (it claims to be saved, yet the file disagrees — pinning the
out-of-band change to the file side). Otherwise it reports the honest
"unsaved". First-session baseline seeding in koolook_sidebar.js moved
after the drift check so a freshly seeded baseline can't fabricate the
claims-saved condition.

Tests: two new cases (unsaved-edits, no-baseline); drift-expecting cases
now establish the claims-saved baseline; the states-match and
canonicalization tests baseline first and assert reason is absent so
they pin the match branch rather than the new early exit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(snapshot): gate first-session baseline seeding on a proven boot check (#276)

Review follow-up. Ordering the drift check before the seeding was not
enough: seeding still ran whenever the fingerprint key was absent, so a
session whose tracked-preset name outlived its baseline (upgrade from
before the key existed, or a persist that lost to localStorage quota)
baselined whatever the live state happened to be. That both showed
"saved" for state never saved to the named file and handed the NEXT boot
a live state claiming saved against a file it never matched — a fresh
false "drifted", i.e. #276 re-opened from the other side.

Seeding is now gated on bootCheckProvedAligned(), a pure predicate
exported from snapshot.js (the entry point imports ComfyUI's app.js and
can't be loaded headlessly — same split as extension_guard.js's
claimSidebarRegistration). Only a boot check that reached the
fingerprint comparison and matched authorizes seeding; null and
reason-carrying outcomes prove nothing.

Also: branch the !claimsSaved log so the no-baseline case stops claiming
"unsaved edits since the last named save" when there is no such save,
and refresh the _autosaveSubdir redirect rationale, which still framed
the live state as the corrupt side.

Tests: predicate truth table, an end-to-end blocking case carrying a
counterfactual leg that proves the gate is load-bearing (ungated seeding
does flag drift on the next boot), and the allow case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 12:23:49 +02:00
malkuthro 7b00a551d9 feat(publish): independent output type for published setups; fix(pipeline): auto-versioning re-runs each queue (#267)
Publish: Koolook_PublishOutput output_mode/input_switch/switch; setupSurface.app.outputSwitch; routers auto-bound as the output selector; unwired output types hidden; 'Same as input' offered only when every input type has a wired writer; result path follows the output switch. Pipeline: EasyAIPipeline IS_CHANGED keeps version=auto/next perpetually dirty so the vNNN disk scan re-runs every queue; literal versions stay cacheable.
2026-07-19 23:34:30 +02:00
961a95b9bd feat(ltx-director): reference-image identity — Ghost Mask + schedulable guide attention (#264)
* feat(ltx-director): Ghost Mask reference image (REF / character sheet)

Add a no-LoRA reference-image identity anchor to LTXDirector__koolook, the
way a WAN VACE reference works: attach reference image(s) and the model keeps
the character's face / mouth consistent even when keyframes show a closed (or
different) mouth than the audio-driven motion needs. Targets the mouth /
identity drift seen in the talking-goat lip-sync workflow.

Mechanism (Ghost Mask): the reference image(s) are appended as masked guide
frames *after* the clean video region, so the transformer attends to them for
identity but they never appear in the output; the trailing reference frames
are then sliced off the result.

- LTXDirector__koolook: new optional `reference_images` (IMAGE) +
  `reference_strength` inputs, and appended `clean_latent_frames` /
  `clean_pixel_frames` outputs. All additive/optional -> saved workflows
  unaffected. Refs are appended AFTER snap/ease so they are never grid-snapped
  or ease-expanded; the auto latent is grown to hold them. ltx_director.py is
  under ruff's forks/ exclude.
- reference_ghost.py (new, Koolook-original, pure stdlib): trailing-reference
  geometry (insert frames, grown latent length, slice bounds). Unit-tested.
- k_clean_latent_slice.py (new root node `CleanLatentSlice`): torch.narrow
  slice to drop the trailing reference frames (start=0,
  length=clean_latent_frames). torch imported lazily; clamp math unit-tested,
  slice path tested when real torch is present (skips under the CI stub).
- Registered the node; added to sync_to_dev RUNTIME_PATHS.
- Attribution: the Ghost Mask APPROACH is adapted (idea only, reimplemented)
  from CGlide/WhatDreamsCost-CSGlide (main_cs @ 21dd076, GPL-3.0, license
  verified 2026-06-24, GPL->GPL OK). THIRD_PARTY.md, forks_manifest.yaml,
  UPSTREAM_PIN.yaml, CHANGELOG updated.

Phase 1: Ghost Mask only. The LoRA-backed Licon MSR prefix mode and the
editor's character-slot UI are intentionally NOT ported.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(sidebar): action-led star footer ("Give a ★ on GitHub")

Reword the GitHub discovery footer from the passive "Help creators find
us on GitHub" to an action-led "Help creators find us! Give a ★ on
GitHub", with the star as the explicit ask. Render the sentence as one
inline text flow (not separate flex children) so the footer's `gap`
doesn't space out the words; the ★ glyph keeps its gold styling inline and
now carries role="img"/aria-label="star" so it is announced. Verified
rendering in an isolated harness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ltx-director): Ghost Mask broke motion in the append-and-crop pipeline

The upstream guide pipeline (LTXDirectorGuide + LTXDirectorCropGuides)
appends every guide_data image as an extra latent frame and crops them
back off via `exact_crop_frames = latent.shape[2] - initial_latent_length`.
The Ghost Mask code (modeled on CGlide's CleanLatentSlice flow) ALSO
pre-grew the video latent by n_refs, so initial_latent_length became
clean+n_refs: CropGuides then left n_refs frames uncropped AND the audio
latent stayed at the clean length, desyncing the A/V joint transformer and
breaking motion.

Fix: don't pre-grow the latent. References are added to guide_data exactly
like timeline keyframes and ride the same append-and-crop path — the guide
node appends them and CropGuides removes them, audio untouched. Auto latent
is sized to the clean length again. CleanLatentSlice/clean_latent_frames
remain available for bare pipelines that don't use LTXDirectorCropGuides.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(ltx): LTX Guide Reference Strength — per-stage reference boost

Adds a small Koolook node that rewrites the strength of ONLY the Ghost
Mask reference frames in guide_data (the trailing entries the Director now
tags with `reference_count`), leaving the keyframe pins untouched. Insert
it before a stage-2 refinement guide and set ~0.9 to lock identity during
the low-denoise pass without disturbing the motion set in stage 1 — the
deterministic form of "more reference in stage 2".

Chosen over vendoring the 600-line upstream LTXDirectorGuide (which would
force a node-ID rename + graph rewire): this is one small node inserted on
the stage-2 guide_data wire. The sigma-ramp alternative (ramp reference up
at low sigma) is deferred — the reference hook is noise_mask + a core-model
attention term, so it needs a live-model spike, not a clean static patch.

- Director tags guide_data["reference_count"] = n_refs.
- k_ltx_guide_reference_strength.py: pure strength rewrite (absolute /
  multiply, clamped), unit-tested; registered; added to sync paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(ltx): LTX Reference Bind Schedule — sigma-ramp the reference attention

Confirmed against core (comfy/ldm/lightricks/model.py): the reference's
guide attention strength is turned into a log-space additive self-attention
bias in `_build_guide_self_attention_mask`, which is rebuilt EVERY denoise
step and explicitly supports strength > 1.0 to amplify. That's a clean
per-step hook — so the "inverse sigma curve" idea is buildable, not just a
stage knob.

New node mirrors LTXAVBindSchedule: a model_function_wrapper captures the
per-step sigma and stashes a gain in transformer_options; an object-patch on
diffusion_model._build_guide_self_attention_mask multiplies the strength of
ONLY the trailing `num_references` guide entries (references are appended
after keyframes) by a smoothstep ramp — neutral early (motion forms), rising
to peak_strength late (identity locks during refinement). Keyframe pins and
noise-mask freezing untouched; orthogonal to reference_strength. One pass, no
re-noise, no LoRA.

- ref_gain ramp + _boosted_entries unit-tested (11 tests); model wiring
  validated by live render.
- Registered; added to sync paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ltx): mark Reference Bind Schedule inert without an IC-LoRA

Live diagnostic on the AV model showed the builder is reached and the sigma
gain flows in, but resolved_guide_entries=None despite 3276 guide tokens.
Root cause: LTXDirectorGuide only records guide_attention_entries when an
IC-LoRA is active (`if is_lora_active:` gates _append_guide_attention_entry).
With no LoRA there is no per-guide attention bias to scale, so the
attention-ramp lever does not exist in the Ghost Mask path — it can only
attenuate/amplify entries that an IC-LoRA created.

Rather than silently no-op, the node now logs a clear INERT warning when the
entry list is empty, and the docstring/DESCRIPTION/CHANGELOG state the
IC-LoRA requirement. The node remains correct for the IC-LoRA path (its
partner). No-LoRA reference strengthening stays on the noise-mask lever
(reference_strength / LTX Guide Reference Strength).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(ltx): Reference Bind Schedule works without an IC-LoRA (fabricates entries)

The attention-bias identity channel (_build_guide_self_attention_mask) is
base-model functionality, but LTXDirectorGuide only populates the per-guide
entries when an IC-LoRA is active, so the node was inert in the no-LoRA Ghost
Mask path. Now, when entries are missing, the node FABRICATES them for the
trailing reference tokens: it splits num_guide_tokens across
(num_keyframes + num_references) guide frames and assigns the sigma-ramped
strength to the reference share, then hands off to the core builder. This
gives the reference the same amplified-attention channel an IC-LoRA would use
— inside the Director, no guide-node vendor, no graph rewire (the node is
already in the user's setup). When an IC-LoRA IS active it scales the real
entries as before.

- New `fabricate_reference_entries(num_guide_tokens, num_references,
  num_keyframes, strength)` (pure, unit-tested) + `num_keyframes` input.
- Wrapper now does scale-or-fabricate; clearer one-shot diagnostics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ltx): keep num_keyframes last so pre-existing RefBind widgets stay aligned

num_keyframes was inserted mid widget-order, which shifted ramp_start/ramp_end
for workflows saved before it existed. Move it to the end of the optional
inputs so saved [num_references, peak_strength, ramp_start, ramp_end] values
keep mapping correctly; num_keyframes just defaults to 0 on reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(test): drop unused pytest import (ruff F401)

The guide-reference-strength test uses plain asserts; the stray
`import pytest` tripped ruff F401 and reddened the Lint & Smoke CI job.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ltx-director): default keyframe guidance 0.8 (smooth), not 1.0

The timeline editor auto-built the guide_strength string from each clip's
per-segment guideStrength, filling 1.0 for any clip left unset (and the
Python fallback matched). 1.0 is a hard pin, so a clip you forgot to lower
snapped instead of transitioning smoothly. Unset clips now default to 0.8
in the editor (display + serialization) and the node; explicit per-clip
values still override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(ltx): Keyframe Soften Schedule — smooth motion early, sharp poses late

New model-patcher node LTXKeyframeSoftenSchedule: sigma-schedules the LTX
keyframe denoise-mask pins so the model forms smooth motion between poses
early (pins loosened at high sigma) and resolves sharp poses late (pins
restored by a crossover). Uses the sampler's per-step denoise_mask_function
hook — no IC-LoRA required — and adds no guide frames (so it can't fabricate
extra poses). Audio-safe: only the video half of the NestedTensor A/V mask is
softened; the audio mask is untouched, so lip-sync is unaffected.

Two normalized 0-1 widgets map onto the sensitive useful range: max_soften
(ease, ->0-0.20) and crossover (invention window, ->0-0.05). Pure soften_gain
ramp unit-tested (11 tests); model wiring render-validated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(review): chain denoise hook, optional_latent clean-length, audio test, CGlide manifest

Addresses PR #264 review findings:

- MEDIUM (Soften node): chain a pre-existing denoise_mask_function instead of
  clobbering it (mirrors the sibling LTX patchers' wrapper-chaining), so the
  node composes with anything else that sets that hook.
- HIGH #2 (Director): clean_pixel_frames/clean_latent_frames now derive from the
  ACTUAL optional_latent shape when one is connected, so Ghost-Mask reference
  geometry and the reported counts follow the real latent instead of the
  timeline duration (with a safe fallback + warning).
- LOW (Soften node): reconcile docstring (crossover is sigma-progress, maps to
  internal 0-0.05), one-time warn on the sigma-read fallback, and extract a
  testable soften_denoise_mask() helper.
- LOW: lock the audio-safety guarantee with NestedTensor-stub tests — video
  mask softened, audio mask untouched, part-ordering asserted.
- #6: add a formal forks_manifest.yaml entry for the CSGlide Ghost Mask
  idea-only adaptation (was prose-only in another entry's notes; THIRD_PARTY.md
  already carries the full attribution).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: AI Machine <ai.machine@x.baconcph.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:30:46 +02:00
b9a896041b feat(sidebar): add GitHub discovery footer nudge (#261)
Adds a discreet line directly below the update-available footer in the Kforge Labs sidebar: "Help creators find us on GitHub". Gated on the same showUpdateFooter flag so it shows in the live panel but stays out of the read-only A/B compare columns. Ko-fi/funding deliberately stays out of the sidebar (repo-only, tracked as a follow-up).

Co-authored-by: Cristian Predut <cristian.predut@x.baconcph.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:06:24 +02:00
malkuthro bda4df576a Release v0.4.1 (#250)
* Release v0.4.0: update footer notice

* Release v0.4.1
2026-06-19 01:38:40 +02:00
6f158c9020 fix(nodes): Easy_LoadVideo empty-branch degrade (bundles #243 publish-tab cleanup) (#244)
* docs(setups): add setup runner quickstart runbook

Standalone reference for opening the published-setup mockup frontend
(setup runner simulator): the `<comfy-address>/koolook/setup_runner_simulator.html`
URL pattern, the live-registry vs setup-file (inspect-only) load paths,
and demo mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sidebar): close throwaway workflow tabs left by publish capture

Publishing a setup captures ComfyUI's API-format prompt by loading the
graph onto the canvas (graphToPrompt) and reloading the prior canvas.
In current Comfy each loadGraphData spawns an "Unsaved Workflow" tab, so
every publish left two stray tabs behind.

captureWorkflowApiPrompt now snapshots the open workflows before capture
and, afterward, re-activates the user's original tab and closes whatever
the capture opened, via app.extensionManager.workflow. All of it is
guarded and best-effort: the workflow-store API is Comfy-version
specific, so if it is absent capture still succeeds and behavior falls
back to today (no crash, no broken publish).

Covered by a source-pattern test. Needs live verification against the
1.45 frontend (dev-sync) to confirm the closeWorkflow API shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(nodes): Easy_LoadVideo degrades to empty on an unselected folder branch

When an upstream mode switch routes a path into Easy_LoadVideo, both the
EXR sequence loader and the video loader execute eagerly. On the EXR
branch the video loader was handed a folder (no filename) and raised
ValueError, crashing the whole prompt -- even though that branch is
discarded downstream. Lenient sequence loaders just ignore a wrong-type
path; the video loader was the only one that hard-failed.

_compose_input_video_path now returns EMPTY_BRANCH_SENTINEL instead of
raising when input_path resolves to an existing directory. load_video
turns that into a correctly-shaped empty result (IMAGE zeros,
frame_count 0, empty AUDIO, empty video_info) matching VHS_LoadVideoPath
returns, so the dead branch no longer crashes the prompt. VALIDATE_INPUTS
and IS_CHANGED handle the sentinel too. A path that does not exist is
still a genuine mistake and keeps raising.

torch is imported lazily in the empty-result builder so the path logic
stays importable/testable without torch.

Covered by unit tests for the sentinel (absolute + relative dirs), the
still-raises case, and a load_video integration test that asserts the
empty result and that the loader never runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(setups): expose prompt as an always-on field in the publish frontend

The publish surface modelled `prompt` as a 4th source mode (mutually
exclusive with EXR/QT/Img) and hid it (visible:false), so a published
setup couldn't offer "pick a source AND describe the shot". This makes
prompt a standalone, always-on input.

- published_surface.js: prompt becomes visible + standalone + multiline,
  rendered independently of the source switch; the author's prompt-widget
  text is surfaced as a `placeholder` hint (submitted default is empty so
  an untouched hint is never sent), with a `help` line. The switch
  excludes standalone fields from its source options.
- setup_runner_simulator.js: renders always-on standalone inputs alongside
  the selected source (textarea + placeholder + help), includes them in the
  run payload, and updates the demo setup.
- setup_runner_simulator.html: .field-help styling.
- contract doc: documents the always-on prompt behaviour.
- launch.json: portability fix (hardcoded C:\Python313 -> python).

Contract test updated + a new test asserting prompt is always-on, carries
the placeholder, and is excluded from the switch source options (runs on
CI where Node is available). Behaviour verified in the runner simulator
(demo mode) in a browser: textarea + hint, EXR/QT/Img-only switch, prompt
stays visible across source changes, payload includes the typed prompt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(setups): mirror always-on prompt field in the Python publish inference

The publish surface is inferred server-side in Python (koolook_setups.py
_infer_app_surface, called by publishSetup), which overrides the client.
The earlier change only updated the JS (published_surface.js), so
re-publishing still produced the old surface with a hidden prompt.

Mirror the JS here: prompt is a standalone, always-on input (visible +
multiline) with the author's prompt-widget text as a placeholder hint and
an empty submitted default; it is excluded from the source switch options.
Keeps koolook_setups.py and published_surface.js in sync.

Registry test updated to the new shape. Server + publish-contract suites
green (115); full non-sidebar suite green (530).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(setups): keep the runner simulator polling through long renders

The poller used a fixed 30s total budget, so a multi-minute render (e.g.
428s) timed out mid-run with "Timed out waiting for run" even though
ComfyUI was still rendering and the run later landed as succeeded.

timeoutMs is now a "no-progress" window: while ComfyUI keeps reporting the
run as queued/running, the deadline is pushed forward, so a long-but-active
render never spuriously times out; only a stalled/vanished run gives up.
Default raised 30s -> 120s. The run status line shows elapsed seconds while
active so a long wait visibly ticks instead of looking frozen.

Happy path (queued -> running -> succeeded) unchanged; verified in the
runner simulator (demo mode) in a browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(setups): serve simulator assets with no-store so dev-syncs take effect

The runner-simulator HTML/JS route returned no cache headers, so browsers
cached the assets and kept running stale JS after a dev-sync -- e.g. the
old 30s poller persisted client-side even though the server was serving
the long-render fix, which looked like "the fix didn't land". Serve these
dev-synced assets with Cache-Control: no-store so a normal reload always
picks up the current code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(setups): add live run/poll smoke check for the published-setup API

tools/check_setup_run.py queues a published setup through the same routes
the runner simulator uses and polls the run endpoint with the same
deadline-extension logic, printing the status timeline. Lets us self-verify
end-to-end run tracking against a live ComfyUI (e.g. "does a long render
reach succeeded?") without driving the browser UI.

Used it to confirm the run/poll server flow is correct (terminal status
detected in ~2s; run-000001 reported succeeded), isolating the simulator's
earlier "Timed out" to stale browser cache rather than a server bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(nodes): Koolook_NextVersion auto-detects the next output version

Adds filesystem-aware version auto-detection so a re-run lands on a fresh
version instead of failing with "Output file already exists and overwrite
is disabled" (the real EasyAIPipeline failure seen on the RMGB setup).

- koolook_versioning.next_version_token(directory, name, ...): scans for
  existing <name>_vNNN files AND sequence-folders (handles name_v002.png,
  name_v002.0001.exr, name_v003/), returns highest+1 zero-padded; missing/
  empty dir -> start (v001); exact base-name match so other shots never
  bump this one; empty name matches bare vNNN; configurable prefix/padding.
- Koolook_NextVersion node (Koolook/Publish): folder + name -> next version
  token. Wire once into both the EXR and video version inputs so branches
  stay in sync. IS_CHANGED returns NaN to re-scan every run.

Tested: helper edge cases + node registration/run. Preflight clean (20
node IDs). Full non-sidebar suite green (539).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(nodes): 'auto' version keyword for EasyAIPipeline + Easy_VideoCombine

No new node or wiring needed: type 'auto' (or 'next') in the version field
and the node scans its own output folder for existing <name>_vNNN entries
and writes the next free version -- fixing "Output file already exists and
overwrite is disabled" without manual version bumps.

- koolook_versioning.is_auto_version(): detects the auto/next keyword.
- EasyAIPipeline (build_pipeline_outputs): on auto, scans the version-folder
  parent (base, or base/shot/method when subfolders are on) for vNNN dirs and
  picks the next; version computed after path segments so the scan dir is known.
- Easy_VideoCombine: on auto, resolves the token after the output dir + name
  root are composed, scanning that dir for <name>_vNNN outputs.
- Non-auto/typed/wired tokens and disable_versioning are unchanged.

Tests: is_auto_version keywords + EasyAIPipeline auto (v003 after v001/v002,
v001 first run). Full non-sidebar suite green (542), preflight clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(nodes): drop redundant Koolook_NextVersion node

The 'auto' version keyword on EasyAIPipeline + Easy_VideoCombine covers
auto-detection without any new node or wiring, so the standalone
Koolook_NextVersion node is redundant. Remove it (and its node test) to keep
the node list clean; the shared next_version_token helper stays (the 'auto'
keyword uses it).

Node-ID count back to 19; full non-sidebar suite green (541), preflight clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tools): satisfy security gate for setup smoke check

* fix(pr244): address verified review findings

* fix(video): fail if relative auto-version scan root is unavailable

---------

Co-authored-by: Cristian Predut <cristian.predut@x.baconcph.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: AI Machine <ai.machine@x.baconcph.com>
2026-06-19 00:10:59 +02:00
malkuthroandClaude Opus 4.8 ae2480eee9 feat(setups): publish success feedback + published-setup discoverability (#227) (#239)
* feat(setups): publish success feedback + published-setup discoverability (#227)

Post-publish feedback (issue #227 Part 1):
- POST /koolook/api/setups now returns the on-disk storagePath
- new POST /koolook/api/setups/reveal opens the published-setups folder
  (shared _open_dir_in_file_manager helper; the snapshot reveal route was
  refactored onto it, behaviour-identical)
- the publish modal renders a success card reusing the snapshot Save dialog's
  "Saved to ... Open folder" language (Open folder / Copy path / Close)
  instead of closing silently; a draft publish still gets a confirmation

Published discoverability (reuses the tag system, no new schema):
- publishing auto-tags the source workflow `published` -> a "published" badge
  in the Workflows tree + the Tags-section "published" pool
- a new "P" toggle in the sidebar Tools row prunes the Workflows tree to
  published-only, folders force-expanded, so setups show in their origin
  structure; toggling off restores the full tree
- workflowHasTag() shared helper (unit-tested)

Deferred, still open on #227: separate destination/library setting (Part 2),
relocating the storage folder beside the snapshot library, and clearing the
filter when entering Compare mode.

Tests: +server (storagePath, reveal route happy + 404), +client (reveal
client, buildPublishSuccessView view-model), +sidebar (workflowHasTag).
Full suite: 610 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(setups): store published setups beside the snapshot library (#227)

- the published-setup registry path now derives from the configured snapshot
  library (`_configured_dir().parent / koolook-published-setups/setups.json`)
  instead of a fixed user-dir folder, so it follows libraryPath /
  KFORGELABS_PRESETS. The default config resolves to the same path as before
  (target == legacy), so existing default installs are unchanged.
- non-destructive one-time migration: copy a legacy user-dir registry to the
  new location when the new one is absent, leaving the original in place.
- sidebar: clear the "P" published filter when entering Compare mode so it
  doesn't render both compare columns filtered with no toggle to undo it.

Tests: +tests/server/test_published_setups_relocation.py (path beside library;
migration copy / no-op-when-present / no-op-when-no-legacy). Full suite: 614
passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(tests): remove unused pathlib.Path import (ruff F401)

The relocation test only uses the `tmp_path` fixture, not `Path` directly, so
`from pathlib import Path` was unused and failed the Lint & Smoke (ruff) gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(setups): address PR #239 review — migration masking + tag persist

- HIGH: a failed legacy->new migration no longer masks real setups with the
  bundled sample. The default registry keeps the legacy file as its primary
  storage when migration could not produce the new file, so stranded data
  stays visible. (+2 tests: keeps-legacy-on-copy-failure / uses-new-on-success)
- MEDIUM/LOW: the publish flow now awaits the `published`-tag persist so the
  success card reflects a committed tag (persist failures already surface via
  persistMutation's critical toast), and warns when the source workflow could
  not be tagged (vanished mid-publish) rather than on a harmless republish.

Full suite: 616 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(setups): harden migration against partial/corrupt relocation target

Addresses the remaining HIGH from the PR #239 consolidated re-review: a
zero-byte/partial/corrupt setups.json at the relocated path could shadow the
intact legacy registry (existence was treated as proof of migration).

- migration now copies via temp file + atomic os.replace (the same pattern
  FileSetupStorage.save_setups uses), so an interrupted copy can never leave
  a partial setups.json at the target; failed-copy temp files are cleaned up.
- primary selection is corruption-aware: when the relocated file exists but
  does not parse as a setups document while the legacy file is intact, the
  registry keeps serving the legacy file and logs a loud diagnostic. The
  unreadable file is left untouched (never overwritten) as evidence. A valid
  relocated file stays authoritative; if both are corrupt, the relocated path
  stays primary so storage diagnostics point at the current location.

Tests (+4): corrupt-target-prefers-intact-legacy (file left untouched),
valid-target-stays-authoritative, both-corrupt-keeps-target, and
interrupted-copy-leaves-no-partial-target-and-no-tmp-litter.

Full suite: 620 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:54:12 +02:00
malkuthro c3c34f0171 Capture API prompt when publishing setups (#233)
* feat(sidebar): capture API prompt when publishing setups

* feat(setups): preview and load canonical setup records

* fix(setups): close publish review gaps
2026-06-08 20:28:35 +02:00
malkuthro 85206ab321 feat(setups): add external runner simulator (#231)
* feat(setups): add external runner simulator

* feat(setups): render app-surface simulator form

* docs(setups): capture publish-to-run frontend plan

* docs(setups): preserve sidebar inventory breadcrumbs

* fix(setups): address runner simulator review findings

* fix(setups): validate simulator run payload shape
2026-06-08 08:30:10 +02:00
malkuthro 3b35e6a195 feat(setups): infer setup surface from workflow groups (#222)
* feat(setups): infer setup surface from workflow groups

* fix(setups): validate inferred setup surfaces on read

* fix(setups): reject boolean setup geometry
2026-06-07 14:45:16 +02:00
malkuthro b4314f3b3d feat(sidebar): preserve workflow groups in saved selections (#220) 2026-06-07 12:41:14 +02:00
malkuthro 16023662e6 feat(setups): publish sidebar workflows (#216) 2026-06-07 11:07:52 +02:00
malkuthroandClaude Opus 4.8 38d1f8425d feat(snapshot): Compare Phase 2 — bidirectional path-preserving copy + folder merge + diff filter (#197) (#202)
* feat(snapshot): Compare Phase 2 — bidirectional path-preserving copy + folder merge + diff filter (#197)

Makes the read-only A/B Compare view (live kit A ↔ loaded snapshot B) fully
actionable, in both directions, with orientation signage and a diff browser.

Copy. Right-click a node/workflow on the read-only SOURCE side → "Copy to
<target>". Path-preserving: items land at the SAME folder path they came
from (new copyWorkflowIntoStore auto-creates/merges dirs, deep-clones the
graph, applies a skip-identical / keep-both collision policy; tags + module
ride along). Right-click a folder → "Copy folder (with contents)" bulk-copies
everything under it (the Workflows root copies the whole tree — merging two
snapshots) with an added/kept-both/already-there summary. Archived versions
are excluded from the diff and from bulk copy.

Both directions. B→A writes the live kit via persistMutation (Save round-
trips it). A→B writes back into the snapshot FILE on disk (writePreset),
targeting the exact file/dir it was loaded from (autosave dir threaded
through onChoose). The A→B path is serialized + epoch-guarded: it re-clones
the latest snapshot inside the queued task and only swaps the in-memory
snapshot in after a successful write while the same Compare session is open
(no resurrecting a closed view, no concurrent-copy lost update).

Orientation + diff browser. Each column shows a SOURCE/TARGET footer stripe;
the swap flips which side is the editable target and moves the read-only
guard with it. The bottom green/red legend doubles as filters (new/modified)
that collapse the SOURCE panel to just those items and back. The highlight,
counts, and filter all follow the source side and mean "in the source, not
the target" (the copy candidates).

The two panels keep independent folder-expansion maps so a copy's re-render
never collapses the other side; copies open the destination folder (not the
Archive child) so you can see where they landed.

Non-destructive throughout: no applySnapshot on the Compare path; the read-
only side only writes through the explicit copy affordance; graphs are deep-
cloned so neither store aliases the other.

Tests: copy-engine behaviour (path create/merge, skip/keep-both, deep-clone,
bulk folder, archived exclusion) + structural invariants (read-only guard,
bidirectional additive copy, path-preserving, serialized/epoch-guarded
write-back, source-following diff, panel-isolated expansion, folder pull-in).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(snapshot): guard the Compare copy engine against prototype pollution (#197, #203)

copyWorkflowIntoStore writes destination keys from user-controlled workflow
names / directory segments (a loaded snapshot can be hand-crafted), so reject
__proto__ / constructor / prototype via the same isSafeObjectKey() guard the
other name-keyed store mutators adopted in #203. copyFolderIntoStore inherits
the guard through it. Added after rebasing onto main so the new merge surface
matches the hardened sinks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(snapshot): Compare A→B is an in-memory working copy with an explicit Save (#197)

A→B no longer writes to disk per copy. The loaded snapshot (B) is treated as
an in-memory working copy: copying into it mutates `compareSnapshot` and marks
it dirty — no writePreset, so a rotating autosave (which "Load latest" can
hand you) is never a write target. The user Saves explicitly via a footer
button: writePreset to a NAMED snapshot in the default library dir, pre-filled
with the shadowed/loaded name (compareDefaultSaveName strips the `_autosave`
suffix to reach the named parent). Exiting Compare with unsaved edits prompts
to discard first.

Each column footer now has two lines: line 1 = SOURCE/TARGET + name; line 2 =
save behavior — "Live — changes auto-save" for your kit, and for the snapshot
either "Read-only file", "edits need Save", or "● Not saved" + a Save button
(shown whenever the snapshot is dirty, so it survives a swap). The word "live"
disappears from the UI; the footer carries the meaning.

Removes the per-copy persistSnapshotEdit + compareEpoch + snapshotWriteChain
machinery (no longer needed — there's a single explicit Save). B→A is
unchanged (auto-persists into the live kit). Non-destructive guarantees hold:
no applySnapshot; sources are clones; the snapshot panel stays read-only apart
from the pull-in. Fresh-eyes review: no Critical/Important findings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(snapshot): Compare bottom = two separate stripes; clean autosave name (#197)

Per maintainer feedback, split the bottom into two distinct stripes instead
of cramming save state into the SOURCE/TARGET footers:
  - Stripe 1: the SOURCE/TARGET footer under each column — orientation only.
  - Stripe 2: a dedicated, full-width SAVE stripe with the snapshot's save
    state, instructions, the dirty indicator, and the Save button.

Also fix the long autosave name: an autosave-loaded target showed the raw
`Foo_autosave/pre_load_...` path in the header and footer. compareSnapName()
now derives a clean name (the named parent + " · autosave"); the Save button
reads "Save as file…" and still pre-fills the named parent so A->B merges
land in a named file, never the rotating autosave.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(snapshot): guard Compare snapshot Save against silent overwrite (#197)

PR #202 review (data-safety): saveCompareSnapshot wrote `writePreset(name, …)`
with no existence check and no overwrite confirm. Since the Save name is
pre-filled with the snapshot's own name and the server route ends in an atomic
os.replace, accepting the default could silently overwrite a DIFFERENT
same-named snapshot file. Now mirrors the live Save flow (modals.js
promptForName): sanitizeName the input, presetExists() → abort on null
(library unreachable) → confirm on true (Overwrite?) → write only on false.
Adds a `compareSaving` in-flight guard so rapid Save clicks can't fire
overlapping writePreset POSTs.

Also dedup: copyWorkflowIntoStore's collision check now reuses the existing
`normalizedStoresEqual` instead of the byte-identical local `graphsEqual`
helper (removed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:26:26 +02:00
malkuthro 7f8c09ad29 feat(sidebar): clean up workflow archives (#201) 2026-06-02 22:25:09 +02:00
malkuthroandClaude Opus 4.8 f7e0666cc0 fix(sidebar): guard name-keyed store mutators against prototype pollution (#203)
CodeQL js/prototype-polluting-assignment (alert #1, medium) flagged
`dir.workflows[wfName].archived = true` in archiveWorkflow: a "__proto__"
name resolves to Object.prototype, passes the truthy existence check, and
the assignment pollutes the prototype process-wide. The same shape exists
across every exported mutator that uses an untrusted directory/workflow
name as an object key.

Add a shared isSafeObjectKey() denylist guard (__proto__, constructor,
prototype) with an early `return false` at all 12 keyed-mutation sinks:
add/rename/delete/move Directory, save/archive/unarchive/rename/delete/move
Workflow, and add/removeTag. Read-only accessors and clearArchive (which
derives names from own entries) are left untouched — reads don't pollute.

Supersedes the Copilot autofix draft #167 (extends it from a partial set
to every sink). Validated with `node --check`; repo has no JS test runner.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:33:27 +02:00
8058e17456 fix(snapshot): paired drift-corruption fix — duplicate-install guard + boot-time mismatch (#161, #162) (#186)
* fix(snapshot): prevent drift corruption from duplicate installs + boot-time mismatch (#161, #162)

Closes #162 and #161 as the paired fix the issues describe — both touch
the same data-loss surface and verify against the same dev-sync rig.

installs at import time, log critical, register nothing in the
alphabetical loser; client-side `window` sentinel as belt-and-suspenders;
align .env.example / dev-iteration-loop / sync_to_dev / README at the
Registry-derived `koolook/` path.

compare against live /userdata + picks, set a drift flag on mismatch;
gate periodic + pre-load autosave routing to `_unsaved_autosave/` while
drifted; promote status pill to "drifted" precedence over "saved" with a
recovery tooltip; auto-clear on the next markStateSaved (Save / Load).

26 new tests (14 install guard, 4 sidebar sentinel, 8 boot drift) —
316 total passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(snapshot): apply review fixes — canonical fingerprint + early loser exit + drift timestamp

Review-driven follow-ups on PR #186:

1. **Symmetrize file-side fingerprint (MEDIUM).** `detectBootDrift`
   now uses a new `_canonicalStateFingerprint()` that normalizes the
   workflows tree through `normalizeWorkflowsStore` AND deep-sorts keys
   via `sortJsonValue`. Without this, hand-edited / cross-machine /
   pre-v0.3 files would false-positive as drifted on first boot
   (e.g. `archived: "false"` string vs `false` bool, missing
   `directories: {}`, key insertion-order asymmetry from the
   `{...wf, archived, module, tags}` spread). `_computeFingerprint`
   stays unchanged so the localStorage save baseline contract holds
   for existing users. New regression test verifies a shape-only diff
   does NOT trigger drift.

2. **Gate fork imports on `_is_winning_install` (LOW).** Non-winning
   duplicate installs now short-circuit before the heavy torch / PIL /
   diffusers / aiohttp imports, exiting in ~5 LoC instead of paying
   full import cost just to discard the mappings.

3. **Drift timestamp uses `driftDetectedAt`, not stale
   `lastNamedSaveAt` (LOW).** The localStorage save baseline could be
   from a prior session whose live state was already corrupt —
   surfacing it as "Last named save" in the drift tooltip would
   mislead. Replaced with the session-noticed-at timestamp from
   `_bootDriftDiagnostics`.

352 tests passing (316 prior + 26 from PR #186 + 1 new normalize-
symmetry case).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(install-guard): fail safe on unreadable / non-UTF-8 siblings (#162)

Re-review follow-up. The import-time duplicate-install scan could crash
the entire plugin load (fail-silent — the alphabetical winner vanished
too, not just the loser) on two unguarded paths:

- detect_duplicate_koolook_installs: the per-entry is_dir()/is_file()
  over custom_nodes/ siblings was unguarded, so a non-traversable
  sibling (restrictive perms, broken mount, a special dir) raised
  PermissionError up into __init__.py. No duplicate even required.
  Now caught per-entry and skipped.
- read_pyproject_version: read_text(encoding="utf-8") raises
  UnicodeDecodeError (a ValueError, NOT OSError) on a non-UTF-8 /
  binary pyproject.toml, escaping the `except OSError`. Now degrades
  to "?" via `except (OSError, ValueError)`.
- __init__.py: wrapped the guard call site as a final backstop — any
  unforeseen guard failure now degrades to register-normally rather
  than aborting the import.

Both paths reproduced before the fix; the first surfaced while running
the suite from a /tmp checkout whose parent held a root-owned dir.

3 regression tests added (non-traversable sibling skipped; non-UTF-8
pyproject -> "?"; report survives a non-UTF-8 sibling). Full suite:
452 passed (449 + 3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: kflame99 <261674183+kflame99@users.noreply.github.com>
2026-06-02 18:44:57 +02:00
malkuthro b5b46d7fcc feat(sidebar): Snapshot Compare — read-only A/B side-by-side with diff tint (#181) (#183)
Phase 1 of #181: read-only side-by-side Compare. A/B toggle renders the existing sidebar twice — live working copy (A) + a loaded snapshot (B), B labeled with its own name + diff-tinted (green=only in comparison, red=same name/graph differs ignoring savedAt). Non-destructive: read-only capture guard on B, onChoose chooser never calls applySnapshot. Phase 2 (pull-in + A<->B swap) tracked in #197.
2026-06-02 08:22:15 +02:00
malkuthroandkflame99 d0fb000239 fix(snapshot): show disk-renamed snapshot names (#174)
* fix(snapshot): show disk-renamed snapshot names

* fix: address kflame99 review feedback (#174)

Split the autosave embedded-name guard into its own test and make the root snapshot filename assertion whitespace-tolerant.

Co-Authored-By: kflame99 <kflame99@users.noreply.github.com>

---------

Co-authored-by: kflame99 <kflame99@users.noreply.github.com>
2026-05-22 22:43:26 +02:00
malkuthro 0fea0a6a70 fix(sidebar): keep archive collapsed during search (#165) 2026-05-18 22:20:21 +02:00
malkuthro e3f94cca6b fix(sidebar): reuse draft id for workflow loads (#166) 2026-05-18 11:43:55 +02:00
malkuthro 96d4b51d7b Release v0.3.5 (#160)
* Release v0.3.5 prep

* fix(sidebar): bypass stale browser cache for snapshot state

* test(sidebar): guard no-store snapshot fetches
2026-05-18 00:54:49 +02:00
malkuthro 1c1c870439 fix(sidebar): keep loaded workflows untitled in Comfy (#157) 2026-05-17 22:04:38 +02:00
malkuthro bc7ec09971 Fix sidebar workflow persistence recovery (#155)
* fix(sidebar): recover workflow saves after fallback

* fix(sidebar): merge workflow fallback recovery

* fix(sidebar): clear redundant workflow fallback
2026-05-17 16:30:11 +02:00
malkuthro 55dab39c9b Trim workflow context menu move list (#153) 2026-05-16 23:22:23 +02:00
malkuthro c7961998aa feat(snapshot): refine recovery choices and snapshot dialog styling (#146)
* Refine snapshot load dialog recovery UI

* Make snapshot load recovery choices explicit

* Align snapshot picker styling

* Respect Comfy theme variables in snapshot dialogs

* Document superseded snapshot recovery design

* Fix load recovery selection races

* Prevent duplicate snapshot dialog actions

* Harden snapshot recovery UI fallbacks
2026-05-15 10:35:13 +02:00
malkuthroandClaude Opus 4.7 8e489ad37a feat(snapshot): redesigned Save/Load dialogs + folder picker (#137) (#144)
* feat(snapshot): row-augment max(periodic, pre_load_*) + pytest infra (#137)

Server-side preparation for the snapshot dialogs redesign. The Load
dialog's "newer auto-save available" row flag now reflects the freshest
recovery file regardless of which mechanism wrote it — before this we
only stat'd ``periodic.json``, missing cases where the user's most
recent Load wrote a fresher ``pre_load_*.json`` snapshot inside the
same autosave subfolder.

Extracts ``_latest_autosave_mtime(autosave_dir, named_mtime)`` from the
inline block in ``list_presets`` so the scan is unit-testable. The
helper walks ``periodic.json`` and every ``pre_load_*.json`` once,
returns the max mtime if strictly newer than the named file, else
``None``. Same shape as before, broader source set.

Introduces pytest as a first-class test runner:

  * ``tests/server/test_listing_autosave_mtime.py`` — 5 row-augment
    test cases (periodic-only, pre_load-only, max-of-both, no-newer,
    missing-dir).
  * ``[tool.pytest.ini_options]`` in ``pyproject.toml`` scopes
    discovery to ``tests/`` and uses ``importmode=importlib``.
  * ``[project.optional-dependencies] test = [...]`` defines the
    test extra (pytest + aiohttp, which is a runtime dep of
    ``koolook_routes`` that must be importable even when only a pure
    helper is under test).
  * ``.github/workflows/ci.yml`` gains a ``pytest`` job that installs
    via ``pip install .[test]`` and runs ``pytest -q``.

Guards ``__init__.py``'s relative imports in a ``try / except
ImportError / else`` block. Pytest's package collector walks up from
test files looking for ``__init__.py`` and tries to import the one at
the repo root — but the relative imports inside it only resolve under
ComfyUI's custom-node loader, not under a standalone Python import.
Without the guard, every pytest run aborts during collection with
``attempted relative import with no known parent package``. The
fallback emits a one-line diagnostic and registers empty mappings so
the package directory can still be imported by tooling. ComfyUI's
loader still hits the ``else`` branch and behaviour is unchanged for
real users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(snapshot): folder picker + agent-driven visual harness (#137)

Commit 2 of the snapshot dialogs redesign. Introduces the navigate-
into folder picker from mockup section 6 — the picker that opens when
the user clicks Save to… / Load from… in the redesigned dialogs (and
that the Settings cog will be removed in favour of, once commit 6
lands). Path input is the source of truth: type/paste + Enter
navigates, clicking a folder row drills in, ↑ Up climbs one level.
Files appear greyed below the directories as a "yes, this is the
folder I expected" affordance.

End-visible path overflow: the path input uses ``direction: rtl`` +
``unicode-bidi: plaintext`` so when the path exceeds the input width
the leading ``/Users/…`` clips on the left, never the trailing folder
name. Per mockup section 6.

Visual harness — the path the issue called out as "visual verification
is a hard gate":

  * ``.claude/launch.json`` defines ``design-server`` (``python3 -m
    http.server 8765``). Agent / maintainer can spin this up via
    ``preview_start`` or directly from a terminal.
  * ``docs/designs/_harness/folder-picker.html`` mounts the real
    ``showFolderPicker`` from ``web/sidebar/modals.js`` against a
    canned filesystem, with one trigger button per mockup-illustrated
    state (default / new-folder-input / empty / error). Cache-busts
    module imports so editing modals.js while the harness is open
    picks up fresh code on reload.
  * ``docs/maintainers/visual-harness.md`` documents the workflow,
    when to use the harness vs. dev-sync, and the backtick-in-CSS-
    comments pitfall that broke this commit twice during development.
  * ``CLAUDE.md`` now lists the harness as the preferred agent-driven
    path; dev-sync stays the integration-level gate.

Server: ``/koolook/presets/browse`` gains an opt-in ``?files=1``
parameter that returns child JSON files alongside the directories.
Default response shape unchanged so the legacy Settings dialog
keeps working. ``_list_child_files`` mirrors ``_list_child_dirs``'s
swallow-OSError-per-entry policy so a broken symlink mid-listing
doesn't sink the whole response.

Tests: six new pytest cases in
``tests/server/test_browse_list_children.py`` covering directory
sorting, autosave-subfolder hiding, JSON-only filter, case-insensitive
extension match, and the empty-result paths. ``pytest`` runs all 11
tests cleanly under the existing CI job from commit 1.

What's still verified via dev-sync (not via the harness):
  * Real Comfy theme tokens (the harness fakes ``--comfy-menu-bg``
    etc. with dark-theme defaults).
  * End-to-end Save → Load round-trip across the new library path.
  * Picker interaction with ComfyUI's keyboard manager.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(snapshot): modal headers stay title-only; route gloss to tooltip (#137)

Follow-up to the folder picker commit. Maintainer flagged that the
"Where snapshots are saved and loaded from." subtitle below the title
was extra information the user doesn't need at first glance — if the
title isn't self-explanatory, fix the title, don't pad it with a
sentence. The mockup at docs/designs/snapshot-dialogs.html section 6
shows a title-only header for exactly this reason.

Locks the rule in as a project convention so every dialog box behaves
the same way going forward:

  * docs/maintainers/conventions.md — new "Modal headers" section
    spells out the rule, what counts as a "descriptive subtitle"
    (remove) vs. a "functional info row" (keep), and how to plumb
    explanatory copy via a hover tooltip on the title instead.
  * web/sidebar/modals.js — makeModalShell now accepts ``titleTooltip``,
    applied as the ``title`` attribute on the modal title element so
    the gloss is mouseover-discoverable but never visible by default.
    showFolderPicker drops its ``subtitle`` param (it was added one
    commit ago, no other callers) in favour of ``titleTooltip``.
  * docs/designs/_harness/folder-picker.html — harness re-points its
    explanatory text to ``titleTooltip``. Verified in the harness: the
    visible header is title-only, the tooltip surfaces on hover.

Applies prospectively to the Save / Load dialogs (commits 3 and 4) —
they will also be title-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(snapshot): redesigned Save dialog with inline library row + Save to... + 4-state button (#137)

Commit 3 of the snapshot dialogs redesign. The Save dialog is now a
single modal (was three: three-way overwrite / input prompt / confirm)
that matches mockup section 2:

  * Inline library-path info row at the top of the body — "Saved to"
    label on the left, "Open folder ↗" link on the right, full path
    below. Pure information; every action lives in the bottom command
    bar.
  * Bottom command bar with split layout: Save to... on the left,
    Cancel + Save as new... + Save on the right (spacer in between).
  * Save to... opens the folder picker from commit 2. On Use this
    folder, the dialog calls saveSettings() to persist the new library
    path, refreshes its in-place library row, and flips the primary
    button into Dirty state.
  * The primary Save button cycles through the four states from
    mockup section 5:
      - Default ("Save") — clean state, primary blue.
      - Dirty ("Save to new folder") — folder differs from the one the
        dialog opened with; the label spells out the consequence.
      - In progress ("Saving...") — disabled, dimmed primary.
      - Done ("Saved") — disabled, subtle green, brief pause before
        the dialog closes.
  * Save as new... still routes through the existing showInputModal
    plus presetExists guard, but the prompt no longer carries a
    library-path subtitle — the parent dialog already shows the
    target. Convention from the previous commit applied.

tree.js now passes the new capability functions (saveSettings,
browseDirectories, createBrowseDirectory, revealPresetFolder) into
showSaveSnapshotDialog so the dialog can invoke the folder picker
end-to-end inside the Save flow.

Visual harness: docs/designs/_harness/save-dialog.html exercises all
five mockup states (default tracked, untracked Save-as-new, dirty,
in-progress, done) so the maintainer (or future agent) can re-verify
without dev-syncing. Verified all states render correctly in the
preview server.

Static guard against the recurring backtick-in-CSS-comments trap:
tests/sidebar/test_css_template_literal.py asserts that the CSS
template literal in web/sidebar/constants.js contains exactly two
backticks (open + close) and nothing in between. The trap bit this
work three times (commits 2 and 3) — a literal backtick inside a
comment closes the template early and silently bricks the entire
sidebar. The test runs under the same pytest job introduced in
commit 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(snapshot): redesigned Load dialog with scoped recovery + inline delete (#137)

Commit 4 of the snapshot dialogs redesign. The Load dialog is now a
single modal matching mockup section 3:

  * Inline library row at the top, same shape as the Save dialog —
    "Loaded from" label + Open folder ↗ link + leaf folder name +
    full path. No 📂 emoji button (the link supersedes it).
  * Preset rows: click to load. The "Replace current state?" confirm
    modal is gone — clicking Load is the intent, the dialog title
    already says Load snapshot, no double-confirm.
  * Scoped recovery: when a preset has a newer autosave (per the
    server-side row-augment from commit 1), the first click DOES NOT
    load. It expands exactly ONE recovery row directly beneath the
    parent, showing the freshest entry (newest of periodic.json or
    pre_load_*.json) with its kind badge + meta line. The user then
    chooses: re-click the named row to load the named version, OR
    click the recovery row to restore the auto-save. Both options
    are surfaced inline; the user picks by clicking the one they
    want.
  * Inline delete confirm: × outlines the target row red, the bottom
    Close button transforms to 'Yes — delete "<name>"' (danger
    styling). A second click commits; Escape cancels and reverts.
    No second modal.
  * Bottom command bar matches mockup section 3 split layout:
    [Load from…] on the left, [Close] on the right. Load from…
    opens the folder picker from commit 2; on Use this folder it
    persists via saveSettings() and refreshes the listing.

The pre-#137 YES/NO choice modal (``promptApplyChoice``) and the
collapsible Recovery auto-saves <details> section are both removed —
the scoped-recovery inline row + the click flow above subsume their
job. (Commit 5 in the issue plan was specifically for dropping the
YES/NO modal, which happens here as a natural consequence of the
routing change; commit 5 will be a small follow-up if needed.)

Behavior preserved from the pre-#137 implementation:
  * doNamedLoad — pre-load auto-save backup chain, partial-failure
    tracker handling, identical toast messages.
  * doAutosaveRestore — same backup chain, same markStateAutosaved()
    semantics, same restored-name derivation from subdir.

Tree.js plumbs the three new caps (saveSettings, browseDirectories,
createBrowseDirectory) into showLoadSnapshotDialog so the dialog can
invoke the folder picker end-to-end.

Visual harness: docs/designs/_harness/load-dialog.html exercises
default, empty, scoped-recovery, and pending-delete states. Verified
all four render correctly in the preview server.

Net diff: -56 lines in the sidebar source (the redesign deletes more
than it adds because the YES/NO modal + collapsible recovery
disclosure + confirm modal for plain loads are all gone).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(designs): reconcile snapshot redesign sketch with shipped behaviour (#137)

Commit 5 of the snapshot dialogs redesign. The code-level removal of
the autosave-newer YES/NO modal happened in commit 4 as a natural
consequence of the scoped-recovery inline row taking over its job;
this commit reconciles the design docs so they describe what was
actually built.

docs/designs/snapshot-dialogs-redesign.md:
  * "What changes" item 4 reworded — the YES/NO modal is dropped;
    the affordance is now an inline scoped recovery row that opens
    directly beneath the clicked preset.
  * "What changes" gains items 2, 3, 5 covering the Save/Load dialog
    redesigns and the inline delete confirm that landed in commits 3
    and 4.
  * Mermaid flow rewritten to reflect the new routing (preset click
    → scoped recovery expand → user picks named or recovery row),
    matching commit 4's onPresetClick implementation.

docs/designs/snapshot-dialogs.html "Locked-in decisions":
  * Updated the autosave-modal item from "modal head reads X" to
    "modal — dropped" and explains the inline scoped recovery row
    replacement.
  * Added the title-only modal header convention (commit 2 follow-up)
    pointing to docs/maintainers/conventions.md.
  * Added a pointer to the visual harness workflow added in commit 2.

No code changes — the YES/NO modal was already gone after commit 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(snapshot): remove Settings cog + showSnapshotSettingsDialog + CHANGELOG (#137)

Final commit of the snapshot dialogs redesign. The Settings cog disappears
from the Snapshot toolbar — the snapshot row is now status indicator
| Load | Quick Save | Save, matching mockup section 1. Library-path
management now lives inside the Save dialog ("Save to…") and Load
dialog ("Load from…"), both of which open the folder picker introduced
in commit 2 and persist via the existing `saveSettings` route.

Removals:
  * `showSnapshotSettingsDialog` (~325 lines) — the dialog body, its
    inline directory browser, and the "save folder location" state
    machine. The same browse functionality lives in `showFolderPicker`
    now; the same persistence happens via `saveSettings()` called from
    inside Save / Load.
  * `showThreeWayDialog` (~45 lines) — only consumer was the pre-#137
    Save flow (overwrite / Save as new / Cancel), which the redesigned
    Save dialog absorbed into its primary action bar.
  * `renderLibraryLocation` (~17 lines) — replaced by inline rendering
    in the Save and Load dialogs' library rows.
  * Settings cog toolbar button + `openSettingsDialog` wiring in
    tree.js (~12 lines).
  * Orphan `.koolook-browse-*`, `.koolook-path-input-row`,
    `.koolook-load-library-row*`, `.koolook-settings-save-note` CSS
    rules in constants.js (~16 lines) — only consumed by the removed
    dialog. `.koolook-settings-folder-name` and `.koolook-settings-
    folder-path` stay; the new Save / Load library rows reuse them
    verbatim (git-blame continuity for the visual style).

Adds:
  * CHANGELOG.md [Unreleased] entry summarising the whole #137
    feature — picker, redesigned Save/Load dialogs, scoped recovery,
    inline delete, removed Settings cog, pytest infrastructure.

Net diff for this commit: -377 lines.

Cumulative PR diff (commits 1–6 + harness/convention follow-ups):
all six issue ACs satisfied. The maintainer should dev-sync once
before merging to confirm real-Comfy theme tokens render correctly
(harness fakes the dark-theme defaults) and end-to-end Save → Load
round-trips against the live preset folder behave.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): install pytest + aiohttp directly, not via pip install . (#137)

The Pytest job introduced in commit 1 of this PR was failing on CI
because ``pip install .[test]`` tries to build a wheel of the entire
project. setuptools's flat-layout discovery then trips on the
non-Python ``web/`` and ``forks/`` directories at the repo root and
errors out with "Multiple top-level packages discovered in a flat-
layout: ['web', 'forks']".

The project is a ComfyUI custom node loaded directly from its source
directory, not a pip-installable package. There's no wheel to build.
Install the test deps explicitly instead — the ``[project.optional-
dependencies] test`` block in pyproject.toml is the spec, CI mirrors
it by hand.

Worked locally because the venv install path went straight to
``pip install pytest aiohttp`` and never touched the project.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(snapshot): untracked dirty state + Load dialog Escape listener leak (#137)

Two findings from the external review on PR #144:

**Blocker — Save dialog's primary action with no tracked preset + folder
changed via Save to...**

``updatePrimaryLabel`` was entering the "dirty" state whenever the
library path diverged from the original, regardless of whether a
tracked preset existed. The primary click handler then routed "dirty"
to ``doOverwrite(current)`` — and with ``current === null``,
``sanitizeName(null)`` would throw / silently coerce to an empty
string instead of asking the user for a name.

Fix: ``isDirty()`` only matters when there is something to overwrite.
With no tracked preset, the primary stays in ``default-new``
regardless of folder divergence. The Save to... button still updates
the library row, so the user has feedback the destination changed; the
eventual primary click then routes to the Save-as-new name prompt and
writes into the new folder cleanly.

Verified in the visual harness: opening Save as untracked, switching
the library to ``/Volumes/Shared/different-library`` via the picker,
then inspecting the primary button:

  * Label stays "Save" (was "Save to new folder" pre-fix)
  * Tooltip "Choose a name and write the snapshot." (was "Write into
    /Volumes/Shared/different-library.")
  * Library row still reflects the new path (saveSettings DID run)

**Major — Load dialog leaked a document keydown listener per session**

The inline-delete Escape-to-cancel handler was registered with
``document.addEventListener`` but never removed. Each open/close cycle
of the Load dialog accumulated another stale handler; long sessions
ended up firing the cancelDelete check on every Escape press across
every dialog (no-op because ``pendingDelete`` was null in closed
dialogs, but the listener was still alive and called).

Fix: capture the handler by name, attach on open, tear down on close
by wrapping ``overlay.remove`` (mirrors the pattern the modal shell
already uses at ``modals.js:52-66`` for its own Escape handler).

Verified by instrumenting ``document.{add,remove}EventListener`` and
opening + closing the Load dialog five times in a row: every keydown
listener added during the cycle was removed before the next cycle
opened. No accumulation.

CI gates: ruff, compileall, bandit (via preflight equivalent), pytest
12/12, preflight 4/4 — all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:00:34 +02:00
k_flame_99andkflame99 41ff4de460 feat(workflows): add right-click update from selection or canvas (#133)
* feat(workflows): add context-menu update from current canvas

* feat(workflows): update from selection or canvas

* refactor(workflows): keep sidebar app access inside canvas_io

* fix(workflows): repair selection-update block syntax and duplication

* nit(workflows): clarify selection-unavailable toast path

---------

Co-authored-by: kflame99 <261674183+kflame99@users.noreply.github.com>
2026-05-14 19:39:42 +02:00
k_flame_99andkflame99 dc58e7b224 fix(snapshot-settings): make folder save explicit and non-destructive (#130)
* fix(snapshot-settings): make folder save explicit and non-destructive

* docs(changelog): note snapshot-settings folder-save behavior fix

---------

Co-authored-by: kflame99 <261674183+kflame99@users.noreply.github.com>
2026-05-13 19:19:45 +02:00
malkuthroandClaude Opus 4.7 111ca6d40d fix(sidebar): resolve placeholder-drop Promise on every cancel path (#116)
`dropPlaceholdersForPacks` wraps `showConfirmModal` in `new Promise`,
but only the OK path resolved — Cancel, Escape, and click-outside left
the Promise pending forever (#84 part 2). Caller's `await` never
returned; any future UI state tied to the result would have been stuck.

Two-layered fix so the wider modal contract is sound, not just the one
call site:

- `showConfirmModal` (web/sidebar/modals.js) now fires `onCancel` for
  the Cancel button, Escape, AND overlay-click. An idempotent `settled`
  flag keeps the callback to a single fire; the OK path flips the flag
  before tearing down the overlay so it doesn't trigger onCancel via
  the wrapped `overlay.remove`. Previously only the Cancel button ran
  the callback — Esc and click-outside silently bypassed it.

- `dropPlaceholdersForPacks` (web/sidebar/canvas_io.js) now passes
  `onCancel: () => resolve({ ok: false, reason: "cancelled" })`,
  matching the function's other failure shapes (`no-packs`,
  `load-failed`).

Side win: same modals.js change closes a latent Esc/click-outside leak
in the existing "Discard offline copy" recovery toast at
`web/koolook_sidebar.js:130`, which had wired `onCancel` for the Cancel
button only.

Refs #84 (part 2 — part 1, stale serialized link cleanup, is on
codex/pr-cleanups).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:15:56 +02:00
malkuthro f98633faed fix(sidebar): keep one context menu open (#112) 2026-05-08 21:36:33 +02:00
malkuthroandClaude Opus 4.7 ea4ee071e7 feat(snapshot): YES/NO modal on newer auto-save + 24h timestamps (#111)
* feat(snapshot): autosave UX — YES/NO modal when newer + 24h local timestamps

Two snapshot-UX threads in one slice:

1. **24-hour timestamps with relative-recent tier** in the Load dialog
   and snapshot status tooltip. The previous `en-US` 12-hour format
   produced "12:56 AM" for timestamps just past midnight, which read
   visually like noon. Now: `just now` / `12 min ago` / `today 14:32` /
   `yesterday 23:58` / `May 6 14:32`. Centralized in a new
   `format_time.js` helper so future surfaces stay aligned.

2. **YES/NO choice when an auto-save is newer than the saved version.**
   Click any preset row in Snapshot → Load and, if the matching
   `<preset>_autosave/periodic.json` is more recent than the named
   save's mtime, the confirm dialog upgrades from "Load preset" to a
   YES/NO question with both timestamps shown side-by-side. YES loads
   the auto-save, NO loads the older deliberate save (the row clicked),
   Esc cancels. Standard pre-load auto-save protection still runs on
   either path. Server-side `/koolook/presets/list` augments root rows
   with `latestAutosaveMtime` via one extra `stat()` per row — no file
   read, cheap on large libraries. Aligns the system with the user's
   mental model: named saves stay deliberate (only updated on Save /
   Quick Save), and the auto-save is treated as a recovery copy the
   system surfaces at decision time rather than a parallel save the
   user has to remember to check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(snapshot): restore-from-auto-save baselines as "auto-saved", not "saved"

`doAutosaveRestore` was calling `markStateSaved()` after a successful
restore, which baselined the *named-save* fingerprint to the auto-save's
content. The dot turned green even though `<preset>.json` on disk still
held the OLDER deliberate save — blurring the deliberate-save model the
Path C UX deliberately reinforces (named saves only change on Save /
Quick Save).

New `markStateAutosaved()` export in `snapshot.js` baselines the
*periodic* fingerprint instead, so the dot reads "auto-saved" (blue)
until the user explicitly commits the restored state via Quick Save or
Save. Threaded through `tree.js` → `showLoadSnapshotDialog` via the
existing dependency-injection pattern. Toast message also updated to
nudge: `Restored auto-save of "Foo" — Quick Save to commit to the
named file.`

Fixes the regression flagged on the PR #111 review. Applies to BOTH
restore call sites (the new YES path AND the long-existing Recovery
disclosure) since they share the helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:20:26 +02:00
malkuthro 27705be037 feat(sidebar): add onboarding guide (#107)
* feat(sidebar): add onboarding guide

* docs(guide): clarify tag search casing

* docs(guide): clarify workflow save copy

* docs(guide): refine sidebar visuals

* docs(guide): refresh save snapshot visual

* docs(guide): refresh load snapshot visual

* docs(guide): refresh module save visual

* docs(guide): use real sidebar screenshot

* docs(guide): render sidebar overview screenshot directly

* docs(guide): soften sidebar overview annotations

* docs(guide): clarify nodes action bar callout

* docs(guide): clarify workflow action bar callout

* docs(guide): simplify sidebar overview markers

* docs(guide): align sidebar overview intro

* docs(guide): use real snapshot recipe screenshots

* docs(guide): simplify project workflow diagram

* docs(guide): make function cards scannable

* docs(guide): sketch module recipe icon focus

* docs(guide): use real icon screenshot for module recipe

* docs(guide): add cropped workflow action screenshot

* docs(guide): use full toolbar screenshot for module recipe

* docs(guide): use real search screenshot

* docs(guide): use real node search screenshot

* docs(guide): standardize screenshot recipe cards

* docs(guide): tighten recipe callout styling

* docs: require visual QA for UI work

* docs(guide): address pass-2 review nits
2026-05-08 13:44:55 +02:00
malkuthro b185118950 feat(sidebar): refresh action icons (#108)
* feat(sidebar): refresh action icons

* fix(sidebar): label glyph toolbar buttons
2026-05-08 13:10:35 +02:00
malkuthroandClaude Opus 4.7 7545d1dba1 fix+refactor(sidebar): Insert toast surfaces both miss types + Create-directory toolbar tidy (#106)
* refactor(sidebar): move Create-directory to right-click on Workflows header

The 📂 (pi-folder-open) button used to live in the Workflows action bar
between the section label and the Save buttons. Tree-structure operations
(create / rename / delete a folder) already live on right-click of the
row representing the structure being mutated — but top-level dir creation
was the lone outlier in the toolbar.

Move it to right-click on the "Workflows" header row so the action bar
contains only save actions (Save canvas, Save selection) and every
tree-structure operation lives where the others do.

Implemented via a new optional `rootContextMenu` field on section
descriptors plumbed through `buildFolder`; only the Workflows section
opts in. Fresh-install path is unchanged — the Save modal's "+ New
directory…" entry still creates the first directory when the workflow
store is empty.

Closes #104.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sidebar): Insert toast surfaces pack misses + subgraph misses when both present

`insertWorkflowOntoCanvas`'s pre-flight error path had two failure modes
behind one "missing type" classifier: pack-misses (registered_node_types
has no entry → install the pack) and subgraph-misses (UUID-typed wrapper
whose definition wasn't registered → native Load registers as a side
effect). The "all-misses-are-subgraph" early-return only fired when
EVERY missing type was a UUID; mixed cases fell through to a single
"install the pack(s)" message that listed UUIDs alongside pack types but
silently dropped any mention of subgraph registration.

User experience: install pack, retry Insert, hit a second unexplained
"still missing" failure on the unregistered subgraph.

Branch the toast three ways — pure-pack / pure-subgraph / mixed — and
make the mixed case name both fixes in one shot ("install pack(s) +
right-click → Load once after install to register the subgraph"). The
pure-subgraph message also tightened to mention the right-click → Load
gesture explicitly, since the previous "Load the workflow once" was
ambiguous between left-click (which Inserts on modules) and right-click.

Console.warn now logs `{ packMisses, subgraphMisses }` separately for
diagnostics rather than the lumped `missingTypes` array.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sidebar): de-jargon Insert toast — talk in gestures, not "subgraph definition not registered"

User feedback: "I got that message. I didn't understand why I can't
insert it with a left click." The previous wording — "subgraph
definition not registered" — was technically accurate but unhelpful. A
ComfyUI user wants to know what to *do*, not what's broken under the
hood.

Toasts now talk in gestures and outcomes:
- Pure-subgraph: "Can't Insert "X" yet — it needs to be Loaded once
  first this session. Right-click → Load it, then left-click Insert
  will work."
- Pure-pack: "Can't Insert "X" — missing node(s): A, B. Install the
  required pack(s) first, then retry."
- Mixed: "Can't Insert "X" yet — two steps: (1) install missing pack(s)
  for A, B, then (2) right-click → Load it once. After that, left-click
  Insert will work."

The "Load" verb mirrors the actual menu-item label in
`workflowRowContextMenu` so the user doesn't hunt for a "paste" or "set
up" item that doesn't exist.

Engineer-speak (subgraph / registered / definition) stays in code
comments + console.warn for devs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sidebar): tighten Insert toast wording — drop the "why", keep the gesture

Per maintainer feedback ("Isn't this version a shorter version, better?"),
drop the "it needs to be Loaded once first this session" explanatory
clause from the pure-subgraph case and the "two steps:" preamble from
the mixed case. The user wants to know what to *do*, not why it's
broken. If the registration wears off on a fresh session the toast just
re-fires and the same gesture solves it again — no need to pre-warn.

New wording:
- Pure-subgraph: "Can't Insert "X" yet — Right-click → Load it once,
  then left-click Insert will work."
- Pure-pack: "Can't Insert "X" — missing node(s): A, B. Install the
  pack(s) first, then retry."
- Mixed: "Can't Insert "X" yet — (1) install pack(s) for A, B,
  (2) Right-click → Load it once. Then left-click Insert will work."

The "left-click Insert will work" outcome confirmation stays — the user
just tried that gesture and got an error, so reassuring them it WILL
work after the fix closes the loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:45:33 +02:00
malkuthroandClaude Opus 4.7 4f282e7e79 feat(snapshot): durability + recovery UX — atomic writes, local time, in-UI fallback recovery (#105)
* fix(snapshot): atomic preset writes + local-timezone tooltips

Hardens the snapshot library against mid-write corruption and stops
the auto-saved indicator from rendering Central Time on every machine.

`POST /koolook/presets/file` now stages writes to `<file>.json.tmp`
and `os.replace`s the final name into place — same atomic-rename
pattern `_write_settings` already uses. Covers every preset write
path through the same route (named, periodic, pre-load, future
recovery snapshots). The `.tmp` suffix is invisible in Load and
Recovery dialogs because both list endpoints filter to `.json`.

`formatCentralTime` is now `formatLocalTime` with the hardcoded
`timeZone: "America/Chicago"` removed — the browser formats with the
user's system timezone, and `timeZoneName: "short"` renders the
matching abbreviation (PDT / EDT / CET / …).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(snapshot): drop timezone abbreviation from local-time tooltip

Follow-up to b0f17b9. `timeZoneName: "short"` rendered the user's local
timezone abbreviation (`GMT+2`, `CDT`, `PDT`, …) in the auto-saved hover
tooltip, but the abbreviation read as clutter — the user already knows
their own timezone. Tooltip now shows just the wall-clock time, e.g.
`May 7, 2026, 11:43 PM`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(snapshot): recovery section deep-link + save-time visibility

What you actually asked for last round, properly delivered this time.

The Load dialog's Recovery auto-saves section was a list of file names
with no path context, no save time, and no escape hatch to the file
system — so power users could see the autosaves existed but couldn't
inspect them in Finder, sort by mtime, or pick a specific recovery
point. Three additions close that gap:

1. **`📂 Open` button at the top of the Load dialog** opens the snapshot
   library folder in the OS file manager. Server route
   `POST /koolook/presets/reveal` shells out to `open` (macOS),
   `explorer.exe` (Windows), or `xdg-open` (Linux), with path-traversal
   grounded at the library base via `_resolve_target`.

2. **`📂 Open` button on each `<preset>_autosave` group header** deep-
   links into that specific subfolder, so the user lands directly on
   the autosave files for one preset.

3. **Per-row save time** — recovery rows now show
   `saved May 7, 2:34 PM` from the file's mtime instead of the
   `exported <date>` that hid the time-of-day. The bare filename
   replaces the redundant `Periodic auto-save · <subdir> · <iso>`
   displayName so the timestamp is visible at narrow modal widths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(snapshot): offline-fallback banner gets Restore + Discard actions

The "Offline workflow data found" critical toast used to be a one-way
street — Copy details + Dismiss, but nothing ever cleared the
localStorage key, so the banner re-fired on every page load forever.

Two recovery actions close the loop:

- **Restore as snapshot** — wraps the offline workflows into a full
  snapshot envelope (live picks bundled in so a later Load wouldn't
  wipe them) and writes it as `recovered-<iso>.json` into the snapshot
  library via the now-atomic `writePreset`. Clears the localStorage
  key on success → banner self-extinguishes. The user lands on a
  durable, in-UI-visible artifact.

- **Discard offline copy** — `showConfirmModal` confirm-then-clear.
  Same end state for the banner; explicit user-acknowledged data loss.

Mechanics:

- `criticalToast` extended to accept `actions: [{label, onClick,
  primary?, busyLabel?}]`. Existing callers (`{ copyText }` only) are
  unchanged.
- Toast firing moved out of `loadWorkflowsStore()` and into
  `koolook_sidebar.js` (entry point) where the action handlers can
  reach `writePreset` / `loadUserPicks` / `showConfirmModal` without
  creating circular imports.
- `clearOfflineFallback()` exported from `workflows_store.js` for
  the localStorage `removeItem` step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(snapshot): unify Load dialog header and Recovery group layout

Three visual-consistency fixes so the library section and each
`<preset>_autosave` group header read like the same UI control instead
of two slightly-different ones.

1. **Saved-time on every row.** `loadPreview` now threads `mtime` from
   the listing endpoint into the preview object, and `formatPreviewMeta`
   prefers it over the snapshot's self-reported `exportedAt`. Regular
   snapshot rows now show `saved May 7, 11:53 PM` — same format the
   recovery rows already used. Falls back to `exported <date>` when
   mtime isn't available.

2. **Recovery group headers get the full path.** Subdir name on top,
   `<library>/<subdir>` underneath — the same CSS pair
   (`folder-name` + `folder-path`) the library section uses. Reuses
   `renderLibraryLocation` with a new `title` option so the autosave
   header skips the "Library folder:" prefix while inheriting all
   styling.

3. **Library Open button matches the group Open button.** Was
   `koolook-modal-button` (larger), now `koolook-snapshot-row-btn`
   (smaller, same as group headers). Same flex options on both rows
   (`flex-start` alignment, `space-between` justification, `gap: 8px`).

`formatAutosaveMeta` collapsed back into `formatPreviewMeta` since the
formats converged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(snapshot): single disclosure arrow + identical row layouts

Two visual fixes after the previous round still left the Recovery
section feeling different from the Snapshots section.

- Drop the literal `▸` / `▾` characters from the recovery summary —
  the native `<details>` element already renders a disclosure
  triangle, so the previous text duplicated it. User saw two arrows
  side-by-side.
- Wrap each group's rows in `.koolook-snapshot-list` so they get the
  same bordered-box treatment around them that the library snapshot
  rows have. Strip the outer border + visuals from
  `.koolook-recovery-list` (now just a scroll container) and
  `.koolook-recovery-group-header` (now just padding) so the inner
  classes drive appearance — same `folder-name` + `folder-path` pair
  + `koolook-snapshot-row-btn` Open button + `koolook-snapshot-list`
  rows wrapper as the library section. Both surfaces should now read
  as the same UI control.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Revert "chore(snapshot): single disclosure arrow + identical row layouts"

This reverts commit 82874c3b3ada977c236c03e57479a29126ee68e7.

* Reapply "chore(snapshot): single disclosure arrow + identical row layouts"

This reverts commit 76e52bdf7cce5c6e84e883f8714921e2f506a9b3.

* fix(sidebar): remove backticks from CSS comments inside template literal

The comments I added in 82874c3 contained literal backticks (around
identifiers like `koolook-snapshot-list`) — fine in CSS, FATAL inside
a JS template literal. The CSS string in `ensureStyle()` is a
backtick-delimited template, so an inner backtick prematurely closed
the template, the rest of constants.js parsed as broken JS, the module
failed to load, and every sidebar file that imports from constants.js
collapsed with it. Net effect: entire Kforge Labs sidebar disappeared
in the standalone app.

Why pre-commit `node --check` missed it: that command defaults to
script mode for `.js` files and tolerates this edge case. The browser
loads our files as ES modules (stricter), and would have flagged the
backtick. Verified the fix with `node --check` on a `.mjs` copy of
every sidebar file before committing.

Comments rewritten without backticks. Added an in-CSS-comment warning
calling out the constraint so future changes don't regress.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(snapshot): tempfile-based atomic write + onCancel for showConfirmModal

Addresses two review findings on PR #105.

P1: The previous atomic-write used `<file>.json.tmp` — a fixed,
predictable name. An attacker (or accidental user) with write access
to a shared library could plant that name as a symlink pointing
outside the library, and `Path.write_bytes` would FOLLOW the symlink
past the path-traversal guard. Replaced with `tempfile.mkstemp`,
which (1) opens with `O_CREAT | O_EXCL` semantics so any existing
dirent (including a symlink) at the target name aborts the open, and
(2) generates a randomized name so an attacker can't pre-plant
anything there. The temp file still lives inside the validated
target_dir and `os.replace` still does the atomic swap onto the final
name.

P2: `showConfirmModal` had no cancel callback. The recovery toast's
"Discard offline copy" handler wrapped it in a `new Promise(resolve =>
…)` that only resolved on confirm, so cancelling left the toast button
stuck in `"Discarding…"`. Added `onCancel` parameter; the recovery
toast now resolves `{ dismiss: false }` on cancel, re-enabling the
button. Also closes the second half of #84 — `dropPlaceholdersForPacks`
in canvas_io.js had the same Promise-leak shape and the missing
cancel callback was the root cause.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:14:57 +02:00
malkuthro e5e07f6c50 feat(sidebar): preserve subgraph definitions in modules (#103) 2026-05-06 22:13:34 +02:00
malkuthro 6f1025f66b fix(sidebar): preserve module links on insert (#102) 2026-05-06 19:08:57 +02:00
malkuthro 719a240934 fix(sidebar): clamp hover preview card (#101) 2026-05-06 17:20:12 +02:00
malkuthro 7d7bc0d132 feat(sidebar): browse snapshot library folders (#99)
* feat(sidebar): browse snapshot library folders

* fix(sidebar): handle snapshot folder save edge cases

* docs(sidebar): clarify browse folder threat model
2026-05-06 16:54:57 +02:00
malkuthroandClaude Opus 4.7 a0a8469448 feat(sidebar): build-tag footer + theme auto-pull + × removability + pack badges (#98)
Five user-visible changes to the Kforge Labs sidebar that complete the
dev-sync verification loop and fix theme-mode discovery:

- Build-tag footer at the bottom of the sidebar: `dev <sha> · <time>`
  on line 1 (SHA at 13px monospace, the at-a-glance identifier),
  italic <scope> on line 2. Reads `web/_dev_build.json` written by
  `scripts/sync_to_dev.py --scope "<short scope>"`. Visible-in-app
  companion to PR #95's chat-report convention — closes the gap when
  the maintainer is juggling parallel sessions and can't tell whether
  the browser tab is showing the latest sync. Absent on registry
  installs; footer renders empty there.

- Theme/category mode now mirrors repo-mode population: REPOS auto-
  pull union user-picks, deduped, with the new auto-pull-hidden set
  filtered out. The earlier picks-only design dropped the entire
  Koolook pack from theme mode on a stock install, even though those
  same nodes appeared in repo mode — silently violating the "my
  favorites in two views" mental model. `gatherNodesByTheme` rewritten
  to seed from REPOS first, then user picks, with the same
  autoCategoryRoots + hidden filtering repo mode uses.

- `×` works on every favorite, including auto-pulled REPOS nodes.
  Adds `koolook.autoPullHidden.v1` localStorage Set; `×` on an auto-
  pulled row calls `hideAutoPullType(type)` so the next render skips
  it. `addToMyPicks` calls `unhideAutoPullType` as a side effect so
  re-adding via `+` automatically restores visibility. Same uniform
  "remove / bring back" UX whether the node was a user pick or REPOS
  auto-pull.

- Pack-name badges restored on theme-mode leaves only. Small dim
  `Koolook` / `KJNodes` text on the right of each row, same opacity
  as the search-flatten breadcrumb. Memory aid for "where did this
  node come from?" Repo mode and search-flatten still skip the badge
  (origin is conveyed by tree nesting / breadcrumb prefix).

- `scripts/sync_to_dev.py --scope` flag: extends PR #95's brief-
  output design with a JSON artifact (`<target>/web/_dev_build.json`)
  carrying commit + timestamp + worktree + scope, which the sidebar
  footer fetches on render. Doesn't change the existing one-line
  chat-report contract or the `_build_line()` shape.

Tested via dev-sync against the live ComfyUI install:
- Footer renders correctly (`dev afe539e · 2026-05-06 15:38` + scope)
- Image bucket in theme mode now contains Koolook image nodes
- Pack badges visible on theme leaves
- × hides auto-pulled Koolook nodes; toolbar + restores them

CHANGELOG entries added; the prior conflicting [Unreleased] notes
about picks-only theme + pack-badge removal are flagged as superseded
in the new entries (release prep can squash).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:06:02 +02:00
malkuthroandClaude Opus 4.7 8dfb966be0 feat(sidebar): snapshot durability — per-preset autosave subfolders, Quick Save, status indicator, sticky persist toasts (#94)
* feat(sidebar): snapshot durability — pre-load + periodic auto-saves, sticky persist toasts, Save-dialog library path

Preventive measures after a state-loss incident: a Load erased work, with no
visible warning and no recovery path. Four changes:

1. **Save dialog library path.** Both the input modal ("Save as new…") and
   the three-way overwrite dialog now show a tail-truncated breadcrumb
   ("…/user/koolook-presets2/") with the full path in the tooltip — the user
   sees where the save will land before clicking. New
   `formatLibraryPathBreadcrumb` helper + `.koolook-modal-pathline` CSS;
   threaded through `getLibraryInfo` (already wired for Load).

2. **Pre-load auto-save.** Every destructive Snapshot Load writes
   `_autosave_pre_load_<iso>.json` to the library BEFORE `applySnapshot`. On
   auto-save failure the Load is aborted entirely with a sticky critical
   toast — the whole premise is "Load never destroys state without a
   recovery point." Pre-load autosaves don't rotate; every Load is a
   permanent recovery file.

3. **Periodic auto-save.** Every 5 minutes a defensive snapshot is written
   to `_autosave_periodic_<iso>.json` IF state changed (string-equality
   fingerprint on picks+workflows, exportedAt stripped). Skipped when tab
   is hidden. Rotates: keeps last 5. First tick at +30s so load/seed
   settle first. Started from sidebar setup() after registration.

4. **Critical (sticky) toasts.** New `criticalToast(msg, {copyText})`
   variant — no auto-dismiss; optional "Copy details" button hands the
   user unsaved JSON for manual recovery. Wired into:
     * `commit()` fallback-only path — warns "saved to browser-local
       fallback only" once per outage, re-arms when /userdata recovers
     * `persistMutation` full-failure path — replaces auto-dismiss toast,
       includes the unsaved JSON via Copy
     * `loadWorkflowsStore` corrupt-file path — sticky so "your data
       looks lost" can't be missed
     * `loadWorkflowsStore` stale-fallback-detected path — was a 4.5s
       toast, now sticky with Copy of the fallback JSON; turns evening-
       work-in-localStorage recovery into an explicit user action

No API or storage changes outside the snapshot library. Existing user
snapshots untouched. New `_autosave_*` files are valid snapshots and
appear in the Load list (sorted to the top via leading underscore) —
deletable via the regular row × button.

Files: 6 changed, +433 / -10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sidebar): snapshot status indicator — current preset + saved/auto-saved/unsaved dot

Replaces the static "Snapshot" label in the action row with a dynamic
status display:
  ● <preset name> · <state>

Dot color encodes status:
  • green  — saved      (live state matches the last named save)
  • blue   — auto-saved (matches the latest periodic auto-save only)
  • orange — unsaved    (diverged from any save / auto-save)
  • grey   — none       (no preset tracked)

Tooltip carries the long-form explanation including the latest auto-save
ISO timestamp.

State machine:
  • markStateSaved() baselines the fingerprint after a successful named
    save (writeAndToast) or successful Load (applySnapshot).
  • Periodic auto-save updates _lastPeriodicFingerprint + _lastPeriodicAt
    AFTER a successful tick — flips "unsaved" to "auto-saved" without a
    mutation event.
  • setCurrentPresetName fires SNAPSHOT_STATUS_CHANGED_EVENT so the
    indicator name refreshes when Load/Save updates the tracker.

Persistence:
  • Saved fingerprint is mirrored to localStorage
    ("koolook.snapshot.savedFingerprint.v1") so the saved/unsaved
    distinction survives page reloads. Without this, every reload would
    show "saved" until the user mutated something — defeating the point.
  • At session start, if the tracker has a name AND no persisted
    fingerprint exists, we baseline once (the "first-ever session" edge).
    Imperfect for "user closed mid-edit" but the alternative (always
    show unsaved on first load) is worse UX.

Fingerprint = JSON.stringify({picks, workflows}). Same shape used by
periodic auto-save's change detection — single source of truth, no
risk of "saved" and "auto-saved" disagreeing about what the state is.

Files: 5 changed, +237 / -21.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sidebar): per-preset autosave subfolders + Quick Save button

Reorganizes auto-save storage to keep the user's library tidy and add a
fast-path save action.

NEW LAYOUT:

  <library>/
    Wan video kit.json                 ← user-named save (Load list)
    Wan video kit_autosave/            ← shadow folder (HIDDEN from Load)
      periodic.json                    ← single rolling file, overwritten
      pre_load_<iso>.json × ≤5         ← rotates, permanent per Load
    another preset.json
    _unsaved_autosave/                 ← used when no preset is tracked
      periodic.json
      pre_load_<iso>.json × ≤5

WHY:

The previous layout dropped every auto-save as a flat `_autosave_*.json`
file at the library root. Two problems:
  1. Cluttered the user's Load dialog (sorted to top via leading
     underscore but still visible).
  2. Filesystem view (Total Commander, Finder) showed dozens of these
     files alongside the user's named saves — visually noisy.

The per-preset subfolder layout makes the on-disk structure self-
documenting: "the shadow recovery for X lives at X_autosave/". Hidden
from the Load list at the server (filters subdirs + legacy `_autosave_*`
flat files), so the regular Load flow only enumerates user-named presets.

WHY one rolling `periodic.json` (not timestamped):

The latest periodic auto-save IS the most useful periodic state. Earlier
periodic snapshots are worse copies of approximately the same work.
Trades disk diversity for cleanliness — pre-load files cover the
"recovery from a deliberate destructive action" case independently.

WHY timestamped pre-load files (rotated, keep 5):

Each Load is a deliberate destructive action and deserves a permanent
recovery point. Rotation bounds disk; rare "5 Loads back" recovery loss
is the trade-off for not letting the folder grow unbounded.

QUICK SAVE BUTTON:

New `pi pi-save` icon button between Load and Save. When the user has a
preset loaded/saved and wants to save again on top of the same name,
they don't need the dialog with confirmation. One click, no prompt,
toast confirms ("Quick-saved 'X'."). Disabled (greyed out) when no
preset is tracked — the regular Save button remains the entry point
for naming a new preset.

Refreshes its enabled state on `SNAPSHOT_STATUS_CHANGED_EVENT` so that
loading a preset, saving as new, or clearing the tracker via partial-
load failures all flip the button state correctly.

SERVER (koolook_routes.py):

  • New `_DIRNAME_RE` validator (same charset as filenames, no extension)
  • New `_resolve_target(lib_base, subdir, name)` — symlink-escape check
    grounded at LIBRARY base (not subdir base) so a hostile symlink
    inside an auto-save subfolder can't redirect writes outside.
  • GET / POST / DELETE / HEAD `/koolook/presets/file` accept optional
    `?dir=<subdir>` query param. Server auto-creates the subdir on POST.
  • GET `/koolook/presets/list` accepts optional `?dir=` for autosave-
    subfolder enumeration (used by the JS pruner). Without `dir`, hides
    subdirs AND legacy `_autosave_*` flat files at root so the Load
    dialog only sees user-named presets.

JS (snapshot.js):

  • `writePreset` / `readPreset` / `deletePreset` / `presetExists` /
    `listPresets` accept `{dir}` option, propagated to fileQuery.
  • `_autosaveSubdir()` resolves `<presetname>_autosave` or
    `_unsaved_autosave` from the current preset tracker.
  • `writePreLoadAutosave` writes to subfolder, returns
    `<subdir>/<filename>` token; new `_prunePreLoadAutosaves`.
  • Periodic timer overwrites `<subdir>/periodic.json` (single file)
    instead of rotating timestamped files.
  • Removed the global flat-file `_autosave_*` constants.

JS (tree.js):

  • New Quick Save button between Load and Save in the snapshot row.
  • `refreshQuickSaveDisabled` toggles the button on every status event.

BACKWARDS COMPATIBILITY:

Existing users with `_autosave_*` files at the library root from the
previous layout will see them stop being written to (the new code
writes to subfolders only). The server-side list filter hides them
from the Load dialog so they don't visually pollute, but the files
themselves remain on disk for manual recovery / cleanup.

Files: 3 changed, +277 / -96.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sidebar): address review — recovery autosave UI + subtitle on overwrite-confirm

Two review findings on PR #94:

  [P2] Pre-load autosaves were written into per-preset subfolders but
       the Load dialog only listed root-level user-named presets, so
       recovery was unreachable from the UI.

  [P3] The Save-as-new flow's existing-name → "Overwrite?" confirm
       dialog dropped the library breadcrumb (showConfirmModal lacked
       subtitle support, even though showInputModal and
       showThreeWayDialog had it).

P2 — RECOVERY AUTO-SAVES IN THE LOAD DIALOG:

Added a new server endpoint `/koolook/presets/autosaves/list` that walks
every `*_autosave/` subdir (including `_unsaved_autosave/`) and returns
a flat list of recovery files with their subdir, name, mtime, and size.
Why a dedicated endpoint? The user-facing list MUST hide subdirs to keep
the Load list clean; the recovery path is the explicit "show me autosave
folders" API, separated cleanly so its boundary is grep-able.

JS gets a matching `listAutosaves()` helper that fans out preview reads
to extract displayName + counts (N+1 cost is real but bounded — typical
user has ~10-30 autosave files, paid only when the recovery section is
expanded).

The Load dialog gains a collapsible `<details>` section ("▸ Recovery
auto-saves") below the regular preset list. Lazy-loaded on first expand.
Items are grouped by subdir so users can scan visually for the preset
they want to recover from. Each row shows a kind badge (PRE-LOAD orange
/ PERIODIC blue / OTHER), the snapshot's display name, and the standard
preview meta (pick + workflow counts + exportedAt).

Click → confirm modal → optional pre-recovery autosave (recursive
protection — restoring an autosave is itself a destructive Load) →
applySnapshot. Post-restore, the tracker is set to the original named
preset derived from the subdir (`Foo_autosave` → "Foo"), or null for
`_unsaved_autosave`. State indicator flips to "saved" via markStateSaved.

P3 — SUBTITLE ON CONFIRM MODAL:

`showConfirmModal` gains an optional `subtitle` parameter (string or
HTMLElement), same contract as showInputModal and showThreeWayDialog.
The Save-as-new flow's overwrite-confirm now passes `buildPathSubtitle()`
so the user keeps library context across the input → exists-check →
confirm chain. Important when Settings → libraryPath has been changed
mid-session, or facility users juggle multiple shared libraries.

Files: 5 changed, +355 / -1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:15:10 +02:00
malkuthroandClaude Opus 4.7 d763b36adc chore(legal): add NOTICE + SPDX headers across web/ (#93)
Codify attribution before the sidebar gets more visibility. NOTICE is the
single human-readable source of truth — copyright statement, plain-English
attribution request with a copy-pasteable credit line, pointer to LICENSE
and forks/THIRD_PARTY.md so it shows the project respects others'
attribution too.

SPDX-FileCopyrightText / SPDX-License-Identifier headers added at the top
of every web/*.js (entry point + sidebar/ + ai_pipeline) so license
scanners (GitHub, FOSSA, reuse-tool) parse each file correctly and
stripped-attribution forks become an obviously deliberate act rather than
"I forgot to copy a file."

GPL-3.0-only matches the LICENSE file (no "or later" wording in it). Year
range 2025-2026 spans first commit (2025-09-28) to today.

Comment-only change — no runtime impact.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:28:20 +02:00
malkuthroandClaude Opus 4.7 75b7e76f11 feat(sidebar): node-preview fidelity — read nodeData, 5-col grid, widget pills (#78) (#82)
Two changes that ship together because the visual rework was masking the
real bug:

1. `readSlots` (and the title/category/description reads in
   `renderPreviewCard`) were looking at static `nodeClass.INPUT_TYPES` /
   `RETURN_TYPES` properties, which **don't exist** on
   ComfyUI-registered nodes — ComfyUI parks the original V1 def at
   `nodeClass.nodeData` (set by `litegraphService.ts`'s
   `registerNodeDef` right before `LiteGraph.registerNodeType`). Every
   card was rendering as just header-and-badge because the slot arrays
   came back empty for every ComfyUI node. Reading from `nodeData`
   first (with the old property names as a legacy fallback for any
   non-ComfyUI registered nodes) fixes the empty-card class.

2. Visual rework to look like a real node mock instead of a generic
   info card. Mirrors upstream NodePreview.vue more faithfully: flat
   header row (small colored dot + display name) over a red PREVIEW
   badge; slot rows in a 5-column grid (`[dot] [input-name] [spacer]
   [output-name] [dot]`) so inputs and outputs line up horizontally as
   parallel sockets; widgets rendered as individual rounded pills with
   `◀ name [spacer] value ▶` chrome to mimic LiteGraph widget UI.

Closes #78

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:16:42 +02:00
malkuthroandClaude Opus 4.7 d021e8c8da feat(sidebar): theme-classifier rework + Nodes-row polish (#80)
* revert(sidebar): drop drag-link-release menu priority patch

The patch in `web/sidebar/connection_menu.js` was reorder-only — it sorted
existing menu items so curated picks floated to the top. In practice
ComfyUI's drag-link-release menu has its own pre-filter that surfaces only
~4 stock nodes per socket type (e.g. for an IMAGE-compatible drop target:
VAEEncode, VAEEncodeForInpaint, SaveImage, PreviewImage). Most user
picks never appear in that filtered list, so the reorder had nothing to
hoist and the feature was invisible in practice.

A useful version of this would have to ADD picks to the menu — walk the
user's curated picks, type-match each against the dragged socket, inject
matches as new menu items — rather than just reorder. That's a larger
patch and out of scope for tonight; filing as a follow-up rather than
shipping dead code.

Removed: `web/sidebar/connection_menu.js`, the `patchConnectionMenu`
import + call in `web/koolook_sidebar.js`, and the corresponding
[Unreleased] CHANGELOG entry.

Kept: spotlight-on-add, modal drag-out fix, and the `pinExpanded`
buildFolder fix from #76 — all independently useful.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sidebar): theme-classifier rework + Nodes-row polish

Replaces the previous "category mode" (canonical-segment first-segment
grouping, which produced a fragmented tree of `image` / `images` /
`Image-Save` / `KJNodes/image` / `Koolook/Image` for the same semantic
theme) with a broad keyword classifier that folds anything mentioning
an image-related word into a single "Image" bucket regardless of source
pack or category casing. Picks-only; flat list inside each theme; falls
back to pack-prefix-stripped first segment when no classifier matches.

Five coordinated changes shipped together:

1. Theme classifier (new). `THEME_CLASSIFIERS` is an ordered list of
   13 broad themes (Image / Video / Mask / Audio / Model / Sampler /
   Conditioning / Text & Prompt / Math & Logic / Pipeline & Batch /
   I/O / Camera / Utility) with regex pattern arrays. `extractTheme`
   builds a search corpus from packLabel + category + display title +
   type ID and walks the classifiers in order — first match wins.
   First match for "Image Comparer (rgthree)" is "Image" (not Math)
   because Image is earlier in the list. Symbols touched in tree.js:
   `gatherNodesByTheme`, `extractTheme`, `classifyTheme`,
   `THEME_CLASSIFIERS`, `emitThemeChildren`, `collectFlatFromThemes`,
   `countNodesInThemes`. Replaces `gatherNodesByCategory`,
   `emitCategoryTreeChildren`, `collectFlatFromCategoryTree`,
   `countNodesInCategoryNode`, `ensureSyntheticBucket`.

2. Pack-name badges removed. `makeNodeLeafRow` no longer accepts a
   `packBadge` parameter; the badge rendering block is gone; all
   callers (3 sites) updated; CSS rule `.koolook-pack-badge` removed
   from constants.js. The breadcrumb prefix on search-flatten rows
   already conveys origin in a cleaner form, and theme mode by
   definition doesn't care about source pack.

3. Mode-toggle icons swapped. Repo mode: `pi-database` -> `pi-sitemap`
   (now actually tree-shaped). Theme mode: `pi-sitemap` -> `pi-bars`
   (sandwich). Better icon-to-meaning mapping.

4. Default mode flipped to "category" (theme). Newcomers more often
   think "where are my image nodes" than "which pack is this from."
   Existing users with `koolook.groupMode.v1` already in localStorage
   keep their saved choice.

5. Layout polish. `+` button uses a `pi pi-plus` icon at 12px (matches
   toolbar icon-button height) with a new dusty-green accent class
   `koolook-add-btn-green` (rgba 120/165/100). New divider between the
   search field and the Nodes action bar. `koolook-sidebar` gained
   `padding-top: 10px` so the Snapshot row no longer hugs the panel
   top edge.

CHANGELOG [Unreleased] updated with Changed entries for items 1+2.

Note: a CSS comment containing backticks (`koolook-icon-btn`) inside
a JS template literal silently broke module load on the first attempt —
backticks closed the template early, leaving "koolook-icon-btn" as a
stray identifier the browser rejected with "Unexpected identifier
'koolook'". Fixed by removing the backticks from the comment. `node
--check` did not catch this; the only reliable check is browser-side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(sidebar): address fresh-eyes review nits on theme classifier

Three NITs + one IMPORTANT comment from the PR #80 review pass:

- IMPORTANT: Add a code comment on `\bget\b` / `\bset\b` in the Utility
  classifier warning future editors not to generalize to unbounded
  `/get/` / `/set/`. Current narrow forms are intentional — they're
  the last-resort net for trivial getter/setter nodes that didn't hit
  any earlier classifier.
- NIT: Fix the `classifyTheme` doc-comment example for "Image Comparer"
  to be technically accurate. The previous wording claimed Math's
  `/\bcompare\b/i` would match "Comparer" — it wouldn't, because the
  trailing word boundary fails on "Comparer". The actual reason it
  lands in Image is the `/image/i` substring hit on the "Image" word
  in the corpus, not ordering.
- NIT: Drop `.toLowerCase()` on the search corpus in `extractTheme`.
  Every classifier pattern carries the `/i` flag, so the explicit
  case fold was redundant work.
- NIT: Remove dead `(nc && ...)` defensive checks after the early
  return on `!nc`. Per CLAUDE.md "no defensive coding for impossible
  cases" — `nc` is guaranteed non-null on the lines below the early
  return. Same in `gatherNodesByTheme` where `nc` is fetched after
  `extractTheme` already filtered out unresolved picks; added a comment
  noting the invariant.

Behavior unchanged. All pure correctness/style polish.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:41:14 +02:00
malkuthroandClaude Opus 4.7 215616f546 revert(sidebar): drop drag-link-release menu priority patch (#79)
The patch in `web/sidebar/connection_menu.js` was reorder-only — it sorted
existing menu items so curated picks floated to the top. In practice
ComfyUI's drag-link-release menu has its own pre-filter that surfaces only
~4 stock nodes per socket type (e.g. for an IMAGE-compatible drop target:
VAEEncode, VAEEncodeForInpaint, SaveImage, PreviewImage). Most user
picks never appear in that filtered list, so the reorder had nothing to
hoist and the feature was invisible in practice.

A useful version of this would have to ADD picks to the menu — walk the
user's curated picks, type-match each against the dragged socket, inject
matches as new menu items — rather than just reorder. That's a larger
patch and out of scope for tonight; filing as a follow-up rather than
shipping dead code.

Removed: `web/sidebar/connection_menu.js`, the `patchConnectionMenu`
import + call in `web/koolook_sidebar.js`, and the corresponding
[Unreleased] CHANGELOG entry.

Kept: spotlight-on-add, modal drag-out fix, and the `pinExpanded`
buildFolder fix from #76 — all independently useful.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:35:04 +02:00
malkuthroandCursor ed6f3dad02 feat(sidebar): Modules — splice a saved cluster into the live canvas
Tag any saved workflow with the literal tag `module` and the Kforge
Labs sidebar starts treating it as a reusable building block: a green
`pi pi-plus-circle` icon replaces the file glyph, left-click
**inserts** the cluster at the viewport center (with internal links
re-created and the new nodes left selected), and a distinct hover
tooltip surfaces the changed semantics. Designed for the
`EXR-output stack` / `depth-only render` / `Wan 2.2 prompt
module` pattern: small clusters the maintainer wants to drop into
many workflows without rebuilding them by hand.

What changed:

1. New non-destructive primitive `insertWorkflowOntoCanvas` in
   `web/sidebar/canvas_io.js`. Pre-flights every referenced node type
   against `LiteGraph.registered_node_types` and aborts cleanly with a
   toast naming the missing types — a partial insert with stub nodes
   is worse than no insert. Then deep-clones the saved graph, calls
   `node.configure(...)` per node with `node.id = -1` so
   `app.graph.add()` allocates fresh ids that can never collide with
   the live canvas, and finally walks the saved `links[]` to
   recreate internal connections via `originNode.connect(...)` (which
   auto-allocates fresh link ids — no manual link remap needed). Bbox
   placement reuses the existing CSS-pixel correction so the cluster
   lands at the visible viewport center on HiDPI displays.

2. Right-click on any workflow row now offers `Insert into canvas`
   alongside the existing `Load` — Load remains available for every
   row regardless of tag, so a module can still be loaded as a full
   session if the user wants.

3. Convention-driven module classification in
   `web/sidebar/constants.js` (`MODULE_TAG = module`). Picked a
   tag instead of a new `isModule` schema field so existing saved
   entries can be promoted to modules with one right-click → Tags add,
   no migration needed, and the existing Tags section groups them for
   free. Tree rendering looks up the tag at row-render time
   (`isModuleWorkflow` helper) and switches both the leaf icon
   (green `pi pi-plus-circle`) and the left-click handler (Insert
   instead of Load). Active rows in the Workflows section AND every
   row under the Tags section honor the flip; archived rows stay on
   Load (they're old versions, `review` not `splice`).

4. `showSaveWorkflowModal` gains a `Save as module` checkbox. The
   selection-save toolbar button passes `defaultModule: true` (the
   typical `module` candidate is a small cluster the user just
   selected); the whole-canvas save passes `defaultModule: false`.
   The tag is added in the SAME `persistMutation` as the entry write
   (folded into the `mutate` callback in `saveAndToast`), so a
   commit failure rolls back BOTH the save and the tag together — no
   `module`-but-untagged ghosts.

Docs: new `Modules` section in
`docs/maintainers/workflows-sidebar.md` covering the convention, the
checkbox flow, per-surface behavior, and how to promote an existing
saved workflow to a module without re-saving.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:32:06 +02:00
malkuthroandClaude Opus 4.7 ced6492257 feat(sidebar): category mode + flatten-on-search + hover preview (#74)
* feat(sidebar): category mode + flatten-on-search + hover preview (#73)

Three navigation/discoverability improvements to the Kforge Labs sidebar's
Nodes section, shipped together:

1. Category-first grouping mode. Segmented toggle in the Nodes action row
   (📦 Repository / 🌐 Category) re-groups picks by their node-class
   CATEGORY path instead of by repo, with canonical-key normalization
   (`lower().replace(/[\s_-]+/g, "")`) so `Loaders`/`loaders` and
   `Image/Upscaling`/`image/upscaling` collapse into one folder. Synthetic
   `(unresolved)` and `(uncategorized)` buckets surface picks whose pack
   isn't loaded or whose class has no CATEGORY. Pack-name badge on
   category-mode leaves preserves the source signal. Mode persists in
   localStorage with cross-tab sync.

2. Flatten-on-search. When the search field is non-empty the Nodes
   section drops its tree structure and renders a flat list of matching
   leaves with `Cat › Subcat › Display` breadcrumb prefixes. Workflows
   and Tags sections retain their tree-under-filter behavior — the
   change is scoped to Nodes only.

3. Hover preview card. Mirrors ComfyUI's official NodePreview.vue: plain
   HTML/CSS card with a colored title bar (HSL hue from CATEGORY hash),
   two columns of type-colored slot dots (reads
   `app.canvas.default_connection_color_byType` first, falls back to
   LiteGraph's static palette), widgets with truncated defaults,
   optional description. 250ms hover debounce, viewport-flip, max-height
   cap, pointer-events: none, leak-proof teardown wired into renderTree.
   "Pack not loaded" stub for unresolved picks. New module
   `web/sidebar/node_preview.js` holds the engine; one
   `attachHoverPreview(row, type)` call inside `makeNodeLeafRow` covers
   every leaf-emit site (repo tree, category tree, flat search).

Closes #73

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sidebar): preview review-fixes — log INPUT_TYPES errors, teardown on tab/blur, type-guard slot colors

Three small additive fixes from the multi-agent review on PR #74:

1. console.warn when INPUT_TYPES() throws so a broken pack is visible
   during debugging instead of silently rendering an empty-looking node.
   Matches the project-wide convention (picks_store.js, canvas_io.js,
   installer.js).

2. visibilitychange + window.blur listeners that call teardownPreview.
   Closes the leak path where the user navigates away mid-hover via
   keyboard sidebar-tab switch, modal stealing focus, or page hiding —
   none of which cross a row's mouseleave region, so the reactive
   teardown never fired and the pointer-events:none card sat on screen
   undismissable.

3. typeof === "string" guard on both fallback branches in slotColor so
   a misbehaving custom-node pack injecting a non-string into
   default_connection_color_byType produces a visible neutral-grey dot
   instead of a silent transparent dot.

The category_grouping.js extraction (MEDIUM finding, file split) is
queued as a separate follow-up issue per the "one PR per topic" rule
in CLAUDE.md — pure no-behavior refactor, lands cleaner on its own.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:39:48 +02:00
malkuthroandClaude Opus 4.7 96b07a2c12 feat(sidebar): picks priority — drag-link menu, spotlight on add, modal hardening (#76)
* fix(sidebar): merge preset HEAD route into GET handler

PR #68 shipped koolook_routes.py with @routes.get + @routes.head on the
same path. aiohttp's add_get auto-registers HEAD by default, so the
explicit @routes.head collided with the auto-synthesized HEAD and
crashed ComfyUI startup with "Added route will never be executed,
method HEAD is already registered".

Trying allow_head=False on the GET to opt out triggered a second crash:
ComfyUI's server.py mirrors every PromptServer route into a parallel
/api/... table via api_routes.route(method, path)(handler, **kwargs),
and RouteTableDef.route's inner closure rejects unknown kwargs with
TypeError. So no kwarg-based opt-out is viable.

Drop the dedicated HEAD handler. Aiohttp's auto-HEAD-from-GET delivers
HEAD probes to get_preset; we branch on request.method to skip the
file read for HEAD — preserving the cheap-existence optimization the
client's presetExists relies on for multi-MB JSON over Dropbox / iCloud
/ NFS-mounted libraries. CHANGELOG [Unreleased] updated to match.

This bug never reached users — koolook_routes.py was added in #68
(post-v0.2.0) and was never published to Comfy Registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sidebar): Tools row + starter preset replaces curated_defaults

Two coordinated UX changes for fresh-install distribution + admin
ergonomics — designed together and shipped as one unit.

1. New "Tools" row sits between the Snapshot row and the search field.
   Moves the Export and "Install missing for picks" buttons up out of
   the Nodes row (admin/advanced operations don't belong in the
   everyday + Add toolbar). Adds a new "Drop placeholders onto canvas"
   button — the security_level=normal escape hatch for installing
   missing packs: instantiates one placeholder per missing pack on a
   fresh canvas tab so ComfyUI/Manager's standard "Install Missing
   Custom Nodes" detection takes over via Manager's UI flow (which
   uses different gates than our programmatic /customnode/install/
   git_url call). Nodes row keeps only the everyday + button.

2. curated_defaults.json retired in favor of starter_preset.json.
   Fresh installs no longer get picks seeded directly into localStorage
   on first load. Instead the bundled web/starter_preset.json (full
   snapshot format — picks + workflows + tags + archive) is copied
   into the user's snapshot library directory as starter.json. The
   user opens Snapshot -> Load -> starter to apply in one click.
   Unifies fresh-install distribution with the per-user snapshot
   library (#68); maintainer flow becomes "build state in ComfyUI ->
   click the (down-arrow) Tools-row button -> paste over
   web/starter_preset.json -> commit." Existing users with non-empty
   picks are explicitly skipped by the new seeder, so their state is
   untouched.

Removed: seedDefaultsIfNeeded, exportPicks, web/curated_defaults.json,
SEEDED_KEY, DEFAULTS_URL.

Added: seedStarterPresetIfNeeded, exportStarterPreset (snapshot.js),
STARTER_SEEDED_KEY, STARTER_URL, STARTER_PRESET_FILENAME,
discoverMissingPacks (installer.js), dropPlaceholdersForPacks
(canvas_io.js), web/starter_preset.json.

Docs: CLAUDE.md "Curated Nodes sidebar — distributed defaults" ->
"Kforge Labs sidebar — starter preset distribution".
docs/maintainers/curated-sidebar.md rewritten.
CHANGELOG.md [Unreleased] updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sidebar): picks priority — drag-link menu, spotlight on add, modal hardening

Three coordinated UX features for curated picks plus two underlying bug
fixes uncovered while wiring them.

1. Drag-link-release menu hoists curated picks. When a connection is
   dragged from a socket and released on empty canvas, ComfyUI's compat-
   filtered suggestion list now sorts items in the user's curated picks
   above everything else, with a "── from your picks ──" separator before
   the rest. Detection via the "Add Node" + "Add Reroute" sentinel pair
   in the menu's options (narrow enough that other ContextMenu uses are
   untouched). Purely additive — no compatible nodes are removed, only
   re-ordered. New `web/sidebar/connection_menu.js` exports a
   `patchConnectionMenu()` registered alongside `patchCanvasMenu()`.

2. Spotlight effect on add. Clicking the toolbar `+` (or the canvas
   right-click "Add to Kforge Labs") collapses every Nodes-section pack
   folder, then auto-expands the pack + subcategory the just-saved node
   lives in — pedagogical aid that helps users learn which pack each
   node belongs to. Multi-select adds light up every hit pack
   simultaneously. Duplicate adds get the spotlight too (the educational
   reminder still applies). New helper `findPackPathForType` mirrors the
   gather code's REPO-precedence-then-user-pick-fallback. New exported
   `spotlightAddedPicks(typeNames)` does the collapse + pin sequence.

3. Modal drag-out-of-input no longer dismisses the dialog. Shared modal
   shell `makeModalShell` gained a mousedown / click-intent check: a
   click on the overlay only fires dismiss when the gesture STARTED on
   the overlay too. Drag-selecting text inside an input that releases in
   the overlay's dark area used to dismiss the modal mid-edit. Affects
   every Koolook dialog. Snapshot Settings path field also gained a
   `title` tooltip so the full path is hover-readable.

Bug fixes uncovered + carried in this PR:

- `pinExpanded` paths now expand on the immediate render. buildFolder's
  resolution chain gained a `pinnedPaths` check between `forceExpanded`
  and `pathStates`. Phase 3 of `renderTree` writes pins into pathStates
  AFTER Phase 2 builds the DOM, so without this check the pin had no
  effect on the current render. Improves the existing workflow-save
  pinning (visibly correct now) and is load-bearing for the new
  spotlight feature.

CHANGELOG [Unreleased] updated with all five entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:33:31 +02:00
malkuthroandClaude Opus 4.7 a6a31c8086 feat(sidebar): Tools row + starter preset replaces curated_defaults (#72)
* fix(sidebar): merge preset HEAD route into GET handler

PR #68 shipped koolook_routes.py with @routes.get + @routes.head on the
same path. aiohttp's add_get auto-registers HEAD by default, so the
explicit @routes.head collided with the auto-synthesized HEAD and
crashed ComfyUI startup with "Added route will never be executed,
method HEAD is already registered".

Trying allow_head=False on the GET to opt out triggered a second crash:
ComfyUI's server.py mirrors every PromptServer route into a parallel
/api/... table via api_routes.route(method, path)(handler, **kwargs),
and RouteTableDef.route's inner closure rejects unknown kwargs with
TypeError. So no kwarg-based opt-out is viable.

Drop the dedicated HEAD handler. Aiohttp's auto-HEAD-from-GET delivers
HEAD probes to get_preset; we branch on request.method to skip the
file read for HEAD — preserving the cheap-existence optimization the
client's presetExists relies on for multi-MB JSON over Dropbox / iCloud
/ NFS-mounted libraries. CHANGELOG [Unreleased] updated to match.

This bug never reached users — koolook_routes.py was added in #68
(post-v0.2.0) and was never published to Comfy Registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sidebar): Tools row + starter preset replaces curated_defaults

Two coordinated UX changes for fresh-install distribution + admin
ergonomics — designed together and shipped as one unit.

1. New "Tools" row sits between the Snapshot row and the search field.
   Moves the Export and "Install missing for picks" buttons up out of
   the Nodes row (admin/advanced operations don't belong in the
   everyday + Add toolbar). Adds a new "Drop placeholders onto canvas"
   button — the security_level=normal escape hatch for installing
   missing packs: instantiates one placeholder per missing pack on a
   fresh canvas tab so ComfyUI/Manager's standard "Install Missing
   Custom Nodes" detection takes over via Manager's UI flow (which
   uses different gates than our programmatic /customnode/install/
   git_url call). Nodes row keeps only the everyday + button.

2. curated_defaults.json retired in favor of starter_preset.json.
   Fresh installs no longer get picks seeded directly into localStorage
   on first load. Instead the bundled web/starter_preset.json (full
   snapshot format — picks + workflows + tags + archive) is copied
   into the user's snapshot library directory as starter.json. The
   user opens Snapshot -> Load -> starter to apply in one click.
   Unifies fresh-install distribution with the per-user snapshot
   library (#68); maintainer flow becomes "build state in ComfyUI ->
   click the (down-arrow) Tools-row button -> paste over
   web/starter_preset.json -> commit." Existing users with non-empty
   picks are explicitly skipped by the new seeder, so their state is
   untouched.

Removed: seedDefaultsIfNeeded, exportPicks, web/curated_defaults.json,
SEEDED_KEY, DEFAULTS_URL.

Added: seedStarterPresetIfNeeded, exportStarterPreset (snapshot.js),
STARTER_SEEDED_KEY, STARTER_URL, STARTER_PRESET_FILENAME,
discoverMissingPacks (installer.js), dropPlaceholdersForPacks
(canvas_io.js), web/starter_preset.json.

Docs: CLAUDE.md "Curated Nodes sidebar — distributed defaults" ->
"Kforge Labs sidebar — starter preset distribution".
docs/maintainers/curated-sidebar.md rewritten.
CHANGELOG.md [Unreleased] updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:18:15 +02:00
malkuthroandClaude Opus 4.7 93363cc625 feat(sidebar): snapshot library v2 — configurable filesystem path via KFORGELABS_PRESETS (#68)
* feat(sidebar): snapshot library — named presets for picks + workflows

Implements #46 item 1 (custom save location + cross-machine portability)
as a preset library: each preset is one named JSON file under
/userdata/koolook-presets/, capturing the full Kforge Labs state.

Use cases addressed
-------------------
- **Personal portability:** copy `koolook-presets/` between machines, or
  sync ComfyUI's userdata folder via Dropbox/iCloud/Drive — your kit
  follows.
- **Team / facility shared library:** multiple workstations on the same
  ComfyUI server share `/userdata/koolook-presets/` natively. One person
  publishes "facility kit", everyone else loads it.
- **One-off transfer (different servers):** Manage tab's Download button
  saves a snapshot to local disk; Upload imports it into another machine's
  library.

UI
--
New top-level **Snapshot** action row above the search field, mirroring
the Nodes/Workflows action-row pattern. Three icon buttons (Load / Save /
Manage), each opens the same modal at a different starting tab.

- Load tab: lists every preset with metadata (workflow count · pick
  count · export date). Click a row → "Replace current state?" confirm
  → restores both picks and workflows.
- Save tab: input field defaults to `preset YYYY-MM-DD`, fully editable.
  Existing-name save prompts to overwrite.
- Manage tab: per-row Download / Delete buttons + a top-of-tab Upload
  action (file picker imports a JSON snapshot from disk into the library).

Schema
------
```json
{
  "kind": "koolook-snapshot",
  "version": 1,
  "name": "Wan video kit",
  "exportedAt": "2026-05-05T08:42:13.000Z",
  "picks": [...],
  "workflows": { "directories": {...} }
}
```
`kind` + `version` for future schema migrations. Anything without
`kind === "koolook-snapshot"` is rejected on import.

Module shape
------------
- `web/sidebar/snapshot.js` (new) — server I/O on the preset directory,
  schema validation, gather/apply, download/upload helpers. UI-free.
- `web/sidebar/modals.js` — new `showSnapshotModal({initialTab, ...})`
  that takes capability functions instead of importing snapshot.js
  directly, so the UI module stays UI-only.
- `web/sidebar/workflows_store.js` — new `replaceAllWorkflows(rawStore)`
  for bulk-replace (bypasses persistMutation since the entire cache is
  being swapped) + `getAllWorkflowsForExport()` for the gather flow.
- `web/sidebar/picks_store.js` — new `setAllPicks(picks)` exported
  helper.
- `web/sidebar/tree.js` — new top action row + 3 toolbar buttons +
  one wiring helper.
- `web/sidebar/constants.js` — CSS for tab strip, list rows, manage
  buttons.

Deliberate scope cuts (per the design discussion)
-------------------------------------------------
- No continuous sync. Each save is a deliberate act.
- No conflict resolution. Same preset name written from two machines
  on the same server = last-write-wins. Documented as a known limit;
  revisit when it bites.
- No selective import (preset = full state). Merge can be a v2.
- No custom userdata path config. The `koolook-presets/` location is
  fixed; portability comes from the snapshot files themselves, not
  from rerouting storage.

Verification
------------
- `node --input-type=module --check` on all modified + new JS files —
  clean.
- `python tools/preflight_release.py --skip manager-meta -v` PASS.
- Synced to live install on Mac for browser smoke-test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sidebar): snapshot library now uses configurable filesystem path

Reworks PR #67 (which served snapshots from /userdata/koolook-presets/)
to expose the library through Koolook's own server-side endpoints,
making the storage location configurable via an env var. The userdata
namespace is per-install — fine for personal use, useless for the
"facility shared library" use case the user explicitly called out.

Architectural change
--------------------
- The client no longer talks to ComfyUI's `/userdata/*` API for snapshot
  files. New JS-side endpoints: `/koolook/presets/{info,list,file}`.
- Server side: new `koolook_routes.py` registers aiohttp routes on
  ComfyUI's PromptServer at custom-node load time. Path-traversal
  protection at the route boundary via a strict filename whitelist
  regex (`^[A-Za-z0-9 _.()\-]+\.json$`).
- Storage location is read from the `KFORGELABS_PRESETS` env var on
  every request (so re-launching ComfyUI with a new value picks it
  up without any cache fuss). Default fallback:
  `<comfyui-userdata>/koolook-presets/`.
- Auto-creates the configured directory on first save (parent must
  already exist — for NFS/SMB mounts the mount must be present).

Use cases this enables vs. PR #67
---------------------------------
| Use case                | PR #67 | This PR |
|-------------------------|--------|---------|
| Personal, single-machine | ✓     | ✓       |
| Personal cross-machine  | symlink-or-sync userdata | point env var at Dropbox folder |
| NFS/SMB facility share  | ✗ (locked to userdata) | ✓ point env var at mount |
| Multiple parallel libs  | ✗     | per-instance env var |
| Read-only distribution  | ✗     | ✓ (server detects, save fails cleanly) |

UI difference vs. PR #67
------------------------
- The Manage tab gains a small `i` icon at the right edge of the
  upload row. Hover tooltip shows the current library path, source
  (default vs `KFORGELABS_PRESETS` override), and writability status.
  No header line, no extra UI weight.

Files added/changed
-------------------
- `koolook_routes.py` (new, ~180 lines) — aiohttp routes + env-var
  config + filename validation. Loaded by `__init__.py` at startup;
  failure is non-fatal (the rest of Koolook still works, just the
  snapshot feature is unavailable).
- `__init__.py` — registers `koolook_routes` import-time side-effect.
- `scripts/sync_to_dev.py` — `koolook_routes.py` added to
  `RUNTIME_PATHS` so dev-sync includes it.
- `web/sidebar/snapshot.js` — server I/O section rewritten to call
  `/koolook/presets/*` instead of `/userdata/koolook-presets/*`. Drops
  the URL-encoding workaround (we own the route now, so the
  `name=<file>` query param is clean and the server validates).
- `web/sidebar/modals.js` — `showSnapshotModal` accepts a
  `getLibraryInfo` capability function; the Manage tab calls it and
  populates the info-icon tooltip.
- `web/sidebar/tree.js` — passes `getLibraryInfo` from `snapshot.js`
  to the modal.
- `.env.example` — new `KFORGELABS_PRESETS` block with macOS / Linux /
  Windows examples for personal-sync, NFS facility share, and SMB
  facility share patterns.
- `CHANGELOG.md` — Added entry rewritten for the env-var design;
  Internal entry on the new server-side module.

Why a separate v2 PR (instead of patching #67)
----------------------------------------------
The userdata-namespace design in #67 was the wrong architecture for
what the user actually wanted. Patching it to the env-var design
would have meant a near-total rewrite of #67's server I/O paths
mid-review. Cleaner: branch fresh from main, cherry-pick #67's UI
+ stores work as a base, layer the new endpoint design on top.
PR #67 will be closed as superseded once this lands.

Verification
------------
- `node --input-type=module --check` on every modified JS file —
  clean.
- `python -c "import ast; ast.parse(...)"` on `koolook_routes.py`
  + `__init__.py` — clean.
- `python tools/preflight_release.py --skip manager-meta -v` PASS.
- Synced to live install on Mac. User to smoke-test against a real
  ComfyUI session (the previous URL-encoding 405 should be gone
  since the new route handlers own the URL space).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sidebar): redesign snapshot UX — single-click Save, real Settings panel

User feedback on #68's first cut: tabbed modal is too complicated, can't
see existing presets in the Save flow, the env var alone isn't a real
settings UI, the default-path text was stale (still claimed
`/userdata/koolook-presets/` after the configurable-path landed).

UX changes
----------
- **Save button → one click.** If a preset is currently loaded
  (tracked in localStorage as `koolook.snapshot.currentPresetName.v1`,
  survives reloads), prompts "Save over '<name>'?" with three options:
  Save (overwrite) / Save as new… (rename) / Cancel. If nothing is
  loaded yet, prompts for a name with `preset YYYY-MM-DD` default,
  fully editable.
- **Load → single dialog.** Preset list with per-row × Delete button.
  Header line shows the current library path so the user can see where
  they're loading from. No tabs.
- **Cog → real Settings panel.** Single field for the library path
  (text input, absolute path). Save / Reset to default / Close. Read-
  only line shows the currently-resolved path + source label
  (settings panel / env var / built-in default). The "Manage" tab and
  its Download / Upload sub-actions are gone — the user can already
  reach the file on disk after a save, no in-app file ops needed.

Server side
-----------
- New `GET /koolook/presets/settings` returns the saved library path +
  resolved path + source.
- New `POST /koolook/presets/settings` writes the saved library path
  to `<comfyui-userdata>/koolook-settings.json`. Empty value clears
  the override.
- Resolution chain in `_configured_dir()`:
    settings file > KFORGELABS_PRESETS env var > built-in default
  Reads each on every call; no module-load caching.
- `info` endpoint gains a `source` field ("settings" | "env" | "default")
  the UI uses for the readout line.

Client side
-----------
- `showSnapshotModal` (tabbed) replaced by three exports:
    showSaveSnapshotDialog, showLoadSnapshotDialog,
    showSnapshotSettingsDialog.
  Each is self-contained, no shared state.
- `snapshot.js` adds `getSettings`/`saveSettings` for the Settings
  dialog and `getCurrentPresetName`/`setCurrentPresetName` for the
  one-click Save tracking. Settings ride on the new server endpoints;
  current-preset tracking is browser-local.
- `applySnapshot` now also updates the current-preset tracker (via
  the dialog wrapper, not the function itself) on successful load.
- `tree.js` toolbar wires three direct callbacks instead of one
  `openSnapshotModal(initialTab)` helper.

Stale text fix
--------------
- The Save dialog's hint text in #68's first cut hardcoded
  "/userdata/koolook-presets/<name>.json" — stale from the userdata-
  era PR #67. The redesigned Save flow doesn't show that text at all
  (the path is no longer relevant to the user-typed name); the Load
  dialog's header line and the Settings dialog's read-only field are
  the canonical surfaces for "where my presets actually live."

Deliberately out of scope
-------------------------
- No per-save path picker. The library is one location at a time, set
  in advance via Settings or env var. Saving to ad-hoc locations would
  fragment the library and break the "facility shared library" use case.
- No in-app Download / Upload (was in #68's first cut). Files are on
  disk at the configured path; the OS file manager handles transfer.

Verification
------------
- `node --input-type=module --check` on every modified JS file —
  clean.
- `python -c "import ast; ast.parse(...)"` on `koolook_routes.py` —
  clean.
- `python tools/preflight_release.py --skip manager-meta -v` PASS.
- Synced to live install on Mac. User to smoke-test against a real
  ComfyUI session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sidebar): address PR #68 review — atomicity, security, error UX

Multi-agent review surfaced 2 BLOCKING + 5 HIGH + 4 MEDIUM + 3 LOW.
Validated the BLOCKINGs against the actual code (both real) and
applied the bundle of fixes that hold up. Modal-size HIGH (#7 file
size) deferred to a follow-up — that's a wider refactor.

BLOCKING fixes
--------------
- **`applySnapshot` partial-failure tracker gating** (modals.js Load
  dialog). `setCurrentPresetName(...)` was called unconditionally
  before checking `{picksOk, workflowsOk}` from the apply. Partial
  apply (one of picks/workflows persisted, the other didn't) left
  the tracker pointing at the preset, so the next Save would offer
  "Save over '<name>'?" and silently overwrite the on-disk preset
  with the corrupted half-state. Now: tracker is set only on
  `picksOk && workflowsOk`; partial failure clears it (and the toast
  tells the user "Reload to recover prior state. Tracker cleared.").

- **`replaceAllWorkflows` non-atomic rebind** (workflows_store.js).
  Rebinds `workflowsCache` before awaiting persist, with no rollback.
  Now: snapshots the cache via the existing `snapshotCache()`
  primitive that `persistMutation` uses; on persist failure, restore
  the prior cache and return false. Load is fully atomic.

HIGH fixes
----------
- **Workflows-store invariant block updated.** Lists `replaceAllWorkflows`
  as the fourth legitimate rebind site (with rollback semantics).
  "Fifth rebind" warning bumped to "sixth."
- **Explicit HEAD route on `/koolook/presets/file`** that just does
  `is_file()` + status, no body work. The auto-synthesis path was
  reading the full file from disk just to discard it — wasteful on
  multi-MB snapshots over Dropbox/iCloud/NFS.
- **Client `sanitizeName` mirrors server whitelist regex**
  (`^[A-Za-z0-9 _.()\-]+$`). Characters outside the whitelist (`+`,
  `&`, `'`, accented letters, emoji, etc.) collapse to `_` rather
  than triggering an opaque HTTP 400 at save time.
- **Error-reason surfacing via `await resp.text()`.** RFC 7540 strips
  HTTP/2 reason phrases; behind any HTTP/2-terminating proxy
  `resp.statusText` is empty. Reading the body always carries
  aiohttp's `reason=` text. Used by writePreset, deletePreset,
  saveSettings, readPreset.

MEDIUM fixes
------------
- **Symlink follow-through hardening.** `_resolve_within_base` helper
  computes `(base / name).resolve()` and asserts it's still under
  `base.resolve()`. Defends against a symlink planted in a facility-
  shared library dir redirecting reads/writes off-share.
- **Atomic settings write.** `_write_settings` writes to `<tmp>` then
  `os.replace`, so an interrupted process can't truncate the file
  and silently revert the user's saved library path on next load.
- **`__init__.py` import wrapped in try/except.** The "non-fatal"
  comment promised that an import failure wouldn't break node
  registration; previously only `install()` returning False was
  guarded. Now any module-load exception is caught and logged with
  the `[Koolook]` prefix, and the rest of the plugin loads.

LOW housekeeping
----------------
- Dropped dead exports `downloadSnapshotAsFile` + `readSnapshotFile`
  (left over from an earlier iteration that had Download/Upload UI).
- Dropped 5 dead CSS rules (`.koolook-snapshot-tabs*`,
  `.koolook-snapshot-tab-pane`) from when the modal was tabbed.
- `currentPresetName` cleared when the user deletes the currently-
  loaded preset (Load dialog's promptDelete now checks via the new
  `getCurrentPresetName` capability).
- `loadPreview` warns to console on `kind` mismatch — non-snapshot
  JSON files in the library directory are now visible in DevTools
  rather than silently filtered.
- `presetExists` is tri-state (`true` / `false` / `null`) — the
  Save flow refuses to write when the library is unreachable rather
  than silently skipping the overwrite-confirm prompt.

Deferred
--------
- modals.js size (1080 lines) — extracting the snapshot dialogs into
  their own file is a follow-up refactor. Not blocking merge.
- Doc drift on maintainer docs / README env-var convention — separate
  documentation sweep.

Verification
------------
- `python -c "import ast; ast.parse(...)"` on all .py files — clean.
- `node --input-type=module --check` on every modified JS file —
  clean.
- `python tools/preflight_release.py --skip manager-meta -v` PASS.
- `grep -rn "downloadSnapshotAsFile\\|readSnapshotFile\\|koolook-snapshot-tab" web/`
  — zero hits (dead code gone).
- Synced to live install for browser smoke-test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: nudge — re-trigger CI after webhook miss on d6d2bf8

The CI workflow didn't fire on the prior push (Socket Security ran but
GitHub Actions Lint/Smoke/Pre-flight/Security Gate didn't). Empty
commit to refire the pull_request webhook.

* fix(routes): drop unnecessary f-string prefixes flagged by ruff (F541)

Two adjacent string literals in the symlink-escape error message had
an `f` prefix despite containing no placeholders. CI's ruff check
fails on F541 — strip the prefixes so the lint gate passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:06:46 +02:00