feat: the empty front door is the install's first screen (milestone 387 step B4)
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 5s
CI / extension-version (push) Successful in 6s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 36s
Build images / build-web (push) Successful in 1m17s
Build images / smoke-web (push) Skipped
CI / integration (push) Successful in 2m3s
Build images / build-ml (push) Successful in 2m24s
Build images / promote (push) Skipped

The front door is now a feed, and a blank feed implies things should be
here in a way a blank masonry does not. On a fresh install this is the
first screen anyone sees — including someone who is not the operator,
which is what milestone 328 is making possible.

Tells the two empties apart, which is the point. "No sources yet" gets
the on-ramp; "sources configured, nothing landed yet" gets told that
the first check takes a while and pointed at Downloads. Telling someone
to add a source when they already have three and are mid-backfill reads
as the app not knowing its own state.

Needs total_sources on schedule-status to distinguish them —
deliberately not auto_sources, which counts only what is on a schedule,
so a source with auto_check off would have read as "nothing
configured". Both exact-shape assertions updated in THIS change rather
than after CI caught them, which is the lesson from B3's red push.

An absent status falls back to the on-ramp on purpose: it is merely
redundant to an established operator, whereas "see what's running"
shown to someone with nothing configured is a dead end.

A filtered miss is deliberately NOT the onboarding case — the operator
has posts, they just narrowed past them. Showing a fresh-install
on-ramp there would tell someone with a full library to go set it up.

This is where logo.svg lands, as the operator asked. It earns its place
on a first-run screen and not on a populated feed, and gives the
on-ramp something to compose around instead of prose plus two buttons.
Large: the mark stops reading below ~48px, which is why the 22px nav
slot has a different one. Pinned by test so a later tidy-up cannot
quietly shrink it to a glyph.

