HALO Knowledge Docs
Generated knowledge spine · research/lanes/04-lovable-blocks.md

06 · Lovable ceiling + blocks

Where prompt-to-app stopped, and the reusable blocks hiding in the code.

Generated from research/lanes/04-lovable-blocks.md · regenerate with /opt/homebrew/opt/node@24/bin/node site/scripts/generate-docs.mjs

The work is complete — resending the finished deliverable in full.


1. Lovable residue audit

The precise Lovable/human split is not determinable from this repository. Git history begins at 6bf902e (2026-08-02, "Initial Halo CRM Convex app") with 631 files already tracked, including 24 convex/ modules and a complete convex/schema.ts. The Lovable phase, the Supabase era, and a partial Convex migration all happened before the first commit. No git-derived Lovable-vs-human percentage is honest, and I am not offering one. What would determine it: the Lovable project itself (lovable.dev/projects/1befee89-8644-4b7e-bbaf-107dea97b20b) with its own edit history, or the pre-import repository history if it survives elsewhere. Absent those, only artefact evidence inside the tree speaks.

OBSERVED artefact evidence — residue is real but thin. README.md is the untouched stock Lovable template, still directing readers to "open Lovable and click on Share -> Publish" — advice now false, since CLAUDE.md documents a Vercel static build plus a separate npx convex deploy. lovable-tagger@^1.1.7 sits in devDependencies (package.json:96), imported at vite.config.ts:4 and wired at vite.config.ts:36-38 (mode === 'development' && componentTagger()); package.json:8 keeps a build:dev script whose only purpose, per CLAUDE.md, is that it "keeps lovable-tagger". components.json is stock shadcn — style: "default", baseColor: "slate", cssVariables: true, standard aliases. tailwind.config.ts is the stock shadcn scaffold (full hsl(var(--*)) token mapping, accordion-down/accordion-up keyframes) with hand-appended brand and premium palettes (tailwind.config.ts:63-84) and seven extra keyframes — extended, never restructured. public/lovable-uploads/ holds 22 files and is still the live path for load-bearing brand assets: the invoice logo and signature (src/components/creator-invoicing/InvoicePdfGenerator.tsx:42-43), the remittance logo (src/components/referral-invoicing/RemittancePdf.ts:31), the payslip logo, and the og:image (index.html:17). Fourteen files reference that directory; renaming it buys nothing, so it outlived the tool by a month.

src/pages/Index.tsx is the stock Lovable redirect shell with a lastVisitedRoute localStorage restore grafted on. src/pages/NotFound.tsx is the stock 404 that has since accreted business logic — hardcoded route-migration hints for creator-analytics and creator-invoices (src/pages/NotFound.tsx:29-38).

Correcting a claim I made earlier in this analysis: the shadcn set is not uniformly stock. Spot-checked against canonical shadcn, separator.tsx, skeleton.tsx, and card.tsx are byte-for-byte upstream. But button.tsx is restyledrounded-[15px] replacing rounded-md, transition-all duration-300, a seventh premium variant, and every variant carrying transform hover:-translate-y-1 hover:opacity-90, with default remapped to bg-gradient-premium-yellow text-black. Grepping brand tokens across the directory, exactly 4 of 57 files are restyled (button.tsx, toggle.tsx, plus hand-added LetsPlayButton.tsx and premium-button.tsx). Of the 57, six are hand-added non-shadcn additions (back-button, checkbox-group, country-code-selector, location-picker, premium-{button,card,input}, LetsPlayButton); the remaining ~51 are the stock Radix dump, 19 carrying the leading blank line characteristic of Lovable file writes.

The finding that survives regardless of where the boundary sits: src/components/ui/ is 5,283 LOC across 57 files, and git diff --stat 6bf902e HEAD -- src/components/ui/ returns empty — not one byte changed across a month of documented development that rewrote half the app. Whoever generated that floor, it was correct enough never to need touching.

INFERRED: the current convex/ tree (11,274 LOC, 56 tables) is overwhelmingly post-Lovable, on internal evidence rather than dates — it carries dense first-person reasoning about production failures no generator would have encountered (convex/currency.ts:6-9 on VITE_-prefixed secrets leaking into the public bundle; convex/driveStructure.ts:23 on the exact spelling of USED : POSTED).

