docs(phase-9): spec for per-client intake links + project picker
Phase 9 design doc. Same format as Phase 8. Headline: - intake_links.client replaces intake_links.project for new rows; old shape stays live - projects.show_in_intake default false; owners opt in per project - /intake/<token> becomes a picker page; selecting a project redirects to /intake/<token>/p/<projectId> for the wizard - daily cap is now per (link, project) via a new intake_link_submissions collection - Phase 8 invariants (worst a hostile body achieves is a strangely-worded card; same 404 wording for unknown/revoked/ expired; server decides created_by_role=anonymous; parseBrief whitelist) all carry forward unchanged Three plans in 9a/9b/9c. ~3 working days.
This commit is contained in:
parent
b02ebb7cf6
commit
917736ebb0
1 changed files with 499 additions and 0 deletions
|
|
@ -0,0 +1,499 @@
|
|||
# Phase 9 — Per-Client Intake Links + Project Picker
|
||||
|
||||
**Date:** 2026-09-21
|
||||
**Status:** Draft for review
|
||||
**Phase:** 9
|
||||
**Depends on:** Phase 8 (public anonymous intake, PWA shell, per-project markdown context)
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Phase 8 made the public route work end-to-end. The next thing the
|
||||
developer asked for after playing with it for an hour is the link shape
|
||||
itself: **one URL per client to share, with a project picker in front of
|
||||
the wizard**, instead of one URL per project. The reasoning is in the
|
||||
thread above this spec; the short version is that human clients do not
|
||||
think in projects, they think in companies, and "send us a request —
|
||||
`rjb.example.com/intake`" beats "send us a request for the SE-Sprint-2
|
||||
migration — `/intake/hkXHJPUS…`" by a mile.
|
||||
|
||||
Three things change, and nothing else moves:
|
||||
|
||||
1. **The link is bound to a client, not a project.** Each client has a
|
||||
small set of links (typically one) that open a picker page listing the
|
||||
client's active projects.
|
||||
2. **The picker page is the new entry.** It is a real, anonymous,
|
||||
authenticated-by-token page that lists projects the client has and
|
||||
asks the user to pick one before the wizard runs.
|
||||
3. **The wizard itself does not change.** It still takes a `projectId`
|
||||
and still asks questions and still writes a card. It just gets that
|
||||
`projectId` from the URL of the picker-selected project rather than
|
||||
from the link row.
|
||||
|
||||
The security property Phase 8 set up carries forward unchanged: **the
|
||||
worst a hostile body achieves is a strangely-worded card in the Inbox**.
|
||||
The picker is a UI change to how the link and the wizard connect — it
|
||||
does not add a way for a hostile body to escape the parseBrief whitelist
|
||||
or the parse-and-server-decide-fields rule.
|
||||
|
||||
Out of scope for this spec:
|
||||
|
||||
- **Re-introducing per-project links.** The new shape is additive; the
|
||||
old per-project shape stays live. Existing Phase 8 tokens keep
|
||||
working as-is and continue to point at their original project.
|
||||
- **Multi-client links.** One token, one client, easy to rotate. A
|
||||
token cannot span clients, today or later.
|
||||
- **Reordering intake by client-defined workflow.** Each project still
|
||||
gets its own card; the picker just routes which project's wizard runs.
|
||||
- **Renaming projects in the picker to be friendlier than the internal
|
||||
`slug`.** A later phase can add a `client_display_name`; for now the
|
||||
picker shows the project's actual name.
|
||||
- **Branch-by-tenant lookup of intake links, analytics, etc.** One
|
||||
PocketBase superuser; one rule set. Per-tenant isolation was deliberately
|
||||
out from Phase 1 onwards.
|
||||
|
||||
## 2. What is already settled
|
||||
|
||||
Carried forward unchanged from the Phase 8 spec and its ancestors:
|
||||
|
||||
| Decision | Where from |
|
||||
|---|---|
|
||||
| The link is a capability, not a session. Rotation is the threat model. | Phase 8 §3 |
|
||||
| The brief is re-parsed through `parseBrief`, which is a whitelist | Phase 4b |
|
||||
| Column, priority, `accepted`, `created_by_role` are set by the server, never the body | Phase 1, Phase 4b |
|
||||
| Submitted text is data, never instructions | Phase 1 + 3 preamble |
|
||||
| The wizard is stateless — the transcript travels in the prompt | Phase 4 |
|
||||
| Today's signed-in wizard does not change | Phase 8 / 8 / 8c |
|
||||
| Phase 8's "minimal intake layout with no app shell" is the picker page's layout too | Phase 8 Task 9 |
|
||||
| The PWA manifest, service worker, and iOS meta already cover any `/intake/*` URL | Phase 8 Tasks 3–5 |
|
||||
|
||||
## 3. Threat model for the picker
|
||||
|
||||
The Phase 8 threat model is the floor. Three Phase-9-specific concerns
|
||||
sit on top of it; I'll name each and what we do about it.
|
||||
|
||||
**Concern 1 — Listing too many projects.**
|
||||
A picker that dumps every active project a client has leaks work the
|
||||
client didn't know about. A picker with too few projects is useless.
|
||||
Resolution: each project carries a `show_in_intake: bool` (default
|
||||
`false`, owner opts in). The picker queries `project.active AND
|
||||
project.show_in_intake` — never the wider set. A confidential project
|
||||
("Acme Rebrand — pre-announce") simply isn't in the list until the
|
||||
owner says so.
|
||||
|
||||
**Concern 2 — Cross-client leakage through the picker.**
|
||||
The token resolves to one client. The picker lists *that client's*
|
||||
projects. Cross-client is structurally impossible because the picker
|
||||
reads projects through the link's `client`, not by enumerating all
|
||||
projects.
|
||||
|
||||
**Concern 3 — Enumeration via the picker.**
|
||||
There is no "is this token valid?" probe because the picker is the
|
||||
response to an unknown token: same 404 wording as the wizard in
|
||||
Phase 8. The picker cannot be used to test whether a project name
|
||||
exists on the platform.
|
||||
|
||||
The Phase 8 controls carry forward unchanged:
|
||||
- rate limit per `(token, ip)`
|
||||
- daily cap (now per `(link, project)` — see §5)
|
||||
- token shape check before any DB read
|
||||
- 404 not 403 on bad tokens (same wording for unknown/revoked/expired)
|
||||
- server-decides-fields (the body never sets `column`, `priority`,
|
||||
`created_by_role`, etc.)
|
||||
- `parseBrief` whitelist on the model's output
|
||||
|
||||
The token is unchanged: 24 bytes CSPRNG → base64url → 32 chars.
|
||||
|
||||
## 4. Data model
|
||||
|
||||
Two schema changes, both additive. The Phase 8 `intake_links.project`
|
||||
shape stays live alongside the new shape; the two are distinguished by
|
||||
which field is populated on the row.
|
||||
|
||||
### 4.1 `intake_links.client` (new required field on intake_links)
|
||||
|
||||
Replace `intake_links.project` (Phase 8) with `intake_links.client` for
|
||||
new rows. The schema definition in `scripts/setup-remote.ts` flips:
|
||||
|
||||
```ts
|
||||
// scripts/setup-remote.ts — intake_links collection declaration
|
||||
fields: [
|
||||
{
|
||||
name: "client",
|
||||
type: "relation",
|
||||
required: true,
|
||||
collectionId: (await pb.collections.getOne("clients")).id,
|
||||
cascadeDelete: true,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
// project field is gone for new rows. Migration §10 keeps
|
||||
// existing rows intact via a partial-index trick.
|
||||
...
|
||||
],
|
||||
indexes: [
|
||||
"CREATE UNIQUE INDEX idx_intake_links_token ON intake_links (token)",
|
||||
"CREATE INDEX idx_intake_links_client ON intake_links (client, active)",
|
||||
],
|
||||
```
|
||||
|
||||
`project` becomes an *optional* field, kept on the collection for the
|
||||
migration window (§10). New rows leave it empty. The TypeScript
|
||||
`IntakeLinkRecord` becomes:
|
||||
|
||||
```ts
|
||||
export interface IntakeLinkRecord extends BaseRecord {
|
||||
client: string;
|
||||
/** Migration window only: projects this link binds to directly (Phase 8 shape).
|
||||
* Empty on Phase 9 rows. The wizard reads projects via the client. */
|
||||
project: string;
|
||||
/** The owner's name for this link, e.g. "Acme — till phone". */
|
||||
label: string;
|
||||
token: string;
|
||||
active: boolean;
|
||||
expires_at: string;
|
||||
max_submissions_per_day: number;
|
||||
submission_count: number;
|
||||
// submission_count_today and submission_count_today_date become
|
||||
// per-project for Phase 9 — see §5.
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 `projects.show_in_intake` (new optional bool)
|
||||
|
||||
```ts
|
||||
{ name: "show_in_intake", type: "bool", required: false }
|
||||
```
|
||||
|
||||
Default `false`. Owner opts a project in by toggling it on the existing
|
||||
project admin page (one new field on `BackgroundMdEditor`'s sibling
|
||||
panel — same shape).
|
||||
|
||||
### 4.3 API rules (intake_links)
|
||||
|
||||
Same as Phase 8 — owner-only at the rule level. The wizard reads
|
||||
through the superuser connection; the rule exists so a signed-in client
|
||||
manager cannot enumerate links, same as before.
|
||||
|
||||
### 4.4 Daily cap, per `(link, project)`
|
||||
|
||||
Phase 8 tracked today's submissions on the link row itself:
|
||||
`submission_count_today`, `submission_count_today_date`. With the new
|
||||
per-client shape, that counter now means "across all projects under
|
||||
this link" — which is wrong. One busy project could lock the others
|
||||
out at the cap.
|
||||
|
||||
Phase 9 splits it: the daily counter moves onto a *new* collection,
|
||||
`intake_link_submissions`, one row per (link, project, date):
|
||||
|
||||
```ts
|
||||
{
|
||||
link: string; // intake_links.id
|
||||
project: string; // project id
|
||||
date: string; // YYYY-MM-DD
|
||||
count: number; // submitted today
|
||||
}
|
||||
unique index: (link, project, date)
|
||||
```
|
||||
|
||||
The wizard's daily-cap check (`isLinkUsable`) becomes:
|
||||
|
||||
```
|
||||
cap = max_submissions_per_day || 100
|
||||
for the chosen project: count = today's row, or 0
|
||||
return count < cap
|
||||
```
|
||||
|
||||
Existing per-project Phase 8 links keep using `submission_count_today`
|
||||
on the link row for that one project — the field stays, the new
|
||||
collection is additive for the new shape only.
|
||||
|
||||
### 4.5 What the owner must opt
|
||||
|
||||
Owner-side:
|
||||
|
||||
1. On each project: flip `show_in_intake` to `true` for the projects
|
||||
they want in the picker. Default off, so a freshly-created project
|
||||
never appears in the picker until explicitly opted in.
|
||||
2. On each client: mint at least one intake link from
|
||||
`/admin/clients/[clientId]` (a new page) or via a "New link for this
|
||||
client" button on an existing client-detail page.
|
||||
|
||||
The first link minted under the new shape is effectively the default
|
||||
"send a request" link. Owners can mint more if they want segmented
|
||||
URLs (e.g. one per support channel), but most clients need one.
|
||||
|
||||
## 5. Public route
|
||||
|
||||
Two URLs, two steps, both unauthenticated and the same minimal
|
||||
layout Phase 8 Task 9 wrote.
|
||||
|
||||
### 5.1 `/intake/[token]` — the picker
|
||||
|
||||
Server component. Resolves the link server-side (same `resolveIntakeLink`
|
||||
as Phase 8 — it does not care whether the row carries `project` or
|
||||
`client`). On unknown/revoked/expired, **404 with the same wording as
|
||||
the wizard** so a hostile body cannot probe.
|
||||
|
||||
On success, renders a project picker:
|
||||
|
||||
- Reads every project on the link's client with
|
||||
`active = true AND show_in_intake = true`.
|
||||
- If zero or one project qualifies, the page skips the picker and
|
||||
behaves like a per-project link:
|
||||
- One project → server-side redirect to `/intake/[token]/p/[projectId]`.
|
||||
- Zero projects → static "no projects enabled" page with the
|
||||
developer's email or phone (also owner-controlled).
|
||||
- Two or more → renders a small list (`<ul>` of project names).
|
||||
Each item is a `<Link>` to `/intake/[token]/p/[projectId]`.
|
||||
|
||||
The picker is the only place a project is exposed to the public. No
|
||||
list of clients, no list of all projects, no alphabetical sort key
|
||||
that would let a hostile body infer project names by timing.
|
||||
|
||||
### 5.2 `/intake/[token]/p/[projectId]` — the wizard
|
||||
|
||||
Server component. Same as Phase 8's `/intake/[token]` page, except:
|
||||
|
||||
- The token resolves to a link (same as Phase 8).
|
||||
- The URL's `projectId` is verified to belong to the link's client
|
||||
AND have `show_in_intake = true`. Mismatch → 404.
|
||||
- The wizard runs with that `projectId`.
|
||||
|
||||
`IntakeWizard` and `IntakePublic` already accept `projectId` as a
|
||||
prop (Phase 4 / Phase 8). Nothing in the wizard itself changes.
|
||||
|
||||
### 5.3 Caching and PWA
|
||||
|
||||
The Phase 8 service worker already intercepts `/intake/*` HTML
|
||||
network-first with an offline fallback. The picker and wizard URLs
|
||||
both fall under that pattern. No SW changes needed; the cache key
|
||||
naturally covers both paths.
|
||||
|
||||
The manifest's `start_url` is unchanged (`/intake/`). A returning PWA
|
||||
user lands on the picker first; selecting a project re-uses the
|
||||
cached wizard HTML for that project if available.
|
||||
|
||||
## 6. Server Action & submission route
|
||||
|
||||
Both change shape to carry `projectId`. The change is *additive* to
|
||||
the action's inputs — older fields stay for backwards compat with the
|
||||
existing Phase 8 wizard code path which never sets `projectId` (the
|
||||
link's `project` carries it).
|
||||
|
||||
### 6.1 `intakeStepPublicAction`
|
||||
|
||||
```ts
|
||||
export async function intakeStepPublicAction(input: {
|
||||
token: string;
|
||||
projectId: string; // NEW: required on the new shape. Empty on the
|
||||
// old shape, where the link's project fills in.
|
||||
turns: Turn[];
|
||||
finishNow: boolean;
|
||||
}): Promise<InterviewStep>;
|
||||
```
|
||||
|
||||
Internally:
|
||||
|
||||
1. Resolve `link` from token.
|
||||
2. If `projectId` is provided, verify it belongs to `link.client` and
|
||||
has `show_in_intake = true AND active = true`.
|
||||
3. If `projectId` is empty, fall back to `link.project` (Phase 8 path).
|
||||
4. Rate limit + daily cap (now per `(link, project)`) + token shape —
|
||||
same shape check, same reasons.
|
||||
5. `nextStep({ projectId, turns, finishNow })`.
|
||||
|
||||
The `clientIpFromHeaders(next/headers())` call from the recent fix
|
||||
(`b02ebb7`) carries over unchanged.
|
||||
|
||||
### 6.2 `POST /api/public/intake`
|
||||
|
||||
```ts
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
// Same body whitelisting + token-shape check + 404 handling as Phase 8.
|
||||
// NEW: read `projectId` from the body; verify it against the link.
|
||||
// The card-create call uses the verified projectId.
|
||||
}
|
||||
```
|
||||
|
||||
The integration tests from Phase 8 (`intake-submit-public.test.ts`)
|
||||
exercise the Phase 8 path with `projectId` left empty. New Phase 9
|
||||
tests (file `intake-submit-picker.test.ts`) exercise the picker path
|
||||
with both tokens and project ids on the wire.
|
||||
|
||||
## 7. Owner UI
|
||||
|
||||
### 7.1 Client admin page (new)
|
||||
|
||||
`/admin/clients/[clientId]` currently exists? (No — Phase 8 only added
|
||||
links at the project level.) Phase 9 adds:
|
||||
|
||||
- A new route `/admin/clients/[clientId]` (server component,
|
||||
owner-only via the existing admin layout).
|
||||
- Reads the client plus the active projects on it (the owner's view of
|
||||
what's deployable for intake purposes).
|
||||
- Renders three panels stacked:
|
||||
1. **Public intake links** — the same `<IntakeLinksPanel>` from
|
||||
Phase 8, but now `link.project` is always empty on new rows.
|
||||
Mint / rotate / revoke / per-link daily cap.
|
||||
2. **Projects** — a list of the client's projects with a small
|
||||
`<ShowInIntakeToggle>` next to each (one toggle calls one new
|
||||
Server Action per project). Disabled + active styled. Toggling
|
||||
`show_in_intake` is two clicks, not three.
|
||||
3. *(Future)* a "client URL" badge — a copyable URL of the form
|
||||
`https://<host>/intake/<token>` — landed on the page when at
|
||||
least one active link and at least one opted-in project exist.
|
||||
|
||||
The admin layout already enforces owner-only access; no extra guard
|
||||
needed here.
|
||||
|
||||
### 7.2 Project admin page (modify)
|
||||
|
||||
`/admin/projects/[projectId]` already exists. One small addition:
|
||||
|
||||
- A new `<ShowInIntakeToggle projectId={...} current={...} />` rendered
|
||||
in the existing admin page, just above the
|
||||
`<BackgroundMdEditor>`. It calls a new Server Action
|
||||
`setProjectShowInIntakeAction({ projectId, show })`. Owner-only.
|
||||
|
||||
The Public-intake-links section on the project page stays (Phase 8
|
||||
shape: a per-project link can still be minted). It just becomes the
|
||||
"legacy / experimental" path; the new client-level link is the
|
||||
default.
|
||||
|
||||
### 7.3 No new Server Actions beyond what is listed above
|
||||
|
||||
- `setProjectShowInIntakeAction` (owner-only)
|
||||
- The picker page itself needs no Server Action — it's a server
|
||||
component that reads the database and renders. Submission still
|
||||
goes through `intakeStepPublicAction` and `POST /api/public/intake`.
|
||||
|
||||
## 8. Migration of existing Phase 8 tokens
|
||||
|
||||
Phase 8 left any number of per-project tokens live in PocketBase.
|
||||
Most owners will want to consolidate to a single per-client link,
|
||||
but doing that automatically could change the URL a client already has.
|
||||
**Phase 9 deliberately does not auto-rotate Phase 8 tokens.** They keep
|
||||
working with the Phase 8 behaviour: the old URL still renders the
|
||||
wizard, the wizard still runs against the link's `project`, the
|
||||
existing rate limit + daily cap continue to apply on the link row.
|
||||
|
||||
Owners can:
|
||||
|
||||
- Leave them as-is indefinitely (they're still secure).
|
||||
- Manually rotate them from the project admin page (which now reads
|
||||
as "this is a Phase 8 shape; consider minting a Phase 9 link
|
||||
from `/admin/clients/[clientId]` instead" but does not force it).
|
||||
- The owner UI surfaces a per-link `created_via` field (added to
|
||||
`intake_links`):
|
||||
`"phase-8-project-link" | "phase-9-client-link"` set at insert
|
||||
time.
|
||||
|
||||
The choice not to auto-rotate is on purpose — a leaked Phase 8 link
|
||||
must remain revokable through the same mechanism it was minted
|
||||
through.
|
||||
|
||||
## 9. Daily cap, per `(link, project)`
|
||||
|
||||
Section 4.4 named the shape. Two operational consequences:
|
||||
|
||||
1. **Existing `submission_count_today` on the link row stays.** It's
|
||||
the canonical counter for *Phase 8* links. Phase 9 links use the
|
||||
new `intake_link_submissions` collection for *their* cap.
|
||||
2. **The UI shows per-project counts.** Where Phase 8 says `1 total`,
|
||||
Phase 9 says `quotes=12, billing=3` — at most one per project the
|
||||
client has, summed across the lifetime of the link. The picker
|
||||
itself does NOT show today's counter to the public; the counter is
|
||||
owner-side only.
|
||||
|
||||
## 10. Plan split
|
||||
|
||||
Three plans in the same format as Phase 4b / 8a / 8b / 8c.
|
||||
|
||||
| Plan | Scope | Day-estimate |
|
||||
|---|---|---|
|
||||
| **9a — schema + per-project opt-in + owner toggle** | Add `intake_links.client` (optional, additive), add `projects.show_in_intake`, add `intake_link_submissions`, add migration seed; new `<ShowInIntakeToggle>`; `setProjectShowInIntakeAction`. Daily-cap unit tests for the new per-(link, project) shape. | 1 day |
|
||||
| **9b — picker page + route split + new owner page** | `/admin/clients/[clientId]` route + panel; `/intake/[token]` picker page; `/intake/[token]/p/[projectId]` wizard page; `intakeStepPublicAction` accepts `projectId`; `POST /api/public/intake` verifies it; `<IntakePublic>` takes a `projectId` from URL; client URL badge in owner UI. | 1 day |
|
||||
| **9c — integration tests + verification** | `intake-submit-picker.test.ts` mirroring Phase 8 Task 17 shape; happy path, hostile project-id, revoked project, mid-cap; full migration smoke; updated old-test signatures if needed. | 1 day |
|
||||
|
||||
Total: 3 working days. The Phase 8 architecture carries the load; the
|
||||
new work is narrow.
|
||||
|
||||
## 11. Acceptance criteria
|
||||
|
||||
1. **Single-URL UX.** A client with one Phase 9 link and three projects
|
||||
gets a picker page listing those three; selecting any one runs the
|
||||
wizard against it.
|
||||
2. **Opt-in is the default.** A newly-added project is NOT in the
|
||||
picker until the owner flips `show_in_intate` to `true`.
|
||||
3. **One URL per client.** `/admin/clients/[clientId]` shows every
|
||||
link minted for that client, with mint / rotate / revoke / per-link
|
||||
daily cap, same `IntakeLinksPanel` UI Phase 8 shipped.
|
||||
4. **Worst a hostile body achieves** on the picker path matches the
|
||||
Phase 8 invariant: a strangely-worded card in the Inbox of the
|
||||
chosen project. `parseBrief` whitelists the output; server decides
|
||||
`column`, `priority`, `accepted`, `created_by_role="anonymous"`,
|
||||
`created_by=""`.
|
||||
5. **Token shape check fires first.** A 32-char base64url for `token`,
|
||||
a real project id for `projectId`. Anything else → 404, same wording.
|
||||
6. **Daily cap is per `(link, project)`.** Two projects under the same
|
||||
link each get their own 100/day budget; one doesn't lock the other.
|
||||
7. **Phase 8 tokens keep working.** A pre-existing per-project link
|
||||
(with `link.project` set) still resolves the Phase 8 way; the test
|
||||
suite doesn't regress.
|
||||
8. **The picker page is itself a static, public, signed-out route.**
|
||||
No AppShell, no theme provider, the minimal layout Phase 8 wrote.
|
||||
9. **The PWA service worker continues to cache `/intake/*`.** No
|
||||
service-worker change; the picker is part of the same cache
|
||||
allowlist.
|
||||
10. **The signed-in wizard / signed-in route / signed-in owner UI
|
||||
is unchanged.** Existing tests still pass; signed-in behaviour
|
||||
does not regress.
|
||||
|
||||
## 12. What this spec deliberately doesn't do
|
||||
|
||||
- **Cross-client links.** A token still binds to exactly one client.
|
||||
Multi-tenant intake is a different problem.
|
||||
- **Recategorising links automatically.** No system process that
|
||||
detects a Phase 8 link and "upgrades" it. The owner does the work
|
||||
explicitly if they want; otherwise the old shape lives forever.
|
||||
- **Inline client-name overrides.** The picker shows the project's
|
||||
actual `name`. A future Phase 10 can add `client_display_name` to
|
||||
`projects` for client-branded naming if it ever matters.
|
||||
- **Renaming the old URL.** `/intake/[token]` with `project` on the
|
||||
link still resolves to the project; the picker is only added to
|
||||
client-shaped rows. There are no redirects to introduce.
|
||||
- **Search / tag filtering in the picker.** Picker is alphabetical
|
||||
by project name. If a client has 30 projects the picker is a long
|
||||
list — that's their problem to solve (their team should be a smaller
|
||||
list), not ours.
|
||||
- **Branch-by-tenant analytics on intake.** One PocketBase, one
|
||||
collection, one schema. Reporting on intake still happens through
|
||||
the same dashboard phase 2 shipped.
|
||||
|
||||
---
|
||||
|
||||
## 13. Definition of done
|
||||
|
||||
1. `npm run test:all` passes: ~459 unit, ~75 integration (existing
|
||||
Phase 8 set + the new picker path).
|
||||
2. `npm run build` succeeds; colour check empty (modulo the spec-known
|
||||
PWA themeColor metadata exception).
|
||||
3. The full picker flow worked end-to-end in the developer's browser
|
||||
before merge.
|
||||
4. Phase 8 manual walkthrough steps 19.3–19.8 still pass.
|
||||
5. Owner can opt a project in/out from `/admin/projects/[id]`.
|
||||
6. A wiped-and-rebuilt dev PocketBase from this spec produces a
|
||||
working picker with one seeded link and three opted-in projects.
|
||||
|
||||
---
|
||||
|
||||
## 14. Deliberately not in scope for this spec
|
||||
|
||||
The owner-side UX work — drag-and-drop reorder of links, link
|
||||
notes/description editor, copy-URL-to-clipboard button on the client
|
||||
admin page — is a Phase 9.5 task list. This spec closes on the
|
||||
minimum surface that makes the picker flow work; everything else is
|
||||
polish on top.
|
||||
Loading…
Reference in a new issue