Skip to content
Merged

Dev #24

Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,28 @@ jobs:
- name: Build
run: pnpm build

- name: Start Database Services
run: docker compose up -d

- name: Migrate Database
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/fairshare?schema=public
JWT_SECRET: supersecret
STRIPE_SECRET_KEY: test_key
run: pnpm --filter backend prisma:migrate

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add health check before running migrations.

There's no wait between starting Docker Compose services and running migrations. The database may not be ready to accept connections immediately after docker compose up -d returns, which could cause intermittent CI failures.

🛠️ Proposed fix: Add a wait-for-healthy step
      - name: Start Database Services
        run: docker compose up -d

+     - name: Wait for Database
+       run: |
+         timeout 60 bash -c 'until docker compose exec -T postgres pg_isready; do sleep 1; done'
+
      - name: Migrate Database

Alternatively, if your docker-compose.yml has a healthcheck defined, you can use:

run: docker compose up -d --wait
🧰 Tools
🪛 Checkov (3.2.519)

[medium] 67-68: Basic Auth Credentials

(CKV_SECRET_4)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/ci.yml around lines 62 - 70, The CI runs migrations
immediately after the "Start Database Services" step so the Postgres container
may not be ready; add a health/wait step between the "Start Database Services"
and "Migrate Database" steps to ensure readiness. Either change the start
command to use docker compose up -d --wait (if healthchecks exist) or add a
separate step that polls the DATABASE_URL (e.g., using pg_isready, wait-for-it,
or a small loop against localhost:5432) and only proceeds when the DB is
accepting connections before running the pnpm --filter backend prisma:migrate
command.


- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps

- name: Run E2E Tests
env:
CI: true
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/fairshare?schema=public
JWT_SECRET: supersecret
STRIPE_SECRET_KEY: test_key
NEXT_PUBLIC_APP_URL: http://localhost:3000
run: pnpm run e2e