2. The Lovable ceiling

Each item has a structural reason a prompt-to-app tool cannot reach it — not "would write it badly," but "has nowhere for this to live."

A scheduler. convex/crons.ts:16-21 registers crons.interval("reconcile shared drive", { minutes: SCAN_INTERVAL_MINUTES }, internal.driveScan.scanAll, {}). The comment gives the cause: creators upload straight into Drive via a share link, "so nothing tells the CRM a file arrived — polling is the only way to notice." A generated SPA has no server-side clock.

OAuth under a restricted scope. convex/googleDrive.ts speaks Drive v3 over REST with a stored refresh token. CLAUDE.md records why the account is Workspace with the consent screen Internal: "External would require CASA verification for the restricted auth/drive scope." No code generator passes a Google security assessment. Credentials live in the Convex deployment environment precisely because of the VITE_ leak rule.

A taxonomy whose source of truth is outside the code. convex/driveStructure.ts:1-12: the hand-built template - Media and template - streaming folders in the real Drive are authoritative — "if the team renames a folder there, change it here to match rather than the other way round, or provisioning will start creating duplicates." Two encoded facts are pure field knowledge: the used-content folder is USED : POSTED with spaces around the colon (driveStructure.ts:23 — "a mismatch here silently creates a second folder next to the real one and files would be counted as unused forever"), and category folders carry a positional prefix that differs between templates, so SCRIPTS is 4) under media and 3) under streaming.

Idempotent, failure-tolerant provisioning. convex/googleDrive.ts:362,491 route through findOrCreateFolder; per CLAUDE.md, "re-running finishes a partial job rather than duplicating." Deliberately fire-and-forget: creators:add and onboardingSubmissions:accept both ctx.scheduler.runAfter(0, internal.googleDrive.provisionOneCreator, …) — "scheduled, never awaited, so a Drive outage can't fail an onboarding" — with failure surfacing as connected: false plus a retryProvisioning escape hatch, and seed/demo/reset paths deliberately not provisioning "otherwise every reset would spam the real Drive." Four distinct failure-mode decisions in one subsystem.

An index with a deletion invariant. convex/driveScan.ts:100-115: build seen from the scan result, then for every indexed row absent from it — "either moved to USED : POSTED or deleted from Drive" — call ctx.storage.delete(row.thumbnailId) before ctx.db.delete(row._id). Invert that order and you orphan blobs permanently. The read side (convex/driveIndex.ts:14-18) makes the complementary call: alert state is computed at read time, "so changing the 48-hour or 7-day thresholds is a constant edit in driveStructure.ts rather than a migration over every row."

A quota-aware rate cache with deliberate staleness. convex/currency.ts:38-42: one row per currency per day, because the free tier "429s past it, so fetching per invoice render would exhaust the allowance in a single browsing session"; latestRates falls back to the most recent stored rate — "a slightly stale rate is far better than a blocked invoice."

Frozen-rate financial correctness. convex/schema.ts:441-447: payoutCurrency/conversionRate are stamped onto an invoice when net sales are first entered and frozen, because a live lookup "would mean a sent invoice silently changing value as the market moves, so what the creator was told to expect would stop matching the books." The consequence is worked out at convex/referralInvoicing.ts:325-334: paying AUD 1,000 against a USD 2,000 invoice "has to reduce the balance by its USD worth at the invoice's fixed rate — not by 1,000 — or the remaining balance is simply wrong."

A temporal-ownership model. convex/schema.ts:196-201 on referralLinks: "A single pointer on the creator cannot answer 'what were we owed in September' once someone is unlinked." Commission is computed from links overlapping the queried month (referralInvoicing.ts:16-33), "so unlinking stops future earnings without erasing past ones." A slowly-changing-dimension design arrived at from a business failure.

A derived-vs-stored reconciliation ledger. convex/paymentLog.ts:80-228 merges four sources into one view where some rows are derived (keyed by sourceKey) and others stored, confirmations attach to either, and soft-deletes stay "findable under the Deleted status so they can be undone" (paymentLog.ts:100).

