Skip to content

Picture UI improvements - #2458

Open
rmunn wants to merge 35 commits into
developfrom
feat/picture-ui-improvements
Open

Picture UI improvements#2458
rmunn wants to merge 35 commits into
developfrom
feat/picture-ui-improvements

Conversation

@rmunn

@rmunn rmunn commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Fixes #2442.

IMPORTANT: When merging, please edit the merge commit and trim it down. Claude wrote more than 3/4 of the commit messages in this PR in its overly-chatty style. If you commit this with GitHub's default "smash all the commit messages together and call it good" message, it will become ridiculously long in a git log.

Done:

  • Three-dots menu in corner, with "Edit", "Download", and "Delete" options.
  • Clicking picture brings it up fullscreen
  • Fullscreen shows all non-empty captions
  • Tap to load, default to not autoload
  • Image caching so tap-to-load only has to be tapped once
  • Captions can be collapsed (with a tap) in fullscreen mode if they are too big
  • Make pictures array bindable in PicturesEditor.svelte
  • Collapsed captions should show a visual indicator that they can be tapped to uncollapse. Ditto for uncollapsed captions, there should be a visual "you can tap here" indicator of some kind.
  • Verify that uploaded file name is being kept (it is, no code change needed)

Screenshots, please bikeshed the UI.

Click-to-load placeholder:

image

