diff --git a/coulomb_social/apps/core/views.py b/coulomb_social/apps/core/views.py
index 20b9d08..99b10f9 100644
--- a/coulomb_social/apps/core/views.py
+++ b/coulomb_social/apps/core/views.py
@@ -2,6 +2,7 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render
+from coulomb_social.apps.spaces.assets import local_asset_url
from coulomb_social.apps.spaces.services import spaces_for_member
from .principal import build_principal
@@ -23,6 +24,8 @@ def app_home(request: HttpRequest) -> HttpResponse:
member = principal.get("member")
spaces = list(spaces_for_member(member))
+ for space in spaces:
+ space.visual_url = local_asset_url(space.slug, space.visual)
return render(
request,
"core/app_home.html",
diff --git a/coulomb_social/templates/core/app_home.html b/coulomb_social/templates/core/app_home.html
index 516d4e5..755ca83 100644
--- a/coulomb_social/templates/core/app_home.html
+++ b/coulomb_social/templates/core/app_home.html
@@ -17,13 +17,17 @@
{% for space in spaces %}
+ {% if space.visual_url %}
+
+ {% endif %}
{{ space.title }}
· {{ space.slug }}
{% if space.description %}
{{ space.description }}
{% endif %}
- {% if space.visual %}
+ {% if space.visual and not space.visual_url %}
Visual: {{ space.visual }}
{% endif %}
diff --git a/docs/usecases/registry.json b/docs/usecases/registry.json
index 13e81ea..98b7b87 100644
--- a/docs/usecases/registry.json
+++ b/docs/usecases/registry.json
@@ -23,7 +23,8 @@
"tests/test_reichelag_workflow.py::test_full_protocol_duplicate_edit_and_clipboard_control",
"tests/test_reichelag_workflow.py::test_protocol_naming_rename_and_collision_safety",
"tests/test_private_assets.py::test_owner_and_reader_get_exact_private_image",
- "tests/test_private_assets.py::test_outsider_foreign_tenant_revoked_archived_and_anonymous_denied"
+ "tests/test_private_assets.py::test_outsider_foreign_tenant_revoked_archived_and_anonymous_denied",
+ "tests/test_private_assets.py::test_display_uses_private_images_without_clipboard_changes"
],
"browser": [
"tests/usecases/test_reichelag_browser.py::test_weekly_call_through_real_ui"
diff --git a/scripts/test-usecase-browser.mjs b/scripts/test-usecase-browser.mjs
index b122e19..8b9a787 100644
--- a/scripts/test-usecase-browser.mjs
+++ b/scripts/test-usecase-browser.mjs
@@ -50,6 +50,7 @@ try {
}
await login('writer');
assert.match(await page.locator('main').innerText(), new RegExp(scenario.space_title));
+ await page.waitForFunction(() => document.querySelector('.space-list .space-cover')?.naturalWidth === 1);
await page.locator(`a[href="/app/spaces/${scenario.space_slug}/"]`).click();
await page.getByRole('link', {name: scenario.previous_title, exact: true}).click();
await page.waitForFunction(() => document.querySelector('#page-content img')?.naturalWidth === 1);
@@ -124,6 +125,7 @@ try {
assert.match(await page.locator('#page-content').innerText(), /Completed point/);
assert.doesNotMatch(await page.locator('#page-content').innerText(), /New notes/);
await login('reader');
+ await page.waitForFunction(() => document.querySelector('.space-list .space-cover')?.naturalWidth === 1);
await page.goto(`${detail}?page=${scenario.next_slug}`);
assert.match(await page.locator('#page-content').innerText(), /New notes/);
await page.waitForFunction(() => document.querySelector('#page-content img')?.naturalWidth === 1);
@@ -143,6 +145,7 @@ try {
const denied = await page.goto(`${detail}pages/${scenario.next_slug}/edit/`);
assert.equal(denied.status(), 403);
await login('outsider');
+ assert.equal(await page.locator('.space-list .space-cover').count(), 0);
const forbidden = await page.goto(`${detail}?page=${scenario.next_slug}`);
assert.equal(forbidden.status(), 404);
assert.equal(await page.locator('#page-content').count(), 0);
diff --git a/tests/test_private_assets.py b/tests/test_private_assets.py
index 4e7be10..7cc36ec 100644
--- a/tests/test_private_assets.py
+++ b/tests/test_private_assets.py
@@ -135,6 +135,7 @@ def test_nested_raster_allowed_but_html_disguised_as_png_denied(images, client):
@pytest.mark.django_db
+@pytest.mark.usecase("CSOC-UC-008")
def test_display_uses_private_images_without_clipboard_changes(images, client):
space, owner, _, _ = images
body = '\n\n[Open visual](assets/cover.png)\n'
@@ -149,6 +150,14 @@ def test_display_uses_private_images_without_clipboard_changes(images, client):
response = client.get(reverse("spaces:detail", kwargs={"slug": space.slug}) + "?page=notes")
assert b'alt="Pictures cover"' in response.content
assert b'alt="Notes visual"' in response.content
+ response = client.get(reverse("core:app_home"))
+ assert b'class="space-cover"' in response.content
+ assert f'src="{image_url(space)}"'.encode() in response.content
+ assert b"Visual: assets/cover.png" not in response.content
+ client.force_login(images[3].user)
+ response = client.get(reverse("core:app_home"))
+ assert b'class="space-cover"' not in response.content
+ assert image_url(space).encode() not in response.content
@pytest.mark.parametrize("reference", ["https://remote.test/image.png", "//remote.test/a.png",
diff --git a/tests/usecases/test_reichelag_browser.py b/tests/usecases/test_reichelag_browser.py
index fd204ec..832e338 100644
--- a/tests/usecases/test_reichelag_browser.py
+++ b/tests/usecases/test_reichelag_browser.py
@@ -36,7 +36,7 @@ def test_weekly_call_through_real_ui(tmp_path, settings, request):
user_engine_user_id=subject, issuer="https://local.dev/issuer", subject=subject,
)
space = create_space(actors["writer"], title=scenario["space_title"],
- slug=scenario["space_slug"])
+ slug=scenario["space_slug"], visual=scenario["visual"])
pageops.assets_dir(space.slug).joinpath("cover.png").write_bytes(
base64.b64decode(scenario["visual_base64"])
)
diff --git a/workplans/CSOC-WP-0008-production-transfer-acceptance.md b/workplans/CSOC-WP-0008-production-transfer-acceptance.md
index 2a92362..463d717 100644
--- a/workplans/CSOC-WP-0008-production-transfer-acceptance.md
+++ b/workplans/CSOC-WP-0008-production-transfer-acceptance.md
@@ -585,3 +585,13 @@ revision/concurrency, cross-space body/incoming links and broader fidelity remai
T09 still awaits founder Visual and complete workflow acceptance; user asked to
refresh ReichelAG. No task/workplan completion or Bubble retirement inferred.
Receipt: docs/release/private-visuals-2026-10-07.md.
+
+## User Visual review: missing Spaces-list preview — 2026-10-07
+
+Founder confirms the Visual appears on the page, but not in Spaces-list entries.
+T05/T09 retain this concrete review gap. Add the space cover to its existing
+list entry using the same private route and membership-filtered query. Extend
+UC-008 fast and real-browser checks for writer/viewer list-preview loading and
+outsider exclusion. Preserve page/clipboard/content/access behavior. This is a
+correction within the approved private-Visual image-only upgrade scope, not
+acceptance of the complete use case. No wider UI framework work included.