- name: Upload coverage
if: always()
uses: actions/upload-artifact@v4
Expand Down
1 change: 1 addition & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ model Group {
shareToken String? @unique
shareEnabled Boolean @default(false)
createdAt DateTime @default(now())
deletedAt DateTime?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "1) Group queries:"
rg -n --type=ts -C2 'prisma\.group\.(findUnique|findFirst|findMany)\(' apps/backend/src

echo
echo "2) Soft-delete filters present:"
rg -n --type=ts -C2 'deletedAt\s*:\s*null' apps/backend/src/groups apps/backend/src/balances

Repository: Arun-kushwaha007/FairShare

Length of output: 3109


🏁 Script executed:

#!/bin/bash

echo "=== Checking groups.service.ts around line 114 ==="
sed -n '110,130p' apps/backend/src/groups/groups.service.ts

echo
echo "=== Checking groups.service.ts around line 380 ==="
sed -n '375,390p' apps/backend/src/groups/groups.service.ts

echo
echo "=== Checking groups.service.ts around line 418 ==="
sed -n '415,435p' apps/backend/src/groups/groups.service.ts

echo
echo "=== Checking balances.service.ts around line 50 ==="
sed -n '45,65p' apps/backend/src/balances/balances.service.ts

Repository: Arun-kushwaha007/FairShare

Length of output: 2835


Enforce soft-delete filters on all user-facing group read operations.

Current group queries bypass soft-delete checks in several critical paths:

  • getGroupById() (line 114): No deletedAt filter; returns deleted groups
  • toggleShare() (line 380): No deletedAt filter on lookup; allows modifications to deleted groups
  • getGroupByShareToken() (line 418): No deletedAt filter; public share links to deleted groups remain accessible
  • Balance export in balances.service.ts (line 50): No deletedAt filter; deleted group data can be exported

All group reads must include where: { ..., deletedAt: null } or equivalent guard to prevent access to deleted groups.

🧰 Tools
🪛 GitHub Actions: CI

[error] Command failed with exit code 1: tsc --noEmit (backend@1.0.0 lint).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/prisma/schema.prisma` at line 70, Add a soft-delete guard to
every group read so deleted groups are never returned or modified: update the
group lookup calls in getGroupById, toggleShare, getGroupByShareToken and the
balance export logic (balances.service.ts) to include a Prisma where clause
filtering on deletedAt: null (or the equivalent query guard used across the
codebase) and ensure any single-group fetches use this same condition before
returning or performing mutations; if a lookup already combines other where
filters, merge deletedAt: null into that object so all reads and any share/token
lookups reject soft-deleted groups.

creator User @relation("group_creator", fields: [createdBy], references: [id])
members GroupMember[]
expenses Expense[]
Expand Down
7 changes: 6 additions & 1 deletion apps/backend/src/groups/groups.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
Expand Down Expand Up @@ -82,4 +82,9 @@ export class GroupsController {
) {
return this.groupsService.toggleShare(id, user.sub, enabled);
}

@Delete(':id')
remove(@Param('id') id: string, @CurrentUser() user: JwtPayload) {
return this.groupsService.delete(id, user.sub);
}
}
36 changes: 35 additions & 1 deletion apps/backend/src/groups/groups.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export class GroupsService {
userId,
},
},
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
});
Expand Down Expand Up @@ -235,7 +236,10 @@ export class GroupsService {

async getUserSummary(userId: string): Promise<{ totalBalanceCents: string }> {
const balances = await this.prisma.balance.findMany({
where: { userId },
where: {
userId,
group: { deletedAt: null }
},
select: { amountCents: true },
});

Expand All @@ -254,6 +258,7 @@ export class GroupsService {
userId,
},
},
deletedAt: null,
},
include: {
_count: {
Expand Down Expand Up @@ -670,6 +675,35 @@ export class GroupsService {
};
}

async delete(groupId: string, actorUserId: string): Promise<{ success: true }> {
const membership = await this.prisma.groupMember.findUnique({
where: {
groupId_userId: {
groupId,
userId: actorUserId,
},
},
});

if (!membership || membership.role !== 'OWNER') {
throw new ForbiddenException('Only the group owner can delete the group');
}

await this.prisma.group.update({
where: { id: groupId },
data: {
deletedAt: new Date(),
shareEnabled: false,
shareToken: null
},
});
Comment on lines +678 to +712

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Soft-deleted groups are still accessible through existing member endpoints.

This only hides deleted groups from list/dashboard-style queries. Because assertMembership() and the other groupId lookups do not check deletedAt, a member with an old URL can still fetch or mutate a deleted group after this method runs.

Suggested direction
-  private async assertMembership(groupId: string, userId: string): Promise<void> {
-    const membership = await this.prisma.groupMember.findUnique({
-      where: {
-        groupId_userId: {
-          groupId,
-          userId,
-        },
-      },
-    });
+  private async assertMembership(groupId: string, userId: string): Promise<void> {
+    const membership = await this.prisma.groupMember.findFirst({
+      where: {
+        groupId,
+        userId,
+        group: {
+          deletedAt: null,
+        },
+      },
+    });
 
     if (!membership) {
       throw new ForbiddenException('Actor is not a group member');
     }
   }

The same rule needs to be applied to direct group reads/updates in this file, otherwise deleted groups remain operational through stale clients.

🧰 Tools
🪛 Biome (2.4.10)

[error] 677-677: Expected a semicolon or an implicit semicolon after a statement, but found none

(parse)


[error] 677-677: expected ) but instead found :

(parse)


[error] 677-677: the target for a delete operator cannot be a single identifier

(parse)


[error] 677-677: Expected a semicolon or an implicit semicolon after a statement, but found none

(parse)


[error] 677-677: Expected a statement but instead found '>'.

(parse)

🪛 GitHub Actions: CI

[error] Command failed with exit code 1: tsc --noEmit (backend@1.0.0 lint).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/groups/groups.service.ts` around lines 677 - 698, The
soft-delete only sets deletedAt but other methods (e.g. assertMembership(), any
findUnique/findFirst/getById/group lookup helpers) still return groups with
deletedAt set; update those lookup functions and any direct group reads/updates
in this file to filter out soft-deleted groups by adding a condition like where:
{ id: groupId, deletedAt: null } (or include deletedAt: null in composite where
clauses used by assertMembership()), and make them throw NotFound/Forbidden when
the group is not found; ensure all usages of assertMembership(),
findUnique/findFirst on Group, and any group update/read helpers reference this
deletedAt check so deleted groups are inaccessible via member endpoints.


await this.redis.invalidateGroupCache(groupId);
await this.redis.invalidateUserDashboardCache(actorUserId);
Comment on lines +714 to +715

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Other group members' dashboard caches are not invalidated.

Only the actor's dashboard cache is invalidated. Other group members will continue seeing the deleted group in their cached dashboards until the cache TTL (120s) expires. Consider invalidating dashboard caches for all group members.

🛠️ Suggested approach
+    // Fetch all member IDs before invalidation
+    const members = await this.prisma.groupMember.findMany({
+      where: { groupId },
+      select: { userId: true },
+    });

     await this.redis.invalidateGroupCache(groupId);
-    await this.redis.invalidateUserDashboardCache(actorUserId);
+    // Invalidate dashboard cache for all members
+    await Promise.all(
+      members.map((m) => this.redis.invalidateUserDashboardCache(m.userId))
+    );

     return { success: true };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await this.redis.invalidateGroupCache(groupId);
await this.redis.invalidateUserDashboardCache(actorUserId);
// Fetch all member IDs before invalidation
const members = await this.prisma.groupMember.findMany({
where: { groupId },
select: { userId: true },
});
await this.redis.invalidateGroupCache(groupId);
// Invalidate dashboard cache for all members
await Promise.all(
members.map((m) => this.redis.invalidateUserDashboardCache(m.userId))
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/groups/groups.service.ts` around lines 701 - 702, The code
currently invalidates only the actor's dashboard cache after group changes;
update the logic in GroupsService (around the calls to invalidateGroupCache and
invalidateUserDashboardCache) to also invalidate dashboard caches for all other
group members: fetch the member IDs for the affected group (use the existing
groupId/group membership retrieval method in this service), then call
this.redis.invalidateUserDashboardCache(memberId) for each member (exclude or
include actorUserId as desired), batching or using Redis pipeline if available
to avoid N+1 latency; keep the existing await
this.redis.invalidateGroupCache(groupId) call and ensure errors are
handled/logged.


return { success: true };
}

async resolvePendingInvites(userId: string, email: string): Promise<void> {
const invites = await this.prisma.groupInvite.findMany({
where: { email: email.toLowerCase() },
Expand Down
8 changes: 8 additions & 0 deletions apps/backend/src/redis/redis.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ export class RedisService {
}
}

async invalidateUserDashboardCache(userId: string): Promise<void> {
try {
await this.redis.del(`user:${userId}:dashboard`);
} catch (error) {
this.logger.warn(`Redis invalidate skipped: ${error instanceof Error ? error.message : 'unknown error'}`);
}
}

private async safeGet(key: string): Promise<string | null> {
try {
return await this.redis.get(key);
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/dashboard/groups/[groupId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export default async function GroupDetailPage({ params }: GroupDetailPageProps)
<Suspense fallback={<div className="h-40 rounded-3xl border border-[var(--fs-border)] bg-[var(--fs-card)] animate-pulse" />}>
<GroupActions
groupId={groupId}
groupName={group.name}
currency={group.currency}
members={members}
shareEnabled={group.shareEnabled}
Expand Down
49 changes: 26 additions & 23 deletions apps/web/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,16 @@ export default async function DashboardPage() {
const totalBalanceCents = dashboard.totalBalanceCents;
const isPositive = Number(totalBalanceCents) >= 0;

const primaryCurrency = groups[0]?.currency ?? 'USD';
const totalBalanceLabel = groups.length > 1 ? 'Multi-Crew Balance' : 'Total Balance';

return (
<DashboardLayout>
<div className="space-y-8">
<div className="grid grid-cols-2 gap-3 sm:gap-4 md:grid-cols-4">
<SummaryCard
title="Total Balance"
value={formatMoney(totalBalanceCents, 'USD')}
title={totalBalanceLabel}
value={formatMoney(totalBalanceCents, primaryCurrency)}
Comment on lines +62 to +71

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't render a mixed-currency sum as one currency.

dashboard.totalBalanceCents is aggregated across all groups, but this now formats it using groups[0]?.currency. If a user has both USD and EUR groups, the card will show a mixed total as if it were entirely USD/EUR.

Suggested guard
   const primaryCurrency = groups[0]?.currency ?? 'USD';
+  const hasSingleCurrency = new Set(groups.map((group) => group.currency)).size <= 1;
   const totalBalanceLabel = groups.length > 1 ? 'Multi-Crew Balance' : 'Total Balance';
@@
           <SummaryCard
             title={totalBalanceLabel}
-            value={formatMoney(totalBalanceCents, primaryCurrency)}
+            value={hasSingleCurrency ? formatMoney(totalBalanceCents, primaryCurrency) : '—'}
             icon="dollar"
             change="Live"
             trend={isPositive ? 'up' : 'down'}
-            hint={isPositive ? 'Surplus Protocol' : 'Deficit Detected'}
+            hint={
+              hasSingleCurrency
+                ? isPositive
+                  ? 'Surplus Protocol'
+                  : 'Deficit Detected'
+                : 'Multiple currencies'
+            }
           />

Longer term, this probably wants a per-currency breakdown from the backend instead of a single aggregate.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/app/dashboard/page.tsx` around lines 62 - 71, The totalBalanceCents
is being formatted with primaryCurrency (groups[0]?.currency) which incorrectly
presents a mixed-currency aggregate as a single currency; compute the set of
distinct currencies from groups (e.g., map groups -> currency and dedupe) and if
more than one currency exists, do not call formatMoney(totalBalanceCents,
primaryCurrency); instead set a clear fallback value (e.g., "Multiple
currencies" or render a per-currency breakdown) and pass that to
SummaryCard.value; when all groups share the same currency continue using
primaryCurrency with formatMoney. Ensure you update the block around
primaryCurrency, totalBalanceCents, SummaryCard and any display logic to use an
isMixedCurrencies guard (distinct currencies check) before formatting.

icon="dollar"
change="Live"
trend={isPositive ? 'up' : 'down'}
Expand Down Expand Up @@ -98,13 +101,13 @@ export default async function DashboardPage() {
</div>

{attentionItems.length > 0 ? (
<GlassCard className="p-5 sm:p-6 border-white/5 bg-white/[0.01]">
<GlassCard className="p-5 sm:p-6 border-[var(--fs-border)] bg-[var(--fs-surface)] shadow-[var(--fs-shadow-soft)]">
<div className="flex items-start justify-between gap-4 mb-5">
<div>
<p className="text-[10px] font-bold tracking-widest text-zinc-500 uppercase">
<p className="text-[10px] font-bold tracking-widest text-[var(--fs-text-secondary)] uppercase">
Needs Attention
</p>
<h2 className="mt-1 text-xl font-black italic tracking-tight text-white uppercase">
<h2 className="mt-1 text-xl font-black italic tracking-tight text-[var(--fs-text-primary)] uppercase">
Resolve the next blockers
</h2>
</div>
Expand All @@ -116,20 +119,20 @@ export default async function DashboardPage() {
{attentionItems.map((item) => (
<div
key={item.groupId}
className="rounded-2xl border border-white/5 bg-white/5 p-4"
className="rounded-2xl border border-[var(--fs-border)] bg-[var(--fs-primary)]/5 p-4"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-black tracking-tight text-white uppercase">
<p className="text-sm font-black tracking-tight text-[var(--fs-text-primary)] uppercase">
{item.groupName}
</p>
<p className="mt-1 text-[11px] font-bold uppercase tracking-[0.16em] text-zinc-500">
<p className="mt-1 text-[11px] font-bold uppercase tracking-[0.16em] text-[var(--fs-text-secondary)]">
{item.currency} · {item.memberCount} members
</p>
</div>
<Link
href={`/dashboard/groups/${item.groupId}`}
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-[10px] font-black uppercase tracking-[0.14em] text-white hover:bg-white/10"
className="rounded-xl border border-[var(--fs-border)] bg-[var(--fs-primary)]/10 px-3 py-2 text-[10px] font-black uppercase tracking-[0.14em] text-[var(--fs-text-primary)] hover:bg-[var(--fs-primary)]/20"
>
Open
</Link>
Expand All @@ -153,7 +156,7 @@ export default async function DashboardPage() {
{item.dueRecurringCount} due recurring
</Link>
) : null}
<span className="inline-flex items-center gap-2 rounded-full bg-white/5 px-3 py-1.5 text-[11px] font-bold uppercase tracking-[0.12em] text-zinc-300">
<span className="inline-flex items-center gap-2 rounded-full bg-[var(--fs-primary)]/5 px-3 py-1.5 text-[11px] font-bold uppercase tracking-[0.12em] text-[var(--fs-text-secondary)]">
{formatMoney(item.netBalanceCents, item.currency)}
</span>
</div>
Expand All @@ -175,13 +178,13 @@ export default async function DashboardPage() {

<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<GlassCard className="p-5 sm:p-8 border-white/5 bg-white/[0.01]">
<GlassCard className="p-5 sm:p-8 border-[var(--fs-border)] bg-[var(--fs-surface)] shadow-[var(--fs-shadow-soft)]">
<div className="flex items-center justify-between mb-8">
<div>
<h2 className="text-sm font-black italic tracking-tight text-white uppercase">
<h2 className="text-sm font-black italic tracking-tight text-[var(--fs-text-primary)] uppercase">
Crews & Nodes
</h2>
<p className="text-[10px] font-bold tracking-widest text-zinc-600 uppercase mt-0.5">
<p className="text-[10px] font-bold tracking-widest text-[var(--fs-text-secondary)] uppercase mt-0.5">
Distributed spending groups
</p>
</div>
Expand All @@ -198,18 +201,18 @@ export default async function DashboardPage() {
<Link
href={`/dashboard/groups/${group.id}`}
key={group.id}
className="flex items-center justify-between p-4 rounded-xl border border-white/5 bg-white/5 hover:bg-white/10 transition-all cursor-pointer group"
className="flex items-center justify-between p-4 rounded-xl border border-[var(--fs-border)] bg-[var(--fs-primary)]/5 hover:bg-[var(--fs-primary)]/10 transition-all cursor-pointer group"
>
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-zinc-900 flex items-center justify-center text-zinc-500 group-hover:text-purple-400 transition-colors">
<div className="h-8 w-8 rounded-lg bg-[var(--fs-background)] flex items-center justify-center text-[var(--fs-text-muted)] group-hover:text-[var(--fs-primary)] transition-colors">
<CreditCard size={14} />
</div>
<span className="text-xs font-black tracking-tight text-white uppercase">
<span className="text-xs font-black tracking-tight text-[var(--fs-text-primary)] uppercase">
{group.name}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">
<span className="text-[10px] font-bold text-[var(--fs-text-secondary)] uppercase tracking-widest">
{group.currency}
</span>
<ArrowUpRight
Expand All @@ -220,8 +223,8 @@ export default async function DashboardPage() {
</Link>
))}
{groups.length === 0 && (
<div className="col-span-full py-12 text-center rounded-2xl border border-dashed border-white/5">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-700">
<div className="col-span-full py-12 text-center rounded-2xl border border-dashed border-[var(--fs-border)]">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[var(--fs-text-muted)]">
NO_ACTIVE_GROUPS_LOCATED
</p>
</div>
Expand All @@ -231,17 +234,17 @@ export default async function DashboardPage() {
</div>

<div className="flex flex-col gap-6">
<div className="relative overflow-hidden rounded-2xl sm:rounded-[2.5rem] border border-white/5 bg-gradient-to-br from-purple-600/20 to-indigo-600/20 p-5 sm:p-8 flex flex-col justify-between gap-4 sm:aspect-square">
<div className="relative overflow-hidden rounded-2xl sm:rounded-[2.5rem] border border-[var(--fs-border)] bg-gradient-to-br from-[var(--fs-primary)]/20 to-[var(--fs-primary)]/10 p-5 sm:p-8 flex flex-col justify-between gap-4 sm:aspect-square">
<div className="absolute top-0 right-0 p-8">
<TrendingUp size={48} className="text-purple-500/20" />
</div>
<h3 className="text-2xl sm:text-3xl font-black italic tracking-tighter text-white leading-none">
<h3 className="text-2xl sm:text-3xl font-black italic tracking-tighter text-[var(--fs-text-primary)] leading-none">
OPTIMIZE <br /> FLOW.
</h3>
<p className="text-xs font-bold text-purple-200/50 uppercase tracking-widest leading-relaxed">
<p className="text-xs font-bold text-[var(--fs-primary)]/60 uppercase tracking-widest leading-relaxed">
Review the groups with pending settlements and due recurring bills first.
</p>
<button className="w-full h-12 rounded-2xl bg-white text-[10px] font-black uppercase tracking-widest text-[#030303] hover:bg-purple-400 transition-all">
<button className="w-full h-12 rounded-2xl bg-[var(--fs-primary)] text-[10px] font-black uppercase tracking-widest text-white hover:bg-[var(--fs-primary)]/80 transition-all">
Review attention queue
</button>
</div>
Expand Down
15 changes: 8 additions & 7 deletions apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
--fs-section-inline-wide: clamp(1.25rem, 3vw, 3rem);
--fs-card-surface: color-mix(in srgb, var(--fs-surface) 90%, transparent);
--fs-card-surface-strong: color-mix(in srgb, var(--fs-surface) 96%, transparent);
--fs-card-border-strong: color-mix(in srgb, var(--fs-border) 78%, white 22%);
--fs-card-border-strong: color-mix(in srgb, var(--fs-border) 78%, var(--fs-surface) 22%);
}

html {
Expand Down Expand Up @@ -43,7 +43,7 @@

.marketing-card:hover {
box-shadow: var(--fs-shadow-elevated);
@apply -translate-y-1 border-white/15;
@apply -translate-y-1 border-[var(--fs-border)] shadow-xl;
}

.glass-panel {
Expand Down Expand Up @@ -102,12 +102,12 @@
@apply inline-flex items-center gap-2 rounded-full px-4 py-2 text-[0.65rem] font-bold uppercase tracking-[0.25em];
border: 1px solid color-mix(in srgb, var(--fs-primary) 28%, var(--fs-border) 72%);
background-color: color-mix(in srgb, var(--fs-primary) 10%, var(--fs-surface) 90%);
color: color-mix(in srgb, white 84%, var(--fs-primary) 16%);
color: var(--fs-primary);
}

.neo-border {
border: 4px solid white;
box-shadow: 4px 4px 0px 0px rgba(255, 255, 255, 1);
border: 4px solid var(--fs-text-primary);
box-shadow: 4px 4px 0px 0px var(--fs-text-primary);
}

.neo-pop-hover {
Expand Down Expand Up @@ -144,9 +144,10 @@

.grid-bg {
background-image:
linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
linear-gradient(to right, var(--fs-border) 1px, transparent 1px),
linear-gradient(to bottom, var(--fs-border) 1px, transparent 1px);
Comment on lines +147 to +148

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

opacity on .grid-bg will fade all child content, not just the grid lines.

Line 150 applies element-level opacity, so any text/content inside a .grid-bg container becomes translucent and may hurt readability/contrast. Prefer encoding transparency in the gradient colors instead of the whole element.

🔧 Proposed fix
  .grid-bg {
    background-image:
-      linear-gradient(to right, var(--fs-border) 1px, transparent 1px),
-      linear-gradient(to bottom, var(--fs-border) 1px, transparent 1px);
+      linear-gradient(to right, color-mix(in srgb, var(--fs-border) 15%, transparent) 1px, transparent 1px),
+      linear-gradient(to bottom, color-mix(in srgb, var(--fs-border) 15%, transparent) 1px, transparent 1px);
    background-size: 40px 40px;
-    opacity: 0.15;
  }

Also applies to: 150-150

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/app/globals.css` around lines 147 - 148, The .grid-bg rule uses
element-level opacity which makes all children translucent; replace that
approach by removing the opacity property from the .grid-bg selector and instead
add alpha channels to the gradient color stops (e.g., use rgba()/hex with alpha
or CSS color-mix for --fs-border) so only the grid lines are semi-transparent
while text and child elements retain full opacity; update the CSS gradients in
the .grid-bg rule (the two linear-gradient declarations) to use transparentized
versions of var(--fs-border) rather than relying on opacity.

background-size: 40px 40px;
opacity: 0.15;
}

.glitch-text {
Expand Down
23 changes: 3 additions & 20 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,6 @@ import { themeStylesheet } from '../src/design/theme';
import { Navbar } from '../components/layout/Navbar';
import { Footer } from '../components/layout/Footer';

const themeInitScript = `
(function() {
try {
const storageKey = 'fs-theme';
const stored = localStorage.getItem(storageKey);
const mode = stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system';
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const resolved = mode === 'system' ? (prefersDark ? 'dark' : 'light') : mode;
document.documentElement.dataset.theme = resolved;
document.body.dataset.theme = resolved;
} catch (_) {
document.documentElement.dataset.theme = 'light';
document.body.dataset.theme = 'light';
}
})();
`;

export const metadata: Metadata = {
title: 'FairShare - Smart Expense Sharing',
description: 'Split group expenses without confusion. FairShare helps friends, roommates, and teams track shared spending and settle up faster.',
Expand All @@ -45,12 +28,11 @@ const spaceGrotesk = Space_Grotesk({ subsets: ['latin'], variable: '--font-displ

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" data-theme="light" className={`${manrope.variable} ${spaceGrotesk.variable}`}>
<html lang="en" className={`${manrope.variable} ${spaceGrotesk.variable}`}>
<head>
<style id="fs-theme-vars">{themeStylesheet}</style>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body className="min-h-screen bg-black text-white selection:bg-yellow-400 selection:text-black">
<body className="min-h-screen selection:bg-[var(--fs-primary)] selection:text-white">
<Providers>
<Navbar />
<div>{children}</div>
Expand All @@ -60,3 +42,4 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</html>
);
}

4 changes: 2 additions & 2 deletions apps/web/app/theme.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
:root { color-scheme: light; }
[data-theme='dark'] { color-scheme: dark; }
:root { color-scheme: dark; }
[data-theme='light'] { color-scheme: light; }

body {
background: radial-gradient(1200px 600px at 10% 0%, color-mix(in srgb, var(--fs-primary) 15%, transparent), transparent 60%),
Expand Down
4 changes: 2 additions & 2 deletions apps/web/components/home/GridBackground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ export function GridBackground() {
}}
/>
{/* Vertical fade (top & bottom) */}
<div className="absolute inset-0 bg-gradient-to-b from-[#030303] via-transparent to-[#030303]" />
<div className="absolute inset-0 bg-gradient-to-b from-[var(--fs-background)] via-transparent to-[var(--fs-background)]" />
{/* Radial fade from center */}
<div
className="absolute inset-0"
style={{ background: 'radial-gradient(ellipse 70% 50% at 50% 40%, transparent 0%, #030303 100%)' }}
style={{ background: 'radial-gradient(ellipse 70% 50% at 50% 40%, transparent 0%, var(--fs-background) 100%)' }}
/>
</div>
);
Expand Down
Loading
Loading