"Image not found" placeholder if download fails (e.g. the picture was not moved into LinkedFiles originally so the Mercurial repo doesn't have it):

image

Fullscreen view with all captions visible:

image

Fullscreen view with captions collapsed:

image

Please bikeshed the captions and chevron indicator. Is the indicator too small? Should it be given a background to stand out more? Should it be over on the left? Should the captions be centered under the picture in this view like they are in the entry view? (My opinion there is that the captions should be left-justified in this view, but centered in the entry view, but I could be wrong).

Three-dots menu in corner:

image

rmunn and others added 4 commits July 18, 2026 17:11
Replace the pencil affordance on a picture with a reusable three-dots menu offering
Edit, Download, and Delete, each wired to the existing behaviors (edit dialog, download,
delete-with-confirmation). The menu is a new PictureActionsMenu component built on the
responsive-menu primitive (dropdown on desktop, drawer on mobile) and is also opened by
a long-press of the image so touch users don't have to hit the small target.

Download logic is extracted into a shared picture-actions.ts (used by the menu and the
edit dialog); PicturesEditor's delete-with-confirm is generalized so the menu and the
dialog share it. The picture click still opens the edit dialog for now.

Test infra: EntryViewComponent.waitForEntryLoaded() keyed off `.i-mdi-dots-vertical`,
which is no longer unique now that each picture renders a three-dots menu; switch it to
the lexeme-form field, a stable per-entry "loaded" signal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clicking (or tapping) a sense picture now opens a new PictureViewerDialog instead of
the edit dialog — the three-dots menu (added previously) already reaches the editor.

The viewer shows the picture sized to the viewport but never larger than its native
resolution (PictureImage gains a size="full" variant; the dialog shrinks to fit). It
carries the same three-dots actions menu (Edit/Download/Delete) acting on the current
picture, left/right arrows to move between pictures when the sense has more than one,
and below the image the current picture's non-empty captions shown read-only with
writing-system abbreviation labels. The shown picture is tracked by id so navigation and
deletion stay in sync with the reloaded entry; the dialog closes if that picture is gone.

PictureImage's click now calls onView (aria-label "View Picture"); the menu's Edit still
uses onEdit. The viewer's actions menu is disabled while an operation is in flight, matching
the edit dialog's double-invoke guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an entry-view-scoped image service (context-based) that fetches and decodes each
picture mediaUri once and hands out a shared blob object URL. Two pictures with the same
mediaUri — or the same picture shown in both the field and a dialog — now load a single
time instead of once per PictureImage instance.

The service is initialized in EntryEditor (the entry view, which remounts per entry), so
its cached object URLs are revoked when that view is torn down. PictureImage loads through
it (preload in an $effect, reactive read via a SvelteMap-backed get in a $derived), falling
back to a component-local cache when rendered without an entry-view scope. EditPictureDialog
and PictureViewerDialog get the cache transitively since they render PictureImage within the
EntryEditor subtree. A #disposed guard prevents a load that finishes after teardown from
minting an object URL that would never be revoked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `$effect` would run twice if the `picture` object got replaced by a
different one that had the same `mediaUri`. By moving `mediaUri` into a
`$derived` we avoid the double run of the `$effect`.
@rmunn rmunn self-assigned this Jul 20, 2026
@github-actions github-actions Bot added the 💻 FW Lite issues related to the fw lite application, not miniLcm or crdt related label Jul 20, 2026
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Picture editing now supports project-scoped image caching, on-demand loading, long-press actions, fullscreen viewing with navigation and captions, immediate local CRUD updates, shared downloads, updated demo behavior, localization, and expanded Playwright coverage.

Changes

Picture experience

Layer / File(s) Summary
Image loading and download services
image-service.svelte.ts, picture-actions.ts, EditPictureDialog.svelte
Cached image load states, project-scoped object URLs, explicit downloads, cleanup, and shared download failure handling.
Picture image actions and loading UI
PictureImage.svelte, PictureActionsMenu.svelte
Lazy loading, responsive action menus, long-press behavior, viewer entry points, and localized loading/error states.
Fullscreen viewer and editor state
PictureViewerDialog.svelte, PicturesEditor.svelte, SenseEditorPrimitive.svelte
Fullscreen navigation and caption controls, viewer actions, bindable picture state, and immediate add/edit/delete synchronization.
Demo media behavior and UI validation
in-memory-demo-api.ts, entry-view.component.ts, sense-pictures.test.ts
Models explicit remote downloads and validates picture loading, menus, CRUD, downloads, viewer behavior, captions, and cache persistence.
Picture UI localization
locales/en.po, locales/es.po, locales/fr.po, locales/id.po, locales/ko.po, locales/ms.po, locales/sw.po, locales/vi.po
Adds and reattributes strings for loading prompts, actions, viewer navigation, captions, accessibility labels, and errors.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested reviewers: hahn-kev

Poem

A rabbit hops where pictures gleam,
Through menus, viewers, a cached-up dream.
Click to load, then arrows fly,
Captions bloom beneath the sky.
Edit, download, delete with cheer—
The picture patch is hopping here! 🐰

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes cover the linked issue’s main goals: fullscreen viewing, actions menu, tap-to-load, captions, caching, and bindable pictures.
Out of Scope Changes check ✅ Passed The file changes are all related to picture UI behavior, localization, caching, or supporting tests/demo behavior.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title is concise and accurately summarizes the main change set around picture UI enhancements.
Description check ✅ Passed The description directly matches the implemented picture menu, fullscreen viewer, tap-to-load, caching, and bindable pictures changes.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/picture-ui-improvements

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@argos-ci

argos-ci Bot commented Jul 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Argos notifications ↗︎

Build Status Details Updated (UTC)
default (Inspect) ✅ No changes detected - Jul 24, 2026, 7:02 AM
e2e (Inspect) ✅ No changes detected - Jul 24, 2026, 7:09 AM

rmunn and others added 9 commits July 20, 2026 13:36
Pictures no longer auto-load. The entry-scoped image cache is the sole gate: a picture
whose mediaUri isn't cached shows a "Click to load" / "Tap to load" placeholder (styled
like the error placeholder) and is fetched only when the user clicks/taps it. Once a
mediaUri is cached, every picture sharing it — and the edit dialog / fullscreen viewer —
display it immediately. No backend change.

image-service: add a 'not-loaded' state; get() reports it for an uncached mediaUri; the
former auto-preload is now an on-demand load() called from the click handler. PictureImage
drops its auto-load effect, renders the placeholder, and its click loads when not-loaded
else opens the viewer; the three-dots menu stays available even before the image loads.