Also extracts mountWithStore into the shared test support module.
Writing the second spec created exactly the copy-paste that open issue
3109 tracks for the backend row factories, so it is consolidated now
rather than at copy three, and recorded as snippet 3829 with the two
traps it does NOT solve — named slots rendering nothing, and
components that fetch on mount.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
This commit is contained in:
2026-09-09 23:10:06 -04:00
co-authored by Claude Opus 5
parent 3f8306f705
commit 6b19012bb6
8 changed files with 207 additions and 11 deletions
+10
View File
@@ -235,6 +235,15 @@ async def scheduler_status(session: AsyncSession) -> dict:
select(func.count()).select_from(Source) select(func.count()).select_from(Source)
.where(Source.enabled.is_(True), no_access_sources_clause()) .where(Source.enabled.is_(True), no_access_sources_clause())
)).scalar_one() )).scalar_one()
# #387 B4: lets the front door tell "nothing configured yet" (a fresh
# install — show the on-ramp) apart from "configured, still fetching" (a
# first run in progress — show what's running). Telling someone to add a
# source when they already have three and are mid-backfill is worse than
# saying nothing. Deliberately NOT auto_sources, which counts only what is
# on a schedule: a source with auto_check off still means "configured".
total_sources = (await session.execute(
select(func.count()).select_from(Source).where(Source.enabled.is_(True))
)).scalar_one()
return { return {
"last_tick_at": last_tick_at, "last_tick_at": last_tick_at,
@@ -243,5 +252,6 @@ async def scheduler_status(session: AsyncSession) -> dict:
"auto_sources": len(rows), "auto_sources": len(rows),
"failing_sources": failing_sources, "failing_sources": failing_sources,
"no_access_sources": no_access_sources, "no_access_sources": no_access_sources,
"total_sources": total_sources,
"platform_cooldowns": {p: dt.isoformat() for p, dt in cooldowns.items()}, "platform_cooldowns": {p: dt.isoformat() for p, dt in cooldowns.items()},
} }
@@ -0,0 +1,116 @@
<template>
<div class="fc-empty">
<!-- The brand mark's second home (#387 A-side logo work, operator-confirmed).
It earns its place HERE and not on a populated feed: this is the first
screen a fresh install shows anyone, and it gives the on-ramp something
to be composed around rather than a bare line of prose plus buttons.
Large on purpose — logo.svg stops reading below ~48px, which is why the
22px nav slot has its own mark. No wrapping card: the file deliberately
carries no plate and is meant to sit on the page surface. Vendored
locally, so this renders on an install with no network — which is
exactly the state this screen appears in. -->
<img src="/logo.svg" alt="" class="fc-empty__mark" width="150" height="150" />
<!-- FIRST RUN: sources exist, nothing has landed yet. Telling someone to
add a source when they already have three and are mid-backfill is
worse than saying nothing at all. -->
<template v-if="hasSources">
<p class="fc-empty__lead">Nothing has arrived yet.</p>
<p class="fc-empty__sub">
{{ sourceCountLabel }} configured. The first check can take a while —
deep history is fetched in chunks.
</p>
<div class="fc-empty__actions">
<v-btn
size="small" variant="tonal" color="accent"
prepend-icon="mdi-progress-download"
:to="{ path: '/subscriptions', query: { tab: 'downloads' } }"
>See what's running</v-btn>
</div>
</template>
<!-- FRESH INSTALL: the on-ramp, in the order the steps actually depend on
each other a source cannot fetch anything without a credential. -->
<template v-else>
<p class="fc-empty__lead">Nothing here yet.</p>
<p class="fc-empty__sub">
FabledCurator follows the creators you subscribe to and files what they
post. Two steps to start.
</p>
<div class="fc-empty__actions">
<v-btn
size="small" variant="tonal" color="accent" prepend-icon="mdi-key-variant"
:to="{ path: '/subscriptions', query: { tab: 'settings' } }"
>Add a credential</v-btn>
<v-btn
size="small" variant="text" prepend-icon="mdi-plus"
:to="{ path: '/subscriptions' }"
>Add a source</v-btn>
</div>
<p class="fc-empty__hint">
A credential comes first a source can't fetch anything without your
logged-in session.
</p>
</template>
</div>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useSourcesStore } from '../../stores/sources.js'
const store = useSourcesStore()
const { scheduleStatus: status } = storeToRefs(store)
// Absent status is treated as "fresh install", which is the safe way round:
// the on-ramp is useful to a first-run operator and merely redundant to an
// established one, whereas "see what's running" shown to someone with nothing
// configured is a dead end.
const hasSources = computed(() => (status.value?.total_sources || 0) > 0)
const sourceCountLabel = computed(() => {
const n = status.value?.total_sources || 0
return `${n} ${n === 1 ? 'source' : 'sources'}`
})
onMounted(() => {
// The ribbon may already have loaded this on the front door; in Browse's
// Posts tab nothing has. Failure is swallowed — an empty feed must still
// explain itself when the status call is unavailable (rule #164).
if (!status.value) store.loadScheduleStatus().catch(() => {})
})
</script>
<style scoped>
.fc-empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 6px;
padding: 48px 16px 32px;
}
.fc-empty__mark {
/* Sits back a little: it frames the message rather than competing with it. */
opacity: 0.9;
margin-bottom: 8px;
}
.fc-empty__lead {
font-size: 1.05rem;
color: rgb(var(--v-theme-on-surface));
}
.fc-empty__sub, .fc-empty__hint {
color: rgb(var(--v-theme-on-surface-variant));
max-width: 34rem;
}
.fc-empty__hint { font-size: 0.8rem; margin-top: 4px; }
.fc-empty__actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
margin-top: 10px;
}
</style>
+5 -4
View File
@@ -66,11 +66,11 @@
</div> </div>
<div v-else-if="store.items.length === 0 && store.done" class="fc-posts__empty"> <div v-else-if="store.items.length === 0 && store.done" class="fc-posts__empty">
<!-- A filtered miss is NOT the onboarding case: the operator has posts,
they just narrowed past them. Showing a fresh-install on-ramp here
would be telling someone with a full library to go and set it up. -->
<p v-if="hasActiveFilter">No posts match your search or filters.</p> <p v-if="hasActiveFilter">No posts match your search or filters.</p>
<p v-else>No posts yet. Subscribe to a source on the <FeedEmptyState v-else />
<RouterLink to="/subscriptions">Subscriptions</RouterLink>
tab to start capturing posts.
</p>
</div> </div>
<div v-else> <div v-else>
@@ -92,6 +92,7 @@ import { usePostsStore } from '../stores/posts.js'
import PostsFilterBar from '../components/posts/PostsFilterBar.vue' import PostsFilterBar from '../components/posts/PostsFilterBar.vue'
import PostCard from '../components/posts/PostCard.vue' import PostCard from '../components/posts/PostCard.vue'
import FeedStatusRibbon from '../components/posts/FeedStatusRibbon.vue' import FeedStatusRibbon from '../components/posts/FeedStatusRibbon.vue'
import FeedEmptyState from '../components/posts/FeedEmptyState.vue'
// The ingestion-status ribbon is a FRONT-DOOR concern, not a feed concern — // The ingestion-status ribbon is a FRONT-DOOR concern, not a feed concern —
// inside Browse's Posts tab you are looking FOR something, and the Subscriptions // inside Browse's Posts tab you are looking FOR something, and the Subscriptions
@@ -0,0 +1,61 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import FeedEmptyState from '../../src/components/posts/FeedEmptyState.vue'
import { useSourcesStore } from '../../src/stores/sources.js'
import { mountWithStore } from '../support/mountComponent.js'
// #387 B4. This is the first screen a fresh install shows anyone, so the thing
// worth pinning is that it tells the two empties apart. Telling an operator to
// "add a source" when they already have three and are mid-backfill is worse
// than saying nothing — it reads as the app not knowing its own state.
const mountWith = (status) => mountWithStore(FeedEmptyState, () => {
useSourcesStore().scheduleStatus = status
})
describe('FeedEmptyState', () => {
beforeEach(() => {
globalThis.fetch = vi.fn(async () => { throw new Error('offline') })
})
afterEach(() => vi.restoreAllMocks())
it('a fresh install gets the on-ramp, credential first', () => {
const w = mountWith({ total_sources: 0 })
expect(w.text()).toContain('Add a credential')
expect(w.text()).toContain('Add a source')
expect(w.text()).not.toContain("See what's running")
})
it('an install that is already fetching is told so, not told to set up', () => {
const w = mountWith({ total_sources: 3 })
expect(w.text()).toContain('3 sources')
expect(w.text()).toContain("See what's running")
expect(w.text()).not.toContain('Add a credential')
})
it('singularises a lone source', () => {
const w = mountWith({ total_sources: 1 })
expect(w.text()).toContain('1 source')
expect(w.text()).not.toContain('1 sources')
})
it('falls back to the on-ramp when the status call never answered', () => {
// Safe direction: the on-ramp is merely redundant to an established
// operator, whereas "see what's running" shown to someone with nothing
// configured is a dead end.
const w = mountWith(null)
expect(w.text()).toContain('Add a credential')
})
it('shows the brand mark, sized to be readable', () => {
// logo.svg stops reading below ~48px — that is why the 22px nav slot has a
// different mark. If this ever shrinks to a glyph, it is the wrong asset.
const img = mountWith({ total_sources: 0 }).find('img.fc-empty__mark')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('/logo.svg')
expect(Number(img.attributes('width'))).toBeGreaterThanOrEqual(48)
// Decorative: the surrounding prose already carries the meaning.
expect(img.attributes('alt')).toBe('')
})
})
@@ -3,7 +3,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import FeedStatusRibbon from '../../src/components/posts/FeedStatusRibbon.vue' import FeedStatusRibbon from '../../src/components/posts/FeedStatusRibbon.vue'
import { useSourcesStore } from '../../src/stores/sources.js' import { useSourcesStore } from '../../src/stores/sources.js'
import { freshPinia, mountComponent } from '../support/mountComponent.js' import { mountWithStore } from '../support/mountComponent.js'
// #387 B3. This ribbon is the ONLY place phase A's work reaches someone who // #387 B3. This ribbon is the ONLY place phase A's work reaches someone who
// wasn't already looking for it, so what it does and does not say is the whole // wasn't already looking for it, so what it does and does not say is the whole
@@ -12,11 +12,9 @@ import { freshPinia, mountComponent } from '../support/mountComponent.js'
// zero — a "0 failing" on the front door is noise that trains you to ignore // zero — a "0 failing" on the front door is noise that trains you to ignore
// the line, which is exactly what would hide the real number later. // the line, which is exactly what would hide the real number later.
function mountWith (status) { const mountWith = (status) => mountWithStore(FeedStatusRibbon, () => {
const pinia = freshPinia()
useSourcesStore().scheduleStatus = status useSourcesStore().scheduleStatus = status
return mountComponent(FeedStatusRibbon, { pinia }) })
}
describe('FeedStatusRibbon', () => { describe('FeedStatusRibbon', () => {
beforeEach(() => { beforeEach(() => {
+10
View File
@@ -35,3 +35,13 @@ export const VTooltipStub = {
name: 'VTooltip', name: 'VTooltip',
template: '<div><slot name="activator" :props="{}" /><slot /></div>', template: '<div><slot name="activator" :props="{}" /><slot /></div>',
} }
// Mount with a fresh pinia and the store already seeded, for components that
// read store state during render. Without it, the seeding has to be inlined
// between createPinia and mount in every spec — the same copy-paste that issue
// #3109 tracks for the backend row factories.
export function mountWithStore (Component, seed, opts = {}) {
const pinia = freshPinia()
seed()
return mountComponent(Component, { ...opts, pinia })
}
+1 -1
View File
@@ -55,7 +55,7 @@ async def test_schedule_status_shape(client):
body = await resp.get_json() body = await resp.get_json()
assert set(body) == { assert set(body) == {
"last_tick_at", "next_due_at", "due_now", "auto_sources", "last_tick_at", "next_due_at", "due_now", "auto_sources",
"failing_sources", "no_access_sources", "failing_sources", "no_access_sources", "total_sources",
"platform_cooldowns", "platform_cooldowns",
} }
assert isinstance(body["due_now"], int) assert isinstance(body["due_now"], int)
+1 -1
View File
@@ -62,7 +62,7 @@ async def test_summary_returns_rollup_shape(client, monkeypatch):
assert isinstance(body["failing"], int) assert isinstance(body["failing"], int)
assert set(body["scheduler"]) == { assert set(body["scheduler"]) == {
"last_tick_at", "next_due_at", "due_now", "auto_sources", "last_tick_at", "next_due_at", "due_now", "auto_sources",
"failing_sources", "no_access_sources", "failing_sources", "no_access_sources", "total_sources",
"platform_cooldowns", "platform_cooldowns",
} }