3. Block extraction

Centrepiece: invoice-lifecycle — two implementations, one concept

Creator and referral invoicing total 8,385 LOC across both verticals: src/pages/CreatorInvoicing.tsx (349) + src/components/creator-invoicing/ (4,581) + convex/invoicing.ts (705) = 5,635; src/pages/ReferralInvoicing.tsx (763) + src/components/referral-invoicing/ (1,408) + convex/referralInvoicing.ts (579) = 2,750.

The repo asserts they are one idea, three times. convex/referralInvoicing.ts:5-9 opens: "the money-out counterpart to creator invoicing." convex/schema.ts:181 annotates sendStatus as "mirrors the creator invoicing workflow." src/components/referral-invoicing/RemittancePdf.ts:5-14 says the document is "deliberately identical in layout to the creator invoice… two house styles would read as two different businesses."

The shared invariant core (what the block owns). Both tables (schema.ts:420-471 vs schema.ts:147-190) carry the same settlement spine: an amount, paid as an authoritative running total, paidAt, receiptStorageId/receiptFileName/receiptContentType, payoutCurrency/conversionRate/rateFetchedAt frozen at raise time, sendStatus/sendStatusAt, invoiceNumber. Both settle to a 0.01 epsilon (invoicing.ts:10 named SETTLED_EPSILON; referralInvoicing.ts:344 inline) so float noise never reads as outstanding. Both accumulate part-payments against a running total rather than replacing it. Both freeze the FX rate at raise time for the identical documented reason. Both track sending separately from payment — invoiceStatus.ts:88-91: "an invoice can be sent and unpaid, or paid without ever being formally 'sent'." Both derive status rather than storing it. Both are compound-indexed on (subject, period): by_creator_week vs by_partner_month.

The delta — the contract's parameterisation surface:

AxisCreatorReferralContract parameter
Directionmoney inmoney out`direction: 'receivable' \'payable' — drives the PDF bank block's owner (RemittancePdf.ts:12-14`)
Period4 cycles/month, CYCLE_START_DAYS = [1,8,16,24] (invoicing.ts:15-18)calendar month, monthOf = d => d.slice(0,7) (referralInvoicing.ts:12)period: PeriodStrategy
AmountnetSales * (percentage/100) (invoicing.ts:32-35)commission over links overlapping the month (referralInvoicing.ts:16-33)computeAmount(subject, period)
Payment currencyrecorded as enterednormalised to USD at the frozen rate (referralInvoicing.ts:325-337)`settlementCurrency: 'native' \'base'`
sendStatus4 states: `not_sent\sent\no_response\delayed (invoiceStatus.ts:97-108`)3 states: `not_sent\sent\approved (schema.ts:181`)sendStates: State[]
Partial-payment effectresets to not_sent (invoicing.ts:445) — an unbilled remainder must not sit as "sent"advances to approved on settle (referralInvoicing.ts:347)onPartialPayment / onSettled hooks
Audit trailpayments[], each with its own receipt (schema.ts:451-463)single receipt + attachments[], "a statement per creator" (schema.ts:169-178)`evidence: 'per-payment' \'per-document'`
ReversalamendPayment recalculates paid from the amended array "so the total can never drift" (invoicing.ts:453-464)unmarkPaid zeroes and reverts to sent (referralInvoicing.ts:424-443)`reversal: 'amend' \'reset'`
Subject visibilityactive, OR terminated <14d, OR terminated with unpaid balance (invoicing.ts:99-108)all partnerssubjectFilter

The mutation surfaces correspond nearly one-to-one: markInvoicePaid/markPaid, setPaidTotal/setPaidAmount, setPayoutCurrency/setPartnerCurrency, generateReceiptUploadUrl (identical name in both), amendPayment/unmarkPaid. Sixteen exports vs eleven — the creator side's extra five are period-cycle machinery (getOrCreateEntry, updatePercentageForward, migrateToPeriodCycles) the monthly model doesn't need, which is the period parameter showing up as code.

