Skip to main content

max / makenotwork

v0.6.5: UX audit consolidation + inline-style sweep + founder pricing Two-track release. Pre-Phase-1 UX audit consolidation lands alongside soft-launch founder-pricing engineering that had been WIP. UX audit consolidation pass (Phases A-K) - Inline style="..." attributes: 1,276 → 6 across 100+ templates. The six remaining are server-computed {{pct}}% width/height exceptions (chart bars, storage fill, revenue bar, checklist progress). - Per-tab CSS sections promoted to canonical primitives: * .progress-bar-container + .progress-bar with --slim, --rounded, --highlight modifiers (replaces .wiz-progress-*, .batch-progress-*, .checklist-progress-*, scoped .import-page .progress-bar) * .upload-status with __row + __msg.is-success/.is-error * .empty-state (replaces 7 per-tab *-empty classes) * .field-status / .save-status aliased with .success/.error/.saving * .section-lead (replaces 9 per-tab leads) * .callout with --danger / --warning / --solid-warning (replaces .account-callout*, .cart-warning, .dns-callout*) * .list-row primitive aliased to .psection-row, .section-mgmt-row, .bundle-picker-row, .checklist-row * .small recipe aliased to .btn-tiny, .cart-row-btn, .library-row-btn * .card--bordered absorbs .tier-card, .feature-card, .use-case-card, .fork-card; .card-muted absorbs .account-tip-card, .account-status-card * .minw-300..800 utilities replace per-tab min-width table classes - style.css 11,267 → 10,900 (−367 lines). Removed 179 lines of identical duplicate section blocks left by parallel-agent appends, plus dead scope rules where the body class never existed. - JS handlers refactored to toggle .hidden / .is-error / .is-faded classes instead of mutating style.display, style.color, style.opacity (insertions.js, project-sections.js, blog-editor.js, item-details.js). - Broken inline color refs (--success-color, --error-color, --warning-color, --accent-color, --primary-color — none of which exist as vars) fixed by mapping to real tokens (--success, --error, --warning, --accent, --primary-dark). - Several dead <style> blocks removed (creators.html, receipt.html, dashboard-import.html, stripe_disclaimer.html, item_embed.html, dashboard-blog-editor.html). Founder pricing engineering - New migration 116_founder_pricing.sql adds annual/founder Price ID columns and founder_window tracking. - Config gains creator_tier_annual_prices, creator_tier_founder_prices, creator_tier_founder_annual_prices maps and creator_founder_window_open flag. - Stripe checkout/subscriptions, payments/webhooks, auth, admin routes, user models updated to honor founder pricing + window state. - Pricing site-docs (pricing.md, guide/stripe.md, guide/tiers.md) updated with founder pricing copy. Stripe rc.5 migration docs leftovers consolidated. Per remediation-plan.md success criteria: inline-style grep under 100 (6), no checkmark glyphs in any template, no Bootstrap/#000/#fff literals in CSS, every "selected" state is .is-selected, charter primitive table has canonical class per row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-20 21:31 UTC
Commit: 59108584214e4c90541a64894e912972c6bab771
Parent: 21b99d5
200 files changed, +5768 insertions, -4531 deletions
M .gitignore +3
@@ -40,3 +40,6 @@ server/rustdoc-out/
40 40 # Mutation testing output
41 41 mutants.out*
42 42 **/mutants.out*
43 +
44 + # Claude Code agent worktrees
45 + .claude/worktrees/
@@ -3551,7 +3551,7 @@ dependencies = [
3551 3551
3552 3552 [[package]]
3553 3553 name = "makenotwork"
3554 - version = "0.6.4"
3554 + version = "0.6.5"
3555 3555 dependencies = [
3556 3556 "anyhow",
3557 3557 "argon2",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.6.4"
3 + version = "0.6.5"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -32,35 +32,41 @@
32 32
33 33 ### Signup → Verify → Login → Logout
34 34
35 - - [ ] `GET /join` — signup form renders
36 - - [ ] Submit signup with valid username, email, password (8+ chars)
37 - - [ ] Server logs verification email (or Postmark sends it)
38 - - [ ] Verification link in email works (`/verify-email?user=...&expires=...&sig=...`)
39 - - [ ] After verification, email_verified flag is true (check `/dashboard` details tab)
40 - - [ ] `GET /login` — login form renders
41 - - [ ] Login with correct credentials — redirects to `/dashboard`
42 - - [ ] `POST /logout` — session destroyed, redirects to `/`
43 - - [ ] Accessing `/dashboard` after logout redirects to `/login`
44 - - [ ] Login with wrong password — shows error, does not reveal whether user exists
45 - - [ ] Resend verification email works (`/api/resend-verification`)
35 + Tested against `testaccount123` (`test@makenot.work`) on 2026-05-16.
36 +
37 + - [x] `GET /join` — signup form renders
38 + - [x] Submit signup with valid username, email, password (8+ chars)
39 + - [x] Server logs verification email (or Postmark sends it) — implied by successful click below
40 + - [x] Verification link in email works (`/verify-email?user=...&expires=...&sig=...`)
41 + - [x] After verification, email_verified flag is true — confirmed in prod DB 2026-05-16 21:05 UTC
42 + - [x] `GET /login` — login form renders
43 + - [x] Login with correct credentials — redirects to `/dashboard`
44 + - [x] `POST /logout` — session destroyed, redirects to `/`
45 + - [x] Accessing `/dashboard` after logout redirects to `/login`
46 + - [x] Login with wrong password — shows error, does not reveal whether user exists (prod logs confirm `Failed login attempt` warn with `attempts` counter — no user enumeration in response)
47 + - [x] Resend verification email works (`/api/resend-verification`) — confirmed during testaccount123 signup flow
46 48
47 49 ### Account Lockout + Recovery
48 50
49 - - [ ] Fail login 5 times — account locks for 15 minutes
50 - - [ ] Lockout notification email sent with one-time login link
51 - - [ ] One-time login link works (logs you in)
52 - - [ ] One-time login link cannot be reused (single-use)
53 - - [ ] After lockout expires, normal login works again
51 + Tested against `testaccount123` on 2026-05-16 21:09 UTC.
52 +
53 + - [x] Fail login 5 times — account locks for 15 minutes (DB: `failed_login_attempts=5`, `locked_until=21:24:54`)
54 + - [x] Lockout notification email sent with one-time login link (Postmark log: subject "Security alert: Account locked")
55 + - [x] One-time login link works (logs you in) — DB cleared to `attempts=0, locked_until=NULL` after click
56 + - [x] One-time login link cannot be reused (single-use) — second click on same link rejected
57 + - [ ] After lockout expires, normal login works again — N/A (lockout cleared by OTP, not by timer); covered by normal login already verified above
54 58
55 59 ### Password Reset
56 60
57 - - [ ] `GET /forgot-password` — form renders
58 - - [ ] Submit email — reset email sent (15-minute expiry link)
59 - - [ ] Reset link loads form (`/reset-password?user=...&expires=...&sig=...`)
60 - - [ ] Submit new password — succeeds, can login with new password
61 - - [ ] Old password no longer works
62 - - [ ] Expired reset link rejected
63 - - [ ] Reusing same reset link after password change rejected (HMAC includes password hash)
61 + Tested against `testaccount123` on 2026-05-16 21:13 UTC.
62 +
63 + - [x] `GET /forgot-password` — form renders
64 + - [x] Submit email — reset email sent (15-minute expiry link)
65 + - [x] Reset link loads form (`/reset-password?user=...&expires=...&sig=...`)
66 + - [x] Submit new password — succeeds, can login with new password (breached-password advisory fired non-blocking, breach count 2,557 — working as designed)
67 + - [x] Old password no longer works
68 + - [x] Expired reset link rejected
69 + - [x] Reusing same reset link after password change rejected (HMAC includes password hash)
64 70
65 71 ### Creator Onboarding
66 72
@@ -140,10 +146,12 @@
140 146
141 147 ### Free Item Claim
142 148
143 - - [ ] As buyer, find a free item on `/discover`
144 - - [ ] `POST /api/library/add/{item_id}` — item added to library
145 - - [ ] Item content accessible
146 - - [ ] `DELETE /api/library/remove/{item_id}` — item removed from library
149 + Tested by `max` on GO (GoingsOn Desktop free item) — transaction recorded 2026-05-10.
150 +
151 + - [x] As buyer, find a free item on `/discover`
152 + - [x] `POST /api/library/add/{item_id}` — item added to library (transaction row, status=completed, amount=0)
153 + - [x] Item content accessible
154 + - [x] `DELETE /api/library/remove/{item_id}` — item removed from library
147 155
148 156 ### File Upload + Delivery
149 157
@@ -0,0 +1,123 @@
1 + # MNW Design System Charter
2 +
3 + The MNW UI is composed from a small fixed set of primitives. Every screen is assembled from these — like an OS rendering applications, not like a website where each page reinvents its own widgets. If a screen needs something not in this charter, the answer is to extend the charter, not to write a one-off.
4 +
5 + Source of truth for visual identity: [`_meta/docs/brand.md`](../../_meta/docs/brand.md). Source of truth for primitives: this file plus `static/style.css`.
6 +
7 + ## Tokens
8 +
9 + Every visual value is a token defined in `:root` (`static/style.css`). No raw hex, no off-scale spacing, no bespoke shadow.
10 +
11 + | Tier | Token group | Where defined |
12 + |---|---|---|
13 + | Color | `--background`, `--detail`, `--highlight`, `--light-background`, surface family, `--text-muted`, `--border`, semantic (`--success/-bg`, `--warning/-bg/-border`, `--danger/-bg`, `--error/-bg`), `--stripe`, health (`--health-ok/-warn/-error/-unknown`), diff (`--diff-add/-bg`, `--diff-del/-bg`), `--focus-ring`, `--highlight-faint`, `--overlay` | `style.css:40-95` |
14 + | Type | `--font-heading` (Young Serif), `--font-mono` (IBM Plex Mono), `--font-body` (Lato) | `style.css:42-44` |
15 + | Spacing | `--space-1`...`--space-6` mapping to `0.25 / 0.5 / 0.75 / 1 / 1.5 / 2 rem` | `style.css:96-103` |
16 + | Radius | `--radius-sm` (2px), `--radius-md` (4px), `--radius-round` (50%) | `style.css:105-108` |
17 + | Shadow | `--shadow-1` (subtle lift), `--shadow-2` (raised card), `--shadow-3` (overlay/modal) | `style.css:110-113` |
18 +
19 + Pure `#000` and `#fff` are forbidden outside the token table. Bootstrap-derived yellows (`#fff3cd`, `#ffc107`) are forbidden — use `--warning-bg` / `--warning-border`.
20 +
21 + ## Type tiers (from `brand.md`)
22 +
23 + Every text element maps to exactly one tier:
24 +
25 + - **H1 / wordmark / section heads** — `--font-heading` (Young Serif), `normal` weight, color `--detail`.
26 + - **H2 / H3 / meta / taglines / footer** — `--font-mono` (IBM Plex Mono), `normal` weight.
27 + - **Body / lists / table cells / labels** — `--font-body` (Lato).
28 +
29 + No fourth typeface. No `font-family` declarations in templates.
30 +
31 + ## Components — canonical primitive table
32 +
33 + For each primitive, exactly one canonical class **or** one canonical partial. Variants are class modifiers; nothing else.
34 +
35 + | Primitive | Canonical class / partial | Variants | States |
36 + |---|---|---|---|
37 + | Button | `.btn-primary` / `.btn-secondary` / `.btn-danger` | primary, secondary, danger | `:hover`, `:focus-visible`, `:disabled`, `.htmx-request` |
38 + | Form field | `partials/form_field.html` wrapping `.form-group` | (none) | `.form-group--error` |
39 + | Form layout | `.form-container`, `.form-row` | (none) | — |
40 + | Hint text | `.hint` | — | — |
41 + | Table | `.data-table`, `.compact-table` | — | `.sortable.ascending`, `.sortable.descending` |
42 + | Tabs | `.tabs` + `.tab` | — | `.tab.is-selected` |
43 + | Card | `.card` + `.card-title`, `.card-meta`, `.card-description` | `.grid-card` for grid layouts | `:hover`, `:focus-within` |
44 + | Badge | `.badge` | `-success`, `-warning`, `-danger`, `.free` | `.is-selected` |
45 + | Tag | `.tag` (inside `.tag-input` for editing) | — | — |
46 + | Empty state | `partials/empty_state.html` (.empty-state) | — | — |
47 + | Loading skeleton | `partials/loading_skeleton.html` | row, card, list | — |
48 + | Info box | `.info-box` | — | — |
49 + | Warning box | `.warning-box` | — | — |
50 + | Error message | `.error-message` (via `partials/error_fragment.html`) | — | — |
51 + | Alert | `partials/alert.html` -> `.alert` | `-note`, `-tip`, `-warning`, `-caution` | — |
52 + | Modal | `.modal-overlay` + `.modal` + `.form-actions` | — | — |
53 + | Confirm dialog | `partials/confirm_dialog.html` | — | — |
54 + | Toast | `partials/toast.html` -> `.toast` | `-success`, `-error`, `-warning` | — |
55 + | Pagination | `partials/pagination.html` -> `.pagination` | — | `.pagination button.active` |
56 + | Section header | `.section-header` | — | — |
57 + | Breadcrumb | `.breadcrumb` | — | — |
58 + | Banner | `.banner` (page-top one-liner notice; sandbox, restart) | `-info`, `-warning` | — |
59 + | Hero callout | `.landing-founder-banner` (multi-paragraph marketing callout on the landing page; not a banner) | — | — |
60 +
61 + The `.banner` class is the only remaining Phase 0 cleanup deliverable. Token groups (spacing / radius / shadow) and the five parameterized primitives (`empty_state`, `loading_skeleton`, `form_field`, `confirm_dialog`, `pagination`) shipped as part of the consolidation pass: tokens live in `static/style.css` `:root`, the parameterized primitives are macros in `templates/partials/_ui.html`.
62 +
63 + ### How to use the macros
64 +
65 + The codebase has two partial conventions:
66 +
67 + - **`{% include %}` partials** — share the parent template's context. Good for header / nav / chrome that doesn't need parameters. Examples: `partials/site_header.html`, `partials/admin_nav.html`.
68 + - **Macros in `partials/_ui.html`** — parameterized primitives. Import once and call:
69 +
70 + ```jinja
71 + {%- import "partials/_ui.html" as ui -%}
72 + ...
73 + {% call ui::empty_state("No items yet", "Create one to get started.") %}
74 + {% call ui::form_field("Title", "title", value, "Up to 80 characters.", error) %}
75 + {% call ui::confirm_dialog("Delete item?", "This can't be undone.", "/item/123/delete", "Delete", "/item/123") %}
76 + {% call ui::pagination(current_page, total_pages, "/items?page=") %}
77 + ```
78 +
79 + When a primitive needs an HTMX endpoint (e.g. server-rendered confirm dialogs returned to a swap target), wrap it in a small Rust template struct that calls the macro in its body.
80 +
81 + ## State vocabulary
82 +
83 + Exactly one spelling for each interaction state, applied to every interactive primitive:
84 +
85 + - **Hover** — `:hover` lifts background one surface step (`--background` -> `--light-background` -> `--surface-muted`) **or** dims opacity to `0.8` for solid-fill buttons. Pick by component type; do not mix on the same component.
86 + - **Focus** — `:focus-visible` shows the `--focus-ring` violet outline. Custom interactive containers (`.type-card`, `.pricing-card`, sort headers) must opt in by adding `:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }`.
87 + - **Selected / active** — `.is-selected` modifier applies `background: var(--highlight-faint)` plus the focus-ring border. Migrate `.tab.active`, `.filter-item.active`, `.view-btn.active`, `.badge.active`, and the `:checked + .type-card-inner` recipe onto this single class.
88 + - **Disabled** — `:disabled` and `[aria-disabled="true"]` show `opacity: 0.5` and `cursor: not-allowed`.
89 + - **Busy / loading** — HTMX-driven via `.htmx-request` on the trigger, plus a `partials/loading_skeleton.html` for content placeholders.
90 +
91 + ## Layout primitives
92 +
93 + - `.padded-page` — standard content padding (`1.5rem`).
94 + - `.centered-page` — landing / login / signup vertical-center layout.
95 + - `.container` — `max-width: 1200px`, used for marketing and content pages.
96 + - Wizard layout in `static/wizard.css` (`.wizard-layout` + `.wizard-sidebar` + `.wizard-content`).
97 + - Media layout in `static/media-player.css` (`.media-container`).
98 +
99 + No new top-level layout containers without an entry in this list.
100 +
101 + ## Rules templates must follow
102 +
103 + 1. **No inline `style="..."`.** If you need a one-off, add a utility class or extend a primitive. The single exception: `style="--var: dynamic"` for server-computed values (progress bars, avatar fallback colors).
104 + 2. **No page-scoped `<style>` blocks.** `buy.html`, `item.html`, `error.html` are grandfathered violations and are scheduled for migration. New templates do not get this exemption.
105 + 3. **No raw hex.** Use tokens. Adding a new color means adding a token.
106 + 4. **No checkmarks, emoji, or icon glyphs in copy.** Words only. The diamond mark is the only graphic element. Status uses `.badge`, not `✓`.
107 + 5. **No new typefaces.** Three tiers, no exceptions.
108 + 6. **Empty / error / loading states use the shared partial.** Never assemble these inline.
109 + 7. **Destructive actions** use `.btn-danger` plus `partials/confirm_dialog.html`. No bare destructive buttons.
110 + 8. **Spacing values come from `--space-*` tokens** once those land. Off-scale values are bugs.
111 +
112 + ## How to extend
113 +
114 + When a screen genuinely needs something not in this charter:
115 +
116 + 1. Propose the primitive in this file (name, canonical class/partial, variants, states).
117 + 2. Add the class to `style.css` in the matching section, or a partial under `templates/partials/`.
118 + 3. Update the inventory table above.
119 + 4. Then use it. Three usages without an entry in the charter means a missing primitive, not a license to inline.
120 +
121 + ## Audit cadence
122 +
123 + Phase 0 establishes the charter. Phases 1-8 (see `docs/todo.md`) audit conformance: every finding in those phases should be expressible as "screen X uses an off-charter primitive Y" or "primitive Y has a gap the charter should address." Free-form aesthetic critique is out of scope — that belongs to brand work, not this charter.
@@ -6,7 +6,9 @@ Items requiring manual action, external accounts, legal engagement, design decis
6 6
7 7 ## 🚨 LAUNCH BLOCKER — Stripe webhooks not delivering (discovered 2026-05-16)
8 8
9 - Symptom: testaccount123 completed a $5 PWYW checkout for "Audiofiles Desktop App" at 21:27 UTC. Stripe redirected to `/stripe/success` (session `cs_live_a1o3Ky7bRCXbnKNUYrGFS1JSmBFYLCxQ8zEk6gtmYfCPsyGAiJJfLNFwGm`). Transaction row is stuck `status=pending`. No item appeared in their library.
9 + **This item is consolidated into the single-sitting Stripe Dashboard knockout session at `~/Code/_meta/human_todo.md` § Stripe Dashboard Knockout Session, Step 1.** Work it from there alongside the other dashboard tasks (founder pricing Price objects, orphan Connect cleanup, Customer Portal activation, etc.) so the dashboard nav is amortized across one sitting.
10 +
11 + Symptom for context: testaccount123 completed a $5 PWYW checkout for "audiofiles Desktop App" at 2026-05-16 21:27 UTC. Stripe redirected to `/stripe/success` (session `cs_live_a1o3Ky7bRCXbnKNUYrGFS1JSmBFYLCxQ8zEk6gtmYfCPsyGAiJJfLNFwGm`). Transaction row is stuck `status=pending`. No item appeared in their library.
10 12
11 13 Root cause: Stripe is not delivering webhook events to `POST /stripe/webhook`.
12 14 - `webhook_events` table: 0 rows
@@ -14,33 +16,19 @@ Root cause: Stripe is not delivering webhook events to `POST /stripe/webhook`.
14 16 - No `/stripe/webhook` hits in prod logs for past 7 days
15 17 - `STRIPE_WEBHOOK_SECRET` is set in `/opt/makenotwork/.env` — value not verified against dashboard
16 18
17 - Action items (Stripe Dashboard at https://dashboard.stripe.com/webhooks):
18 - - [ ] Confirm an endpoint exists for `https://makenot.work/stripe/webhook`
19 - - [ ] Confirm the endpoint is enabled (not disabled/paused)
20 - - [ ] Confirm live-mode toggle is on
21 - - [ ] Check "Webhook attempts" — recent attempts and their response codes (or zero attempts = endpoint missing/wrong)
22 - - [ ] Confirm signing secret matches prod `STRIPE_WEBHOOK_SECRET`
23 - - [ ] Subscribe the endpoint to at least: `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_succeeded`, `invoice.payment_failed`, `payment_intent.succeeded`, `payment_intent.payment_failed`, `account.updated` (Connect creators)
24 - - [ ] After fixing: use Stripe Dashboard → Events → find the testaccount123 session's `checkout.session.completed` event → "Resend" to deliver retroactively, OR manually mark the transaction completed in DB if Stripe shows the charge succeeded
25 - - [ ] Verify by triggering one more test purchase end-to-end; expect a row in `webhook_events` and the transaction to flip from `pending` to `completed`
26 -
27 19 ## External Blockers
28 20
29 21 ### Business Formation (Make Creative, LLC)
30 - - [x] D-U-N-S number — 14-501-2681, received 2026-05-09
31 - - [x] Business bank account — Mercury approved 2026-05-01
32 - - [ ] Transfer startup funds to Mercury business account
22 + All complete — see `todo_done.md`.
33 23
34 - ### Platform Accounts (D-U-N-S received — now unblocked)
24 + ### Platform Accounts
35 25
36 26 | Blocker | Status | Blocks |
37 27 |---------|--------|--------|
38 - | D-U-N-S number | 14-501-2681, received 2026-05-09 | Google Play, Microsoft Partner Center |
39 - | Google Play Developer Account ($25) | Registered 2026-05-09 | GO/BB Android builds |
40 28 | Microsoft Partner Center account | Blocked by Microsoft trust check — ref 715-123225, contact support | Windows Store distribution (optional) |
41 29 | Windows code signing certificate | Not started (individual or traditional cert — Azure Trusted Signing requires 3yr history) | GO/BB/AF Windows builds |
42 30 | OAuth Provider Registration (Fastmail) | Need to send registration info to partnerships@fastmailteam.com | GO Fastmail email OAuth |
43 - | Stripe Customer Portal activation | Dashboard → Settings → Billing → Customer portal → Activate. ~2 min, no code change. | Fan+ "Manage billing" button (deployed 2026-05-14 in MNW 0.5.18); Cancel/Resume work without this. |
31 + | Stripe Customer Portal activation | Consolidated into `_meta/human_todo.md` § Stripe Dashboard Knockout Session, Step 4. | Fan+ "Manage billing" button (deployed 2026-05-14 in MNW 0.5.18); Cancel/Resume work without this. |
44 32
45 33 ---
46 34
@@ -388,23 +376,17 @@ Feature map generated from full codebase walk. Every user-facing feature enumera
388 376
389 377 ### 14. SyncKit (E2E Encrypted Cloud Sync)
390 378
391 - - [x] **SyncKit auth** — OAuth2 PKCE flow tested with GO + AF on live server (2026-05-11)
392 - - [x] **Push/pull sync** — bidirectional encrypted changelog
393 - - [x] Push changes
394 - - [x] Pull changes with cursor-based pagination
379 + - [ ] **Push/pull sync** — remaining sub-items
395 380 - [ ] Table name filter for selective pull
396 381 - [ ] Idempotent push via batch_id
397 382 - [ ] **Device management** — register, list, delete devices
398 - - [x] **E2E key storage** — encrypted keys with optimistic concurrency
399 - - [x] Store key
400 - - [x] Retrieve key
383 + - [ ] **E2E key storage** — remaining sub-items
401 384 - [ ] Version conflict returns 409
402 385 - [ ] **Blob storage** — encrypted blobs with hash dedup
403 386 - [ ] Upload blob
404 387 - [ ] Download blob
405 388 - [ ] Duplicate hash skips re-upload
406 - - [x] **App management** — create apps, generate API keys
407 - - [x] Create sync app (GO + AF apps created)
389 + - [ ] **App management** — remaining sub-items
408 390 - [ ] Regenerate API key
409 391 - [ ] Link app to project/item
410 392 - [ ] Set custom slug
@@ -416,7 +398,7 @@ Feature map generated from full codebase walk. Every user-facing feature enumera
416 398 - [ ] Begin rotation, re-encrypt entries in batches, complete
417 399 - [ ] Verify other device can pull mixed-key entries during rotation
418 400 - [ ] Verify new device setup after rotation uses new key
419 - - [x] **SyncKit production test** — GO + AF sync tested on live server (2026-05-11). BB pending (synckit.toml needed).
401 + - [ ] **BB SyncKit production test** — needs `synckit.toml` (GO + AF complete on live server 2026-05-11; see `todo_done.md`)
420 402
421 403 ### 15. OTA Updates
422 404
@@ -477,34 +459,9 @@ Feature map generated from full codebase walk. Every user-facing feature enumera
477 459 - [ ] **Waitlist application** — apply to join creator waitlist
478 460 - [ ] **Invite code redemption** — use invite code during signup
479 461
480 - ### 20. Documentation Gap Checklist
481 -
482 - Features that exist in code but lack public documentation:
483 -
484 - - [x] Write guide for **git source browser** (web browsing, SSH clone, smart HTTP) — expanded git.md with HTTPS cloning, collaborators, issues, patches
485 - - [x] Write guide for **email-based issue tracker** (unique email-driven feature) — added to git.md
486 - - [x] Write guide for **email patch submission** (git send-email to MT) — added to git.md
487 - - [x] Write guide for **embed widgets** (button/card/player embeds) — new embeds.md
488 - - [x] Write guide for **media library** (dashboard feature for reusable clips) — new media-library.md
489 - - [x] Write guide for **CSV import** (bulk item creation) — new import.md
490 - - [x] Write guide for **custom profile links** (social links on profile) — expanded profile.md Links section
491 - - [x] Expand **sandbox mode** guide — already comprehensive (sandbox.md covers starting, features, limits, converting)
492 - - [x] Expand **passkey setup** guide — already detailed in security.md (setup, login, managing, 20 passkey limit)
493 - - [x] Write guide for **password reset flow** (step-by-step) — new password-reset.md
494 - - [x] Write guide for **account deactivation** (temporary vs permanent deletion) — new account-lifecycle.md
495 - - [x] Write guide for **personalized feed** (/feed page) — new feed.md
496 - - [x] Write guide for **content insertions** (pre/mid/post roll for audio) — new content-insertions.md
497 -
498 - ### 21. Documented-But-Not-Implemented Tracker
499 -
500 - Features documented in public docs that don't exist in code yet:
462 + ### 20–21. Documentation Gap & Documented-But-Not-Implemented
501 463
502 - - [ ] **Live streaming** (Everything tier, "coming soon" in docs)
503 - - [ ] **Earn-back credit program** (mentioned in FAQ)
504 - - [ ] **Content archive guarantee** (12+ month content stays live after cancel)
505 - - [ ] **HLS adaptive bitrate** (on roadmap)
506 - - [ ] **Audio transcoding** (on roadmap)
507 - - [ ] **Mobile apps** (on roadmap)
464 + Closed — see `todo_done.md`. §21 items are roadmap features documented as such; no doc revision needed pre-launch.
508 465
509 466 ### Sign-Off
510 467
@@ -554,12 +511,7 @@ Features documented in public docs that don't exist in code yet:
554 511
555 512 - [ ] Phase 22E: MediaMTX deployment on alpha-west-1 (install binary, systemd unit, Caddy config, Cloudflare DNS, firewall rules)
556 513 - [ ] Add `ffprobe` to production server (Phase 14E-1)
557 - - [x] Generate Tauri signing keys — per-app keys at `~/.tauri/goingson.key{,.pub}` and `~/.tauri/balanced-breakfast.key{,.pub}` (AF is not a Tauri app). BB key rotated 2026-05-16 (original password unrecoverable; no shipped BB binaries with old pubkey)
558 - - [x] Add public keys to `tauri.conf.json` — GO and BB both wired with `plugins.updater.pubkey` + endpoint `https://makenot.work/api/v1/sync/ota/{app}/{{target}}/{{arch}}/{{current_version}}`
559 - - [x] Enable `bundle.createUpdaterArtifacts: true` in both apps' `tauri.conf.json` — Tauri 2 requires opt-in; without it builds skip the updater bundles + `.sig` sidecars silently
560 - - [x] Deploy private keys to astra + windows-x86; password file `~/.tauri/passwords.env` (Linux) / `passwords.ps1` (Windows). Master copy at `_private/tauri-passwords.env`
561 514 - [ ] Deploy private keys to pop-os (offline 2026-05-14, last seen 2d ago — sync when next online)
562 - - [x] Verify signed build produces `.sig` sidecars — confirmed on astra for BB 0.3.3 (AppImage, deb, rpm all signed)
563 515 - [ ] Full end-to-end OTA flow: build signed GO release, upload artifact via MNW release management UI, install on clean machine, confirm `/api/v1/sync/ota/...` returns 200 and Tauri updater accepts the signature
564 516 - [ ] Set S3 lifecycle rule on the upload prefix: delete incomplete/unconfirmed objects after 36 hours (safety net for presigned URL cleanup)
565 517
@@ -85,19 +85,44 @@ backup_pct_of_server = 0.20
85 85
86 86 # ─── Tier prices (canonical: pricing.md) ──────────────────────────────────
87 87 [tiers.founding]
88 - # OPEN: founding ratio (currently 50% of standard) — pricing.md §7 item 2
88 + # Founder pricing — 50% of standard sticker, locked for life. Active until
89 + # the founder window closes (1,000 creators OR exit-beta, whichever first).
90 + # Decision 2026-05-18; see memory `project_founder_pricing.md`.
89 91 basic = 5
90 92 small_files = 10
91 93 big_files = 15
92 94 everything = 30
93 95
94 96 [tiers.standard]
95 - # OPEN: standard rate values are provisional — pricing.md §7 item 3
97 + # Post-founder sticker targets — NOT yet validated. The plan is to calibrate
98 + # the real post-founder rates from signup velocity, tier mix, and support
99 + # load observed during the founder window. Treat these as provisional upper
100 + # bounds, not committed prices.
96 101 basic = 10
97 102 small_files = 20
98 103 big_files = 30
99 104 everything = 60
100 105
106 + [annual_discount]
107 + # Annual billing is 10% off the monthly × 12 total at every tier, founder
108 + # and standard. Two-digit discount, clean pitch. Decision 2026-05-18.
109 + #
110 + # What this actually covers:
111 + # - Stripe per-transaction fees ($0.30 each, charged 12x for monthly vs 1x
112 + # for annual): saves MNW ~$3.30/yr per customer regardless of tier.
113 + # - The 2.9% percent fee is identical either way (a wash).
114 + # - At Basic, 10% off ≈ the literal Stripe saving (close to pass-through).
115 + # - At Everything, 10% off ($36/yr) > the Stripe saving ($3.30/yr); MNW
116 + # absorbs the difference (~$15/yr per Everything customer at founder
117 + # pricing, ~$32/yr per Everything customer at sticker) as a cashflow +
118 + # reduced-billing-failure benefit. Defensible but not pure pass-through.
119 + #
120 + # Computed values (monthly × 12 × 0.9, rounded to nearest dollar):
121 + # Founder: $54 / $108 / $162 / $324
122 + # Standard: $108 / $216 / $324 / $648
123 + multiplier = 0.9
124 + months_equivalent_free = 1.2 # 10% of 12 months
125 +
101 126
102 127 # ─── Tier mix (canonical: tier_mix.md) ────────────────────────────────────
103 128 [tier_mix.assumed]
@@ -0,0 +1,475 @@
1 + # Tier 1 Outreach — Email Sketches
2 +
3 + Brief drafts for hand-personalization. Each follows the same shape: greeting, intro line, situation-specific hook, the pitch + link, ask for feedback, sign-off. P.S. lines deliberately omitted — add where you have a personal angle worth landing.
4 +
5 + Tone: warm, plain, no marketing voice. Use real first names where known; brand names where the creator goes by one publicly.
6 +
7 + Pitch boilerplate (per email, adapt slightly):
8 +
9 + > We charge a flat monthly fee to cover support and infrastructure, and otherwise take no cut on creator sales (excluding the ~3% Stripe takes). The code is source-available — every fee calculation, every privacy claim, auditable. More at https://makenot.work.
10 +
11 + ---
12 +
13 + ## Recently left or actively leaving
14 +
15 + ### Unwoman (Erica Mulkey)
16 + Hi Erica! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. I followed your move off Patreon over the per-creation billing change, and I'm reaching out because the platform I'm building exists for exactly the situation you ended up in — wanting a place that doesn't restructure your income model on you.
17 +
18 + We charge a flat monthly fee to cover support and infrastructure, and otherwise take no cut on creator sales (only the ~3% Stripe takes). The code is source-available. More at https://makenot.work.
19 +
20 + If you're still evaluating options, I'd value your eyes on this — feedback from someone who's already made the call is worth more than feedback from someone who hasn't.
21 +
22 + Best,
23 + Max J.
24 +
25 + ---
26 +
27 + ### Anne Helen Petersen
28 + Hi Anne! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. I read your Substack departure post when it ran, and the part that stuck with me wasn't the bots — it was the line about recovering 70% of subscribers in two weeks. That's the proof point most creators don't have, and it's the one MNW is built around: your audience is yours, your data is yours, and leaving any platform should be one click.
29 +
30 + Flat monthly fee, no cut on sales (only Stripe's ~3%), source-available code. https://makenot.work.
31 +
32 + I'd love to hear what you think — especially if there's anything missing that would have made your Substack-to-Beehiiv move easier.
33 +
34 + Best,
35 + Max J.
36 +
37 + ---
38 +
39 + ### Ed Zitron
40 + Hi Ed! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. I've been reading Where's Your Ed At for a while now, and the platform I've built is, frankly, an attempt to make the thing you keep writing about not be possible. No percentage cut on sales, no investor pressure, no incentive structure that rewards squeezing you over time.
41 +
42 + Flat monthly fee, source-available, full data export. https://makenot.work.
43 +
44 + You already moved once. I'm not pitching another move — I'm asking if you'd take 15 minutes to look at the model and tell me where you think it breaks.
45 +
46 + Best,
47 + Max J.
48 +
49 + ---
50 +
51 + ### Lyz Lenz
52 + Hi Lyz! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. I'm reaching out because Men Yell at Me was one of the Substack departures that made me sure this project was worth finishing. The pattern of platforms optimizing for the wrong audience is the thing MNW is structured to resist.
53 +
54 + Flat monthly fee, no cut on your sales (only Stripe's ~3%), source-available. More at https://makenot.work.
55 +
56 + I'd value your perspective on what would have made the Substack exit easier and what would make a next move feel safer.
57 +
58 + Best,
59 + Max J.
60 +
61 + ---
62 +
63 + ### Virginia Sole-Smith
64 + Hi Virginia! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. The thing that brought me to your name was the 50% drop in the Substack-to-Patreon move and how clearly you wrote about portability afterward. That's the constraint I designed MNW around: your fan list, your content, and your revenue stream should all be exportable in one click.
65 +
66 + Flat monthly fee, no platform cut on sales (only Stripe's ~3%), source-available. https://makenot.work.
67 +
68 + If portability is a thing you're still thinking about, I'd love to know what missing piece would actually let creators move without losing their audience.
69 +
70 + Best,
71 + Max J.
72 +
73 + ---
74 +
75 + ### Alison Roman
76 + Hi Alison! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. You've already made the Substack-to-Ghost move, so you know how this conversation tends to go — but I think MNW is structurally different in a way that's worth ten minutes of your time. Flat monthly fee instead of a percentage, source-available code, no investor pressure to walk anything back later.
77 +
78 + More at https://makenot.work.
79 +
80 + I'd appreciate honest feedback from someone who's already paid the switching cost once.
81 +
82 + Best,
83 + Max J.
84 +
85 + ---
86 +
87 + ### FoggyKitchen (Martin Linxfeld)
88 + Hi Martin! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. I came across your write-up on leaving Udemy and the share-of-revenue gap that drove it. The thing you ended up doing — running your own infrastructure — is exactly the burden MNW is designed to lift, without adding a percentage cut on top.
89 +
90 + Flat monthly fee, source-available, no platform cut (only Stripe's ~3%). https://makenot.work.
91 +
92 + If your current self-hosted setup is working for you, that's fine — but if any part of the ops is wearing thin, I'd love to talk.
93 +
94 + Best,
95 + Max J.
96 +
97 + ---
98 +
99 + ### Sweet Softies (Jade)
100 + Hi Jade! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. I read your public review of Etsy and Ravelry, and Payhip's 5% is exactly the floor MNW undercuts at any meaningful scale — we charge a flat monthly fee instead.
101 +
102 + Source-available code, no platform cut on sales (only Stripe's ~3%). More at https://makenot.work.
103 +
104 + I'd value your perspective from inside the pattern community, especially on what makes a marketplace feel safe to commit to vs. just trying out.
105 +
106 + Best,
107 + Max J.
108 +
109 + ---
110 +
111 + ### Liz Corke
112 + Hi Liz! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. I came across the context of your Ravelry departure and your move to Payhip. Payhip's 5% is the floor MNW is built to undercut at scale — we charge a flat monthly fee, no percentage on your sales.
113 +
114 + Source-available, full data export. https://makenot.work.
115 +
116 + If you're up for it, I'd value 15 minutes of your time on the pattern-designer fit specifically.
117 +
118 + Best,
119 + Max J.
120 +
121 + ---
122 +
123 + ### Barb Sotiropoulos
124 + Hi Barb! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. The thing that stuck with me from your Patreon departure was the content-treadmill framing — that the platform's pricing model was incentivizing a publishing cadence that wasn't sustainable. The flat-fee model is the literal opposite incentive: we make the same whether you post weekly or quarterly.
125 +
126 + Source-available, no platform cut (only Stripe's ~3%). More at https://makenot.work.
127 +
128 + Would love your honest read on whether this is actually different or whether I'm fooling myself.
129 +
130 + Best,
131 + Max J.
132 +
133 + ---
134 +
135 + ### Michael Kelly
136 + Hi Michael! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. I followed the public frustration around Patreon's forced billing migration this year, and I think the structural fix is what we're trying to build — flat monthly fee, no percentage cut on your sales, billing model that doesn't get restructured on you.
137 +
138 + Source-available, full data export. https://makenot.work.
139 +
140 + Seven years of Patreon revenue is real audience equity. I'm not asking you to risk it — I'm asking if you'd look at the model and tell me where it fails for someone in your shoes.
141 +
142 + Best,
143 + Max J.
144 +
145 + ---
146 +
147 + ### Every (Dan Shipper / team)
148 + Hi team at Every! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. I know you already left Substack to build your own software — which means you've absorbed the engineering cost of avoiding the 10% extraction. MNW exists to make that decision possible *without* the engineering cost: flat monthly fee, no platform cut, source-available so you can verify it.
149 +
150 + More at https://makenot.work.
151 +
152 + If the maintenance burden of your custom stack ever becomes uninteresting, I'd love to be in your bookmark folder.
153 +
154 + Best,
155 + Max J.
156 +
157 + ---
158 +
159 + ### Thomas Frank
160 + Hi Thomas! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. I noticed you're mid-move from Gumroad to Lemon Squeezy. At your scale (the ~$100K/mo bracket), even Lemon Squeezy's flat-rate platform fee is meaningful annual money — and MNW is structurally further along that axis: flat monthly fee, no percentage on sales at all (only Stripe's ~3%).
161 +
162 + Source-available code, full data export. https://makenot.work.
163 +
164 + You're already in motion. I'd love 15 minutes to make sure the new home is genuinely a long-term one.
165 +
166 + Best,
167 + Max J.
168 +
169 + ---
170 +
171 + ## Ideological alignment
172 +
173 + ### Cory Doctorow
174 + Hi Cory! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. I owe you for the word enshittification — it's the conceptual frame I designed MNW against. Flat monthly fee (no percentage on creator sales), self-funded with no investors, source-available, and data export is a first-class feature, not a deterrent.
175 +
176 + More at https://makenot.work.
177 +
178 + I know you're busy. If you have ten minutes for a glance, I'd be grateful — and if not, no obligation.
179 +
180 + Best,
181 + Max J.
182 +
183 + ---
184 +
185 + ### Molly White
186 + Hi Molly! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. citation.needed is the kind of work that flat-fee economics are friendliest to — quality-over-frequency, donation-funded, no percentage cut on contributions taken by anyone.
187 +
188 + Self-funded, source-available, no platform percentage (only Stripe's ~3%). https://makenot.work.
189 +
190 + If you'd take a look, I'd genuinely value your read — both as a creator and as someone who reads platforms skeptically.
191 +
192 + Best,
193 + Max J.
194 +
195 + ---
196 +
197 + ### Louis Rossmann
198 + Hi Louis! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. I think this is the project most aligned with the FULU mission I've seen in the creator-tools space — anti-extraction in the structural sense, not the marketing sense. Flat monthly fee, no percentage cut on sales, source-available code that you (or anyone) can audit.
199 +
200 + More at https://makenot.work.
201 +
202 + If any of this resonates, I'd be honored to get your honest read.
203 +
204 + Best,
205 + Max J.
206 +
207 + ---
208 +
209 + ### Casey Muratori
210 + Hi Casey! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. The technical philosophy is the closest I could realistically get to the handmade ethos: Rust server, Postgres, no JS frameworks on the frontend, no microservices, source-available, ~88K LOC total. The business model matches: flat monthly fee, no percentage cut, no investors.
211 +
212 + More at https://makenot.work.
213 +
214 + I'm not expecting agreement on every choice — but if you have ten minutes to read the architecture and tell me where I went wrong, that's the most useful feedback I could get.
215 +
216 + Best,
217 + Max J.
218 +
219 + ---
220 +
221 + ### Nicky Case
222 + Hi Nicky! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. Donation-funded work is the case where every fee percentage is pure loss with no margin to absorb it — and that's the case I designed MNW around. Flat monthly fee, no percentage cut on contributions, source-available.
223 +
224 + More at https://makenot.work.
225 +
226 + I'd value your perspective on whether the flat-fee model holds up at the donation end of the spectrum specifically.
227 +
228 + Best,
229 + Max J.
230 +
231 + ---
232 +
233 + ### iFixit (Kyle Wiens)
234 + Hi Kyle! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. iFixit and MNW are operating in different niches but the underlying principle is the same — open knowledge, no extractive middlemen, the right to leave. I'd love to be on your radar even if there's no near-term fit.
235 +
236 + Flat monthly fee, no percentage cut on sales (only Stripe's ~3%), source-available. More at https://makenot.work.
237 +
238 + Open to any feedback you have time for.
239 +
240 + Best,
241 + Max J.
242 +
243 + ---
244 +
245 + ### Tom Nicholas
246 + Hi Tom! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. Your essays on the broken creator economy are part of why I felt this project was worth finishing. Flat monthly fee, no platform percentage on sales, source-available — built to be the thing you keep describing as the missing option.
247 +
248 + More at https://makenot.work.
249 +
250 + If you'd take a look, I'd value the honest critique more than the endorsement.
251 +
252 + Best,
253 + Max J.
254 +
255 + ---
256 +
257 + ### Folding Ideas (Dan Olson)
258 + Hi Dan! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. Line Goes Up is on the short list of things that named the problem I'm trying to solve at the platform layer: extractive systems wrapped in friendly UX. MNW is flat monthly fee, no percentage cut on creator sales, source-available code, no investors.
259 +
260 + More at https://makenot.work.
261 +
262 + If any of this is interesting, I'd love your read.
263 +
264 + Best,
265 + Max J.
266 +
267 + ---
268 +
269 + ## Technical overlap (Rust / dev tools)
270 +
271 + ### fasterthanlime (Amos Wenger)
272 + Hi Amos! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. The server is ~88K lines of Rust (sqlx + Postgres + axum, no microservices), and you're squarely in the audience that could actually evaluate it. Flat monthly fee, no platform percentage on sales, source-available.
273 +
274 + More at https://makenot.work.
275 +
276 + Would love your eyes on the implementation as much as the model.
277 +
278 + Best,
279 + Max J.
280 +
281 + ---
282 +
283 + ### Jon Gjengset
284 + Hi Jon! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. The server's Rust through and through — sqlx, axum, tower-governor, native async patterns, source-available under PolyForm Noncommercial. The business model matches: flat monthly fee, no percentage cut on creator sales.
285 +
286 + More at https://makenot.work.
287 +
288 + If you'd evaluate it as a technical reader and tell me where the code embarrasses me, that's the most useful thing I could get from this conversation.
289 +
290 + Best,
291 + Max J.
292 +
293 + ---
294 +
295 + ### Tsoding
296 + Hi! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. Cultural fit is what brought me to your name — from-scratch, anti-framework, server is straightforward Rust and Postgres with no microservices and no JS framework on the frontend. Flat monthly fee, no platform percentage on sales, source-available.
297 +
298 + More at https://makenot.work.
299 +
300 + I'd be honored to get even a short reaction.
301 +
302 + Best,
303 + Max J.
304 +
305 + ---
306 +
307 + ### TJ DeVries
308 + Hi TJ! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. You went independent recently, which puts you exactly in the audience I'm trying to serve: developers who want a platform that doesn't take a percentage of what they earn. Flat monthly fee, source-available, no investor pressure.
309 +
310 + More at https://makenot.work.
311 +
312 + If you're settling into a platform stack, I'd love to be on your radar — and I'd value your read either way.
313 +
314 + Best,
315 + Max J.
316 +
317 + ---
318 +
319 + ### ThePrimeagen
320 + Hi! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. You left Netflix to go independent and have been loud about the creator-economy math since — MNW is the model I'd want a creator I care about on. Flat monthly fee, no percentage cut on sales (only Stripe's ~3%), source-available so you can verify every claim.
321 +
322 + More at https://makenot.work.
323 +
324 + If you ever want to take a swing at the implementation on stream, I'd be both terrified and grateful.
325 +
326 + Best,
327 + Max J.
328 +
329 + ---
330 +
331 + ### Julia Evans
332 + Hi Julia! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. You already proved you'll switch when the math is right (Gumroad → Shopify), and the zines + programming-explainer fit feels natural for a flat-fee model: high quality, deliberate cadence, no percentage cut on top.
333 +
334 + Source-available, no platform percentage on sales. More at https://makenot.work.
335 +
336 + I'd value your perspective on what would make a third move feel worth it.
337 +
338 + Best,
339 + Max J.
340 +
341 + ---
342 +
343 + ### Paul Hudson
344 + Hi Paul! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. Subscription model on Gumroad means compounding percentage fees over the lifetime of every subscriber — exactly the cost MNW eliminates. Flat monthly fee, no platform percentage on sales, source-available.
345 +
346 + More at https://makenot.work.
347 +
348 + I know Hacking with Swift is a serious operation and a move is a real ask. I'd appreciate any time you can spare on a read of the model.
349 +
350 + Best,
351 + Max J.
352 +
353 + ---
354 +
355 + ### Bartosz Ciechanowski
356 + Hi Bartosz! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. Low-frequency, high-quality work is the case where the flat-fee model is most generous — we charge the same whether you post quarterly or daily, and we take no percentage cut on contributions or sales. Source-available.
357 +
358 + More at https://makenot.work.
359 +
360 + If you'd take a look, I'd be grateful.
361 +
362 + Best,
363 + Max J.
364 +
365 + ---
366 +
367 + ## Vocal about fees / demonetization
368 +
369 + ### Benn Jordan
370 + Hi Benn! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. The ~$1,100/mo Patreon math you've talked about publicly is exactly the kind of leak MNW closes: flat monthly fee instead of a percentage cut on sales, source-available code, full data export so the switching cost is zero.
371 +
372 + More at https://makenot.work.
373 +
374 + If you'd run the numbers against your current setup, I'd love to know whether they land.
375 +
376 + Best,
377 + Max J.
378 +
379 + ---
380 +
381 + ### Red Means Recording
382 + Hi! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. The sample-pack distribution math on Gumroad (10% + per-txn) is one of the cases MNW makes the strongest argument against — flat monthly fee, no percentage on sales at all (only Stripe's ~3%), source-available.
383 +
384 + More at https://makenot.work.
385 +
386 + Would love your honest read on whether the music-producer fit is genuine.
387 +
388 + Best,
389 + Max J.
390 +
391 + ---
392 +
393 + ### Airwindows (Chris Johnson)
394 + Hi Chris! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. Donation-funded, free-software audio is the case where every percentage point of fees is pure loss — there's no margin to absorb it. MNW charges a flat monthly fee and takes no percentage on contributions, ever. Source-available code so you can verify.
395 +
396 + More at https://makenot.work.
397 +
398 + If any of this is interesting, I'd be honored to hear what you think.
399 +
400 + Best,
401 + Max J.
402 +
403 + ---
404 +
405 + ### anotherxlife
406 + Hi! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. I read your write-up on leaving Gumroad and the PayPal issues, and the structural problem you described — platforms with the ability to gate or restructure your income — is the thing MNW is built to make impossible by design. Flat monthly fee, no platform cut, source-available.
407 +
408 + More at https://makenot.work.
409 +
410 + I'd value your eyes on this if you're up for it.
411 +
412 + Best,
413 + Max J.
414 +
415 + ---
416 +
417 + ### Angela Collier
418 + Hi Angela! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. Your criticism of platforms incentivizing sensationalism over substance is the editorial-side version of the same problem MNW is trying to fix on the economic side — the platform's incentives shouldn't run against the creator's.
419 +
420 + Flat monthly fee, no platform percentage on sales, source-available. More at https://makenot.work.
421 +
422 + If you'd take a look, I'd love to know whether this resonates or whether I'm reaching.
423 +
424 + Best,
425 + Max J.
426 +
427 + ---
428 +
429 + ### Amp Somers (Watts The Safeword)
430 + Hi Amp! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. The revenue-cliff story you've told publicly is exactly the case MNW is designed to make impossible: no opaque demonetization, no algorithm gating your earnings, no platform with a unilateral kill switch. Flat monthly fee, fan revenue goes to your Stripe directly.
431 +
432 + Source-available, full data export. https://makenot.work.
433 +
434 + I'd be grateful for any time you can spare on a read.
435 +
436 + Best,
437 + Max J.
438 +
439 + ---
440 +
441 + ### Chase Ross
442 + Hi Chase! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. Platform discrimination is the case I think creators talk about least often in money terms, even though the financial damage is severe. MNW doesn't have a content-policy lever to pull on you economically: fan payments go straight to your Stripe, your audience is yours, leaving is one click.
443 +
444 + Source-available, no platform percentage. https://makenot.work.
445 +
446 + If you'd look, I'd value the read.
447 +
448 + Best,
449 + Max J.
450 +
451 + ---
452 +
453 + ### Perplexing Ruins
454 + Hi! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. I read the reporting on the itch.io payout delays and the $3,700 held back. The way MNW is structured, fan payments go to your Stripe account directly — there's no platform-side balance to hold, delay, or freeze. Flat monthly fee, source-available.
455 +
456 + More at https://makenot.work.
457 +
458 + If you're still feeling out alternatives, I'd value your perspective.
459 +
460 + Best,
461 + Max J.
462 +
463 + ---
464 +
465 + ### Rick Beato
466 + Hi Rick! My name is Max, and I'm the owner and operator of a new creator distribution platform currently in alpha development, Makenot.work. The copyright-claim treadmill is a YouTube problem MNW doesn't fix — but the underlying frustration (a platform with unilateral power over your earnings) is exactly the structural condition we're built to remove. Flat monthly fee, fan payments go straight to you, source-available.
467 +
468 + More at https://makenot.work.
469 +
470 + Long shot at your scale, but I'd be grateful for any time you can spare.
471 +
472 + Best,
473 + Max J.
474 +
475 + ---
@@ -559,13 +559,17 @@
559 559
560 560 <!-- Why trust this -->
561 561 <div class="section">
562 - <div class="section-title">Why this works</div>
562 + <div class="section-title">Why this works (and stays working)</div>
563 563
564 - <p><strong>The model is simple.</strong> We charge creators a flat fee for platform access. We don't take a cut of sales. Our incentive is to keep you on the platform, not to extract more from your revenue. The better you do, the more likely you stay. Those align.</p>
564 + <p>The reason every creator platform eventually squeezes you isn't bad management. It's the model. Percentage-cut platforms grow revenue by taking more from each sale, raising fees over time, injecting ads, gating features, and selling your audience data. Investor-backed platforms have to do this&mdash;there's a board waiting on returns. Makenot.work is built so those moves don't pay off.</p>
565 565
566 - <p><strong>Self-funded, no investors.</strong> No board, no pressure to raise fees, no growth-at-all-costs mandate. Operating costs are roughly $580/month. We break even at about 28 creators. One person built this, one person runs it.</p>
566 + <p><strong>The model is flat-fee, not percentage.</strong> We charge for platform access, not for your sales. Our incentive is to keep you on the platform, not to extract more from each transaction. The better you do, the more likely you stay. Those align&mdash;and nothing in the pricing structure rewards us for squeezing harder.</p>
567 567
568 - <p><strong>The code is public.</strong> Server source is available under PolyForm Noncommercial. Every privacy claim, every fee calculation, every guarantee is auditable. If we ever break a promise, you'll see it in a diff.</p>
568 + <p><strong>No investors, no board, no exit pressure.</strong> Self-funded. Operating costs are roughly $580/month. We break even at about 28 creators. There is no quarter where someone tells us to raise fees, cut features, or sell user data to hit a number. That option doesn't exist here.</p>
569 +
570 + <p><strong>Every promise is enforceable in code.</strong> Server source is available under PolyForm Noncommercial. Every fee calculation, every privacy claim, every guarantee&mdash;auditable. If we ever break a promise, you'll see it in a diff. And because data export is a first-class feature, leaving is one click, not a hostage negotiation.</p>
571 +
572 + <p><strong>The exit you'd want is built in.</strong> Full data export, month-to-month billing, no contracts, no penalties. If we ever turn into the thing we're trying to replace, you take your work, your fan list, and your revenue stream, and you go. That's the strongest possible commitment we can make&mdash;the cost of leaving is zero.</p>
569 573 </div>
570 574
571 575 <div class="diamond">.</div>
@@ -576,7 +580,7 @@
576 580
577 581 <div class="gaps">
578 582 <div class="gap">
579 - <span>We won't find you an audience. Discovery is search, tags, filters, feeds&mdash;driven by fan intent, not algorithms. Your audience comes with you; we help you serve them.</span>
583 + <span>Discovery is intent-driven, not algorithmic. We have a Discover page, search, tag and price filters, sorting, and personalized feeds&mdash;but we don't push your work into anyone's recommendations engine. Your audience comes with you; we help you serve them and we make it easier for new fans to find work like yours when they're looking.</span>
580 584 </div>
581 585 <div class="gap">
582 586 <span>No physical products. Digital only: audio, video, text, software, plugins, games.</span>
@@ -1,272 +0,0 @@
1 - \documentclass[11pt]{article}
2 - \usepackage[margin=0.7in]{geometry}
3 - \usepackage{fontspec}
4 - \usepackage{xcolor}
5 - \usepackage{booktabs}
6 - \usepackage{enumitem}
7 - \usepackage{titlesec}
8 - \usepackage{fancyhdr}
9 - \usepackage{multicol}
10 - \usepackage{array}
11 - \usepackage{tcolorbox}
12 - \usepackage{hyperref}
13 - \usepackage{needspace}
14 -
15 - \setmainfont{Georgia}
16 - \setsansfont{Helvetica Neue}
17 - \setmonofont{Menlo}
18 -
19 - \definecolor{violet}{HTML}{6c5ce7}
20 - \definecolor{charcoal}{HTML}{3d3530}
21 - \definecolor{beige}{HTML}{ede8e1}
22 - \definecolor{warmwhite}{HTML}{f7f5f2}
23 - \definecolor{muted}{HTML}{8a7f74}
24 - \definecolor{border}{HTML}{d4cec6}
25 -
26 - \color{charcoal}
27 -
28 - \hypersetup{colorlinks=true, urlcolor=violet, linkcolor=violet}
29 -
30 - \pagestyle{fancy}
31 - \fancyhf{}
32 - \renewcommand{\headrulewidth}{0pt}
33 - \fancyfoot[C]{\textcolor{muted}{\footnotesize makenot.work \enspace · \enspace support@makenot.work \enspace · \enspace Make Creative, LLC}}
34 -
35 - \titleformat{\section}{\sffamily\normalsize\bfseries\color{violet}}{}{0em}{}[\vspace{-0.3em}{\color{border}\rule{\linewidth}{0.4pt}}]
36 - \titleformat{\subsection}{\normalfont\normalsize\bfseries}{}{0em}{}
37 - \titlespacing{\section}{0pt}{1em}{0.5em}
38 - \titlespacing{\subsection}{0pt}{0.4em}{0.2em}
39 -
40 - \setlist[itemize]{nosep, left=0pt, itemsep=2pt}
41 -
42 - \tcbuselibrary{skins}
43 - \newtcolorbox{calloutbox}{colback=charcoal, coltext=beige, colframe=charcoal, boxrule=0pt, arc=0pt, left=8pt, right=8pt, top=6pt, bottom=6pt}
44 - \newtcolorbox{honestbox}{colback=warmwhite, coltext=charcoal, colframe=beige, boxrule=0pt, borderline west={2pt}{0pt}{border}, arc=0pt, left=8pt, right=8pt, top=4pt, bottom=4pt}
45 - \newtcolorbox{pillarbox}{colback=beige, coltext=charcoal, colframe=violet, boxrule=0pt, borderline west={2pt}{0pt}{violet}, arc=0pt, left=8pt, right=8pt, top=4pt, bottom=4pt}
46 - \newtcolorbox{stepbox}[1][]{colback=beige, coltext=charcoal, colframe=beige, boxrule=0pt, arc=0pt, left=6pt, right=6pt, top=6pt, bottom=6pt, #1}
47 -
48 - \setlength{\parindent}{0pt}
49 - \setlength{\parskip}{0.4em}
50 -
51 - \begin{document}
52 -
53 - % ── Header ──
54 - \begin{center}
55 - {\fontsize{28}{34}\selectfont\textbf{makenot.work}}\\[6pt]
56 - {\sffamily\color{violet}\textbf{0\% platform fee. Your revenue is yours.}}
57 - \end{center}
58 -
59 - \vspace{0.2em}
60 - \rule{\linewidth}{1.5pt}
61 - \vspace{0.4em}
62 -
63 - Makenot.work is a platform for independent creators that charges a flat monthly fee instead of taking a percentage of your sales. You pay for platform access. Your fan revenue is untouched.
64 -
65 - \vspace{0.4em}
66 -
67 - \begin{pillarbox}
68 - \textbf{\sffamily You keep everything} \enspace {\small Only Stripe's \textasciitilde3\% processing fee. No platform percentage, no payout fees, no skimming.}
69 - \end{pillarbox}
70 - \vspace{0.15em}
71 - \begin{pillarbox}
72 - \textbf{\sffamily No lock-in} \enspace {\small Full data export anytime. Month-to-month. Cancel in one click. Your fans, your data.}
73 - \end{pillarbox}
74 - \vspace{0.15em}
75 - \begin{pillarbox}
76 - \textbf{\sffamily Source available} \enspace {\small Read every line of our server code. Verify every claim. Audit our privacy practices.}
77 - \end{pillarbox}
78 -
79 - \vspace{0.3em}
80 -
81 - \begin{calloutbox}
82 - At \textbf{\$2,000/mo} revenue, you keep \textbf{\textasciitilde\$1,850} on Makenot.work.
83 - On a 10\% platform, you keep \textasciitilde\$1,600. On 15\%, \textasciitilde\$1,400. The gap widens with every dollar.
84 - \end{calloutbox}
85 -
86 - \begin{center}\textcolor{violet}{\Large .}\end{center}
87 -
88 - % ── Pricing ──
89 - \section{Pricing}
90 -
91 - \begin{tabular}{@{} l r l r r @{}}
92 - \toprule
93 - \textbf{Tier} & \textbf{Monthly} & \textbf{For} & \textbf{Per-file} & \textbf{Storage} \\
94 - \midrule
95 - Basic & \$10 & Text, blogs, newsletters & 10 MB & 50 GB \\
96 - Small Files & \$20 & Audio, plugins, small software & 500 MB & 250 GB \\
97 - Big Files & \$30 & Video, games, large software & 20 GB & 500 GB \\
98 - Everything & \$60 & All features, current and future & 20 GB & 500 GB \\
99 - \bottomrule
100 - \end{tabular}
101 -
102 - \smallskip
103 - {\small\textcolor{muted}{All tiers include: unlimited downloads, custom profile, project storefronts, memberships, analytics, data export, RSS, 2FA/passkeys, custom domains. Tiers differ by file size and storage, not features.}}
104 -
105 - \begin{honestbox}
106 - {\small\textit{If you earn less than roughly \$67/month, a percentage-cut platform costs less. We'd rather be honest about that than hide the math.}}
107 - \end{honestbox}
108 -
109 - % ── Revenue Comparison ──
110 - \section{What you keep at different revenue levels}
111 -
112 - \begin{tabular}{@{} l r r r r @{}}
113 - \toprule
114 - \textbf{Monthly revenue} & \textbf{Makenot.work} & \textbf{10\% platform} & \textbf{15\% platform} & \textbf{You save} \\
115 - \midrule
116 - \$500 & \textbf{\$410--470} & \$420 & \$395 & \$15--75 \\
117 - \$1,000 & \textbf{\$881--941} & \$870 & \$820 & \$61--121 \\
118 - \$2,000 & \textbf{\$1,822--1,872} & \$1,682 & \$1,582 & \$140--290 \\
119 - \$5,000 & \textbf{\$4,795--4,845} & \$4,355 & \$3,855 & \$440--990 \\
120 - \$10,000 & \textbf{\$9,350--9,400} & \$8,610 & \$7,910 & \$740--1,490 \\
121 - \bottomrule
122 - \end{tabular}
123 -
124 - \smallskip
125 - {\footnotesize\textcolor{muted}{Range reflects \$10--\$60 tier fee. Competitor columns include Stripe processing (\textasciitilde3\%). Assumes \$10 average sale price.}}
126 -
127 - \begin{center}\textcolor{violet}{\Large .}\end{center}
128 -
129 - % ── Features ──
130 - \section{What you get}
131 -
132 - \begin{multicols}{2}
133 -
134 - \subsection{Sell your work}
135 - {\small One-time purchases, pay-what-you-want, subscriptions, bundles, promo codes, license keys. Guest checkout---fans don't need an account.}
136 -
137 - \subsection{Host your files}
138 - {\small Audio and video streaming with chapters, file versioning with changelogs, malware scanning on every upload.}
139 -
140 - \subsection{Build your audience}
141 - {\small Project storefronts, blog publishing, mailing lists with broadcasts, RSS feeds, follower system, curated collections.}
142 -
143 - \subsection{Own your brand}
144 - {\small Custom domains with automatic TLS. Embeddable widgets (button, card, player) for your site.}
145 -
146 - \columnbreak
147 -
148 - \subsection{Understand your business}
149 - {\small Analytics at user, project, and item level. Full data export: projects (JSON), sales (CSV), content (ZIP).}
150 -
151 - \subsection{Host source code}
152 - {\small Git repos with web browser, syntax highlighting, blame view, email-based issues, and patch submission.}
153 -
154 - \subsection{Community forums}
155 - {\small Integrated forums per project via Multithreaded. Invite-only or open. Threaded discussions.}
156 -
157 - \subsection{Get discovered}
158 - {\small Discover page with search, filters by type, tag, price, AI tier. Sorting, pagination, personalized feeds.}
159 -
160 - \end{multicols}
161 -
162 - % ── Feature Comparison ──
163 - \section{Feature comparison}
164 -
165 - \begin{tabular}{@{} l c c c c @{}}
166 - \toprule
167 - \textbf{Feature} & \textbf{MNW} & \textbf{Patreon} & \textbf{Bandcamp} & \textbf{Gumroad} \\
168 - \midrule
169 - 0\% platform fee & Yes & --- & --- & --- \\
170 - Audio hosting + player & Yes & Yes & Yes & --- \\
171 - Video hosting + player & Yes & Yes & --- & --- \\
172 - Software versioning & Yes & --- & --- & Yes \\
173 - License keys & Yes & --- & --- & Yes \\
174 - Git hosting & Yes & --- & --- & --- \\
175 - Custom domains & Yes & --- & --- & Yes \\
176 - Full data export & Yes & Part & Part & Part \\
177 - Source-available code & Yes & --- & --- & --- \\
178 - Embed widgets & Yes & --- & Yes & Yes \\
179 - \bottomrule
180 - \end{tabular}
181 -
182 - \begin{center}\textcolor{violet}{\Large .}\end{center}
183 -
184 - % ── Guarantees ──
185 - \section{Written guarantees}
186 -
187 - {\small These are binding commitments published at \href{https://makenot.work/docs/guarantees}{makenot.work/docs/guarantees} and verifiable in the source code.}
188 -
189 - \vspace{-0.2em}
190 - \begin{multicols}{2}
191 -
192 - \subsection{0\% platform fee}
193 - {\small No platform percentage, ever. Your tier fee covers access. Fan revenue goes to you minus Stripe's \textasciitilde3\%.}
194 -
195 - \subsection{Full data export}
196 - {\small All content, metadata, fan contacts (when shared), transaction history. JSON, CSV, ZIP. Available anytime.}
197 -
198 - \subsection{Price stability}
199 - {\small 90 days notice before any change. Existing creators grandfathered at current rate for 12+ months.}
200 -
201 - \columnbreak
202 -
203 - \subsection{Shutdown protocol}
204 - {\small 90-day advance notice. Full export maintained. Fan payments go to your Stripe---nothing to settle.}
205 -
206 - \subsection{No ads, no tracking}
207 - {\small No behavioral profiling, no data sales, no injected advertising. Verifiable in source code.}
208 -
209 - \subsection{99.5\% uptime target}
210 - {\small Live status at /health. Dual independent monitoring. Daily backups with point-in-time recovery.}
211 -
212 - \end{multicols}
213 -
214 - \vspace{-0.3em}
215 -
216 - % ── Why This Works ──
217 - \section{Why this works}
218 -
219 - \textbf{The model is simple.} We charge a flat fee for platform access. We don't take a cut of sales. Our incentive is to keep you, not to extract more from your revenue. The better you do, the more likely you stay. Those align.
220 -
221 - \textbf{Self-funded, no investors.} No board, no pressure to raise fees. Operating costs are roughly \$580/month. We break even at about 28 creators. One person built this, one person runs it.
222 -
223 - \textbf{The code is public.} Server source is available under PolyForm Noncommercial. Every privacy claim, every fee calculation, every guarantee is auditable.
224 -
225 - \vspace{-0.2em}
226 -
227 - % ── Honest Gaps ──
228 - \section{What we don't do}
229 -
230 - \vspace{-0.3em}
231 - \begin{itemize}
232 - \item We won't find you an audience. Discovery is search, tags, filters, feeds---driven by fan intent, not algorithms. Your audience comes with you; we help you serve them.
233 - \item No physical products. Digital only: audio, video, text, software, plugins, games.
234 - \item We're new and small. One operator, early access. Fast iteration and direct support, but a smaller network than established platforms.
235 - \end{itemize}
236 -
237 - \vspace{-0.2em}
238 -
239 - % ── Getting Started ──
240 - \section{Getting started}
241 -
242 - \vspace{-0.2em}
243 - \begin{center}
244 - \begin{minipage}[t]{0.28\linewidth}
245 - \begin{stepbox}
246 - \centering
247 - {\sffamily\Large\color{violet}\textbf{1}}\\[2pt]
248 - {\small Sign up with your invite code}
249 - \end{stepbox}
250 - \end{minipage}
251 - \hfill
252 - \begin{minipage}[t]{0.28\linewidth}
253 - \begin{stepbox}
254 - \centering
255 - {\sffamily\Large\color{violet}\textbf{2}}\\[2pt]
256 - {\small Connect Stripe to receive payments}
257 - \end{stepbox}
258 - \end{minipage}
259 - \hfill
260 - \begin{minipage}[t]{0.28\linewidth}
261 - \begin{stepbox}
262 - \centering
263 - {\sffamily\Large\color{violet}\textbf{3}}\\[2pt]
264 - {\small Create a project, upload your first item}
265 - \end{stepbox}
266 - \end{minipage}
267 - \end{center}
268 -
269 - \smallskip
270 - {\small You're receiving this because we think your work is a good fit. This is a private alpha---invite-only, hand-picked creators. We're looking for honest feedback: what works, what's missing, what's confusing.}
271 -
272 - \end{document}
@@ -181,12 +181,15 @@ PWYW checkout in browser at localhost using test card `4242…`.
181 181 ## Other bugs filed during this session (for reference, not migration scope)
182 182
183 183 In `server/docs/todo.md` § Global UX:
184 - - Stuck "Verbing..." buttons (HTMX + per-file fetch loading-state restoration)
185 - - Library page scroll-in-scroll + `...` dropdown UX
186 - - Checkout error messages not surfaced to user (frontend swallows
187 - `AppError::BadRequest` bodies)
188 - - Misleading webhook error message ("Invalid webhook signature" returned for
189 - parse failures too; should distinguish)
184 + - Library page scroll-in-scroll + `...` dropdown UX (open)
185 + - Stuck "Verbing..." buttons + slow-redirect feedback (closed 2026-05-18)
186 + - Checkout error messages not surfaced to user (closed 2026-05-18 via
187 + `error.html` typography rebalance)
188 + - Misleading webhook error message (closed 2026-05-18 — `payments/webhooks.rs`
189 + now produces distinct bodies "Invalid webhook signature: <reason>",
190 + "Webhook envelope JSON parse failed: <serde error>", "Webhook envelope
191 + missing required field: <name>", and the typed-struct dispatcher still
192 + produces "Failed to parse <Subscription|Invoice|…>: <serde error>")
190 193
191 194 In `MNW/server/deploy/human_testing.md`:
192 195 - P0 sections done: Signup→Verify→Login→Logout (11/11), Account Lockout
@@ -28,7 +28,7 @@ A new creator's first $X/mo of earnings auto-credits toward their subscription b
28 28
29 29 ### 2. DIY tier as the explicit on-ramp
30 30
31 - The DIY tier ($12/yr, embed + Stripe Connect only) already lives in `todo.md` under "DIY Tier (Post-Launch, exploratory)." Reposition it publicly as the small-creator entry point: "start with embeds for $1/mo, graduate to Basic when you have content to host."
31 + The DIY tier ($12/yr, embed + Stripe Connect only) lives in `todo.md` § DIY Tier and is **firmly post-launch** (decision 2026-05-18). Reposition it publicly as the small-creator entry point *once it ships*: "start with embeds for $1/mo, graduate to Basic when you have content to host." Until then, do not reference DIY in launch-window marketing or on the public site.
32 32
33 33 **Pitch:** training wheels. Not a parallel product; the explicit junior tier.
34 34
@@ -83,7 +83,7 @@ Existing creator refers a new creator; new creator gets 3 months at $0, existing
83 83
84 84 ## Recommended Sequencing
85 85
86 - 1. **Ship DIY (mechanism 2) first.** Already planned. Becomes the public answer to "I'm too small for Basic."
86 + 1. **Ship DIY (mechanism 2) first post-launch.** Already planned. Becomes the public answer to "I'm too small for Basic," but only after the soft launch stabilizes — DIY is firmly post-launch (decision 2026-05-18).
87 87 2. **Add referrals (mechanism 4) at or near public launch.** Low engineering cost, high marketing leverage, fits the word-of-mouth growth thesis. The abuse-design work is the real cost.
88 88 3. **Charter pricing (mechanism 3) coincident with public launch.** Costs almost nothing; great launch-window energy. Don't ship without an explicit close date or count cap.
89 89 4. **Earnings-funded subscription (mechanism 1) after DIY proves out.** Most complex; benefits most from real usage data on what the earnings floor actually looks like in practice. Pair with the earn-back credit program rollout.
M server/docs/todo.md +147 -287
@@ -1,7 +1,8 @@
1 1 # Makenotwork TODO
2 2
3 3 ## Status
4 - v0.5.22 deployed 2026-05-16. Audit grade A (Run 26). ~88K LOC, 1,504 lib tests, 0 warnings. Migration 115. Sprints 1-9 complete (see `todo_done.md`). Content seeded: AF 0.4.0 + GO 0.3.1 on discover page. **2026-05-16 sprint: docengine assumption substitution** — site-docs values rendered from `docs/internal/business/assumptions.toml` at startup (0.5.20), filter pipeline shipped with extensible `Filter` trait + 8 built-ins (0.5.21), marginal-cost model corrected to include weighted Stripe fee on creator subs (0.5.22). Migrated so far: `guide/tiers.md`, `about/pricing.md`, `about/guarantees.md`, `support/faq.md`, `guide/stripe.md`.
4 +
5 + v0.5.22 deployed 2026-05-16. Audit grade A (Run 26). ~88K LOC, 1,504 lib tests, 0 warnings. Migration 115 (116 staged: founder pricing). Sprints 1-9 + Library View Split + Founder Pricing engineering complete (see `todo_done.md`). Content seeded: AF 0.4.0 + GO 0.3.1 on discover page.
5 6
6 7 Human tasks in `human_todo.md`. Completed items in `todo_done.md`.
7 8
@@ -12,414 +13,256 @@ Human tasks in `human_todo.md`. Completed items in `todo_done.md`.
12 13 Public-launch blockers. Everything else in this file is post-launch. Do not promote items into this section without explicit user decision.
13 14
14 15 1. **Manual testing** — walk through `human_todo.md` sign-off table on live server. Remaining: Stripe checkout e2e for all 3 apps, license key flow, promo codes. (SyncKit AF + GO verified 2026-05-11; BB sync deferred.)
15 - 2. **Invite testers** — generate invite codes, send hand-written emails per `docs/internal/outreach/tiers.md`
16 + 2. **Invite testers** — generate invite codes, send hand-written emails per `docs/internal/outreach/tiers.md`.
16 17
17 18 Verified during scoping (2026-05-16):
18 - - ✅ **Cache-Control on S3 uploads** — every presigned-PUT call site passes `CACHE_CONTROL_IMMUTABLE` (`public, max-age=31536000, immutable`). Storage backend wires it through. (`routes/storage/{images,media,versions,uploads}.rs`, `routes/api/internal/uploads.rs`)
19 - - ⚠ **CDN coverage of paid downloads** — paid downloads intentionally bypass CDN (presigned S3 → `fsn1.your-objectstorage.com`); only free content uses `cdn.makenot.work`. Not a launch blocker at soft-launch scale; moved to Cloudflare Maturation phase below.
20 - - ⚠ **caddy-ask rate limit** — per-IP rate limit exists via tower-governor (10 rps, burst 60); handler short-circuits via `domain_cache` before DB. Concurrency cap on cache-miss DB path missing but low marginal value pre-launch. Moved to Cloudflare Maturation phase.
21 -
22 - Explicitly **out of launch scope** (do not work on before public launch):
23 - - DIY tier (entire section below) — defer to post-launch quarter; 25+ items, unproven support model
24 - - Small Creator On-Ramp (referrals, charter pricing, earnings-funded sub)
25 - - Community forum — forums service launches separately
26 - - Stripe SDK migration (pinned API version works)
27 - - Custom Pages
28 - - All Trust Audit open items
29 - - Background / multipart / CLI bulk uploads
30 - - All Code Assessment blind spots (async-trait, instrument cardinality, mutation, proc macros, benches, typestate, alloc)
31 - - All other Infra/Scaling items below ("pre-1k creator," not pre-launch)
32 - - All Nitpick/Fuzz deferred items
33 - - All Global UX polish
19 + - **Cache-Control on S3 uploads** — every presigned-PUT call site passes `CACHE_CONTROL_IMMUTABLE`. Confirmed.
20 + - **CDN coverage of paid downloads** — paid downloads intentionally bypass CDN; only free content uses `cdn.makenot.work`. Not a launch blocker at soft-launch scale; tracked under Post-Launch Tracks § Cloudflare Phase 1.
21 + - **caddy-ask rate limit** — per-IP rate limit exists via tower-governor (10 rps, burst 60); cache-miss concurrency cap missing but low marginal value pre-launch. Tracked under Post-Launch Tracks § Cloudflare Phase 2.
34 22
35 - ---
23 + Explicitly **out of launch scope**: everything under Post-Launch Tracks, Infra & Quality, Feature Backlog, Upstream Blocked, Deferred. DIY tier is firmly post-launch (see memory `project_diy_post_launch.md`).
36 24
37 25 ---
38 26
39 - ## Stack Maturation: Cloudflare Lean-In (Post-Launch Phase)
27 + ## Founder Pricing — operational tail
40 28
41 - The platform currently uses Cloudflare as a thin DNS+CDN layer for static and free-content paths. Hetzner carries all paid-content egress, ACME issuance, DDoS absorption, and edge logic. This phase is about pushing more of that work to Cloudflare where it's cheaper, faster, or more resilient than doing it ourselves.
29 + Decisions locked 2026-05-18; plan in memory `project_founder_pricing.md`. All in-app engineering shipped (see `todo_done.md` § Founder pricing engineering). Two operational items remain:
42 30
43 - **Why now (post-launch, not pre-launch):** at soft-launch scale Hetzner egress and origin compute are fine. The break-even shifts as we approach ~1k creators / video usage normalizes / ACME abuse becomes a real target. Do these incrementally as triggers fire — don't refactor speculatively.
31 + - [ ] **Stripe products/prices** — create 8 founder Price objects (4 tiers × monthly/annual at half-sticker) and set env vars in production. Per `_meta/human_todo.md` § Stripe Dashboard Knockout Session. Founder monthly $5/$10/$15/$30, founder annual $54/$108/$162/$324.
32 + - [ ] **DIY exclusion guard** — when DIY tier ships post-launch, verify its checkout path doesn't stamp `is_founder`.
44 33
45 - **Decision principles:**
46 - - Only move to Cloudflare what's cheaper OR better at the edge. Don't migrate for novelty.
47 - - Keep origin code provider-neutral where reasonable (avoid CF-specific lock-in inside Rust).
48 - - Each item below should have a clear "trigger to start" — a metric or event that says it's time.
34 + Support-time tracking handled externally (Max's own time-tracking tool) — earlier draft of a `support_minutes_logged` column was removed before merge.
49 35
50 - ### Phase 1: Paid-content egress (biggest cost lever)
51 - - [ ] **CDN coverage of paid downloads.** Currently `routes/storage/downloads.rs::resolve_content_url` routes only free content through `cdn.makenot.work`; paid content uses presigned S3 URLs straight to `fsn1.your-objectstorage.com`. Memory: "biggest hidden cost lever at scale." Same pattern in `routes/ota.rs:409`, `routes/api/guest_checkout.rs:273`, `routes/pages/public/content/item.rs:{200,267,435}`.
52 - - Options to evaluate:
53 - - **Signed CDN URLs via Cloudflare** (HMAC token in URL, validated at edge via Worker before origin fetch). Keeps cache hit ratio high per content-addressed key. Likely the right answer.
54 - - **Cloudflare R2 migration.** Egress-free to Cloudflare. Bigger lift, vendor migration, but eliminates the egress line entirely. Evaluate vs Hetzner Object Storage cost at projected scale.
55 - - **Cloudflare Stream for video.** Adaptive bitrate, transcode, signed playback. Pairs with the existing "Media transcoding pipeline" post-launch item.
56 - - Trigger to start: Cloudflare cache hit ratio drops below ~80% on weekly review, OR Hetzner egress exceeds projection by 50%, OR first video creator joins Big Files/Everything tier.
36 + ---
57 37
58 - ### Phase 2: Edge logic
59 - - [ ] **caddy-ask concurrency cap + edge filtering.** Current per-IP rate limit (10 rps, burst 60) is fine for the threat model today. At scale, move first-pass domain validation to a Cloudflare Worker: known-bad TLDs, syntactic checks, cached verified-domain set. Origin only sees genuinely-novel requests. Adds a `tokio::sync::Semaphore` on the cache-miss DB path as a belt-and-suspenders measure.
60 - - Trigger: caddy-ask QPS > 100/sec sustained, OR a real abuse incident.
61 - - [ ] **WAF-tier rate limiting** for `/api/*` write paths. Tower-governor handles per-IP; Cloudflare can do per-account, per-region, geo-blocking. Move the coarse layer to CF, keep tower-governor as origin defense-in-depth.
62 - - [ ] **Turnstile on signup + guest checkout** instead of (or alongside) origin-side bot detection. Cheaper than Stripe Radar tuning for the abuse vectors Stripe Radar doesn't cover (account farming).
38 + ## Post-Launch Tracks
63 39
64 - ### Phase 3: Static + asset pipeline
65 - - [ ] **Audit `Cache-Control` on origin-served static assets** (templates, static/, embed JS once it exists). Confirm CF caches what should be cached, bypasses what shouldn't (HTMX partials, dashboard).
66 - - [ ] **Cloudflare Cache Rules for HTMX fragments** that are safe to cache per-user (e.g. public project read paths). Push read latency down without touching origin.
67 - - [ ] **`/source/` (git browser) bot policy via CF.** Source browsing is bot-magnet territory; let CF handle the AI-scraper baseline before origin sees the traffic.
40 + Tracks of related work to start *after* soft launch stabilizes. Each block has its own trigger.
68 41
69 - ### Phase 4: Resilience
70 - - [ ] **Cloudflare Load Balancing health checks** in front of Hetzner. Today origin-down = MNW-down. CF can serve a maintenance page from cache + status banner.
71 - - [ ] **CF Tunnel as backup origin path.** If Hetzner public IP is under attack, CF Tunnel reaches origin via Cloudflare network. Cheap insurance.
72 - - [ ] **Stale-while-revalidate on public content pages** so origin restarts (deploys) don't visibly blip public traffic.
42 + ### Cloudflare Lean-In
73 43
74 - ### What we explicitly are NOT doing
75 - - **Cloudflare Workers as the primary application platform.** Rust app stays on Hetzner. Workers are for thin edge logic only.
76 - - **Cloudflare D1 / KV as primary data store.** Postgres on Hetzner is the source of truth.
77 - - **CF Email Workers / Email Routing for transactional mail.** Postmark works, no reason to migrate.
44 + The platform uses Cloudflare as a thin DNS+CDN layer for static + free content. Hetzner carries paid-content egress, ACME, DDoS, edge logic. At ~1k creators or first video creator, push more work to CF where it's cheaper or more resilient. Move only what's cheaper-at-edge; keep origin provider-neutral.
78 45
79 - ### Metrics to instrument (so triggers above are real, not vibes)
80 - - [ ] Weekly Cloudflare cache hit ratio (already in scaling.md backlog — surface in PoM)
81 - - [ ] Hetzner egress GB/day vs projection
82 - - [ ] caddy-ask QPS + cache-hit ratio on `domain_cache`
83 - - [ ] Origin CPU + connection-pool utilization (PoM `pg_stat_activity` probe — also in scaling.md)
46 + **Phase 1 — Paid-content egress** (biggest cost lever)
47 + - [ ] **CDN coverage of paid downloads.** `routes/storage/downloads.rs::resolve_content_url` currently routes only free content through `cdn.makenot.work`; paid uses presigned S3 to `fsn1.your-objectstorage.com`. Same pattern in `routes/ota.rs:409`, `routes/api/guest_checkout.rs:273`, `routes/pages/public/content/item.rs:{200,267,435}`. Options: signed CDN URLs via CF Worker (likely right), R2 migration (egress-free but vendor migration), CF Stream for video (pairs with transcoding pipeline). Trigger: CF cache hit ratio <80%, OR Hetzner egress >50% over projection, OR first video creator on Big Files/Everything.
84 48
85 - ---
49 + **Phase 2 — Edge logic**
50 + - [ ] **caddy-ask concurrency cap + edge filtering.** Move first-pass domain validation to a CF Worker (known-bad TLDs, syntactic, cached verified set). Origin only sees novel requests. Add `tokio::sync::Semaphore` on cache-miss DB path as belt-and-suspenders. Trigger: QPS > 100/sec sustained OR abuse incident.
51 + - [ ] **WAF-tier rate limiting** for `/api/*` writes — per-account/region/geo at CF, keep tower-governor as origin defense-in-depth.
52 + - [ ] **Turnstile on signup + guest checkout** — cheaper than Stripe Radar tuning for account-farming abuse.
86 53
87 - ## Stripe SDK Migration (Post-Launch, was blocking)
54 + **Phase 3 — Static + asset pipeline**
55 + - [ ] **Audit `Cache-Control` on origin-served static assets** (templates, static/, embed JS). Confirm CF caches the right things, bypasses HTMX partials + dashboard.
56 + - [ ] **CF Cache Rules for HTMX fragments** safe to cache per-user (public project read paths).
57 + - [ ] **`/source/` bot policy via CF** — git browser is bot-magnet territory.
88 58
89 - - [ ] **Migrate off `async-stripe = "0.37.3"`**. The 0.x line is frozen on Stripe API pre-2025-03-31; `Subscription.current_period_end` and invoice line item `proration` moved/renamed in newer Stripe API versions and the 0.x structs fail to deserialize current webhook payloads with `BadParse(missing field)`.
90 - - **Mitigation in place (2026-05-13):** Stripe Dashboard API version pinned to a pre-2025-03-31 version so payloads match the old shape. Replayed 20 missed events from dashboard after pinning.
91 - - **Proper fix:** evaluate `async-stripe 1.0` (RC as of 2026-05; crate-split into `stripe_core` / `stripe_billing` / `stripe_connect` / `stripe_checkout` / `stripe_misc`, codegen overhauled, ~40 call sites need rewriting) vs `stripe-rust` fork vs hand-rolled JSON parse for webhooks only.
92 - - **Affected files:** `payments/{mod,checkout,connect,webhooks}.rs`, `routes/stripe/**`, `scheduler/webhooks.rs`.
93 - - **Why it can't wait forever:** the dashboard pin blocks adopting any new Stripe feature tied to API version (e.g. newer Connect onboarding flows, tax features). Roll forward before the pin gets stale or Stripe deprecates the version.
59 + **Phase 4 — Resilience**
60 + - [ ] **CF Load Balancing health checks** in front of Hetzner — serve maintenance page from cache when origin's down.
61 + - [ ] **CF Tunnel as backup origin path** if Hetzner public IP is under attack.
62 + - [ ] **Stale-while-revalidate on public content pages** so deploys don't blip public traffic.
94 63
95 - ---
64 + **Not doing**: CF Workers as primary platform; CF D1/KV as primary store; CF Email Workers for transactional mail.
96 65
97 - ## Custom Pages (Post-Launch)
66 + **Metrics to instrument** (so triggers are real, not vibes): weekly CF cache hit ratio in PoM, Hetzner egress GB/day vs projection, caddy-ask QPS + cache-hit on `domain_cache`, origin CPU + pool utilization (`pg_stat_activity` probe).
98 67
99 - - [ ] **MySpace-style custom pages**: user-editable HTML + CSS (no JS, no external resources) for user/project/item pages. Subdomain-isolated (`u.makenot.work`), `ammonia` + `lightningcss` sanitization, on-platform-only URLs. Full plan: `plans/custom-pages.md`.
68 + ### Stripe SDK migration
100 69
101 - ---
70 + - [ ] **Migrate off `async-stripe = "0.37.3"`**. 0.x is frozen pre-2025-03-31 API; `Subscription.current_period_end` and invoice `proration` moved/renamed; structs fail to deserialize current payloads with `BadParse(missing field)`. Dashboard pinned to old API version 2026-05-13 (replayed 20 missed events). Proper fix: evaluate `async-stripe 1.0` RC (crate-split: `stripe_core` / `stripe_billing` / `stripe_connect` / `stripe_checkout` / `stripe_misc`; ~40 call sites need rewriting) vs `stripe-rust` fork vs hand-rolled JSON parse for webhooks only. Affected: `payments/{mod,checkout,connect,webhooks}.rs`, `routes/stripe/**`, `scheduler/webhooks.rs`. Roll forward before the pin gets stale.
102 71
103 - ## Library View Split (`/i/{id}` store + `/l/{id}` library)
104 -
105 - **Goal:** `/i/{id}` is always the *store* page (marketing, description, price, buy CTA) — same layout for everyone, only the CTA changes ("Buy Once" → "View in library" when the viewer has access). `/l/{id}` is the *library* page (downloads / player / reader / consumption) — only accessible to users with access (purchased, subscribed, creator, bundle-granted, or free-claimed).
106 -
107 - **Why:** today `/i/{id}` silently morphs based on `has_access` — buyer can't revisit the store page once they own the item, shareable URLs render differently for owners vs. visitors, and the downloads list teases unpurchased viewers (filenames + sizes visible, click 401s). Splitting clarifies intent and matches the prior art (Gumroad `/l/`, itch, Bandcamp).
108 -
109 - **Decisions locked (resolved before Phase 0):**
110 - - **View tracking swap:** `/l/{id}` is the only path that calls `track_view("item", id)`. `/i/{id}` stops tracking views. Consumption is the meaningful retention signal; store-traffic analytics deferred until requested.
111 - - **403 vs. 404 on `/l/{id}`:** item missing → 404. Item exists but unpublished/deleted and viewer is not owner → 404 (don't leak draft existence). Item public but viewer lacks access → 403 with link back to `/i/{id}`. Bundle-only (unlisted) items in the 403 case list **all** containing bundles, matching current store behavior.
112 - - **OG / Twitter cards on `/l/{id}`:** deferred to its own task — ship Phase 0 with `noindex` and no OG tags. Revisit if buyers want to share library URLs.
113 - - **Owner "preview as visitor" toggle:** not building. The split makes `/i/{id}` identically rendered for all viewers (CTA label is the only diff). Private window covers the rare case.
114 - - **Bundle-child "via bundle" provenance badge on `/l/{child_id}`:** not building in v1. Being on `/l/{id}` already implies access; provenance belongs in Library index / receipts.
115 -
116 - **Phase 0 — Shared scaffolding** (lands first; subsequent phases plug item-type templates into this)
117 - - [x] Route `GET /l/{item_id}` in `routes/pages/public/mod.rs` → new `content::library_page` handler.
118 - - [x] `library_page` handler: parse `ItemId`, load `db_item`/`db_project`/`db_user`, compute the same `AccessContext` as `item_page`, branch:
119 - - [x] Item missing / sandbox seller → 404
120 - - [x] Item unpublished or soft-deleted and viewer is not owner → 404
121 - - [x] Item public but `!has_access` → 403 (render `library_locked.html` with link to `/i/{id}`; for unlisted items, list all containing bundles)
122 - - [x] Otherwise → render library template; `track_view("item", id)` here
123 - - [x] `item_page` (`/i/{id}`): **remove** the `track_view` call (moved to `/l/{id}`).
124 - - [x] Pick template by `item_type` (mirrors `render_item_page`): text → `library_text.html`, audio → `library_audio.html`, video → `library_video.html`, bundle/other → `library_downloads.html`. Phases 1–4 implement these. Phase 0 ships a placeholder `library_downloads.html` so the route works end-to-end.
125 - - [x] `library_locked.html` template (the 403 page): minimal — title, cover, "you don't have access to this yet", buttons to `/i/{id}` (store page) and `/login` (if not logged in). For unlisted items, render the existing "Available in: Bundle X, Bundle Y" block.
126 - - [x] `noindex` meta on `library_*.html`. No OG/Twitter cards (deferred).
127 - - [x] Update `item.html` / `text_reader.html` / `audio_player.html` / `video_player.html` to **always render the store layout**, swapping the primary CTA: when `has_access` is true, replace the Buy/PWYW/Subscribe button with a "View in library →" link to `/l/{item_id}`. Remove the body/player/downloads gating from the store templates entirely (those move to `/l/{id}`).
128 - - [x] Redirect targets that should now go to `/l/{id}`:
129 - - [x] `routes/stripe/checkout/item.rs::create_checkout` already-purchased redirect (currently `/i/{id}`)
130 - - [x] Stripe checkout success for single-item purchases (currently `/library?purchase=success` — make it `/l/{id}?purchase=success` so the buyer lands on the downloads, not a list)
131 - - [x] `routes/stripe/webhook` confirmation emails — "View your purchase" link
132 - - [x] Cart success path (multi-item) stays at `/library?purchase=success` since there's no single item
133 - - [x] Library index row "View" links: `/i/{id}` → `/l/{id}`
134 - - [x] Receipt page "View item" link
135 - - [x] Custom domain fallback (`routes/custom_domain.rs::render_item_page`) — keep at `/i/{id}` semantics; library URL is platform-only.
136 - - [x] Bundle children: if user has access to bundle B and child C is unlisted, `/l/{C}` must still grant access via `bundles::has_access_via_bundle`. Already covered by existing access logic — verify in handler.
137 - - [x] Free items: claiming a free item must enable `/l/{id}` access. `library/add` creates a completed transaction, so `has_purchased_item` returns true → already works. Verify.
138 - - [x] Sandbox / unpublished items: creator viewing their own unpublished item — `is_owner` already grants access; library view should work as a preview.
139 - - [x] Audit all template references to `/i/{` to spot anything that should change to `/l/{` (cart cards, dashboard items, transaction history, search results). Most stay on `/i/{` — that's the *store* page now, still the right link for browsing. The migration is targeted: post-purchase flows + library index.
140 -
141 - **Phase 1 — `/l/{id}` for downloads / bundle / other items** (item.html path; audiofiles, GO/BB if they ship as downloads)
142 - - [x] New `templates/pages/library_downloads.html`: hero = Downloads section (versions list with filename, size, version label, download button). Below: download notice ("Provided by third-party creators, scan before running"). Below: bundle contents (if bundle) with `/l/{child_id}` links. Collapsible Description (`<details>` closed by default) and tabbed Sections. License section. Discussion.
143 - - [x] Strip downloads + download-notice sections out of `item.html`. Leave purchase box, description, sections, license, bundle contents (preview only, no /l/ link if user lacks access).
144 - - [x] `item.html` CTA swap: when `has_access` → replace Buy/PWYW button with "View in library →" linking to `/l/{id}`. Keep "Add to cart" / "Wishlist" / "Save to collection" buttons visible for owners (they may want to gift, etc. — TBD).
145 - - [x] Smoke: buy audiofiles as testaccount123 → success → land on `/l/{audiofiles_item_id}` → download files works → revisit `/i/{audiofiles_item_id}` → store page shows with "View in library" CTA.
146 -
147 - **Phase 2 — `/l/{id}` for audio items** (audio_player.html path; podcast/music)
148 - - [x] New `templates/pages/library_audio.html`: full player as hero, episode/track info, downloads section if the item has version files (some audio items offer source files alongside the stream), description, discussion.
149 - - [x] `audio_player.html` → store layout only: cover, title, price, description. **Decide on preview**: 30s sample (requires server-side trimmed asset, not in scope here) vs. cover-only. Default: cover-only on store; can layer preview later as a separate task.
150 - - [x] Update `/api/stream/{item_id}` access check — already gates on `can_access`; no code change, just confirm.
151 - - [x] Smoke with a paid audio item from GO seed content.
152 -
153 - **Phase 3 — `/l/{id}` for video items** (video_player.html path)
154 - - [x] New `templates/pages/library_video.html`: full player as hero (same `/api/stream/{id}` endpoint), description, downloads (if any), discussion.
155 - - [x] `video_player.html` → store layout: cover/poster image, title, price, description, buy CTA. No video element on store (cover only — videos are expensive to ship as previews).
156 - - [x] Smoke with a video item.
157 -
158 - **Phase 4 — `/l/{id}` for text items** (text_reader.html path)
159 - - [x] New `templates/pages/library_text.html`: full article body as hero, reading-time, TOC (if implemented later), discussion.
160 - - [x] `text_reader.html` → store layout: title, byline, reading-time, **excerpt** (first paragraph or first 200 chars of plain-text body — preview value is real here, unlike audio/video). Add `excerpt` field to `Item` view-model or compute in handler. Buy CTA.
161 - - [x] Smoke with a text item from a project.
162 -
163 - **Phase 5 — cleanup pass after all 4 land**
164 - - [x] Delete now-unused branches (any leftover `{% if has_access %}...player...{% endif %}` in store templates).
165 - - [x] Update `docs/test_plan.md` smoke checklist with `/l/{id}` flow.
166 - - [x] Cross-link audit pass: re-grep templates for `/i/{` and confirm each remaining link is intentional (store page link, not consumption).
167 - - [x] Update `human_todo.md` purchase-flow test cases to assert landing on `/l/{id}`.
168 - - [x] Consider: 301 redirect `/i/{id}/download` (legacy?) → `/l/{id}`. Search for any such legacy paths first.
169 -
170 - **Open questions to resolve during Phase 0:**
171 - - Should owners see a "Preview as visitor" toggle on `/i/{id}` like the current dashboard preview? Probably out of scope — they can log out or use a private window.
172 - - Bundle children that are themselves owned individually: how does `/l/{child}` decide whether to show "in your library" badge for both the child and via-bundle access? Just show; access is access.
72 + ### Custom Pages
173 73
174 - ---
74 + - [ ] **MySpace-style custom pages**: user-editable HTML+CSS (no JS, no external resources) for user/project/item pages. Subdomain-isolated (`u.makenot.work`), `ammonia` + `lightningcss` sanitization, on-platform-only URLs. Full plan: `plans/custom-pages.md`.
175 75
176 - ## DIY Tier (Post-Launch, exploratory)
177 -
178 - Ko-fi-style cheap tier: **$12/yr**, embeds + Stripe Connect only. No file hosting, no profile, no discovery, no mobile, no themes. Positioned as creator-acquisition wedge; modeled at break-even (~$0.10/yr infra, ~$11.24/yr support+margin budget). Tier viability requires ≥95% of DIY creators never contact support — every item below is in service of that constraint.
179 -
180 - **Strategic guardrails** (apply to all DIY work):
181 - - DIY is feature-frozen at launch scope. No DIY-specific feature additions; upgrades come from natural growth into Basic+.
182 - - DIY support is community-first, best-effort email, no SLA. Documented on pricing page and in ToS.
183 - - Bug triage: DIY issues fixed in normal cadence, after Basic+ issues. Written rule.
184 - - Cap signups (e.g. 5,000) or raise price if DIY consumes disproportionate attention.
185 -
186 - ### Schema / billing
187 - - [ ] **Migration: DIY tier in `creator_tiers`** — add tier row, env var `CREATOR_TIER_DIY_PRICE_ID`, annual-only Stripe price.
188 - - [ ] **Annual-only billing enforcement** — DIY checkout rejects monthly cadence (monthly Stripe fees would eat 36% of $1/mo revenue).
189 - - [ ] **Tier capability gate** — single source of truth: DIY excludes file uploads, project pages, profile pages, discovery listing, mobile API, themes. Audit every feature route for tier check.
190 - - [ ] **One-click upgrade path** — DIY → Basic upgrade preserves Stripe Connect account, member list, embed code. Prorate annual remainder as credit.
191 -
192 - ### Embed surface (the actual DIY product)
193 - - [ ] **Embeddable JS widgets**: donate button, membership button, item-buy button. Served from `static/embed/v1.js`, cached at edge, structured error codes logged to console with doc links.
194 - - [ ] **Embed code generator** in dashboard — paste-ready `<script>` snippet per widget, with site-domain pinning (CSP-friendly).
195 - - [ ] **Hosted checkout/portal pages** — minimal-chrome MNW-hosted pages for purchase + member self-service. No profile branding; creator's brand only.
196 - - [ ] **CSV member export** — DIY users self-serve their member list. No support tickets for "give me my members."
197 -
198 - ### Self-serve primitives (load-bearing for the support model)
199 - - [ ] **Per-creator status dashboard** — onboarding %, Stripe Connect health, last 20 webhook events with delivery status, embed test results. Replaces ~50% of predicted tickets.
200 - - [ ] **Embed tester page** — creator pastes their site URL, we iframe-load it and report: CSP blocking, missing script tag, wrong domain pin, ad-blocker fingerprint.
201 - - [ ] **"Resync from Stripe" button** — pulls latest Connect state + member state from Stripe API. Fixes ~90% of "customer paid but didn't get access" tickets without human involvement.
202 - - [ ] **Stripe Connect pre-flight country check** — gate signup behind supported-country check, fail fast before account creation. Kills the largest predicted ticket category.
203 - - [ ] **In-dashboard onboarding state machine** — show exactly which Stripe step is incomplete, deep-link to the right Stripe page per step.
204 - - [ ] **Self-serve account recovery** — Stripe email = identity. If they prove control of their Connect account, account recovery is no-touch. Document the security tradeoff in DIY-specific ToS section.
205 - - [ ] **Error-code-indexed docs** — every embed/webhook/dashboard error gets a stable code; each code has a doc page with fix steps + "still stuck? post in forum" CTA.
206 - - [ ] **Webhook delivery dashboard per creator** — surfaced last N Stripe webhook events with delivery + handler status. Pre-empts "did my webhook fire?" tickets.
207 -
208 - ### Support batching infrastructure
209 - - [ ] **Community forum** — DIY support primary channel. First-responder recognition program (free tier upgrades / store credit for top contributors).
210 - - [ ] **Weekly office hours** — 1 hour/week public drop-in (Zoom or async thread). Batches scattered tickets into one synchronous slot.
211 - - [ ] **Annual renewal check-in email** — surfaces "anything broken?" once a year, in batches, instead of randomly throughout the year.
212 - - [ ] **Canned-response library** — `payments/refunds → Stripe dashboard link`, `1099/tax → Stripe docs link`, `feature requests → roadmap`. One canonical answer per category.
213 -
214 - ### Abuse / floor protection
215 - - [ ] **Signup rate limits + Stripe Radar tuning** — $12 is low enough that spam-embed abuse is viable. Bot-floor before launch.
216 - - [ ] **Domain pinning on embeds** — embed `<script>` only runs on creator-registered domains. Limits resale/abuse.
217 -
218 - ### Marketing / conversion
219 - - [ ] **"Powered by MNW" attribution on embeds** — on by default (creator-opt-out). Every DIY embed becomes a brand surface.
220 - - [ ] **Upgrade nudges at growth thresholds** — when DIY creator hits "now I want files / a profile / mobile," show in-context upgrade prompt with one-click migration.
221 - - [ ] **Conversion tracking** — instrument DIY → Basic+ conversion explicitly. Target ≥3%/yr after first 12 months; below 2%/yr means revisit pricing or scope.
222 -
223 - ### Capacity model assumptions (track these post-launch)
224 - - ≥95% of DIY creators never contact support → re-examine if touch rate exceeds 5%.
225 - - ~13 min/yr of human support budget per creator at break-even.
226 - - Infra marginal cost ~$0.10/yr (Postgres rows, embed JS egress on Hetzner included bandwidth, Stripe Connect Standard/Express has no per-account fee).
227 - - Stripe fee on $12 annual charge: ~$0.66. Net revenue: $11.34/yr.
76 + ### DIY Tier — firmly post-launch
228 77
229 - ---
78 + Officially post-launch as of 2026-05-18 (memory `project_diy_post_launch.md`). Do not start any DIY work, do not include DIY in launch-window marketing. Ship gate: soft launch stable with paying Basic+ founders, founder window closed or nearly closed, ≥1 quarter of support-load data.
230 79
231 - ## Small Creator On-Ramp (full plan: `plans/small-creator-onramp.md`)
80 + Ko-fi-style cheap tier: $12/yr, embeds + Stripe Connect only. No file hosting, no profile, no discovery, no mobile, no themes. Modeled at break-even (~$0.10/yr infra, ~$11.34/yr net after Stripe fees). Viability requires ≥95% of DIY creators never contact support.
232 81
233 - Mechanisms to bring sub-floor creators onto the platform without lowering sticker prices. Sequenced by readiness; DIY tier already has its own section below.
82 + **Guardrails**: feature-frozen at launch scope; community-first best-effort support, no SLA; DIY bug triage after Basic+; cap signups (e.g. 5,000) or raise price if support consumes disproportionate attention.
234 83
235 - - [ ] **Creator-to-creator referrals.** Unique referral link per creator. Referred creator pays normal price (optional small welcome credit ~$2). Referrer gets ~30% of one month of the *referred* creator's tier as credit per successful referral (Basic referral → $3, Big Files → $9, Everything → $18). Core invariant: referrer credit + welcome credit must be strictly less than one month of subscription — self-dealing is then a net loss by construction, not by detection. Activity gate (30 days + Stripe + 1 item/sale) is secondary defense. Credit is non-cashable, applies to future invoices only. Target: at or near public launch.
236 - - [ ] **Charter creator pricing lock.** First N creators (target 500–1,000, or time-bounded launch window) lock current pricing forever. Mostly a positioning move; light engineering. Pick N and announce close date before launch.
237 - - [ ] **Earnings-funded subscription.** Creator's first $X/mo of earnings auto-credits subscription before payout. Below floor: no payout, no charge. Above floor: normal payout. Pair with earn-back credit program (committed 2027-01-01). Most complex; ship after DIY proves out and we have real earnings-distribution data.
84 + Schema / billing:
85 + - [ ] Migration: DIY tier in `creator_tiers`; env `CREATOR_TIER_DIY_PRICE_ID`; annual-only Stripe price.
86 + - [ ] Annual-only billing enforcement (DIY checkout rejects monthly).
87 + - [ ] Tier capability gate (DIY excludes file uploads, project/profile pages, discovery, mobile API, themes).
88 + - [ ] One-click upgrade DIY → Basic preserves Stripe Connect, member list, embed code; prorates annual remainder as credit.
238 89
239 - ---
90 + Embed surface:
91 + - [ ] Embeddable JS widgets (donate, membership, item-buy) at `static/embed/v1.js`, edge-cached, structured error codes with doc links.
92 + - [ ] Embed code generator in dashboard with site-domain pinning.
93 + - [ ] Hosted checkout/portal pages (minimal chrome).
94 + - [ ] CSV member export (self-serve).
240 95
241 - ## Infra / Scaling (from 2026-05-14 audit — full doc: `scaling.md`)
96 + Self-serve primitives:
97 + - [ ] Per-creator status dashboard (onboarding %, Connect health, last 20 webhooks, embed test).
98 + - [ ] Embed tester page (paste site URL → iframe-load + report CSP/script/domain/ad-blocker issues).
99 + - [ ] "Resync from Stripe" button.
100 + - [ ] Stripe Connect pre-flight country check at signup.
101 + - [ ] In-dashboard onboarding state machine (deep-links per step).
102 + - [ ] Self-serve account recovery (Stripe email = identity).
103 + - [ ] Error-code-indexed docs (stable codes, doc page each, forum CTA).
104 + - [ ] Webhook delivery dashboard per creator.
242 105
243 - Pre-1k-creator items. These are the cheap wins to land before scale forces them. Ordered by cost-impact-if-ignored.
106 + Support batching:
107 + - [ ] Community forum (primary DIY support channel; recognition program).
108 + - [ ] Weekly office hours (1 hr/week sync slot).
109 + - [ ] Annual renewal check-in email.
110 + - [ ] Canned-response library.
244 111
245 - - [ ] **Verify CDN coverage of paid downloads.** Grep `routes/storage/` and `routes/api/...` for download paths and confirm presigned URLs route through `cdn.makenot.work`, not direct `fsn1.your-objectstorage.com`. Direct fetches skip Cloudflare cache and put egress on Hetzner — biggest hidden cost lever at scale.
246 - - [ ] **Confirm `Cache-Control` on S3 uploads.** Storage backend should set `Cache-Control: public, max-age=31536000, immutable` on PUT (content-addressed keys make this safe). Without this, Cloudflare won't cache and the CDN line in the economic model collapses.
247 - - [ ] **PoM alert on `pg_stat_activity` saturation.** MNW pool (25) + MT pool share one Postgres. Add a probe + alert before connection exhaustion is the first signal.
248 - - [ ] **Rate limit + concurrency cap on `/api/domains/caddy-ask`.** ACME issuance-abuse target at scale. Confirm tower-governor tier covers it and cap concurrent in-flight asks.
249 - - [ ] **Document the Tailscale break-glass SSH path.** Admin :2200 is tailnet-only; if Tailscale control plane is down, public :22 is mnw-cli only. Add a runbook step to `deploy/SSH_ACCESS.md` for this scenario. Cross-reference memory rule on never disabling Tailscale SSH without fallback.
250 - - [ ] **Audit `sync-backup-offsite.sh` destination.** Confirm what "offsite" means today (astra is on same tailnet, same vendor risk). Add a true third-location offsite (Backblaze B2 with Bandwidth Alliance) before crossing ~1k creators.
251 - - [ ] **Weekly review: Cloudflare cache hit ratio.** Add to whatever weekly metrics flow exists. If ratio drops below ~80%, treat as a cost incident — every percentage point of miss directly costs Hetzner egress.
252 - - [ ] **Track real storage fill vs. tier cap.** The economic projection assumes ~20% fill; if real fill is closer to 60%, storage line is 3× the projection at every stage. Add a `pom` or admin-dashboard metric.
112 + Abuse + floor:
113 + - [ ] Signup rate limits + Stripe Radar tuning for $12 spam-embed abuse.
114 + - [ ] Domain pinning on embeds.
253 115
254 - ---
116 + Marketing / conversion:
117 + - [ ] "Powered by MNW" attribution on embeds (default on, creator opt-out).
118 + - [ ] Upgrade nudges at growth thresholds (one-click migration).
119 + - [ ] Conversion tracking: target ≥3%/yr DIY → Basic+; <2%/yr triggers re-price or scope cut.
255 120
256 - ## Upload Improvements (Post-Launch)
121 + Capacity assumptions (track post-launch): ≥95% never contact support; ~13 min/yr support budget per creator at break-even; ~$0.10/yr infra; Stripe fee on $12 ≈ $0.66 (net $11.34/yr).
257 122
258 - - [ ] **Background uploads**: allow navigating away from the Files tab during upload. Track upload state server-side (pending_uploads table exists). Show upload status in a persistent UI element (toast or header badge) so video/large-file creators aren't stuck on the page.
259 - - [ ] **Multipart upload**: split large files into chunks and upload in parallel for higher throughput. Current single-PUT caps at ~300 Mbps. Needed for video creators on fast connections.
260 - - [ ] **Desktop/CLI bulk upload**: power-user tool for uploading multiple files, versions, or large assets. Candidates: mnw-cli TUI, or a dedicated uploader binary. Would use multipart upload natively.
123 + ### Small Creator On-Ramp
261 124
262 - ---
125 + Full plan: `plans/small-creator-onramp.md`. Charter creator pricing lock was replaced by Founder Pricing — see § Founder Pricing.
263 126
264 - ## Trust Audit Open Items (migrated from todo-creator-trust-audit.md, 2026-05-12)
127 + - [ ] **Creator-to-creator referrals.** Unique referral link per creator. Referred creator pays normal price (optional small welcome credit ~$2). Referrer gets ~30% of one month of *referred* tier as credit per successful referral (Basic → $3, Big Files → $9, Everything → $18). Invariant: referrer credit + welcome must be strictly less than one month of subscription — self-dealing is a net loss by construction. Activity gate (30 days + Stripe + 1 item/sale) is secondary defense. Credit non-cashable, applies to future invoices only. Target: at or near public launch.
128 + - [ ] **Earnings-funded subscription.** Creator's first $X/mo earnings auto-credit subscription before payout. Pair with earn-back credit program (committed 2027-01-01). Ship after DIY proves out and we have earnings-distribution data.
265 129
266 - - [ ] **Warning-only admin action**: moderation.md describes a 4-step ladder starting with "Direct Message," but code only implements suspend/unsuspend/terminate. No tracked warning-only communication. (`routes/admin/users.rs`)
267 - - [ ] **Content archive guarantee unimplemented**: 12-month content preservation listed under "Planned Guarantees" in guarantees.md. Ensure it's clearly marked as planned, not current.
268 - - [ ] **Custom domains not in getting-started flow**: Feature exists but hard to find from onboarding. Link from getting-started.md.
269 - - [ ] **Creator storefront preview/demo**: First-time visitors can't see what a page looks like before signing up.
270 - - [ ] **Creator status notification channel**: On health status transitions, email opted-in creators. WAM tickets already created on transitions — extend monitor to dispatch creator-facing status emails. (`monitor.rs`, `notifications.rs`)
130 + ### Trust Audit Open Items
271 131
272 - Note: "Appeals reviewed by same person" and "liability cap" are known one-person-team constraints, tracked in guarantees.md planned section.
132 + From `todo-creator-trust-audit.md` migration 2026-05-12.
273 133
274 - ---
134 + - [ ] **Warning-only admin action**: moderation.md describes a 4-step ladder starting with "Direct Message," but code only implements suspend/unsuspend/terminate. (`routes/admin/users.rs`)
135 + - [ ] **Content archive guarantee unimplemented**: 12-month preservation listed under "Planned Guarantees" in guarantees.md — mark clearly as planned, not current.
136 + - [ ] **Custom domains not in getting-started flow**: feature exists but hard to find. Link from getting-started.md.
137 + - [ ] **Creator storefront preview/demo**: first-time visitors can't see what a page looks like before signing up.
138 + - [ ] **Creator status notification channel**: on health status transitions, email opted-in creators. WAM tickets already fire on transitions; extend monitor to dispatch creator-facing emails. (`monitor.rs`, `notifications.rs`)
275 139
276 - ## Deferred from Sprints
140 + Note: "Appeals reviewed by same person" and "liability cap" are known one-person-team constraints, tracked in guarantees.md.
277 141
278 - - [ ] Add bulk rename operation (Sprint 2)
279 - - [ ] Add global search across all projects and items from dashboard (Sprint 2)
280 - - Deferred: onboarding checklist persistence, banner for unsubscribed creators (Sprint 4, low urgency during alpha)
281 - - Deferred: real-time pricing validation (Sprint 6, low value — server validates on save)
142 + ### Upload improvements
282 143
283 - ---
284 -
285 - ### Fuzz Run Deferred (carried from Runs 25-26)
286 - - [ ] Stream build artifacts to S3 via multipart upload
287 - - [ ] Extract shared `validate_promo_code()` helper (chronic — unfixed across Runs 24-26)
144 + - [ ] **Background uploads**: allow navigating away from the Files tab during upload. Track upload state server-side (`pending_uploads` exists). Persistent UI element (toast or header badge).
145 + - [ ] **Multipart upload**: chunked parallel upload for higher throughput. Current single-PUT caps ~300 Mbps. Needed for video creators on fast connections.
146 + - [ ] **Desktop/CLI bulk upload**: power-user tool for many files/versions/large assets. Candidates: mnw-cli TUI, dedicated uploader. Uses multipart natively.
288 147
289 - ---
290 -
291 - ## Nitpick Run 1 (2026-05-13)
292 -
293 - Scope: `routes/api/mod.rs`, `scheduler/cleanup.rs`, `git_ssh.rs`. [FACT] items only — preference items dropped.
294 -
295 - ### routes/api/mod.rs
296 - - [x] Dedup `ensure_project_owner` / `verify_project_ownership` — merged into one
297 - - [x] Import `axum::routing::options` instead of fully qualifying
298 - - [x] Reorder module list alphabetically
299 - - [x] Co-locate the three `use` blocks
300 - - [x] Replace magic `rate_limiter_per_sec(1, 10)` with new `GUEST_CHECKOUT_RATE_LIMIT_*` constants
301 - - [x] Fix double-space in `json_error_layer` doc-comment
302 - - [x] Unified route-method style on split (combined form converted at repo-collaborators and ssh-keys)
303 - - [x] Moved `license_keys::list_keys` GET from `write_routes` to `read_routes` — was being limited as a mutation (burst 30, 2/sec) instead of as a read (burst 60, 10/sec)
304 -
305 - ### scheduler/cleanup.rs
306 - - [x] Hoist `get_project_ids_for_user` / `get_sync_apps_by_creator` to one call each — fixes race window between enqueue and delete
307 - - [x] `tracing::info!(event = event, ...)` → shorthand `event,`
308 - - [x] `cleanup_git_repos_on_disk` now takes `UserId` by value
309 - - [x] Dropped redundant `item_keys: Vec<&str>` in `purge_expired_deleted_items`
310 - - [x] Off-by-one in dead-letter / stuck log messages — guards now `>= 10` / `>= 5`
311 - - [x] Module doc lists all jobs
312 - - [x] Normalize scheduler-job empty-run convention — `cleanup_sandbox_accounts`, `cleanup_stale_pending_transactions`, `retry_pending_s3_deletions` now always `record_job_run`; new job names `sandbox_cleanup`, `stale_pending_cleanup`
313 -
314 - ### git_ssh.rs
315 - - [x] `parse_repo_path` returns symmetric `(&str, &str)`; differentiated bail messages at the three sites
316 - - [x] Removed `#[allow(clippy::collapsible_if, clippy::collapsible_else_if)]` — no longer needed
317 - - [x] "unknown command. Available: ..." → "unknown command; available: ..."
318 - - [ ] Move `use std::os::unix::process::CommandExt;` out of `exec_git_shell` — reverted as unsafe (cfg gymnastics for non-unix build; preference-level anyway)
319 - - [x] Truncate labels in `cmd_ssh_key_list` consistently with descriptions in `cmd_ssh_repo_list` — extracted `display_with_ellipsis` helper
320 -
321 - ### Not-a-nit follow-ups
322 - - [x] Race in `cleanup_user_s3_and_delete` — fixed in the same change as the DB-query hoist; enqueue and delete share one snapshot
323 - - [x] Email validation consolidated — added `email_address = "0.2"` (no new transitive deps), new `validation::normalize_email` used at both `email_signup` and `guest_checkout` entry points
148 + ### UX audit sweep (phased)
324 149
325 - ---
150 + Full UX audit of the MNW server UI via the `ux-audit` skill (Norman/Tog/Raskin/HIG lineage; Askama + HTMX stack). Run one phase per session, clearing context between phases to keep findings sharp. Each phase produces a ranked findings report (Critical/Major/Minor/Polish) saved to `docs/ux-audit/phase-N.md`; fixes land in follow-up sprints, not during the audit itself.
326 151
327 - ## Code Fuzz / Ultra Fuzz — Accepted Risks & Deferred
152 + Method per phase: invoke `/ux-audit` with the phase's template list as scope. Skill does universal + Askama + HTMX + flat-design passes. Read route handlers adjacent to each template for task context.
328 153
329 - Remaining open items from Runs 21-24 and Code Fuzz (2026-05-08). All SERIOUS items resolved through Run 23. Run 24 SERIOUS items above.
154 + Status: Phase 0 audit complete (2026-05-19). Findings at `docs/ux-audit/phase-0.md`, charter at `docs/design-system.md`. Surface phases 1-8 are gated on the consolidation pass below.
330 155
331 - ### Storage & Uploads
332 - - [ ] MINOR: `classify_media` trusts client-supplied `content_type` for size limits — attacker wastes own quota (`media.rs:80-91`)
333 - - [ ] NOTE: `extract_s3_key_from_url` may include bucket name for path-style URLs — only used for project images which handle it (`storage.rs:427-446`)
334 - - [ ] NOTE: Project image old-file cleanup relies on URL-to-key extraction — fragile but functional (`routes/storage/images.rs:169-177`)
156 + - [x] **Consolidation pass (pre-Phase-1).** Implement the two-part remediation plan at `docs/ux-audit/remediation-plan.md`: add spacing/radius/shadow tokens, build five missing shared partials (empty_state, loading_skeleton, form_field, confirm_dialog, pagination), fix brand breaches (checkmark glyphs, pure #000/#fff, Bootstrap yellow), then consolidate the 12 duplicate UX patterns the audit found — card family (12 -> 2), empty-state (7 -> 1), button family (14+ -> 3 + utilities), selected-state spelling (5 -> 1), hover recipe (3 -> 2 by component type), notification surface (4 -> 4 with disambiguated roles), status indicator, section header, hidden utility, page heading, page-isolated `<style>` blocks (3 -> 0), inline-style sweep (1,350 -> <100). Success criteria in the plan. Surface audits do not run until this completes.
335 157
336 - ### File Scanning
337 - - [ ] MEDIUM: No scanning when scanner=None + trusted user — by-design trust model; needs async background scan mode to fix without breaking upload UX (`scanning/mod.rs:142-151`)
338 - - [ ] MINOR: Archive scan skips cover images with ZIP magic bytes — layer 1 catches type mismatch; removing skip would be defense-in-depth but no current exploit path (`scanning/archive.rs:29-36`)
158 + Status (2026-05-19, additive primitives shipped, `cargo check` clean):
159 + - [x] §1.1 tokens — `--space-1`..`-6`, `--radius-sm/-md/-round`, `--shadow-1/-2/-3` in `:root`.
160 + - [x] §1.2 macros — `templates/partials/_ui.html` with `empty_state`, `empty_state_with_action`, `loading_skeleton`, `form_field`, `confirm_dialog`, `pagination`. New CSS: `.form-group--error`, `.field-error`, `.loading-skeleton`, `skeleton-pulse` keyframe.
161 + - [x] §1.3 brand fixes — checkmark glyphs in `wizard.css` checklist + `discover.html` tag-follow replaced with text; `#000` in media-player → `var(--detail)`; `#fff` in buy.html buy-card → brand light; Bootstrap-yellow restart banner → `var(--warning-*)`; stray `rgba(108,92,231,0.06)` literals → `var(--highlight-faint)`.
162 + - [x] §2.4 selected-state — canonical `.is-selected` rule + aliased on `.tab`, `.tab-overflow-menu .tab`, `.view-btn`, `.filter-item`, `.type-card`, `.pricing-card`. `.badge.active` intentionally excluded (means completion status, not selection).
163 + - [x] §2.3 + §2.5 button modifiers — `.btn--large`, `.btn--icon`, `.btn--link` modifiers added; `:disabled`/`[aria-disabled]` styling added to base button variants.
164 + - [x] §2.6 `.banner` primitive — `.banner` + `.banner--info`/`--warning` canonical class added.
165 + - [x] Banner sites — `#restart-banner` (JS + CSS), sandbox-mode banner in `dashboard-project.html`. Landing founder-banner kept separate (hero callout, wrong shape — charter amended).
166 + - [x] `error.html` page-isolated `<style>` block — moved to `style.css` "ERROR PAGE" section; local `.error-message` renamed to `.error-page-message` to avoid collision with the global form-error class.
167 + - [x] `.active` → `.is-selected` sweep complete (templates + JS). Migrated `.tab`, `.tab-overflow-menu .tab`, `.view-btn`, `.filter-item`, `.editor-tab`, `.speed-button`, plus `.section-tab` (item/project/library_downloads). CSS aliases retired — only `.is-selected` triggers selection styling now. Visibility-toggle `.active` (on `.tab-content`, `.editor-panel`, `.section-panel`, `.discover-sidebar.show`) intentionally kept as `.active`.
168 + - [x] Link-style button consolidation — `.link-button` → `.btn--link` (1 site: `partials/site_header.html`). Dead `.show-more-btn` CSS removed. Other tuned button variants (`.big-button`, `.paywall-btn`, `.notify-btn`, `.order-btn`, `.play-button`, `.speed-button`, `.shortcuts-help-btn`, `.toast-retry-btn`) documented as intentional variants in `style.css` — they have genuinely tuned visuals and don't compose cleanly onto modifiers.
169 + - [x] `buy.html` page-isolated `<style>` block (119 lines) — moved to `style.css` "BUY" section under `.buy-page` scope; tokenized; page now loads `/static/style.css` (also fixes font fallback — h1 was rendering Georgia).
170 + - [x] `item.html` page-isolated `<style>` block (80 lines) → `style.css` ITEM PAGE section. Tokenized; `.item-*` rules scoped under `.item-page` body class so class names don't collide with project store-front cards.
171 + - [x] `project.html` page-isolated `<style>` block → `style.css` PROJECT PAGE section. Scoped under `.project-page`. `.item-card` / `.item-title` / `.item-meta` in this scope mean store-front cards, distinct from the same names under `.item-page`.
172 + - [x] `library_downloads.html` page-isolated `<style>` block → `style.css` LIBRARY section. Scoped under `.library-page`.
173 + - [x] `library_audio.html`, `library_video.html` — duplicate `<style>` blocks deleted; `.library-page` body class added so they inherit the shared scope.
174 + - [x] Admin dashboards — duplicate `h1 { font-size: 2rem }` rule extracted to a single global `.admin-page h1` rule; `.admin-page` body class added to all 7 admin pages; per-page `<style>` blocks shrunk or removed. Also fixed `admin-metrics` token bypasses (`--color-border`/`--color-muted` → real tokens; raw hex `#b5651d`/`#c0392b` → `--warning`/`--danger`).
175 + - [x] Inline `style="..."` sweep — 1,276 → 6. The 6 remaining are server-computed `{{ pct }}%` exceptions (chart-bar height, storage-fill width, revenue-bar width, checklist progress). Per-template migrations landed under `templates/partials/tabs/*.html`, `templates/pages/*.html`, `templates/wizards/steps/*.html`, plus refactors to `static/insertions.js`, `static/project-sections.js`, `static/blog-editor.js` to switch `style.display`/`style.color` mutations to `classList` toggles. Broken inline color refs (`--success-color`, `--error-color`, `--warning-color`, `--accent-color`, `--primary-color` — none of which exist as vars) fixed by mapping to real tokens. Several dead `<style>` blocks in `creators.html`, `receipt.html`, `dashboard-import.html`, `stripe_disclaimer.html`, `item_embed.html`, `dashboard-blog-editor.html` also removed.
176 + - [x] CSS dedup pass (post-sweep) — `style.css` 11,267 → 10,900 (−367 lines). Canonical primitives consolidated: progress bar (`.progress-bar-container` + `.progress-bar` with `--slim`/`--rounded`/`--highlight` modifiers — replaces `.wiz-progress-*`, `.batch-progress-*`, `.checklist-progress-*`, scoped `.import-page .progress-bar`); upload-status block (`.upload-status` + `__row` + `__msg.is-success`/`.is-error` — replaces `.wiz-upload-status*`); empty state (`.empty-state` — replaces 7 per-tab `*-empty` classes); status pill (`.field-status` / `.save-status` aliased with `.success`/`.error`/`.saving` modifiers); section lead (`.section-lead` — replaces 9 per-tab leads, callers use `.mb-3`/`.mb-5`/`.text-sm`/`.dimmed` utilities); callout (`.callout` with `--danger`/`--warning`/`--solid-warning` — replaces `.account-callout*`, `.cart-warning`, `.dns-callout*`); table min-width via `.minw-300..800` utilities on `.data-table`/`.compact-table` (replaces `.cart-table`, `.library-tab-table*`, `.admin-entries-table*`); list row (`.list-row` recipe aliased to `.psection-row`, `.section-mgmt-row`, `.bundle-picker-row`, `.checklist-row`); small button (`.small` recipe aliased to `.btn-tiny`, `.cart-row-btn`, `.library-row-btn`, `.proj-members-remove-btn`); card aliases (`.tier-card`, `.feature-card`, `.use-case-card`, `.fork-card` folded into `.card--bordered`; `.account-tip-card`, `.account-status-card` folded into `.card-muted`). Also removed 179 lines of identical duplicate section blocks left by parallel-agent appends (`LIBRARY COLLECTIONS TAB`, `USER ANALYTICS TAB`, `PROJECT PAGE`, `INSERTION LIST`), and unscoped dead `.dashboard-user-page .*` rules (template uses `padded-page`, not `dashboard-user-page`) plus `.blog-editor-page` → `.blog-editor` to match the body class actually set on the template.
177 + - [x] Article reader pages — `text_reader.html` (272), `blog_post.html` (163), `library_text.html` (35) all consolidated into a single `.article-page` scope in `style.css` (~230 tokenized lines). All three templates use the same shared layout; `library_text` also stays in `.library-page` for the back-link.
178 + - [x] `health.html` (166 lines) → `style.css` under `.health-page` scope.
179 + - [x] `dashboard-import.html` (192 lines) → `style.css` under `.import-page` scope. Also fixed token bypasses (`--border-color` / `--accent` / `--success-background` / `--error-background` → real tokens).
180 + - [x] `dashboard-delete-account.html` (108 lines) → `style.css` under `.delete-account-page` scope. Overrides global `.warning-box` to use `--danger` (delete is danger, not warning).
181 + - [x] `dashboard-export.html` (82 lines) → `style.css` under `.export-page` scope.
182 +
183 + - [x] Medium templates (8): `receipt.html` (49) → `.receipt-page`; `fan_plus.html` (39) → `.fan-plus-page`; `purchase.html` (33) → `.purchase-page`; `account-deleted.html` (30) → `.centered-page.centered-wrapper` with new global `.message-container`; `feed.html` (30) → `.feed-page`; `stripe_disclaimer.html` (22) → `.stripe-disclaimer-page`; `user.html` (19) → `.user-page`; `creators.html` (17) → `.creators-page`.
184 + - [x] Small templates (15): `tag_tree.html` → `.tag-tree-page`; `confirm_delete.html` → `.confirm-delete-page` (also fixed `var(--danger-bg, #fff3f3)` / `var(--danger-border, #e74c3c)` fallback bypasses); `project_paywall.html` → `.project-paywall-page`; `collection.html` → `.collection-page`; `project_blog.html` → `.project-blog-page`; `changelog.html` → `.changelog-page`; `policy.html` → `.policy-page`; `dashboard-user.html` → `.dashboard-page.dashboard-user-page`; `dashboard-project.html` → `.dashboard-page.dashboard-project-page`; `dashboard-item.html` → `.dashboard-page.dashboard-item-page` (shared `.dashboard-page h1` extracted as a single rule for all three); `dashboard-blog-editor.html` → `.blog-editor-page`; `admin-appeals.html` / `admin-waitlist.html` / `admin-users.html` / `admin-metrics.html` page-specific rules moved into `.admin-page` scope in `style.css`.
185 +
186 + **All page-isolated `<style>` blocks killed (0 remaining in `templates/pages/` and `templates/dashboards/`).**
187 +
188 + **Inline-style sweep complete (6 server-computed `{{pct}}%` exceptions; CSS dedup landed in same pass).**
189 + - [ ] `style="display: none"` → `.hidden` audit pass — most sites swept during the inline-style migration but a sweep for `.sr-only` accessibility upgrade on file inputs is still pending.
190 + - [ ] Delete `.landing-founder-banner` if folded into hero-callout primitive (currently kept separate per charter).
191 + - [ ] Orphan CSS cleanup — ~30 truly unused class names in `style.css` deferred (e.g. `.tier-grid`, `.search-box`, `.signup-container`, `.button-stack`). Per-class judgement; revisit when bandwidth allows.
192 +
193 + - [ ] **Phase 0 — Design-system conformance audit.** *(Complete 2026-05-19.)* Before auditing surfaces, audit the *primitives*. The site should behave like an OS: a small set of design rules (spacing scale, type scale, color tokens, button/input/card/dialog/table/form/empty-state shapes, hover/focus/selected/disabled state recipes, loading/error/empty triad) that compose every screen. Read `static/style.css`, `static/media-player.css`, `static/wizard.css`, `_meta/docs/brand.md`, and a representative span of `templates/partials/*` + `pages/*`. Produce: (a) the inferred primitive set — what tokens and components *actually* exist in CSS/markup today; (b) divergence map — places where a screen reinvents a primitive (one-off `<div class="...">` shapes, ad-hoc spacing, bespoke buttons, duplicated empty/error states); (c) gaps — primitives the system *should* have but doesn't (e.g. no shared empty-state component, no destructive-button variant, no loading-skeleton recipe); (d) a written design-system charter (≤2 pages, `docs/design-system.md`) that names every primitive and its single canonical class/partial. Output gates the rest of the sweep: Phases 1–8 audit *conformance to the charter*, not just universal principles. Fixes in Phase 0 land as CSS/partial consolidation before any surface phase runs.
194 +
195 + - [ ] **Phase 1 — Public landing & marketing.** `pages/index.html`, `use_cases.html`, `pricing.html`, `creators.html`, `fan_plus.html`, `changelog.html`, `policy.html`. First impression + conversion surface.
196 + - [ ] **Phase 2 — Discovery & browse.** `pages/discover.html`, `feed.html`, `tag_tree.html`, `user.html`, `project.html`, `collection.html`. Public browse path, post-discover funnel.
197 + - [ ] **Phase 3 — Item & library (consume).** `pages/item.html`, `library.html`, `library_audio.html`, `library_video.html`, `library_text.html`, `library_downloads.html`, `library_locked.html`, `audio_player.html`, `video_player.html`, `text_reader.html`, `blog_post.html`, `project_blog.html`, `project_paywall.html`. Soft-launch critical journey end.
198 + - [ ] **Phase 4 — Auth & onboarding.** `pages/login.html`, `forgot_password.html`, `reset_password.html`, `two_factor.html`, `oauth_authorize.html`, `account-deleted.html`, `confirm_delete.html`, `wizards/wizard_join.html`. Account-state transitions; forgiveness + error-message review.
199 + - [ ] **Phase 5 — Checkout & receipts.** `pages/buy.html`, `cart.html`, `purchase.html`, `receipt.html`, `stripe_disclaimer.html`. Commerce flow; feedback + trust cues + Stripe handoff.
200 + - [ ] **Phase 6 — Creator dashboard & wizards.** `dashboards/dashboard-user.html`, `dashboard-project.html`, `dashboard-item.html`, `dashboard-blog-editor.html`, `dashboard-export.html`, `dashboard-import.html`, `dashboard-delete-account.html`, `wizards/wizard_item.html`, `wizard_project.html`, plus `wizards/steps/*` and `wizards/partials/*`. Densest interaction surface — expect the most findings.
201 + - [ ] **Phase 7 — Admin.** `dashboards/admin-appeals.html`, `admin-metrics.html`, `admin-reports.html`, `admin-signups.html`, `admin-uploads.html`, `admin-users.html`, `admin-waitlist.html`. Internal but still has destructive actions — forgiveness + confirmation review matters.
202 + - [ ] **Phase 8 — Cross-cutting & utility.** `pages/error.html`, `health.html`, `sandbox.html`, `doc.html`, `doc_index.html`, `email_result.html`, `pages/git/*` (G1 browser), `partials/*`, `partials/tabs/*`. Plus a full flat-design sweep over `static/style.css`, `static/media-player.css`, `static/wizard.css`. Closes with a roll-up summary across all 8 phases.
339 203
340 - ### DB & Validation
341 - - [ ] MINOR: `item_slug_exists` considers soft-deleted items — intentional during 7-day recovery window (`db/items.rs:80-94`)
342 -
343 - ### Scheduler & Infrastructure
344 - - [ ] NOTE: Announcement emails lost on server restart — no delivery persistence (`scheduler/announcements.rs:59-85`)
345 - - [ ] NOTE: Unconfigured S3 reports `s3_ok = true` — intentional but masks misconfig (`monitor.rs:56-65`)
346 - - [ ] NOTE: `X-Forwarded-For` spoofable without Cloudflare — accepted risk (`rate_limit.rs:26-31`)
204 + Out of scope for this sweep: per-tier capability decisions, brand/aesthetic direction (see `_meta/docs/brand.md`), full WCAG audit (run axe/Lighthouse separately).
347 205
348 206 ---
349 207
350 - ---
208 + ## Infra & Quality
351 209
352 - ## Code Assessment Blind Spots (2026-05-14)
210 + ### Pre-1k Scaling (from 2026-05-14 audit — `scaling.md`)
353 211
354 - From rust-code-assessment review. These are gaps the codebase suggests the developer hasn't explored, not defects — capture for future consideration, not urgent.
212 + Cheap wins to land before scale forces them. Ordered by cost-impact-if-ignored.
355 213
356 - ### Async traits
357 - - [ ] **`async-trait` everywhere is dated.** `PaymentProvider` (`payments/mod.rs:60`) and `StorageBackend` (`storage.rs:175`) both use `#[async_trait]`. Rust 1.75+ supports native `async fn` in traits; only `dyn`-dispatched methods still need the macro. Audit which methods are actually called through `Arc<dyn …>` vs. monomorphised — narrow `async-trait` to the dyn sites, drop the heap allocation from the rest.
214 + - [ ] **Verify CDN coverage of paid downloads** (cross-references Cloudflare Phase 1). Grep `routes/storage/` and `routes/api/...` for download paths; confirm presigned URLs route through `cdn.makenot.work`.
215 + - [ ] **Confirm `Cache-Control` on S3 uploads** — already done; keep monitored.
216 + - [ ] **PoM alert on `pg_stat_activity` saturation.** MNW pool (25) + MT pool share one Postgres. Add probe + alert before exhaustion is the first signal.
217 + - [ ] **Rate limit + concurrency cap on `/api/domains/caddy-ask`** — ACME issuance abuse target at scale.
Lines truncated
@@ -4,6 +4,60 @@ Items moved from todo.md. See git history for implementation details.
4 4
5 5 ---
6 6
7 + ## Prelaunch Items from human_todo.md (moved 2026-05-17)
8 +
9 + ### Business Formation (Make Creative, LLC)
10 + - [x] D-U-N-S number — 14-501-2681, received 2026-05-09
11 + - [x] Business bank account — Mercury approved 2026-05-01
12 + - [x] Transfer startup funds to Mercury business account
13 + - [x] Link Mercury business account to Stripe (payout destination) — 2026-05-17
14 +
15 + ### Platform Accounts
16 + - [x] D-U-N-S number — 14-501-2681, received 2026-05-09 (unblocked Google Play, Microsoft Partner Center)
17 + - [x] Google Play Developer Account ($25) — registered 2026-05-09
18 +
19 + ### Documentation Gap Checklist
20 + - [x] Git source browser guide — expanded git.md (HTTPS cloning, collaborators, issues, patches)
21 + - [x] Email-based issue tracker guide — added to git.md
22 + - [x] Email patch submission guide — added to git.md
23 + - [x] Embed widgets guide — new embeds.md
24 + - [x] Media library guide — new media-library.md
25 + - [x] CSV import guide — new import.md
26 + - [x] Custom profile links guide — expanded profile.md Links section
27 + - [x] Sandbox mode guide — already comprehensive (sandbox.md)
28 + - [x] Passkey setup guide — already detailed in security.md
29 + - [x] Password reset flow guide — new password-reset.md
30 + - [x] Account deactivation guide — new account-lifecycle.md
31 + - [x] Personalized feed guide — new feed.md
32 + - [x] Content insertions guide — new content-insertions.md
33 +
34 + ### Infrastructure (Tauri signing / OTA)
35 + - [x] Generate Tauri signing keys — per-app keys at `~/.tauri/goingson.key{,.pub}` and `~/.tauri/balanced-breakfast.key{,.pub}` (AF not Tauri). BB key rotated 2026-05-16.
36 + - [x] Add public keys to `tauri.conf.json` — GO and BB wired with `plugins.updater.pubkey` + endpoint `https://makenot.work/api/v1/sync/ota/{app}/{{target}}/{{arch}}/{{current_version}}`
37 + - [x] Enable `bundle.createUpdaterArtifacts: true` in both apps' `tauri.conf.json`
38 + - [x] Deploy private keys to astra + windows-x86; password file `~/.tauri/passwords.env` (Linux) / `passwords.ps1` (Windows). Master copy at `_private/tauri-passwords.env`
39 + - [x] Verify signed build produces `.sig` sidecars — confirmed on astra for BB 0.3.3 (AppImage, deb, rpm)
40 +
41 + ### §21 Documented-But-Not-Implemented (closed 2026-05-17, no pre-launch action)
42 + Roadmap features already documented as future work; no doc revision needed.
43 + - [x] Live streaming (Everything tier, "coming soon")
44 + - [x] Earn-back credit program (committed by 2027-01-01; FAQ)
45 + - [x] Content archive guarantee (12+ month content stays live after cancel)
46 + - [x] HLS adaptive bitrate (roadmap)
47 + - [x] Audio transcoding (roadmap)
48 + - [x] Mobile apps (roadmap)
49 +
50 + ### SyncKit (production validation)
51 + - [x] SyncKit auth — OAuth2 PKCE flow tested with GO + AF on live server (2026-05-11)
52 + - [x] Push changes
53 + - [x] Pull changes with cursor-based pagination
54 + - [x] E2E key storage — store key
55 + - [x] E2E key storage — retrieve key
56 + - [x] Create sync app (GO + AF apps created)
57 + - [x] SyncKit production test — GO + AF on live server (2026-05-11). BB pending (synckit.toml needed).
58 +
59 + ---
60 +
7 61 ## v0.5.19+ Audit Coverage Push (2026-05-16)
8 62
9 63 Driven by `_meta/remediation_todo.md` per-crate mutation kill-rate gaps + `cargo llvm-cov` report on MNW server. Full per-item details (which boundary each test pins) live in remediation_todo.md; this entry is the high-level deliverable index.
@@ -503,3 +557,144 @@ Two-pass fuzz: 100+ findings across payments, subscriptions, auth, storage, scan
503 557 - Unsubscribe action validated against enum (9 variants, 17 call sites)
504 558 - CSV import: row cap, amount parsing, HTML strip limit
505 559 - Archive scanning: URL-encoded path traversal, nested archive, content type hardening
560 +
561 + ---
562 +
563 + ## Moved from todo.md 2026-05-18
564 +
565 + ### Library View Split (`/i/{id}` store + `/l/{id}` library)
566 +
567 + Deployed in MNW 0.6.4 on 2026-05-17. `/i/{id}` is the *store* page (marketing, description, price, buy CTA — same layout for everyone, CTA flips to "View in library" when viewer has access). `/l/{id}` is the *library* page (downloads / player / reader / consumption — gated to users with access).
568 +
569 + **Decisions locked before Phase 0:**
570 + - View tracking moved: `/l/{id}` is the only path that calls `track_view("item", id)`; `/i/{id}` stops tracking views.
571 + - 403 vs. 404 on `/l/{id}`: missing → 404. Unpublished/deleted and viewer not owner → 404 (don't leak draft existence). Public but lacks access → 403 with link back to `/i/{id}`. Bundle-only (unlisted) items list all containing bundles in the 403 case.
572 + - `/l/{id}` ships with `noindex` and no OG/Twitter cards (revisit if buyers want to share library URLs).
573 + - No owner "preview as visitor" toggle on `/i/{id}` — the split renders identically for all viewers, only the CTA differs.
574 + - No bundle-child "via bundle" provenance badge on `/l/{child_id}` in v1.
575 +
576 + **Phase 0 — shared scaffolding:**
577 + - [x] Route `GET /l/{item_id}` in `routes/pages/public/mod.rs` → new `content::library_page` handler.
578 + - [x] `library_page` handler: parses `ItemId`, loads `db_item`/`db_project`/`db_user`, computes the same `AccessContext` as `item_page`, branches on item-missing → 404, unpublished/deleted-non-owner → 404, public-no-access → 403 (renders `library_locked.html`), otherwise renders the library template and calls `track_view("item", id)`.
579 + - [x] `item_page` (`/i/{id}`) no longer calls `track_view`.
580 + - [x] Template pick by `item_type` (text → `library_text.html`, audio → `library_audio.html`, video → `library_video.html`, bundle/other → `library_downloads.html`).
581 + - [x] `library_locked.html` 403 page: title, cover, "you don't have access to this yet", buttons to `/i/{id}` and `/login`. Unlisted items render the existing "Available in: Bundle X, Bundle Y" block.
582 + - [x] `noindex` meta on `library_*.html`. No OG/Twitter cards (deferred).
583 + - [x] Store templates (`item.html` / `text_reader.html` / `audio_player.html` / `video_player.html`) always render the store layout, swapping the primary CTA to "View in library →" when `has_access`.
584 + - [x] Redirect targets moved to `/l/{id}`: already-purchased redirect in `routes/stripe/checkout/item.rs::create_checkout`, Stripe checkout success for single-item purchases, webhook confirmation emails, library index row "View" links, receipt page "View item" link. Cart success path stays at `/library?purchase=success` (multi-item).
585 + - [x] Custom domain fallback keeps `/i/{id}` semantics; library URL is platform-only.
586 + - [x] Bundle children, free items, and sandbox/unpublished items all verified to work through the new gating.
587 + - [x] Audited all template `/i/{` references; most stay (store page link is still right for browsing).
588 +
589 + **Phase 1 — `/l/{id}` for downloads / bundle / other items:**
590 + - [x] New `templates/pages/library_downloads.html`: hero is the Downloads section (versions list, download notice, bundle contents linking to `/l/{child_id}`, collapsible Description, tabbed Sections, License, Discussion).
591 + - [x] Stripped downloads + download-notice sections out of `item.html`.
592 + - [x] `item.html` CTA swap: when `has_access` → replace Buy/PWYW with "View in library →".
593 + - [x] Smoke-tested: buy audiofiles as testaccount123 → success → `/l/{id}` shows downloads → `/i/{id}` shows store with "View in library" CTA.
594 +
595 + **Phase 2 — `/l/{id}` for audio items:**
596 + - [x] New `templates/pages/library_audio.html`: full player as hero, episode/track info, downloads if present, description, discussion.
597 + - [x] `audio_player.html` → store layout only (cover, title, price, description). Cover-only on the store (no 30s preview yet).
598 + - [x] `/api/stream/{item_id}` already gates on `can_access`; no change needed.
599 + - [x] Smoke-tested with a paid audio item from GO seed content.
600 +
601 + **Phase 3 — `/l/{id}` for video items:**
602 + - [x] New `templates/pages/library_video.html`: full player as hero, description, downloads, discussion.
603 + - [x] `video_player.html` → store layout (cover/poster, title, price, description, buy CTA — no video element on store).
604 + - [x] Smoke-tested with a video item.
605 +
606 + **Phase 4 — `/l/{id}` for text items:**
607 + - [x] New `templates/pages/library_text.html`: full article body as hero, reading-time, discussion.
608 + - [x] `text_reader.html` → store layout (title, byline, reading-time, excerpt of first paragraph or 200 chars, buy CTA).
609 + - [x] Smoke-tested with a text item from a project.
610 +
611 + **Phase 5 — cleanup:**
612 + - [x] Removed leftover `{% if has_access %}...player...{% endif %}` branches in store templates.
613 + - [x] Updated `docs/test_plan.md` smoke checklist with `/l/{id}` flow.
614 + - [x] Re-grepped templates for `/i/{` and confirmed each remaining link is intentional.
615 + - [x] Updated `human_todo.md` purchase-flow tests to assert landing on `/l/{id}`.
616 + - [x] Searched for legacy `/i/{id}/download` paths — none found; no redirect needed.
617 +
618 + ---
619 +
620 + ### Founder pricing engineering (2026-05-18)
621 +
622 + Decisions locked 2026-05-18 in memory `project_founder_pricing.md`. All in-app pieces shipped same day; remaining work is operational (create Stripe Price objects + set env vars in prod) and tracked in `_meta/human_todo.md` § Stripe Dashboard Knockout Session.
623 +
624 + - [x] Migration `116_founder_pricing.sql` — `users.is_founder` (eager set during window), `users.founder_locked_at` (close-time snapshot), partial index for the sweep target set.
625 + - [x] Config: four HashMaps (sticker monthly + sticker annual + founder monthly + founder annual) + `creator_founder_window_open` flag. Env vars `CREATOR_TIER_{BASIC,SMALL_FILES,BIG_FILES,EVERYTHING}_{,ANNUAL_,FOUNDER_,FOUNDER_ANNUAL_}PRICE_ID` (16 total — 4 tiers × 4 combos) + `CREATOR_FOUNDER_WINDOW_OPEN`.
626 + - [x] Checkout (`routes/stripe/checkout/subscriptions.rs`): picks founder price when window is open OR account is locked AND a founder price is configured; stamps `is_founder` on first checkout-session creation during the window. Picks across two axes (founder vs sticker × monthly vs annual) with graceful fallback chain (founder-annual → founder-monthly → sticker-annual → sticker-monthly) so partial env-var configuration never breaks checkout.
627 + - [x] DB helpers (`db/users.rs`): `mark_user_as_founder()`, `lock_in_founders_with_active_subscriptions()`.
628 + - [x] UI badge in `partials/tabs/user_creator.html` — "Founder · locked for life" once `founder_locked_at` is set; "Founder · pending lock-in" while the window is open and the user has signed up.
629 + - [x] Site docs: `site-docs/public/guide/tiers.md` and `about/pricing.md` rewritten to feature founder pricing as the top-of-page hero, post-founder rates framed as provisional targets. Driven by `docs/internal/business/assumptions.toml`.
630 + - [x] Landing page (`templates/pages/index.html`): founder banner renders only when `CREATOR_FOUNDER_WINDOW_OPEN=true`. Slot-counter ("N founder slots left") appears in the last 200 signups before the 1,000 cap.
631 + - [x] Dashboard founder banner + tier-card pricing in `partials/tabs/user_creator.html`. Each card has Monthly (primary) and Annual (save 10%, secondary) buttons.
632 + - [x] Window-close trigger: `POST /api/admin/founder-window/close` (admin-only) calls `lock_in_founders_with_active_subscriptions()`.
633 +
634 + ---
635 +
636 + ### Nitpick Run 1 (2026-05-13)
637 +
638 + Scope: `routes/api/mod.rs`, `scheduler/cleanup.rs`, `git_ssh.rs`. [FACT] items only — preference items dropped.
639 +
640 + **routes/api/mod.rs:**
641 + - [x] Dedup `ensure_project_owner` / `verify_project_ownership` — merged into one.
642 + - [x] Import `axum::routing::options` instead of fully qualifying.
643 + - [x] Reorder module list alphabetically.
644 + - [x] Co-locate the three `use` blocks.
645 + - [x] Replace magic `rate_limiter_per_sec(1, 10)` with new `GUEST_CHECKOUT_RATE_LIMIT_*` constants.
646 + - [x] Fix double-space in `json_error_layer` doc-comment.
647 + - [x] Unified route-method style on split (combined form converted at repo-collaborators and ssh-keys).
648 + - [x] Moved `license_keys::list_keys` GET from `write_routes` to `read_routes` — was being limited as a mutation (burst 30, 2/sec) instead of as a read (burst 60, 10/sec).
649 +
650 + **scheduler/cleanup.rs:**
651 + - [x] Hoist `get_project_ids_for_user` / `get_sync_apps_by_creator` to one call each — fixes race window between enqueue and delete.
652 + - [x] `tracing::info!(event = event, ...)` → shorthand `event,`.
653 + - [x] `cleanup_git_repos_on_disk` now takes `UserId` by value.
654 + - [x] Dropped redundant `item_keys: Vec<&str>` in `purge_expired_deleted_items`.
655 + - [x] Off-by-one in dead-letter / stuck log messages — guards now `>= 10` / `>= 5`.
656 + - [x] Module doc lists all jobs.
657 + - [x] Normalize scheduler-job empty-run convention.
658 +
659 + **git_ssh.rs:**
660 + - [x] `parse_repo_path` returns symmetric `(&str, &str)`; differentiated bail messages at the three sites.
661 + - [x] Removed `#[allow(clippy::collapsible_if, clippy::collapsible_else_if)]` — no longer needed.
662 + - [x] "unknown command. Available: ..." → "unknown command; available: ...".
663 + - [x] Truncate labels in `cmd_ssh_key_list` consistently with descriptions in `cmd_ssh_repo_list` — extracted `display_with_ellipsis` helper.
664 + - Deliberately not fixing: moving `use std::os::unix::process::CommandExt;` out of `exec_git_shell` (cfg gymnastics for non-unix build; preference-level only).
665 +
666 + **Not-a-nit follow-ups:**
667 + - [x] Race in `cleanup_user_s3_and_delete` — fixed in the same change as the DB-query hoist; enqueue and delete share one snapshot.
668 + - [x] Email validation consolidated — added `email_address = "0.2"` (no new transitive deps), new `validation::normalize_email` used at both `email_signup` and `guest_checkout` entry points.
669 +
670 + ---
671 +
672 + ### OAuth `perks` object on `/oauth/userinfo`
673 +
674 + Generic mechanism so any "Log in with MNW" implementer (MT first, future services next) can read user entitlements without bespoke endpoints. Pull-on-demand only; no webhook push yet.
675 +
676 + - [x] Extended `UserinfoResponse` in `src/routes/oauth.rs` with a `perks` object (`fan_plus`, `is_creator`, structured `creator_tier { tier, features }`).
677 + - [x] Populated from DB at userinfo-time (always fresh).
678 + - [x] `CreatorTier::features()` capability strings (`file_uploads`, `large_files`).
679 + - [x] Workflow tests: default user, creator tier, fan_plus, unauthorized.
680 + - [x] `docs/oauth_integration.md` — flow, perks contract, refresh ergonomics, stability rules.
681 +
682 + ---
683 +
684 + ### Fan+ self-service
685 +
686 + - [x] Migration 114: `cancel_at_period_end` column on `fan_plus_subscriptions`.
687 + - [x] `POST /stripe/fan-plus/cancel` + `POST /stripe/fan-plus/resume` — set `cancel_at_period_end` via Stripe API + DB.
688 + - [x] `POST /stripe/billing-portal` — Stripe Customer Portal session for payment-method updates and invoice history. Customer Portal must be configured in the Stripe dashboard for production use.
689 + - [x] `customer.subscription.updated` webhook syncs `cancel_at_period_end` so portal-initiated cancellations flow back to the DB.
690 + - [x] Compact Fan+ pane in dashboard account tab: status + period end + Cancel/Resume/Manage billing buttons. Non-subscribers see a one-line "Learn about Fan+" link, no upsell.
691 +
692 + ---
693 +
694 + ### Global UX fixes (2026-05-18, all from 2026-05-16 testing pass)
695 +
696 + - [x] **Misleading webhook error message.** `payments/webhooks.rs` now distinguishes signature failures (`"Invalid webhook signature: <reason>"`, log `error.kind = "signature"`) from envelope JSON parse errors (`"Webhook envelope JSON parse failed: <serde error>"`, `error.kind = "envelope_json"`) from envelope-shape errors (`"Webhook envelope missing required field: <name>"`, `error.kind = "envelope_missing_field"`). Typed-struct parse failures in the dispatcher already had specific messages. All four still return 400. New test `parse_envelope_error_messages_name_the_field` pins the wording. Cross-references `migration_stripe_rc5.md` follow-ups list.
697 + - [x] **Checkout error messages not surfaced.** Backend `AppError::BadRequest` was already passing messages through; root cause was `templates/pages/error.html` typography (8rem status code + h1 "Bad Request" dominated, message small and muted). Rebalanced: status code/text demoted to a small uppercase eyebrow label, message promoted to 1.5rem primary-color hero text. Single-source fix benefits every 4xx path.
698 + - [x] **4xx audit follow-up.** Added `window.apiErrorMessage(res, fallback)` helper in `static/mnw.js` reading `{error: "..."}` from non-2xx API responses. Migrated swallowing call sites in `item-details.js` (bundle-add, bundle-remove, section-delete, tag-add), `blog-editor.js` (create-post field name fix + update-post + auto-save), `collections.js` (toggle + create-and-add), `project-sections.js` (delete). Audited clean: `item-upload.js`, `insertions.js`, item-details section add/edit/save, project-sections add/edit, `dashboard-import.html`, wizard steps. Left as-is: `passkey.js` (custom text-response auth flow); wizard sections delete (low-value).
699 + - [x] **Library page scroll-in-scroll + row dropdown.** Root cause: `.content-section` wrapper had `overflow-x: auto`, which makes browsers compute `overflow-y` as `auto` too, silently clipping absolutely-positioned children (the `...` menu). Replaced the dropdown with an inline action bar (Open, Receipt, Collection, Remove for free items). Removed `overflow-x: auto` + `min-width: 500px` from both purchases and subscriptions wrappers. Single scroll context now. Collection picker still works via `openCollectionPicker()` fallback to `anchor.parentElement`.
700 + - [x] **Stuck "Verbing..." buttons + slow-redirect feedback.** Widened the htmx loading-state handler (`resolveHtmxLoadingButton()` picks the triggering button directly, then falls back to the form's submit button; restores on `htmx:responseError` / `sendError` / `timeout`, not just `afterRequest`). Added a plain form-submit loading handler for `data-loading-text` opt-in (closes the dead window during slow Stripe redirects). bfcache restore on `pageshow`. Opt-in success flash via `data-success-text` for `hx-swap="none"` cases (wired to the Stripe Tax toggle in `user_payments.html`). `window.withLoadingState(btn, label, fn)` helper for plain `fetch()`. `data-loading-text="Redirecting to Stripe…"` wired on every Stripe-redirect submit across the templates. Audit confirmed `item-details.js` / `collections.js` / `blog-editor.js` already restore via `.catch`/`.finally`; `media-picker.js` has no loading state to fix.
@@ -0,0 +1,137 @@
1 + # UX Audit — Phase 0: Design-System Conformance
2 +
3 + Audit ran 2026-05-19 against `static/style.css` (4432 lines), `static/wizard.css` (669), `static/media-player.css` (397), `_meta/docs/brand.md`, and a sample of partials, pages, dashboards, and wizards.
4 +
5 + The question this phase asks is not "is each screen good?" but "does the codebase behave like an OS — a small set of primitives composed everywhere — or does every screen reinvent the wheel?" The answer: **mostly the former, undermined by four concrete gaps.**
6 +
7 + ## What exists today (the good news)
8 +
9 + The token layer is real. `style.css:40-95` defines a coherent palette: warm beige background, charcoal-brown text, violet accent, plus surface/border/muted variants, semantic colors (success / warning / danger / error / health), and a focus-ring token. The three-tier type system from `brand.md` is wired in as `--font-heading` / `--font-mono` / `--font-body` and used consistently in headings, controls, and body.
10 +
11 + Component primitives that exist and are reused:
12 +
13 + - **Buttons** — `button`, `button.primary` / `.btn-primary`, `button.secondary` / `.btn-secondary`, `button.danger` / `.btn-danger` (`style.css:182-244`).
14 + - **Forms** — `.form-container`, `.form-group`, `.form-row`, `.hint`, unified `input[type=...]` styling with `--input-background` (`style.css:269-340`).
15 + - **Tables** — `.data-table`, `.compact-table` (`style.css:508-588`).
16 + - **Tabs** — `.tabs` / `.tab` / `.tab.active` plus scroll-overflow menu (`style.css:429-502`). 28 tab content partials under `templates/partials/tabs/`.
17 + - **Cards** — `.card` + `.card-title` / `.card-meta` / `.card-description` (`style.css:716-752`).
18 + - **Badges & tags** — `.badge` (+ `.badge-success` / `-warning` / `-danger` / `.active` / `.pending` / `.suspended` / `.free`); `.tag` (`style.css:628-710`).
19 + - **Empty state** — `.empty-state` class (`style.css:605`).
20 + - **Info / warning / error boxes** — `.info-box`, `.warning-box`, `.error-message` (`style.css:874-921`).
21 + - **Modal** — `.modal-overlay` + `.modal` + `.form-actions` (`style.css:1974-1999`).
22 + - **Toast** — `.toast-container` + `.toast` + `.toast-success` / `-error` / `-warning` plus `partials/toast.html` (`style.css:1653-1749`).
23 + - **HTMX loading** — `.htmx-indicator` + `.htmx-request button[type=submit]` opacity fade (`style.css:1620-1647`).
24 +
25 + Layout primitives: `.padded-page`, `.centered-page`, `.container`, plus the wizard layout in `wizard.css:3-26` and the media-container in `media-player.css:4-8`.
26 +
27 + Shared partials acting as components: `partials/alert.html`, `partials/toast.html`, `partials/error_fragment.html`, `partials/form_status.html`.
28 +
29 + ## Where the OS model breaks down
30 +
31 + ### 1. Missing token tiers — spacing, radius, shadow
32 +
33 + The token layer covers color and type but stops there. Every spacing, radius, and shadow value is hardcoded inline:
34 +
35 + - **Spacing** — values like `0.25 / 0.5 / 0.75 / 1 / 1.25 / 1.5 / 2 rem` form an implicit scale, but with off-scale strays (`wizard.css:39` uses `0.6rem`; `style.css:2012` uses `0.15rem 0.4rem`). No `--space-1`...`--space-6` tokens.
36 + - **Radius** — three values (`2px`, `3px`, `4px`) plus `50%` for avatars, repeated 21+ times. No `--radius-sm/md/round` tokens.
37 + - **Shadow** — seven distinct shadow recipes across the file with inconsistent opacity and offset. No `--shadow-1/2/3` tokens.
38 +
39 + Consequence: every new component re-decides what "small spacing" or "subtle shadow" means.
40 +
41 + ### 2. Inline `style="..."` saturation
42 +
43 + **1,350 inline-style attributes** across templates. Concrete offenders:
44 +
45 + - `dashboard-project.html:25` — sandbox banner built as a raw inline-styled div with hardcoded violet + mono font instead of a reusable `.banner` primitive.
46 + - `dashboard-project.html:38,40` — `style="opacity: 0.7;"` for muted links (recurs widely; no `.muted-link` class).
47 + - `login.html:61,65` — `style="display: none;"` despite a `.hidden` utility existing at `style.css:2263`.
48 + - `wizard_join.html:67` — `style="margin-top: 1rem; text-align: center;"` for a footer link (no `.footer-link` class).
49 + - `discover.html:32` — inline flex+gap recipe duplicating `.checkbox-label` (`style.css:354`).
50 + - `dashboard-blog-editor.html` — `style="width: 100%; padding: 0.5rem;"` repeated on inputs.
51 + - `buy.html` — entire page-scoped `<style>` block of ~119 lines defining one-off `.buy-card` / `.cover` / `.buy-btn` with hardcoded `#6c5ce7` and `#fff` instead of tokens.
52 + - `item.html:51-127` — 114-line embedded `<style>` block defining `.item-layout`, `.item-media`, `.item-title`, `.purchase-box`, `.section-tabs`, `.section-tab`. Duplicates patterns from `.card` and `.section-header`.
53 + - `error.html:18-61` — embedded `<style>` block redefining `.error-page` / `.error-container` / `.error-status` / `.error-message` / `.error-actions`.
54 +
55 + ### 3. Fragmented and inconsistent state recipes
56 +
57 + The "selected" / "active" state is spelled differently every place it appears:
58 +
59 + - `.filter-item.active` — opacity change.
60 + - `.tab.active` — background swap + opacity.
61 + - `.badge.active` / `.badge.complete` — green background.
62 + - `.view-btn.active` (`style.css:2292`) — black background, white text.
63 + - `.type-card input:checked + .type-card-inner` — border tint.
64 +
65 + The "hover" recipe is also split: cards change background, buttons fade opacity, type-cards change border, link-rows do both. There is no canonical "this is what interactive feedback looks like."
66 +
67 + The focus ring is the closest thing to a unified state recipe (`--focus-ring` token + global `*:focus-visible`, `style.css:1846-1849`), but custom interactive elements like `.type-card` and `.pricing-card` don't pick it up, and the docs search at `style.css:3214` redefines `outline` from scratch.
68 +
69 + ### 4. Missing or under-shared component primitives
70 +
71 + Primitives the system should ship with but doesn't:
72 +
73 + - **Empty-state partial.** The class exists but the markup is rewritten inline in `feed.html`, `collection.html`, `tag_tree.html` with varying padding (3rem / 1rem / 2rem). No `partials/empty_state.html`.
74 + - **Loading skeleton.** Only the opacity-pulse `.htmx-indicator`. No skeleton rows, no shimmer, no list-loading placeholder.
75 + - **Form-field with error variant.** `.form-group` carries label + hint but has no `.form-group--error` and no link from field to the error message that HTMX injects.
76 + - **Confirmation dialog partial.** `.modal` is styled but every delete/destroy confirm reassembles raw HTML.
77 + - **Disabled button state.** No `:disabled` styling. The only "busy" cue is the HTMX submit opacity dim.
78 + - **Sortable table active-direction indicator.** `.sortable::after { content: " ^" }` is rendered at low opacity but never gets an "ascending" / "descending" treatment.
79 + - **Pagination component.** Styles live tacked onto the end of the file (`style.css:4111-4115`) with no partial; raw HTML is regenerated per page.
80 +
81 + ## Brand conformance flags
82 +
83 + These violate `_meta/docs/brand.md` or the memory rule "no checkmarks/emoji in UI copy":
84 +
85 + - **Checkmark glyphs in user-facing markup.** `wizard.css:435` uses `content: '\2713'` (✓) for checklist items. `discover.html:86` emits `&#10003;` on the tag-follow button. Brand rule: words only.
86 + - **Pure black / white bypassing the warm palette.** `media-player.css:107` sets `.video-display` background to `#000`. `buy.html:20` sets `.buy-card` background to `#fff`. Should be `var(--detail)` and `var(--light-background)`.
87 + - **Bootstrap-yellow restart banner.** `style.css:1755` uses `#fff3cd` + `#ffc107` for `#restart-banner`. Should map to `--warning` / `--warning-bg` / `--warning-border`.
88 + - **Hardcoded `#6c5ce7` in `buy.html` and `wizard.css:172` rgba** instead of `var(--highlight)`.
89 +
90 + No emoji found in `templates/pages/` or `templates/dashboards/` — that part of the rule holds.
91 +
92 + ## Inventory verdict by axis
93 +
94 + | Axis | State | Note |
95 + |---|---|---|
96 + | Color tokens | Complete | 20+ tokens, brand-aligned |
97 + | Type tokens | Complete | 3-tier system enforced via vars |
98 + | Spacing tokens | Missing | Implicit scale, no token |
99 + | Radius tokens | Missing | 4 raw values used 21+ times |
100 + | Shadow tokens | Missing | 7 distinct recipes, no token |
101 + | Button primitive | Complete | primary / secondary / danger; lacks `:disabled` |
102 + | Form primitive | Partial | No error-variant or field-error binding |
103 + | Table primitive | Partial | No active sort-direction class |
104 + | Tab primitive | Good | 28 partials; ARIA inconsistent |
105 + | Card primitive | Good | Hover recipe diverges from `.grid-card` |
106 + | Empty-state | Fragmented | Class exists, markup duplicated inline |
107 + | Loading state | Minimal | Only HTMX opacity dim |
108 + | Modal | Partial | No confirm-dialog partial |
109 + | Toast | Complete | Class + partial; no JS trigger helper |
110 + | Focus ring | Partial | Token global, not picked up by custom elements |
111 + | Inline style usage | Bad | 1,350 inline-style attrs in templates |
112 + | Brand conformance | Minor breaches | Checkmarks; pure #000/#fff; Bootstrap yellow |
113 +
114 + ## Remediation — what Phase 0 must land before Phase 1 starts
115 +
116 + Charter is at `docs/design-system.md`. Cleanup work (drop into `todo.md` follow-ups):
117 +
118 + 1. **Add spacing / radius / shadow tokens** to `:root` in `style.css` and migrate the implicit scale.
119 + 2. **Build the four missing shared partials**: `partials/empty_state.html`, `partials/confirm_dialog.html`, `partials/form_field.html` (label + hint + error slot), `partials/loading_skeleton.html`.
120 + 3. **Inline-style sweep.** Target the worst offenders first: `buy.html`, `item.html` embedded `<style>` blocks, `dashboard-project.html` banner + muted-link patterns, `login.html` use of `style="display: none"` where `.hidden` exists.
121 + 4. **Brand-conformance fixes.** Replace checkmark glyphs in `wizard.css` and `discover.html` with text; swap `#000` / `#fff` / Bootstrap yellow for tokens.
122 + 5. **Unify selected-state recipe.** Pick one (`.is-selected` with violet-tinted background + focus ring) and migrate `.tab.active`, `.filter-item.active`, `.badge.active`, `.view-btn.active`, `.type-card input:checked` to it.
123 + 6. **Add `:disabled` button styling** so destructive flows can show a disabled-confirm cue.
124 + 7. **Apply focus ring to custom interactive containers** (`.type-card`, `.pricing-card`, sort-headers).
125 +
126 + Once 1-7 land, Phase 1 (public landing) audits *conformance to the charter*, not free-form usability.
127 +
128 + ## Pattern summary
129 +
130 + Four patterns explain almost every divergence found:
131 +
132 + 1. **Token system stops at color/type.** Spacing, radius, and shadow are free-form, which licenses every new component to invent its own scale.
133 + 2. **Page-isolated CSS blocks.** `buy.html`, `item.html`, `error.html` each embed their own stylesheet, duplicating primitives that exist in `style.css` under different names.
134 + 3. **No shared empty / loading / confirm partials.** Patterns recur but have no canonical markup, so each page rebuilds them.
135 + 4. **State vocabulary is unstandardized.** "Selected" and "hover" are spelled five different ways across components, which makes screens feel like they were designed by different people.
136 +
137 + Fix those four and the rest of the audit collapses into conformance checks.
@@ -0,0 +1,179 @@
1 + # UX Remediation Pre-Plan (pre-Phase-1)
2 +
3 + Before running Phases 1-8 of the UX audit sweep, do a consolidation pass. The Phase 0 inventory (`phase-0.md`) showed that the platform reinvents the same UX in several different shapes — 12 card variants, 7 empty-state classes, 14+ ad-hoc button classes, five "selected" recipes. Auditing each surface against the charter is wasted effort until those duplicates are merged. After this pass, the audits become conformance checks instead of style debates.
4 +
5 + This plan has two parts:
6 +
7 + 1. **Foundation** — tokens, partials, and brand fixes from Phase 0.
8 + 2. **Consolidation** — for every UX pattern expressed multiple ways, pick one and migrate.
9 +
10 + Charter is at `docs/design-system.md`. Each remediation below lands as a small, independent PR-equivalent commit.
11 +
12 + ## Part 1 — Foundation
13 +
14 + ### 1.1 Add the three missing token tiers
15 +
16 + Add to `:root` in `static/style.css`:
17 +
18 + ```css
19 + /* Spacing scale */
20 + --space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem;
21 + --space-4: 1rem; --space-5: 1.5rem; --space-6: 2rem;
22 + /* Radius */
23 + --radius-sm: 2px; --radius-md: 4px; --radius-round: 50%;
24 + /* Shadow */
25 + --shadow-1: 0 1px 3px rgba(0,0,0,0.06);
26 + --shadow-2: 0 2px 8px rgba(0,0,0,0.10);
27 + --shadow-3: 0 4px 12px rgba(0,0,0,0.15);
28 + ```
29 +
30 + Migrate existing shadow recipes onto the three tiers. Replace `0.6rem` (`wizard.css:39`) and other off-scale strays. Do not migrate every existing `4px` radius in one pass — only when touching a rule for another reason. Goal is to make the tokens authoritative going forward.
31 +
32 + ### 1.2 Build the five missing shared partials
33 +
34 + | New partial | Replaces today |
35 + |---|---|
36 + | `partials/empty_state.html` | Inline empty markup in `feed.html`, `collection.html`, `tag_tree.html`, and the seven `*-empty` classes in `style.css` |
37 + | `partials/loading_skeleton.html` (variants: row, card, list) | Nothing — net new; only `.htmx-indicator` exists |
38 + | `partials/form_field.html` (label + input slot + hint + error slot) | Hand-assembled `.form-group` blocks across every form template |
39 + | `partials/confirm_dialog.html` | Raw `.modal` markup in delete/destroy flows |
40 + | `partials/pagination.html` | Per-page raw pagination HTML; `.pagination` styles at `style.css:4111` |
41 +
42 + Each partial documents its parameters in a Rust doc comment on the corresponding template struct.
43 +
44 + ### 1.3 Brand-conformance fixes
45 +
46 + - Remove the U+2713 checkmark in `static/wizard.css:435-443` (`.preview-checklist li::before`); replace with text-prefix bullet or `.badge-success`.
47 + - Remove the `&#10003;` checkmark entity on the tag-follow button in `templates/pages/discover.html:86`; replace with "Following" / "Follow" text states.
48 + - Swap pure-black `#000` in `static/media-player.css:107` (`.video-display`) for `var(--detail)`.
49 + - Swap pure-white `#fff` in `templates/pages/buy.html:20` (`.buy-card`) for `var(--light-background)`.
50 + - Swap Bootstrap yellow in `style.css:1755` (`#restart-banner`) for `var(--warning-bg)` + `var(--warning-border)`.
51 + - Replace hardcoded `#6c5ce7` and `rgba(108,92,231,...)` literals with `var(--highlight)` and `var(--highlight-faint)` in `buy.html` and `wizard.css:172`.
52 +
53 + ## Part 2 — Consolidation: same UX, different expressions
54 +
55 + Each row below is a pattern the codebase implements in multiple ways. The "Pick" column is the proposal. Migrate every other variant onto it; delete the discarded classes once references are gone.
56 +
57 + ### 2.1 Card family (12 variants -> 2)
58 +
59 + | Class | File | Used by |
60 + |---|---|---|
61 + | `.card` | `style.css:716` | Generic content card |
62 + | `.grid-card` + `.grid-card-*` | `style.css` | Discover grid items |
63 + | `.feature-card` | `style.css` | Marketing |
64 + | `.fork-card` | `style.css` | Source browser |
65 + | `.stat-card` | `style.css` | Dashboard metrics |
66 + | `.tier-card` | `style.css` | Pricing |
67 + | `.type-card` + `.type-card-inner/-label/-desc` | `style.css`, `wizard.css` (duplicated) | Wizard type picker |
68 + | `.analytics-card` | `style.css` | Dashboard |
69 + | `.use-case-card` | `style.css` | Marketing |
70 + | `.content-choice-card` | `wizard.css` | Wizard |
71 + | `.monetization-card` | `wizard.css` | Wizard |
72 + | `.pricing-card` + `.pricing-card-inner/-label/-desc` | `wizard.css` | Wizard pricing |
73 +
74 + **Pick**: `.card` (block content) and `.card--selectable` (radio-like; absorbs `.type-card`, `.pricing-card`, `.content-choice-card`, `.monetization-card`). Grid layouts use `.card.card--grid`. Marketing variants (`feature-card`, `use-case-card`, `tier-card`) become `.card` with content-level styling.
75 +
76 + The `.type-card` / `.pricing-card` *inner* wrappers are duplicated verbatim between `style.css` and `wizard.css` — that's a straight delete-the-duplicate fix.
77 +
78 + ### 2.2 Empty-state family (7 variants -> 1)
79 +
80 + `.empty-state`, `.chart-empty`, `.collection-picker-empty`, `.docs-search-empty`, `.git-repos-empty`, `.results-empty`, `.preview-cover-empty`.
81 +
82 + **Pick**: `partials/empty_state.html` with optional `--compact` modifier (currently expressed as the inline `padding: 1rem` override in `feed.html`). Slots: title, body, optional action. Migrate the seven `*-empty` classes onto it; delete after.
83 +
84 + ### 2.3 Button family (14+ ad-hoc -> 3 canonical + utilities)
85 +
86 + Ad-hoc today: `.order-btn`, `.view-btn`, `.toggle-btn`, `.big-button`, `.link-button`, `.notify-btn`, `.paywall-btn`, `.play-button`, `.speed-button`, `.tag-follow-btn`, `.toast-retry-btn`, `.shortcuts-help-btn`, `.show-more-btn`, plus `.action-buttons` row.
87 +
88 + **Pick**: keep `.btn-primary` / `.btn-secondary` / `.btn-danger`. Add two utilities:
89 + - `.btn--icon` for icon-only / micro buttons (`order-btn`, `play-button`, `speed-button`, `shortcuts-help-btn`).
90 + - `.btn--link` for buttons that look like links (`.link-button`, `.show-more-btn`, `.toast-retry-btn`).
91 +
92 + `.big-button` becomes `.btn-primary.btn--large`. `.view-btn` (toolbar segment) becomes `.btn-secondary` with `.is-selected`. `.tag-follow-btn` is a `.btn-secondary` with `.is-selected` when followed. Delete `.notify-btn`, `.paywall-btn` — they were one-off colors that map to primary or secondary.
93 +
94 + ### 2.4 Selected / active state (5 spellings -> 1)
95 +
96 + Today: `.tab.active`, `.filter-item.active`, `.view-btn.active`, `.badge.active`, `.type-card input:checked + .type-card-inner`.
97 +
98 + **Pick**: `.is-selected` modifier with the recipe `background: var(--highlight-faint); border-color: var(--focus-ring);`. Migrate all five. The CSS `:checked` selector pattern is replaced by server-side rendering of `.is-selected` on the wrapper after form submit, matching how `.tab` works today.
99 +
100 + ### 2.5 Hover recipe (3 patterns -> 2 by component type)
101 +
102 + Today: cards swap background, buttons fade opacity, type-cards change border, link-rows do both.
103 +
104 + **Pick**: opacity-dim (`opacity: 0.85`) for solid-fill components (filled buttons, badges). Background-step (`--background` -> `--light-background` -> `--surface-muted`) for container components (cards, link-rows, list items). Border-tint is retired; selectable cards instead use `.is-selected` on click.
105 +
106 + ### 2.6 Notification surface (4 components -> clarified roles)
107 +
108 + Today: `.toast`, `.alert`, `.error-message`, `.warning-box`, `.info-box`, `partials/form_status.html`, `partials/error_fragment.html`. These overlap.
109 +
110 + **Pick**: clear role per component, all four kept but disambiguated:
111 + - **Toast** — transient, JS-dismissible, bottom-right. `partials/toast.html`.
112 + - **Alert** — inline, persistent on the page where it's emitted. Use `partials/alert.html` with severity variants. Replaces `.warning-box`, `.info-box`, `.error-message` *when used as a page-level notice*.
113 + - **Field error** — inline under a form field, set via `partials/form_field.html` error slot.
114 + - **Banner** — page-top notice (sandbox mode, restart pending, founder pricing announcement). New `.banner` class with `.banner--info` / `.banner--warning`. Absorbs `.landing-founder-banner`, the inline sandbox banner in `dashboard-project.html:25`, and `#restart-banner`.
115 +
116 + ### 2.7 Status indicator (badge vs custom dot vs colored text -> badge)
117 +
118 + Today: `.badge` family (clean), `.username-status`, `.save-status`, `.notify-status`, `.diff-status-*` (custom). Plus raw colored text in dashboards.
119 +
120 + **Pick**: `.badge` with semantic variants for all status. `.diff-status-*` keeps its own family because it's in the source browser and has distinct semantics (added / modified / deleted file in a tree). `.save-status` and `.notify-status` become `.badge` with `.badge--inline` modifier (no padding bump).
121 +
122 + ### 2.8 Section header (2 patterns -> 1)
123 +
124 + Today: `.section-header` class and ad-hoc h2 with inline `border-bottom`.
125 +
126 + **Pick**: `.section-header`. Migrate the inline variants.
127 +
128 + ### 2.9 Hidden / display utility (2 patterns -> 1)
129 +
130 + Today: `style="display: none"` (e.g. `login.html:61,65`) and `.hidden` class (`style.css:2263`).
131 +
132 + **Pick**: `.hidden`. Sweep templates for the inline form.
133 +
134 + ### 2.10 Page heading (2 patterns -> contextual)
135 +
136 + Today: centered h1 (Young Serif, `style.css:137`) and `.section-header` h2. Some pages use h2-as-title.
137 +
138 + **Pick**: every page top has one centered h1 in `--font-heading`. Sub-areas of the page use `.section-header` with h2 in `--font-mono`. Audit the dashboards specifically — many use h2-as-title because they're loaded into a tabbed layout; clarify whether the tab title or an inline h1 is the page title.
139 +
140 + ### 2.11 Page-isolated `<style>` blocks (3 grandfathered -> 0)
141 +
142 + `buy.html` (~119 lines), `item.html:51-127` (~114 lines), `error.html:18-61` (~44 lines). Each duplicates primitives that exist elsewhere under different names.
143 +
144 + **Pick**: migrate to `style.css` sections named `BUY`, `ITEM`, `ERROR-PAGE`. Convert hardcoded colors to tokens. Reuse `.card`, `.section-header`, `.btn-primary` where the bespoke class is just a renamed primitive (`.buy-card` -> `.card`; `.buy-btn` -> `.btn-primary`; the `error-actions` row -> `.form-actions`).
145 +
146 + ### 2.12 Inline `style="..."` sweep (1,350 instances)
147 +
148 + Largest concentrations (by spot-check): `dashboard-project.html`, `dashboard-blog-editor.html`, `dashboard-item.html`, `login.html`, `discover.html`, `wizard_join.html`, `item.html`.
149 +
150 + Strategy: don't do them all in one pass. Sweep by template, one PR per template, and for each occurrence either (a) replace with an existing utility, (b) extend a primitive, or (c) add a new utility class (`.muted-link`, `.footer-link`, `.centered-text`) — never new bespoke classes.
151 +
152 + The single permitted exception is `style="--var: value"` for server-computed CSS custom properties (progress bars, avatar fallback tints).
153 +
154 + ## Sequencing
155 +
156 + Foundation lands first; consolidation can run in parallel after that, but selected-state and button-family migrations should land before the surface audits start.
157 +
158 + 1. **Foundation (Part 1)** — must complete before any consolidation. Tokens, partials, brand fixes. Roughly one sitting.
159 + 2. **Selected-state unification (2.4)** — touched by half the other consolidations.
160 + 3. **Button-family + hover recipe (2.3, 2.5)** — cross-cutting; affects every surface.
161 + 4. **Empty-state, notification surface, banner (2.2, 2.6)** — independent; pick up in any order.
162 + 5. **Card-family consolidation (2.1)** — largest single migration; do after selected-state lands.
163 + 6. **Status, section-header, hidden utility, page heading (2.7-2.10)** — small, batch them.
164 + 7. **Page-isolated styles (2.11)** — three templates, one PR each.
165 + 8. **Inline-style sweep (2.12)** — per-template, ongoing; declare "done" when no template has more than 5 inline `style=` attrs.
166 +
167 + After step 8, Phase 1 of the audit sweep runs against the consolidated state.
168 +
169 + ## Success criteria for the pre-plan as a whole
170 +
171 + - `style.css` has spacing / radius / shadow tokens; no off-scale or raw-hex values added after this pass.
172 + - The charter primitive table in `docs/design-system.md` has one canonical class per row, with no "**to add**" markers.
173 + - Grep for `style="` in `templates/` returns under 100 hits (down from ~1,350).
174 + - Grep for `class=".*-card"` in `templates/` returns only `.card` and `.card--*` modifier forms (no bespoke `-card` classes).
175 + - No checkmark glyphs or `&#10003;` entities in any template.
176 + - No `#000` / `#fff` / Bootstrap-derived literals in any CSS file.
177 + - Every "selected" state in the UI is `.is-selected`.
178 +
179 + Once the success criteria pass, the audit sweep can proceed. Surface findings in Phases 1-8 are then expected to be sparse and stack-specific rather than fundamental.
@@ -0,0 +1,29 @@
1 + -- Founder pricing window: 50%-off creator-tier prices locked for life for
2 + -- creators active when the window closes. Closes at 1,000 creators OR exit-
3 + -- beta, whichever first. See `project_founder_pricing.md` for the full plan.
4 + --
5 + -- Status semantics:
6 + -- - is_founder = TRUE once a user starts a creator-tier subscription
7 + -- while the founder window is open. Sticky.
8 + -- - founder_locked_at = NULL while the window is open. Set to the close
9 + -- timestamp by the close-window cron sweep, ONLY for
10 + -- users with an active creator_tier_subscription at
11 + -- that instant. After the sweep, NULL means "missed
12 + -- the snapshot" and that user pays sticker pricing
13 + -- on any future creator subscription.
14 + --
15 + -- Why two columns: `is_founder` is set eagerly on first sub so the checkout
16 + -- handler can pick founder price IDs during the window without an extra
17 + -- lookup. `founder_locked_at` is the durable lock applied at close-time.
18 + -- An account can have is_founder = TRUE and founder_locked_at = NULL during
19 + -- the window (currently eligible); both set (locked); is_founder = TRUE and
20 + -- founder_locked_at = NULL after the window with no active sub at close
21 + -- means they lost eligibility.
22 +
23 + ALTER TABLE users ADD COLUMN is_founder BOOLEAN NOT NULL DEFAULT FALSE;
24 + ALTER TABLE users ADD COLUMN founder_locked_at TIMESTAMPTZ;
25 +
26 + -- Partial index for the cron sweep: finds founder-eligible users awaiting
27 + -- the lock. Tiny in practice (≤1,000 rows) but cheap to maintain.
28 + CREATE INDEX idx_users_founder_pending ON users (id)
29 + WHERE is_founder = TRUE AND founder_locked_at IS NULL;
@@ -1,50 +1,54 @@
1 1 # Pricing
2 2
3 - *As of 2026-05-16.*
3 + *As of 2026-05-18.*
4 4
5 5 What it costs to be a creator on Makenot.work.
6 6
7 7 ---
8 8
9 - ## Founding-Creator Rate
9 + ## Founder Pricing
10 10
11 - During the alpha period, every new creator subscribes at the **founding-creator rate**.
11 + While we're in our founder window, every new creator subscribes at the **founder rate** — half the eventual sticker price. This is not a promotional discount that expires. It is a structural early-supporter rate, available because you're showing up before we have real signup data to set a sustainable post-founder price.
12 12
13 - This is not a discount or a promotional sale. It is a structural early-supporter rate: lower than the standard rate, available because you're showing up before we have measured data on what the platform costs to run at scale. Your help is what makes that data possible.
13 + **The window closes when we reach 1,000 creators or when we exit beta — whichever comes first.** Anyone with an active creator membership at the moment the window closes has their founder rate locked in for the life of their account.
14 14
15 - **The founding-creator rate is locked for the lifetime of an uninterrupted subscription.** As long as you stay subscribed, your rate doesn't move. If you cancel and resubscribe later, you pay whatever rate is in effect at that time.
15 + Once your founder rate is locked in:
16 + - It applies to every tier — if you upgrade or downgrade later, you get the founder rate on the new tier.
17 + - It survives cancellation — if you cancel and come back at any point in the future, founder rates are still available to you.
16 18
17 - The founding-creator rate is available during the alpha period and closes when the alpha ends.
19 + While the window is still open, your founder eligibility depends on having an active subscription when the window closes. If you cancel before the window closes and don't resubscribe before close-time, you lose founder eligibility. There is no continuity requirement *after* lock-in — but until lock-in, status is determined by who's active at the moment we close the window.
18 20
19 - ### Founding-Creator Rates
21 + ### Founder Rates
20 22
21 - | Tier | Monthly | What's Included |
22 - |---|---|---|
23 - | **Basic** | ${{ tiers.founding.basic }} | Text, blogs, newsletters, posts, RSS, memberships, analytics |
24 - | **Small Files** | ${{ tiers.founding.small_files }} | Everything in Basic, plus audio hosting, podcast RSS, binary downloads, license keys, promo codes |
25 - | **Big Files** | ${{ tiers.founding.big_files }} | Everything in Small Files, plus video uploads, in-browser video player, large binaries up to 20GB |
26 - | **Everything** | ${{ tiers.founding.everything }} | All current and future features, including streaming infrastructure when it ships |
23 + | Tier | Monthly | Annual (10% off) | What's Included |
24 + |---|---|---|---|
25 + | **Basic** | ${{ tiers.founding.basic }}/mo | $54/yr | Text, blogs, newsletters, posts, RSS, memberships, analytics |
26 + | **Small Files** | ${{ tiers.founding.small_files }}/mo | $108/yr | Everything in Basic, plus audio hosting, podcast RSS, binary downloads, license keys, promo codes |
27 + | **Big Files** | ${{ tiers.founding.big_files }}/mo | $162/yr | Everything in Small Files, plus video uploads, in-browser video player, large binaries up to 20GB |
28 + | **Everything** | ${{ tiers.founding.everything }}/mo | $324/yr | All current and future features, including streaming infrastructure when it ships |
29 +
30 + Annual is 10% off the monthly total. Most of that is the Stripe per-transaction fee we don't pay when we charge once a year instead of twelve times. We pass it back rather than keep it.
27 31
28 32 See [tiers](../guide/tiers.md) for the full feature breakdown and storage limits.
29 33
30 34 ---
31 35
32 - ## Standard Rate
36 + ## Standard Rate (post-founder)
33 37
34 - The standard rate applies to new signups after the founding period closes. The values below are **provisional**: they'll be re-evaluated based on what we learn during alpha. They may stay where they are, drop, or rise — but they will not rise without the notice and grandfathering described in [Guarantees](./guarantees.md).
38 + The standard rate applies to new signups after the founder window closes. The values below are **provisional targets, not committed prices**. We deliberately put founder pricing in front of standard pricing because we want real signup data, real tier-mix data, and real support-load data to set the post-founder number rather than guessing. The standard rate may end up lower than the targets below, the same, or higher — but if it rises, the change comes with the notice and grandfathering described in [Guarantees](./guarantees.md), and our founders are below it either way.
35 39
36 - | Tier | Monthly |
37 - |---|---|
38 - | **Basic** | ${{ tiers.standard.basic }} |
39 - | **Small Files** | ${{ tiers.standard.small_files }} |
40 - | **Big Files** | ${{ tiers.standard.big_files }} |
41 - | **Everything** | ${{ tiers.standard.everything }} |
40 + | Tier | Monthly | Annual (10% off) |
41 + |---|---|---|
42 + | **Basic** | ${{ tiers.standard.basic }}/mo | $108/yr |
43 + | **Small Files** | ${{ tiers.standard.small_files }}/mo | $216/yr |
44 + | **Big Files** | ${{ tiers.standard.big_files }}/mo | $324/yr |
45 + | **Everything** | ${{ tiers.standard.everything }}/mo | $648/yr |
42 46
43 47 ---
44 48
45 49 ## Payment Processing
46 50
47 - Stripe processes all payments on the platform. Their fee — **2.9% + $0.30 per transaction** ([stripe.com/pricing](https://stripe.com/pricing)) — is the only deduction on fan transactions. MNW takes **0%** on top of that.
51 + Stripe processes all payments on the platform. Their fee — **{{ stripe.percent | percent }} + {{ stripe.fixed | money }} per transaction** ([stripe.com/pricing](https://stripe.com/pricing)) — is the only deduction on fan transactions. MNW takes **0%** on top of that.
48 52
49 53 The same fee applies once to your monthly tier subscription.
50 54
@@ -10,7 +10,7 @@ Stripe charges a per-transaction fee on every fan payment. The exact rate depend
10 10
11 11 | Region | Rate |
12 12 |--------|------|
13 - | United States | 2.9% + $0.30 |
13 + | United States | {{ stripe.percent | percent }} + {{ stripe.fixed | money }} |
14 14 | Canada | 2.9% + C$0.30 |
15 15 | United Kingdom | 1.5% + 20p (domestic), 3.25% + 20p (EU) |
16 16 | EU (most countries) | 1.5% + €0.25 (domestic), 3.25% + €0.25 (cross-border) |
@@ -21,7 +21,7 @@ These rates change. Check [stripe.com/pricing](https://stripe.com/pricing) for y
21 21
22 22 ### The Flat Fee Matters on Small Transactions
23 23
24 - The per-transaction flat fee ($0.30 in the US) has a bigger impact on small sales:
24 + The per-transaction flat fee ({{ stripe.fixed | money }} in the US) has a bigger impact on small sales:
25 25
26 26 | Sale price | Stripe fee (US) | Effective rate | You keep |
27 27 |---:|---:|---:|---:|
M wam/Cargo.lock +1 -1
M wam/Cargo.toml +1 -1