Nothing here yet.
-
- FabledCurator follows the creators you subscribe to and files what they
- post. Two steps to start.
-
-
- Add a credential
- Add a source
-
-
- A credential comes first — a source can't fetch anything without your
- logged-in session.
-
+
+
+
+
+ FabledCurator follows the creators you subscribe to and files what
+ they post. Two steps to start.
+
+
+ Add a credential
+ Add a source
+
+
+ A credential comes first — a source can't fetch anything without your
+ logged-in session.
+
+
+
+
+
+
+ You've connected {{ credentialLabel }}. FabledCurator can look up the
+ creators you already subscribe to, so you don't have to add them by
+ hand.
+
+
+ Find what you already subscribe to
+ Add a source
+
+
+ Nothing is added automatically — you'll get a list to pick from.
+
+
+
+
+
+
+ {{ unmatchedCount }}
+ {{ unmatchedCount === 1 ? 'creator you subscribe to is' : 'creators you subscribe to are' }}
+ not being followed here yet.
+
+
+ Choose who to follow
+
+
+ Added one at a time, so a fresh install doesn't start a dozen
+ backfills at once.
+
+
+
+
+
+
+ FabledCurator couldn't reach {{ credentialLabel }} to look up your
+ subscriptions{{ discoveryError ? ` (${discoveryError})` : '' }}. You
+ can still add creators by hand.
+
+
+ Add a source
+ Check the credential
+
+
+
+
+
+
+ Everything you subscribe to on {{ credentialLabel }} is already
+ followed here. Add a creator by hand to start filling the feed.
+
+
+ Add a source
+
+
diff --git a/frontend/test/components/feedEmptyState.spec.js b/frontend/test/components/feedEmptyState.spec.js
index 2b6d11d..904e12c 100644
--- a/frontend/test/components/feedEmptyState.spec.js
+++ b/frontend/test/components/feedEmptyState.spec.js
@@ -2,6 +2,9 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import FeedEmptyState from '../../src/components/posts/FeedEmptyState.vue'
+import { useCredentialsStore } from '../../src/stores/credentials.js'
+import { useMembershipReconcileStore } from '../../src/stores/membershipReconcile.js'
+import { useMembershipSyncStore } from '../../src/stores/membershipSync.js'
import { useSourcesStore } from '../../src/stores/sources.js'
import { mountWithStore } from '../support/mountComponent.js'
@@ -10,9 +13,45 @@ import { mountWithStore } from '../support/mountComponent.js'
// "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
-})
+// The second argument is C6's world. Omitting it means "no credential", which
+// is both the fresh-install default and the rung every B4 case above asserts —
+// so those keep passing unchanged rather than needing the new vocabulary.
+const mountWith = (status, { credentials = [], sync = [], reconcile = [] } = {}) =>
+ mountWithStore(FeedEmptyState, () => {
+ useSourcesStore().scheduleStatus = status
+ useCredentialsStore().byPlatform = new Map(
+ credentials.map((name) => [name, { platform: name }]),
+ )
+ useMembershipSyncStore().platforms = sync
+ useMembershipReconcileStore().platforms = reconcile
+ })
+
+const EMPTY = { total_sources: 0 }
+const SYNCED = [{ platform: 'patreon', last_success_at: '2026-09-12T00:00:00Z' }]
+const NEVER = [{ platform: 'patreon', last_success_at: null }]
+
+// Every rung's distinguishing phrase, so a test can assert that exactly ONE is
+// on screen. Each is chosen to sit on a SINGLE template line: a phrase spanning
+// a line break would never match once the renderer keeps the newline, and a
+// mutual-exclusion check whose phrases never match passes vacuously — coverage
+// in appearance only (rule #167). Both sides are whitespace-normalised anyway,
+// so indentation changes cannot quietly break it either.
+const RUNG_PHRASES = {
+ credential: 'Add a credential',
+ discover: 'Find what you already subscribe to',
+ adopt: 'not being followed here yet',
+ unavailable: "couldn't reach",
+ manual: 'Everything you subscribe to on',
+}
+
+const flat = (s) => s.replace(/\s+/g, ' ').trim()
+
+function rungsShown (w) {
+ const text = flat(w.text())
+ return Object.entries(RUNG_PHRASES)
+ .filter(([, phrase]) => text.includes(flat(phrase)))
+ .map(([name]) => name)
+}
describe('FeedEmptyState', () => {
beforeEach(() => {
@@ -58,4 +97,120 @@ describe('FeedEmptyState', () => {
// Decorative: the surrounding prose already carries the meaning.
expect(img.attributes('alt')).toBe('')
})
+
+ // --- #387 C6: the discovery rung -----------------------------------------
+ //
+ // The loop this closes: a new installer has already told Patreon which
+ // creators they follow, and making them retype that list is the friction the
+ // whole milestone is about. These pin that the rung shown matches what is
+ // actually POSSIBLE — offering discovery before a credential exists, or
+ // after it has proven unreachable, is a button that cannot work.
+
+ it('offers discovery once a credential exists and nothing has synced', () => {
+ const w = mountWith(EMPTY, { credentials: ['patreon'], sync: NEVER })
+ expect(w.text()).toContain('Find what you already subscribe to')
+ expect(w.text()).toContain('patreon')
+ // Nothing is added for them — the offer/never-auto-add line from C4.
+ expect(w.text()).toContain('Nothing is added automatically')
+ })
+
+ it('never offers discovery before a credential exists', () => {
+ // The button would 404 against an unconnected platform. Absence beats a
+ // dead control on the first screen anyone sees.
+ const w = mountWith(EMPTY, { sync: NEVER })
+ expect(w.text()).not.toContain('Find what you already subscribe to')
+ expect(w.text()).toContain('Add a credential')
+ })
+
+ it('a sweep that has never worked reads as unavailable, not as a retry', () => {
+ // Rule #164: an install with no outbound network must reach this screen and
+ // be told plainly. Not a spinner, not a crash, not a button that will fail
+ // the same way — and the manual path stays open.
+ const w = mountWith(EMPTY, {
+ credentials: ['patreon'],
+ sync: [{ platform: 'patreon', last_success_at: null, last_error_type: 'ConnectionError' }],
+ })
+ expect(w.text()).toContain("couldn't reach")
+ expect(w.text()).toContain('ConnectionError')
+ expect(w.text()).toContain('Add a source')
+ expect(w.text()).not.toContain('Find what you already subscribe to')
+ })
+
+ it('a roster that synced once and failed since is NOT unavailable', () => {
+ // It has a roster, just an ageing one — C4's freshness gate handles that.
+ // Collapsing the two would hide a usable roster behind an error banner.
+ const w = mountWith(EMPTY, {
+ credentials: ['patreon'],
+ sync: [{
+ platform: 'patreon',
+ last_success_at: '2026-09-12T00:00:00Z',
+ last_error_type: 'PatreonAuthError',
+ }],
+ reconcile: [{ platform: 'patreon', subscribed_not_tracked: [{ id: 1 }] }],
+ })
+ expect(w.text()).not.toContain("couldn't reach")
+ expect(w.text()).toContain('not being followed here yet')
+ })
+
+ it('counts what there is to adopt, and sends them to the picker', () => {
+ const w = mountWith(EMPTY, {
+ credentials: ['patreon'],
+ sync: SYNCED,
+ reconcile: [{ platform: 'patreon', subscribed_not_tracked: [{ id: 1 }, { id: 2 }] }],
+ })
+ expect(w.text()).toContain('2 creators you subscribe to are')
+ expect(w.text()).toContain('Choose who to follow')
+ })
+
+ it('singularises a lone unmatched creator', () => {
+ const w = mountWith(EMPTY, {
+ credentials: ['patreon'],
+ sync: SYNCED,
+ reconcile: [{ platform: 'patreon', subscribed_not_tracked: [{ id: 1 }] }],
+ })
+ expect(w.text()).toContain('1 creator you subscribe to is')
+ expect(w.text()).not.toContain('1 creators')
+ })
+
+ it('a fully-tracked roster stops offering discovery', () => {
+ // Nothing left to find, so the manual path is the honest last rung rather
+ // than a discovery button that would return an empty list.
+ const w = mountWith(EMPTY, {
+ credentials: ['patreon'],
+ sync: SYNCED,
+ reconcile: [{ platform: 'patreon', subscribed_not_tracked: [] }],
+ })
+ expect(w.text()).toContain('Everything you subscribe to on')
+ expect(w.text()).toContain('Add a source')
+ expect(w.text()).not.toContain('Find what you already subscribe to')
+ expect(w.text()).not.toContain('not being followed here yet')
+ })
+
+ it('shows exactly one rung in every state', () => {
+ // The component claims the rungs are mutually exclusive by construction.
+ // This is that claim, asserted — two on screen at once would be worst
+ // exactly here, on the first screen a new installer ever sees.
+ const states = [
+ [EMPTY, {}],
+ [EMPTY, { credentials: ['patreon'], sync: NEVER }],
+ [EMPTY, { credentials: ['patreon'], sync: [{ platform: 'patreon', last_success_at: null, last_error_type: 'ConnectionError' }] }],
+ [EMPTY, { credentials: ['patreon'], sync: SYNCED, reconcile: [{ platform: 'patreon', subscribed_not_tracked: [{ id: 1 }] }] }],
+ [EMPTY, { credentials: ['patreon'], sync: SYNCED, reconcile: [{ platform: 'patreon', subscribed_not_tracked: [] }] }],
+ ]
+ for (const [status, world] of states) {
+ expect(rungsShown(mountWith(status, world))).toHaveLength(1)
+ }
+ })
+
+ it('an install that is already fetching never sees an on-ramp rung', () => {
+ // hasSources wins over everything C6 added — someone mid-backfill is not
+ // onboarding, whatever their roster says.
+ const w = mountWith({ total_sources: 3 }, {
+ credentials: ['patreon'],
+ sync: SYNCED,
+ reconcile: [{ platform: 'patreon', subscribed_not_tracked: [{ id: 1 }] }],
+ })
+ expect(w.text()).toContain("See what's running")
+ expect(rungsShown(w)).toHaveLength(0)
+ })
})