Honest assessment. This is the best block candidate in the repo and the strongest counter-evidence, because a skilled developer with all the code in front of him and explicit awareness of the duplication — three source comments say "same as the other one" — still chose to copy rather than parameterise. Either the local economics are right (parameterising costs more than duplicating at n=2) or the ten variation points above exceed what a contract can carry without becoming a configuration language. Convert exactly this pair in the hand-conversion pilot and check whether the resulting contract is genuinely simpler than the two files it replaces. If it isn't, the framework has a real problem at the feature rung — better learned on a worked example than on the corpus.

Other candidates

B1 — entity-list-filter-tag (feature). Four instances. src/pages/Creators.tsx:15-27 holds five parallel filter states fed to filterCreators({...}); src/pages/Team.tsx:18-26 is the same shape refactored into one object (TeamFilters) — flat-vs-grouped state is the variation, and the repo contains both spellings. src/pages/Referrals.tsx:51-59 adds tab + sort; src/pages/CustomsTracker.tsx:18,30 is the single-facet degenerate case. Supporting components are near-duplicated with a prefix: src/components/creators/list/{FilterSection,ActiveFilters,EmptyState,CreatorsHeader,CreatorsList}.tsxsrc/components/team/{TeamActiveFilters,TeamEmptyState,TeamHeader,TeamMembersList}.tsx. The shared leaf src/components/TagFilter.tsx:9 hardcodes type: "gender" | "team" | "class" | "review" driving a colour switch (TagFilter.tsx:24-33) — an in-source admission that a parameter was wanted and an enum was used instead. Variation: facet names/count, colourway, tab layer, row-level permission scoping (Creators.tsx:64 narrows to creator.id === creatorId for creator-role viewers).

B2 — branded-document-generator (component). InvoicePdfGenerator.tsx and RemittancePdf.ts share a byte-identical BRAND block of six RGB triples (InvoicePdfGenerator.tsx:14-21RemittancePdf.ts:17-24), identical COMPANY, same LOGO_PATH, same jsPDF layout. Third instance: src/components/payroll/PayslipGenerator.tsx. Variation: bank-block owner (the direction axis again), terms text, signature presence, line-item schema.

B3 — approval-queue (feature). convex/onboardingSubmissions.ts:68,101,132 is the terminal three-state queue (by_status index → declined/accepted, accept firing creator creation and Drive provisioning at line 144). convex/paymentLog.ts:368-409 is the reversible tickable variant — confirmed: v.boolean() toggles both ways over rows that may be derived or stored. Variation: reversible vs terminal, approval side effects, derived-vs-stored row identity, soft-delete/undo.

B4 — multi-currency-money (schema-pattern + integration). convex/currency.ts (SUPPORTED = ["USD","AUD","GBP","EUR"]), currencyRates indexed by_day, daily cache, stale fallback. Consumed by both invoicing verticals plus CurrencyPicker.tsx, ConversionRateModal.tsx, AdjustPaidPopover.tsx. Variation: currency set, provider, cache granularity, and critically rate-pinning policy — floating for previews, frozen for issued documents.

B5 — credential-vault (feature). convex/socialMediaLogins.ts + convex/secureArea.ts + src/pages/SecureLogins.tsx (220) + src/components/secure-logins/ (1,312) + src/components/creators/secure-logins/. Good generic mechanic at socialMediaLogins.ts:30-44: synthesises placeholder rows for a predefinedPlatforms list so prescribed slots always render alongside freeform extras. Blocking defect (OBSERVED): socialMediaLogins.ts:16 returns password: login.password ?? "" — plaintext at rest and over the wire. Extract only with encryption; harvesting as-is propagates the flaw into every assembled app.

B6 — file-grid-with-thumbnails (component). src/components/shared-drive/FileGrid.tsx:24-38 (DriveFile interface) + formatters.ts (fileKind, formatAge, formatBytes, formatDuration) + CategorySidebar.tsx + StatusPill.tsx. Earlier instances: src/components/storage/, src/components/messages/FileUploader.tsx, messages/FileSharing.tsx, src/components/ImageUploader.tsx. Variation: thumbnail source (cached blob vs remote), staleness badge, action set, sidebar presence.