Also saves the investigation into true local-vs-remote detection (which would have required
a 🔴 MiniLcm media-API change) in picture-loading-findings.md, explaining why the cache-based
approach was chosen instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cache lived on EntryEditor, which remounts per entry ({#key entry.id}), so it was
torn down whenever you switched entries — a picture you clicked to load reverted to
"click to load" after navigating away and back.

Make it a project-scoped singleton via projectContext.getOrAdd instead: one ImageService
per open project, created lazily on first use, kept for the project's lifetime, and
disposed (object URLs revoked) when the project context is destroyed via an $effect
cleanup riding getOrAdd's $effect.root. useImageService now resolves it from the project
context (falling back to a component-local cache only outside a project, e.g. stories);
EntryEditor no longer initializes it.

A loaded image now stays cached across entry navigation and displays without re-clicking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In PictureViewerDialog the caption block is now a clickable toggle. Uncollapsed (the
default, unchanged behavior) shows all non-empty captions; clicking collapses to just the
first non-empty caption with its text line-clamped to one line, and clicking again expands
again. Each picture starts expanded (navigation resets the state).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings in new downloadIfMissing param to getFileStream API
Now that getFileStream takes a downloadIfMissing flag, implement the original design: a
picture already cached locally loads automatically, and only a picture that would have to
be fetched from the remote media service shows the "Click to load" / "Tap to load"
placeholder.

ImageService.ensureLocal (called from an $effect on render) probes with downloadIfMissing:
false — a local file displays immediately; a NotFound result means "not downloaded" and
surfaces the placeholder. Clicking it calls download() (downloadIfMissing: true) to fetch
and display. The project-scoped cache still means a mediaUri loaded once shows everywhere.

The in-memory demo API now simulates a remote media service: pre-seeded pictures aren't
available locally until downloaded once, while uploaded files are local. Tests updated to
match (uploads auto-load; the demo's pre-seeded pictures click-to-download). Also drops a
stray third argument to the shared downloadPicture helper in EditPictureDialog, and updates
picture-loading-findings.md to record the final design.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was an accidental commit of a planning document, which doesn't
belong in the PR.
`pictures` is now $bindable, so add/delete/update mutate the sense's picture list directly
for immediate UI feedback instead of waiting for the change to round-trip back through the
entry-changed reload:
- addPicture appends the new picture, deletePicture filters it out, submitEdits swaps it.
- Each reassigns `pictures` (not in-place push/splice) so the parent's bound setter fires.

SenseEditorPrimitive binds via a getter/setter (bind:pictures={() => sense.pictures ?? [],
(v) => (sense.pictures = v)}), matching the function-binding idiom used elsewhere; the
getter keeps the longstanding guard against a runtime-undefined sense.pictures. Pictures
still persist through their own api.createPicture/updatePicture/deletePicture calls (not
the sense onchange flow), so nothing double-persists.

The edit dialog now retains the last picture it showed (lastEditedPicture) so it stays
mounted for its close animation even though a delete removes the picture from `pictures`
sooner than the reload used to.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The caption block is clickable to collapse/expand but had no affordance signalling it.
Add a disclosure chevron (i-mdi-chevron-down) at its trailing edge: it points down while
collapsed (more captions available below) and rotates to point up while expanded, with a
transition. Standard show-more/less indicator; the button keeps aria-expanded for AT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rmunn
rmunn marked this pull request as ready for review July 21, 2026 03:57
@rmunn

rmunn commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

@CodeRabbit help

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

CodeRabbit commands

These commands are invoked using PR/Issue comments.

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai rate limit to show your current review rate limit status.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai evaluate custom pre-merge check --instructions <custom-checks-instructions> --name <custom-checks-title> [--mode <error|warning>] to validate the custom pre-merge checks instructions. Defaults to error when --mode is omitted.
  • @coderabbitai ignore pre-merge checks to override pre-merge checks and get an approval on PR.
  • @coderabbitai run pre-merge checks to run pre-merge checks on the pull request.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai generate unit tests to generate unit tests for this PR.
  • @coderabbitai emit path instructions to emit generated path-specific review instructions for this repository.
  • @coderabbitai resolve merge conflict to automatically resolve merge conflicts.
  • @coderabbitai autofix to automatically fix issues identified in unresolved review comments.
  • @coderabbitai resolve to resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai generate configuration to create a PR that adds the current resolved configuration as .coderabbit.yaml (or show it if already present).
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit configuration file (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, support, documentation and community

  • Visit our status page to check the current availability of CodeRabbit.
  • Create a ticket on our support page for assistance with any issues or questions.
  • Visit our documentation site for detailed information on how to use CodeRabbit.
  • Join our Discord community to connect with other users and get help from the community.
  • Follow us on X/Twitter for updates and announcements.

@rmunn

rmunn commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai rate limit

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Your plan includes PR reviews subject to rate limits. More reviews will be available in 45 minutes.

@rmunn

rmunn commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai Since this is the first PR I've modified today, I don't understand why I am hitting a rate limit. The explanation states "CodeRabbit enforces per-developer PR review limits for each organization", but the first PR I submitted today was this one. Why are you registering a rate limit? Previous pushes to this branch were while it was in draft status, which skips reviews. Did those commits still count against the review rate limit even though no review was triggered? That seems like a bug in your code.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Your plan includes PR reviews subject to rate limits. More reviews will be available in 42 minutes.

@rmunn

rmunn commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Heh, the AI model wasn't programmed to respond to that kind of question.

Brings in the picture sync improvments.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts`:
- Around line 60-86: Update `#load` to wrap both api.getFileStream and
file.stream.stream processing in a try/catch. On any exception, set this.#cache
for mediaUri to an error state with the available error detail, then re-throw
the original error so global handling remains unchanged; preserve the existing
disposed checks and successful/not-downloaded paths.

In
`@frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte`:
- Around line 37-52: Reset the collapsed state when the viewer is opened for a
picture through the direct openViewer flow, not only in showPrevious and
showNext. Ensure every newly opened picture starts with collapsed set to false
while preserving the existing navigation behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c2a21187-26a8-4872-8c42-8ceaad04853b

📥 Commits

Reviewing files that changed from the base of the PR and between 492c7ce and 20b977a.

📒 Files selected for processing (19)
  • frontend/viewer/src/lib/entry-editor/field-editors/EditPictureDialog.svelte
  • frontend/viewer/src/lib/entry-editor/field-editors/PictureActionsMenu.svelte
  • frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte
  • frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte
  • frontend/viewer/src/lib/entry-editor/field-editors/PicturesEditor.svelte
  • frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts
  • frontend/viewer/src/lib/entry-editor/field-editors/picture-actions.ts
  • frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte
  • frontend/viewer/src/locales/en.po
  • frontend/viewer/src/locales/es.po
  • frontend/viewer/src/locales/fr.po
  • frontend/viewer/src/locales/id.po
  • frontend/viewer/src/locales/ko.po
  • frontend/viewer/src/locales/ms.po
  • frontend/viewer/src/locales/sw.po
  • frontend/viewer/src/locales/vi.po
  • frontend/viewer/src/project/demo/in-memory-demo-api.ts
  • frontend/viewer/tests/pages/entry-view.component.ts
  • frontend/viewer/tests/ui/sense-pictures.test.ts

Comment thread frontend/viewer/src/lib/entry-editor/field-editors/image-service.svelte.ts Outdated
Comment thread frontend/viewer/src/lib/entry-editor/field-editors/PictureViewerDialog.svelte Outdated
rmunn and others added 3 commits July 21, 2026 13:34
If getFileStream rejected or the stream/blob processing threw, #load let the exception
propagate but left the cache on 'loading', so the picture spun forever. Wrap the fetch +
stream processing in a try/catch that sets an error state (with the exception's message as
detail) and then re-throws, so the UI recovers while the global handler still reports it.
The disposed checks and the success / not-downloaded paths are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
image-service already permits the retry (download() proceeds from any state except
in-flight/loaded, so it re-attempts from an error); clarified its doc to say so. The gap
was in the UI: PictureImage only routed clicks to download from the 'not-downloaded' state.
Make the error state clickable too — clicking (or tapping) an errored picture calls
download() to retry — and show a "Click to retry" / "Tap to retry" affordance on the error
placeholder. Works in the field, dialog, and viewer (retry is non-mutating, so allowed even
when readonly).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
collapsed was only reset in showPrevious/showNext, so opening the viewer directly (via
openViewer) after collapsing and closing reopened the picture collapsed. Reset collapsed
whenever the viewer opens, so every newly opened picture starts expanded; the navigation
resets (which fire while the viewer stays open) are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rmunn added 2 commits July 21, 2026 14:44
Both locations where the `downloadPicture` function was used were
locally renaming it in the import to `downloadPictureFile`. Simpler to
just call it `downloadPictureFile` in the first place.
@rmunn
rmunn requested a review from myieye July 21, 2026 07:56
: 'h-40 w-auto rounded-md object-contain',
);

// Long-press opens the actions menu, so touch users don't have to hit the small three-dots target.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this code right? Or is there a longPress event that the LLM just completely missed? I would assume that its training data includes lots and lots of Javascript/Typescript aimed at mobile devices, so I assume it's right, but I don't actually know. Maybe there's a much simpler way of handling long-press events that the LLM missed, and which I would also miss because I don't know about it.

Whoever reviews this, please let me know if this chunk of code is correct or not.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing it should reuse something. E.g. our context-menu gives us long presses for free. If that's not the right component here, maybe bits-ui has something smaller?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude-drafted]

Done — dropped the hand-rolled long-press timer and let the actions menu handle it via its context-menu mode (<PictureActionsMenu contextMenu> → bits-ui ContextMenu), which gives touch long-press and right-click for free. No custom long-press code anymore.

@rmunn

rmunn commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

I've done a self-review, and in theory I could commit this, but I'm not quite certain about one thing. The image-service.svelte.ts file has a comment (generated by Claude):

// getOrAdd runs this inside the project context's $effect.root; its teardown revokes the URLs.
$effect(() => () => service.dispose());

I understand what that $effect is doing (returning a teardown function, knowing that since it has no dependencies it will only be rerun when the component it ran in is unmounted). But I'm not totally confident that I understand the setup and teardown of the projectContext. Is this LLM-generated comment right? I'm not certain. And so, just to be on the safe side, I'm not quite willing to just do a self-review. I'd like another set of eyes on this, to make sure that the LLM got it right.

The other part I'm uncertain of, I've flagged: the long-press code. Another place where I don't know enough to judge if the LLM-generated code is correct.

*/
export class ImageService {
readonly #getApi: () => IMiniLcmJsInvokable | undefined;
readonly #cache = new SvelteMap<string, ImageLoadState>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we actually want to cache these across the whole project? that sounds a lot like a memory leak.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we cache them at the entry level then they go away when you switch to another entry and switch back, which I found to be very poor UX. I'll double check whether they get unloaded when you close the project.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, now that we have the downloadIfMissing parameter, then the UX is better even if we don't cache at the project level. Because we can query the backend to say "Do you have this?" and not trigger a download in the process. So I'll revert to the version that cached at the entry level.

async #load(mediaUri: string, downloadIfMissing: boolean): Promise<void> {
const api = this.#getApi();
if (!api) {
this.#cache.set(mediaUri, {status: 'error', reason: 'unknown'});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really like this. We should never get here, change the image service so that either the API is required (preferred) or it throws if you try to use it when it's not ready.

Comment thread frontend/viewer/src/lib/entry-editor/field-editors/PictureImage.svelte Outdated
…IfMissing

36a0db3 moved the cache to project scope so a downloaded picture wouldn't revert
to "click to load" after navigating away and back (EntryEditor remounts per entry
via {#key entry.id}, tearing the cache down). The downloadIfMissing mechanism added
since then makes that unnecessary: ensureLocal() (downloadIfMissing=false) probes
for a locally-available file without touching the network, and the backend keeps a
once-downloaded remote file locally — so a fresh entry-scoped cache re-loads it on
its own when you return, no re-click and no extra download.

So go back to the simpler entry-view Context scope (initImageService in EntryEditor)
while keeping the click-to-download flow: images available locally show immediately,
but a remote-only image stays a placeholder until the user clicks, keeping bandwidth
under their control (metered connections).

Update the navigation test accordingly: the object URL now differs (a fresh cache
mints a new one), so it asserts the first picture re-displays as an <img> with no
"click to load" placeholder rather than an identical src.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* what's already local, a picture downloaded once stays visible when you navigate away and back: the
* fresh entry-scoped cache re-loads it locally, no re-click needed.
*/
export class ImageService {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how much you're planning to rework this. But right now it requires the consumer to deal with the fact that it's a cache. I'd much rather a simpler API, eg loadImage(uri, options) with some options like bypassCache and downloadIfMissing and it will return an ImageState the the caller, no loading state though, a promise should be returned.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done (by @myieye while I was asleep) in commit 86fdf24.

claude and others added 4 commits July 22, 2026 14:36
- Replace the hand-rolled long-press with ResponsiveMenu's contextMenu
  flavor: right-click and touch long-press on the picture open the same
  actions menu, and desktop right-click is no longer suppressed.
- Viewer: deleting the shown picture advances to the nearest remaining
  picture instead of closing; the title shows "Picture N of M" when a
  sense has several; ArrowLeft/ArrowRight navigate (window-level so the
  keys work regardless of what has focus).
- Viewer: stop preventing the dialog's open autofocus, so focus moves
  into the dialog on open (APG dialog pattern).
- Show a generic message in the error placeholder instead of raw
  exception text; unexpected errors already reach the global handler
  (copy-error button + .NET logging) via the rethrow. Document that
  convention in the viewer AGENTS.md.
- Prune comments that narrated the code or duplicated the image-service
  rationale, and fix ones the above changes made stale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hT9fAFxFEXNkMDEUTYNNV
useEventListener with a reactive target replaces the svelte:window +
early-return guard: the keydown listener now only exists while the
viewer is open (one viewer is mounted per sense's pictures field).

The position counter becomes a language-neutral "1 / 4" beside the
stable "Picture" title, dropping the just-added "Picture {0} of {1}"
msgid from all catalogs. Top-right placement was rejected: the dialog's
close button lives there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hT9fAFxFEXNkMDEUTYNNV
The earlier comment consolidation kept the mechanism but dropped the
"mirrors a synced project fetching images on demand" rationale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hT9fAFxFEXNkMDEUTYNNV
@myieye

myieye commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

"I"

  • added keyboard navigation (I don't think looping is necessary)
  • Used our preexisting context-menu instead of the hand-rolled long-press handling
  • Asked Claude to clean up non-valuable comments, but reverted most of that, because it was probably too aggressive for someone else's PR (I have fairly conservative commenting rules)
  • Tweaked the size of the viewer dialog and the images in it and the position of the 3-dots button
  • Tweaked the 3-dots button so it looks more like our other icon buttons
  • I did not get to doing much of a code review. It was pretty much all UX/behavioural/design

I still think we should revisit the large collection of buttons in the edit dialog:
image

myieye and others added 4 commits July 22, 2026 20:46
- Fix the dialog to a fixed-height stage so paging between pictures no
  longer resizes it or moves the prev/next arrows.
- Letterbox the image (object-contain) into a definite-height figure so a
  tall/large image caps to the stage instead of overflowing the dialog.
- Reserve the caption band so caption-count differences don't shift the
  stage.
- Move the actions menu out of the on-image overlay into the top-right
  chrome, paired with the close button in one flex cluster (both icon-xs)
  so they align without mirroring the close button's position.
- Add a size prop to the responsive menu trigger for that pairing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Overlay the viewer's prev/next arrows on the stage edges (anchored to the
stage, not the image, so they hold across aspect ratios), show the caption
band only when captions exist, and let the stage bleed to the screen edges on
mobile. Simplify the placeholder/error wording ("Load picture", "Try again",
"You're offline"). Ignore image taps while an actions menu is open so a touch
ghost-click doesn't also open the viewer, covered by a new touch test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address Kevin's review: replace the ensureLocal/download/get reactive-cache
surface with loadImage(uri, {downloadIfMissing, bypassCache}) that resolves to
the final ImageState (no 'loading' variant, that's the unresolved promise) and
keeps the object-URL cache internal. The api is now required (a getter that
throws if the project isn't ready) instead of silently caching an error state,
and the local fallback is only built when a project context actually exists.
PictureImage owns its own loading UI and awaits loadImage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Swap the load $effect for runed's watch so the reload trigger (mediaUri)
is explicit rather than relying on load() transitively reading it.

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

myieye commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

[Claude-drafted]

Reworked the fullscreen picture viewer and thumbnail chrome — stable layout, touch-safe, good on desktop and mobile.

  • Fixed-height stage — paging between pictures no longer resizes the dialog or shifts the arrows.
  • Image on the plain surface (no letterbox panel): object-contain, centered; a tall/off-aspect picture caps to the stage instead of overflowing past the header (was a real bug).
  • Overlay nav arrows — circular, semi-transparent, on the stage edges; greyed at the ends, hidden for a single picture; ←/→ keys navigate (no wrap).
  • Captions only when present — no dead space; long/multi-writing-system captions cap-and-scroll; tap to collapse.
  • Header is the toolbar — title + "N / M" + actions (⋮) + close (✕), aligned.
  • Mobile — full-screen; image bleeds to the edges.
  • Touch fix — tapping a thumbnail's ⋮ no longer also opens the viewer.
  • Consistent shape — controls that float on images (thumbnail ⋮, viewer arrows) are circles.

Screenshots — landscape + portrait, desktop + mobile:

Desktop Mobile
desktop landscape mobile landscape
desktop portrait mobile portrait

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

@myieye myieye left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't say that I went through the code thoroughly, but I tweaked the UX/UI to what I'm happy with.

@rmunn

rmunn commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

There's a bug now: the caching is broken. If you open the edit dialog via the three-dots menu, then load the picture while inside the edit dialog, the picture in the main view doesn't "notice" that it has been loaded; its mediaUri isn't changed, so the watch() never reloads. It's also possible to trigger this by having two pictures, loading one and going into the fullscreen dialog, then navigating to the unloaded one. Load it while inside the fullscreen dialog, and the entry view picture still shows the placeholder.

I'm working on fixing that bug right now. Claude suggested putting cache awareness back into the PictureImage, which complicates the API. I'll see if there's a simpler way to do it.

Now if the picture is clicked in a dialog, the PictureImage component in
the editor view will be notified that the picture has updated, and will
pick up that newly-loaded picture.
@rmunn

rmunn commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Commit a149c92 fixes the caching bug; now when you load the image in the fullscreen or edit dialog, the PictureImage component in the main entry also updates itself.

rmunn added 2 commits July 24, 2026 12:43
E2E test to verify that the cache bug fixed by the previous commit will
stay fixed.
Removed most of Claude's overly-chatty comments; kept only the ones that
were actually non-obvious to someone skimming the code.
@rmunn

rmunn commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Self-review complete, removed most of Claude's overly-chatty comments in commit 022f2c3. If any of the ones I removed for being obvious were not actually obvious, it'll be easy to go back into 022f2c3 and fish that comment back out.

@rmunn

rmunn commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

IMPORTANT: When merging, please edit the merge commit and trim it down. Claude wrote more than 3/4 of the commit messages in this PR in its overly-chatty style. If you commit this with GitHub's default "smash all the commit messages together and call it good" message, it will become ridiculously long in a git log.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💻 FW Lite issues related to the fw lite application, not miniLcm or crdt related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UI improvements for pictures

4 participants