B7 — entity-detail-tabs (feature). src/pages/CreatorProfile.tsx + src/components/creators/profile/src/pages/TeamMemberProfile.tsx (284) + src/components/profile/ + src/components/team/{ProfileHeader,ProfileImageSection,ProfileImageUploader,ProfileFormContainer}.tsx; edit variants pair as src/pages/TeamMemberEdit.tsxsrc/components/creators/edit/. Variation: tab set, per-role field editability, avatar backend.

B8 — multi-step-wizard (component). src/components/onboarding/multi-step/MultiStepForm.tsx:27-40currentStep over Tabs, react-hook-form + zodResolver(creatorOnboardingSchema), four step components, token validation, submitted state. Second: src/pages/CreatorOnboarding/CreatorInviteOnboarding.tsx. Third (flattened): src/pages/TeamMemberOnboarding.tsx + src/components/team/OnboardingTeamMemberForm.tsx. Variation: step list and per-step schema slice, invite-token gating, draft-save, validation timing — this instance uses mode: "onSubmit", the weakest option, which a contract should expose rather than bake in.

B9 — scheduled-reconcile-job (integration). convex/crons.ts + driveScan.ts + driveIndex.ts. Generic shape: poll an external system on an interval, diff against a local index, upsert present / delete absent with cascading blob cleanup, record per-subject scan failure so the UI can mark numbers stale (driveScan.ts:180), compute alert state at read time. Only one instance, so it fails the recurrence bar — but it is the highest-value block here, because it is exactly the capability class §2 shows Lovable structurally cannot reach.

4. Anti-blocks

The Drive folder taxonomy (convex/driveStructure.ts). Its content mirrors physical folders someone made by hand, down to the spacing in USED : POSTED and per-template numeric prefixes. The mechanism — external-source-of-truth taxonomy imported by both server and UI so they cannot drift — is blockifiable. The eight category names are one company's Drive, and freezing them into a registry would be a lie.

The gamification system — 7,599 LOC (src/pages/TasksRewards.tsx 187 + src/components/gamification/ 5,886 + convex/gamification.ts 1,526) across 17 dedicated tables (schema.ts:601-782): two currencies (bananas, XP) as append-only transaction ledgers, quests scoped to both days and shifts, evidence upload on completion, a shop with purchases. Every parameter is a business decision about one team's shift structure. A contract for it would have as many knobs as the code has lines — the definition of a bad block.

The payment-log derived/stored merge (convex/paymentLog.ts:80-228). The purpose is reconciling these four specific sources under one confirmation ledger; sourceKey construction is domain-shaped. The soft-delete-and-undo mechanic folds into B3; the merge does not generalise.

The auth stack. CLAUDE.md names it exactly: "Auth is bespoke and misleadingly named." SupabaseAuthContext does not authenticate against Supabase — signInWithEmail calls Convex haloAuth.login against a salted SHA-256 hash in authUsers, session in localStorage under haloLocalAuthSession. AuthContext is a re-export. Auth0Provider wraps the app in src/main.tsx with hardcoded fallback credentials and nothing uses useAuth0 — vestigial. updateCredentials and createTeamMember are stubbed to a "pending migration" toast. This is migration debt. It should be replaced by the auth_interface slot the schema already defines (auth.getUser()), never harvested.

The dual-backend split. ~45 files still import @/integrations/supabase/client, which degrades to an invalid URL under SUPABASE_DISABLED so features fail per-call rather than at boot. A correct migration tactic; a state no assembled app should be born into.

The RouteErrorBoundaryuseRouteMemory interaction. The boundary belongs in the scaffold. The specific fix — clearing lastVisitedRoute from the error UI to break the crash-re-entry loop CLAUDE.md documents — is knowledge, not a parameter.

5. The verdict

The repo supports assembly-over-generation, but relocates where the value sits — away from the part a component registry covers.

The strongest support is the frozen floor: src/components/ui/ is 5,283 LOC across 57 files and changed by zero bytes through a month of development that rewrote half the app. Only 4 of 57 carry brand restyling, and tailwind.config.ts was extended, never restructured — the styling: tailwind+css-vars bet in block-contract.schema.json is directly corroborated by an experiment nobody designed.

The number, derived from what the 39 pages do today. Classifying each page together with its component directory and Convex module (LOC verified this session):

  • Genuinely block-assemblable end-to-end (~9 pages): Creators (2,266 LOC full vertical), Team (2,828), CustomsTracker (2,525), Contracts (1,287), CreatorsData (3,003), UserManagement, AccessControlPanel, Messages, Dashboard. All CRUD-over-tables — B1 + B7 + a form block would produce them. Note every page here is under 200 LOC because the work sits in the component directory; page LOC alone badly understates feature cost.
  • Assemblable with real parameterisation (~8): the auth quintet (Login, Register, ForgotPassword, ResetPassword, Invitation), CreatorProfile, TeamMemberEdit, TeamMemberProfile, plus NotFound/Index/ConvexStatus as scaffold furniture.
  • Needed a human (~17), holding the LOC: TasksRewards (7,599), CreatorInvoicing (5,635), Payroll (4,672), CreatorOnboarding (4,374), PaymentLog (3,442), Referrals (2,761), ReferralInvoicing (2,750), SecureLogins (1,673), AIVoice (1,539), SharedDrive (1,399), plus ContractSigning, CreatorUpload, AccountSettings, CreatorAnalytics and others.

By page count, ~45% assemblable. By LOC, ~25%. Weighted across the whole 79,980-LOC codebase (68,706 src/ + 11,274 convex/), the assemblable share — src/components/ui at 5,283 plus the CRUD verticals and their component directories — comes to roughly 20,000–24,000 LOC. Call it 28%; I would defend a 25–30% band.

Three findings that cut against the thesis.

First, and most useful: the recurrences in §3 are not clean, and the developer chose copying four separate times with full knowledge. TagFilter.tsx:9 hardcodes an enum where a parameter belongs. src/components/team/ is src/components/creators/list/ with a prefix on every file. RemittancePdf.ts copies InvoicePdfGenerator.tsx and says so in its header comment. referralInvoicing.ts:5 calls itself the counterpart of invoicing.ts and duplicates 579 lines anyway. Either duplication is locally correct at n=2 — in which case a registry only pays across many apps, and the corpus must be large before it beats generation — or the variation points are richer than they look and contracts will over-parameterise into unusability. I cannot separate these two readings from one repo, and the pilot must be designed to.

Second, the value is in the backend. convex/ is 11,274 LOC and 56 tables, and every item in §2 is drawn from it. The ~72% a block assembler could not produce is not polish — it is the product. But its shape is encouraging: scheduler, OAuth-behind-an-interface, external-source taxonomy, idempotent provisioning, index-with-deletion-invariant, quota-aware cache, frozen-rate money, temporal ownership — eight reusable mechanisms, which is why B9 belongs in the registry despite appearing once.

Third, a scoping correction that strengthens the Cena framing in BLOCK-FRAMEWORK.md rather than undermining it. The scoped case deletes the generality problems. What this repo adds is a sharper target: the registry wins if and only if it carries backend mechanism, not just UI. A corpus of 3,507 shadcn-shaped components is a better src/components/ui/ — and src/components/ui/ is the part that already worked, untouched, for a month. The marginal value of kind: component blocks is the lowest in the schema; kind: integration and kind: feature blocks carrying scheduler/reconcile/money-lifecycle mechanism are where the moat is. The gap is convex/.

Security note outside the brief: convex/socialMediaLogins.ts:16 returns stored passwords in plaintext. If B5 is ever harvested it must be extracted with encryption-at-rest, not copied.

Method limitations, stated plainly. The Lovable/human split is not derivable from this repo and I have not estimated it. The §5 fractions come from classifying what each page does today, not from git archaeology. Feature-vertical LOC counts (page + component dir + Convex module) omit shared hooks/, utils/, and types/, so they are lower bounds on true feature cost.

Canonical source remains research/lanes/04-lovable-blocks.md. This HTML is a generated projection; edit the source, then run generate-docs.mjs.