fix: pre-launch review — security, money safety, privacy, legal, code quality

Security (P0):
- IsJTIRevoked fails closed on DB error (previously accepted revoked tokens)
- Remove dead consume parameter from SCA gate (prevented token replay)
- Rate limiter map TTL-based eviction (prevented memory exhaustion)
- 2FA attempt map already had LRU eviction (verified)

Money Safety (P1):
- Gift card transfer refuses expired destination cards
- Gift card balance deduction has WHERE balance >= amount guard
- Webhook clawback acquires till-sale advisory lock
- Sweep/retry lock keys aligned

Privacy/Cookies (P2):
- Self-host Google Fonts (Playfair Display woff2)
- Replace CARTO map tiles with OpenStreetMap raster tiles
- Replace Wikimedia/icon-icons external images with local SVGs
- Remove external image URLs from CSP

Legal (P3):
- Privacy policy: add 6 missing data categories (gift cards, 2FA, GDPR, notifications, technical, cookies)
- Terms: add Tips section (optionality, non-refundable, same processing as bookings)

Code Quality (P4):
- twofa.Check accepts db.Querier for testability
- depositPromotionMinPct uses literal 0.20 (not misleading alias)
- HolidayHours.svelte uses proper type (not as any[])
- Remove stale TODO comments from main.go

Testing (P5):
- 94 new float64 money validity tests across 3 test files
- Cover VAT, splits, refunds, gift cards, rounding, precision boundaries
- All 27 backend test packages pass
This commit is contained in:
2026-08-22 00:34:51 +01:00
parent 9a12a2d886
commit 4146f8e09a
31 changed files with 4351 additions and 81 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -23,7 +23,8 @@ const (
// deposit REQUIRED at booking time, used by bookings.go): the promotion
// threshold is about already-paid money, not the amount to demand up front,
// even though both are 20% today.
depositPromotionMinPct = RequiredDepositPct
// Currently equals RequiredDepositPct, but intentionally independent for future divergence.
depositPromotionMinPct = 0.20
LoyaltyStampCost = 10
LoyaltyDiscountPercent = 10.0
+2 -2
View File
@@ -355,7 +355,7 @@ const (
// flows clear the pending code themselves on success (enableTwoFA /
// disableTwoFA), so the code must stay valid through the whole handshake here.
func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCode string) (twoFACodeCheckResult, error) {
res, err := twofa.Check(r.Context(), userID, st, reqCode, false)
res, err := twofa.Check(r.Context(), db.Conn, userID, st, reqCode, false)
return twoFACodeCheckResult(res), err
}
@@ -378,7 +378,7 @@ func VerifyTwoFACodeForUser(ctx context.Context, userID, code string) error {
st.Mu.Lock()
defer st.Mu.Unlock()
result, err := twofa.Check(ctx, userID, st, code, false)
result, err := twofa.Check(ctx, db.Conn, userID, st, code, false)
if err != nil {
return err
}
+7 -7
View File
@@ -373,7 +373,7 @@ const (
// error is non-nil only for DB failures (callers return 500); a lockout's
// pending-code invalidation failure is logged here and still reported as a
// lockout.
func Check(ctx context.Context, userID string, st *AttemptState, reqCode string, consume bool) (Result, error) {
func Check(ctx context.Context, q db.Querier, userID string, st *AttemptState, reqCode string, consume bool) (Result, error) {
if now := clock.Now(); now.Sub(st.LastActive()) > AttemptWindow {
st.Count.Store(0)
st.SetLastActive(now)
@@ -384,7 +384,7 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string,
var pendingHash sql.NullString
var pendingExpires sql.NullTime
err := db.Conn.QueryRow(ctx, `
err := q.QueryRow(ctx, `
SELECT two_factor_pending_code_hash, two_factor_pending_code_expires
FROM users
WHERE id = $1
@@ -406,7 +406,7 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string,
if st.Count.Load() >= MaxAttempts {
// Lockout reached: destroy the pending code so a stolen digest
// cannot be replayed against a fresh guessing loop.
if _, err := db.Conn.Exec(ctx, `
if _, err := q.Exec(ctx, `
UPDATE users
SET two_factor_pending_code_hash = NULL,
two_factor_pending_code_expires = NULL
@@ -425,7 +425,7 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string,
// code stays valid for the rest of the handshake — consume mode destroys
// the digest outright, so there is nothing to upgrade.
if legacy && !consume {
if _, err := db.Conn.Exec(ctx, `
if _, err := q.Exec(ctx, `
UPDATE users
SET two_factor_pending_code_hash = $2
WHERE id = $1
@@ -453,7 +453,7 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string,
// locked_until) — a successful 2FA challenge is a strong auth signal, and
// the only way to reach a 2FA verify is an already-authenticated session.
// Best-effort: a failure only logs; the verify has already succeeded.
if _, err := db.Conn.Exec(ctx, `
if _, err := q.Exec(ctx, `
UPDATE users
SET failed_attempts = 0, locked_until = NULL
WHERE id = $1
@@ -477,7 +477,7 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string,
// it, but only the first conditional UPDATE can affect a row — the
// loser sees 0 rows and must fail (MissingOrExpired), so one code
// authorizes exactly ONE operation even across instances.
tag, err := db.Conn.Exec(ctx, `
tag, err := q.Exec(ctx, `
UPDATE users
SET two_factor_pending_code_hash = NULL,
two_factor_pending_code_expires = NULL
@@ -590,7 +590,7 @@ func VerifyForUser(ctx context.Context, userID, code string, consume bool) error
st.Mu.Lock()
defer st.Mu.Unlock()
result, err := Check(ctx, userID, st, code, consume)
result, err := Check(ctx, db.Conn, userID, st, code, consume)
if err != nil {
return fmt.Errorf("2FA verify: %w", err)
}
+1 -1
View File
@@ -155,7 +155,7 @@ func TestVerifyForUser_DBAtomicConsume_Concurrent(t *testing.T) {
defer wg.Done()
st := &AttemptState{}
st.SetLastActive(clock.Now())
res, err := Check(context.Background(), userID, st, "424242", true)
res, err := Check(context.Background(), db.Conn, userID, st, "424242", true)
results <- outcome{result: res, err: err}
}()
}
-2
View File
@@ -464,9 +464,7 @@ func corsMiddleware(next http.Handler) http.Handler {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
// TODO: Enable HSTS in production
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
// TODO: Enable Referrer-Policy in production
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
+1 -1
View File
@@ -16,7 +16,7 @@
httpOnly cookies is the durable fix (out of scope). -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' https: http://localhost:8080 ws:; font-src 'self' data: https://fonts.gstatic.com; frame-src 'self' https://*.squareup.com https://*.square.com"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://tile.openstreetmap.org; connect-src 'self' https: http://localhost:8080 ws:; font-src 'self' data:; frame-src 'self' https://*.squareup.com https://*.square.com"
/>
%sveltekit.head%
</head>
@@ -230,8 +230,7 @@
description: group.description,
weekStarts: group.weekStarts || [],
hours:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(group.hours as any[])?.map(
(group.hours as HolidayHour[])?.map(
(h: {
id: number;
weekday: number;
@@ -8,7 +8,7 @@
email = 'chelsea@emailaddress.com',
instagram = '@crussell',
address = 'Business Centre, Office Street, Work',
profileImage = 'https://images.icon-icons.com/5/PNG/256/MSN_messenger_user_156.png',
profileImage = '',
altText = 'Profile picture'
}: {
name?: string;
@@ -64,9 +64,34 @@
<div class="flex h-full flex-col rounded-lg border-2 border-gray-200 bg-white p-6">
<!-- Profile Picture -->
<div class="mb-4 flex shrink-0 justify-center">
<div class="h-24 w-24 overflow-hidden rounded-full border-4 border-white shadow-lg">
<img src={profileImage} alt={altText} class="h-full w-full object-cover" />
</div>
{#if profileImage}
<img
src={profileImage}
alt={altText}
class="h-24 w-24 rounded-full object-cover ring-4 ring-fuchsia-200"
/>
{:else if name}
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 text-3xl font-bold text-gray-600 ring-4 ring-fuchsia-200">
{name.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()}
</div>
{:else}
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 ring-4 ring-fuchsia-200">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
{/if}
</div>
<!-- Name and Role -->
<div class="mb-4 shrink-0 text-center">
@@ -1,38 +1,14 @@
<script lang="ts">
// Sample portfolio images - replace with your actual nail art images
// Sample portfolio images — local SVG placeholders (replace with actual nail art photos)
const portfolioImages = [
{
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a3/Hand%2C_fingers_-_back.jpg/1024px-Hand%2C_fingers_-_back.jpg',
alt: 'lorem ipsum'
},
{
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9d/Palm%2C_fingers.jpg/1024px-Palm%2C_fingers.jpg',
alt: 'lorem ipsum'
},
{
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/52/Hand_parts_-_en.svg/1920px-Hand_parts_-_en.svg.png',
alt: 'lorem ipsum'
},
{
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Scheme_human_hand_bones-en.svg/1920px-Scheme_human_hand_bones-en.svg.png',
alt: 'lorem ipsum'
},
{
src: 'https://upload.wikimedia.org/wikipedia/commons/a/a1/3D_Medical_Animation_Human_Wrist.jpg',
alt: 'lorem ipsum'
},
{
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Wrist_and_hand_deeper_palmar_dissection-en.svg/1920px-Wrist_and_hand_deeper_palmar_dissection-en.svg.png',
alt: 'lorem ipsum'
},
{
src: 'https://upload.wikimedia.org/wikipedia/commons/3/38/Wrist_extensor_compartments_%28numbered%29.PNG',
alt: 'lorem ipsum'
},
{
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Gray812and814.svg/1920px-Gray812and814.svg.png',
alt: 'lorem ipsum'
}
{ src: '/images/portfolio/placeholder-01.svg', alt: 'Nail Art — Classic' },
{ src: '/images/portfolio/placeholder-02.svg', alt: 'Nail Art — French' },
{ src: '/images/portfolio/placeholder-03.svg', alt: 'Nail Art — Warm' },
{ src: '/images/portfolio/placeholder-04.svg', alt: 'Nail Art — Geometric' },
{ src: '/images/portfolio/placeholder-05.svg', alt: 'Nail Art — Marble' },
{ src: '/images/portfolio/placeholder-06.svg', alt: 'Nail Art — Floral' },
{ src: '/images/portfolio/placeholder-07.svg', alt: 'Nail Art — Ombre' },
{ src: '/images/portfolio/placeholder-08.svg', alt: 'Nail Art — Chrome' }
];
</script>
+15 -2
View File
@@ -73,9 +73,22 @@
onstyleloaded?: () => void;
}
const osmRasterStyle: MapLibreGL.StyleSpecification = {
version: 8,
sources: {
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}
},
layers: [{ id: 'osm', type: 'raster', source: 'osm' }]
};
const defaultStyles = {
dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
light: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'
dark: osmRasterStyle,
light: osmRasterStyle
};
let {
+14 -1
View File
@@ -13,8 +13,21 @@ export const POLICY = {
LOYALTY_DISCOUNT_RATE: 0.1,
// Gratuity suggestion presets offered by the tip surfaces (admin payment
// modal + customer tip page). Deliberately SEPARATE from the deposit policy
// constants above — a tip suggestion is gratuity, not a deposit percentage,
// constants below — a tip suggestion is gratuity, not a deposit percentage,
// and coupling them would silently change the tip buttons if the deposit
// rate ever changed.
TIP_PRESET_PCTS: [10, 15, 20]
} as const;
// Named re-exports so pages can import individual constants directly.
// Keep in sync with the POLICY object above.
export const {
FULL_REFUND_THRESHOLD_HOURS,
PARTIAL_REFUND_THRESHOLD_HOURS,
NO_SHOW_THRESHOLD_HOURS,
DEPOSIT_ADVANCE_HOURS,
PROTECTED_DEPOSIT_MAX_PCT,
REQUIRED_DEPOSIT_PCT,
LOYALTY_DISCOUNT_RATE,
TIP_PRESET_PCTS
} = POLICY;
+1 -6
View File
@@ -120,12 +120,7 @@
<svelte:head>
<link rel="icon" href={favicon} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/fonts/playfair-display.css" />
</svelte:head>
<div class="flex min-h-screen flex-col supports-[height:100dvh]:min-h-dvh">
-6
View File
@@ -94,12 +94,6 @@
</script>
<svelte:head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap"
rel="stylesheet"
/>
</svelte:head>
<section class="py-20 text-center">
+3 -2
View File
@@ -2169,7 +2169,7 @@ import { generateUUID } from '$lib/utils/uuid';
<path
d="M 50 25 L 56 43 L 75 43 L 60 53.5 L 66 71.5 L 50 62 L 34 71.5 L 40 53.5 L 25 43 L 44 43 Z"
fill="black"
/>
/>
</mask>
</defs>
<path
@@ -2786,7 +2786,8 @@ import { generateUUID } from '$lib/utils/uuid';
bind:selectedCardId={buySelectedCard}
bind:saveCard={buySaveCard}
onValidityChange={(v) => (buyCardSelectionValid = v)}
</div>
/>
</div>
{#if buyWaitingForSCA}
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
+1 -2
View File
@@ -57,8 +57,7 @@
email={contact.email}
instagram="crussell"
address={BUSINESS_ADDRESS}
profileImage={contact.profilePicUrl ||
'https://images.icon-icons.com/5/PNG/256/MSN_messenger_user_156.png'}
profileImage={contact.profilePicUrl || ''}
/>
{:else}
<ContactCard
@@ -158,13 +158,15 @@
</p>
<p class="mb-2 font-medium text-gray-800">Financial Data:</p>
<ul class="mb-4 list-disc space-y-1 pl-5">
<li>Gift card codes and balances</li>
<li>Account balances</li>
<li>Gift card codes (hashed), balances, and transaction history (purchases, redemptions, top-ups)</li>
<li>Account balances (from redeemed gift cards or cash refunds)</li>
<li>Payment transaction records (our ledger of record, retained for 7 years for HMRC)</li>
<li>
Saved-card references (tokenised, stored with our payment provider Square &mdash; see
&sect;2.2)
</li>
<li>Square customer IDs (created when you save a card for future payments)</li>
<li>Card tokens (Square <code>ccof:</code> references for recurring payments)</li>
<li>Dormant balance records (Account ID only, no PII)</li>
</ul>
+35 -7
View File
@@ -271,7 +271,35 @@
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">
5. Gift Cards &amp; Account Balances
5. Tips
</h2>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>
<strong>Tips are entirely optional.</strong> You are under no obligation to leave a tip,
and the quality of service you receive is not affected by whether you choose to do so.
</li>
<li>
<strong>When you can tip:</strong> you may add a tip during the payment process or after
the service has been completed, through your account.
</li>
<li>
<strong>Tips are non-refundable once processed.</strong> Because a tip is a voluntary
gratuity paid after (or at the point of) service, it is not subject to the cancellation
and refund tiers that apply to booking payments in section 3. Once the tip transaction
has been completed, it cannot be refunded.
</li>
<li>
<strong>Payment processing:</strong> tips are processed through the same payment methods
and by the same payment provider (Square) as booking payments. The same SCA requirements,
chargeback rules, and refund-to-original-method principles apply to tip payments as to
booking payments (see section 4).
</li>
</ul>
</section>
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">
6. Gift Cards &amp; Account Balances
</h2>
<p class="mb-2 font-medium text-gray-800">Gift card expiry</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
@@ -327,7 +355,7 @@
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">
6. Distance Contracts &amp; Right to Cancel
7. Distance Contracts &amp; Right to Cancel
</h2>
<p class="mb-3">
Purchases made on our Platform (rather than face-to-face in the salon) are
@@ -342,7 +370,7 @@
<li>
<strong>Gift cards bought online</strong> carry this 14-day right, refunded to the
original payment method &mdash; in full if unused, or the unspent balance if partly used
on salon services (the card is then cancelled). See section 5 and our
on salon services (the card is then cancelled). See section 6 and our
<a
href={resolve('/gift-card-terms')}
class="font-medium text-blue-600 underline hover:text-blue-800">Gift Card Terms</a
@@ -365,7 +393,7 @@
</section>
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">7. Liability</h2>
<h2 class="mb-3 text-base font-semibold text-gray-900">8. Liability</h2>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>
Nothing in these Terms excludes or limits any rights you have under consumer law &mdash;
@@ -389,7 +417,7 @@
</section>
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">8. Acceptable Use</h2>
<h2 class="mb-3 text-base font-semibold text-gray-900">9. Acceptable Use</h2>
<p class="mb-3">By using the Platform you agree not to:</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>
@@ -414,7 +442,7 @@
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">
9. Complaints &amp; Dispute Resolution
10. Complaints &amp; Dispute Resolution
</h2>
<p class="mb-3">
If you are unhappy with any part of our service, please contact us first at
@@ -431,7 +459,7 @@
</section>
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">10. Governing Law</h2>
<h2 class="mb-3 text-base font-semibold text-gray-900">11. Governing Law</h2>
<p class="mb-3">
These Terms are governed by the laws of <strong>Scotland</strong>. Any dispute arising out
of or in connection with these Terms is subject to the exclusive jurisdiction of the
@@ -0,0 +1,22 @@
/* Playfair Display — self-hosted (Latin subset only) */
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 400 900;
font-display: swap;
src: url('/fonts/playfair-display-latin-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF,
U+FFFD;
}
@font-face {
font-family: 'Playfair Display';
font-style: italic;
font-weight: 400 900;
font-display: swap;
src: url('/fonts/playfair-display-latin-italic.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF,
U+FFFD;
}
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<defs>
<linearGradient id="bg1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#fce4ec"/>
<stop offset="100%" stop-color="#f8bbd0"/>
</linearGradient>
</defs>
<rect width="400" height="300" fill="url(#bg1)" rx="8"/>
<!-- Nail shape silhouette -->
<ellipse cx="200" cy="150" rx="60" ry="100" fill="#e91e63" opacity="0.15"/>
<ellipse cx="200" cy="150" rx="45" ry="85" fill="#e91e63" opacity="0.25"/>
<!-- Polish stroke -->
<path d="M200 65 Q215 120 200 150 Q185 120 200 65" fill="#f06292" opacity="0.6"/>
<!-- Decorative dots -->
<circle cx="200" cy="100" r="4" fill="#fff" opacity="0.7"/>
<circle cx="200" cy="115" r="3" fill="#fff" opacity="0.5"/>
<circle cx="200" cy="130" r="2" fill="#fff" opacity="0.3"/>
<text x="200" y="260" text-anchor="middle" fill="#ad1457" font-family="Georgia, serif" font-size="14" opacity="0.6">Nail Art — Classic</text>
</svg>

After

Width:  |  Height:  |  Size: 987 B

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<defs>
<linearGradient id="bg2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#e8eaf6"/>
<stop offset="100%" stop-color="#c5cae9"/>
</linearGradient>
</defs>
<rect width="400" height="300" fill="url(#bg2)" rx="8"/>
<!-- French tip nail -->
<ellipse cx="200" cy="150" rx="55" ry="95" fill="#fff" opacity="0.4"/>
<path d="M160 80 Q200 55 240 80" fill="none" stroke="#fff" stroke-width="8" opacity="0.8"/>
<path d="M165 85 Q200 62 235 85" fill="none" stroke="#f8bbd0" stroke-width="3" opacity="0.6"/>
<!-- Sparkle accents -->
<path d="M180 120 L182 126 L188 128 L182 130 L180 136 L178 130 L172 128 L178 126 Z" fill="#fff" opacity="0.6"/>
<path d="M220 140 L221 144 L225 145 L221 146 L220 150 L219 146 L215 145 L219 144 Z" fill="#fff" opacity="0.4"/>
<text x="200" y="260" text-anchor="middle" fill="#3949ab" font-family="Georgia, serif" font-size="14" opacity="0.6">Nail Art — French</text>
</svg>

After

Width:  |  Height:  |  Size: 1022 B

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<defs>
<linearGradient id="bg3" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#fff3e0"/>
<stop offset="100%" stop-color="#ffe0b2"/>
</linearGradient>
</defs>
<rect width="400" height="300" fill="url(#bg3)" rx="8"/>
<!-- Hand silhouette -->
<path d="M160 80 Q170 60 190 70 L195 90 Q200 70 215 75 L215 95 Q225 75 240 85 L235 105 Q250 95 260 110 L250 130 Q265 130 265 150 L260 170 Q255 190 240 200 L220 210 L180 210 Q160 200 150 180 L145 150 Q140 130 150 110 Z" fill="#ff8f00" opacity="0.12"/>
<!-- Nail polish dots on fingertips -->
<circle cx="185" cy="75" r="6" fill="#ff6f00" opacity="0.4"/>
<circle cx="210" cy="80" r="6" fill="#ff6f00" opacity="0.4"/>
<circle cx="235" cy="90" r="6" fill="#ff6f00" opacity="0.4"/>
<circle cx="255" cy="115" r="6" fill="#ff6f00" opacity="0.4"/>
<circle cx="258" cy="145" r="6" fill="#ff6f00" opacity="0.4"/>
<text x="200" y="260" text-anchor="middle" fill="#e65100" font-family="Georgia, serif" font-size="14" opacity="0.6">Nail Art — Warm</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<defs>
<linearGradient id="bg4" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f3e5f5"/>
<stop offset="100%" stop-color="#ce93d8"/>
</linearGradient>
</defs>
<rect width="400" height="300" fill="url(#bg4)" rx="8"/>
<!-- Geometric nail pattern -->
<ellipse cx="200" cy="150" rx="50" ry="90" fill="none" stroke="#8e24aa" stroke-width="2" opacity="0.3"/>
<ellipse cx="200" cy="150" rx="35" ry="70" fill="none" stroke="#8e24aa" stroke-width="1.5" opacity="0.3"/>
<ellipse cx="200" cy="150" rx="20" ry="50" fill="none" stroke="#8e24aa" stroke-width="1" opacity="0.3"/>
<!-- Geometric diamond accent -->
<polygon points="200,100 215,130 200,160 185,130" fill="#ab47bc" opacity="0.4"/>
<polygon points="200,110 208,130 200,150 192,130" fill="#ce93d8" opacity="0.5"/>
<text x="200" y="260" text-anchor="middle" fill="#6a1b9a" font-family="Georgia, serif" font-size="14" opacity="0.6">Nail Art — Geometric</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<defs>
<linearGradient id="bg5" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#e0f2f1"/>
<stop offset="100%" stop-color="#80cbc4"/>
</linearGradient>
</defs>
<rect width="400" height="300" fill="url(#bg5)" rx="8"/>
<!-- Marble nail effect -->
<ellipse cx="200" cy="150" rx="55" ry="95" fill="#fff" opacity="0.3"/>
<path d="M170 80 Q190 100 175 130 Q160 160 185 180 Q210 200 195 220" fill="none" stroke="#26a69a" stroke-width="3" opacity="0.3"/>
<path d="M220 70 Q210 100 230 120 Q250 140 225 170 Q200 200 215 230" fill="none" stroke="#4db6ac" stroke-width="2" opacity="0.25"/>
<path d="M185 90 Q200 110 190 140 Q180 170 200 190" fill="none" stroke="#fff" stroke-width="1.5" opacity="0.4"/>
<!-- Gold foil accent -->
<circle cx="200" cy="130" r="8" fill="#ffd54f" opacity="0.5"/>
<circle cx="195" cy="125" r="3" fill="#fff" opacity="0.6"/>
<text x="200" y="260" text-anchor="middle" fill="#00695c" font-family="Georgia, serif" font-size="14" opacity="0.6">Nail Art — Marble</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<defs>
<linearGradient id="bg6" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#fce4ec"/>
<stop offset="100%" stop-color="#f48fb1"/>
</linearGradient>
</defs>
<rect width="400" height="300" fill="url(#bg6)" rx="8"/>
<!-- Floral nail pattern -->
<ellipse cx="200" cy="150" rx="50" ry="90" fill="#fff" opacity="0.2"/>
<!-- Flower -->
<circle cx="200" cy="120" r="6" fill="#e91e63" opacity="0.5"/>
<circle cx="192" cy="112" r="5" fill="#f06292" opacity="0.4"/>
<circle cx="208" cy="112" r="5" fill="#f06292" opacity="0.4"/>
<circle cx="192" cy="128" r="5" fill="#f06292" opacity="0.4"/>
<circle cx="208" cy="128" r="5" fill="#f06292" opacity="0.4"/>
<circle cx="200" cy="120" r="3" fill="#fff" opacity="0.7"/>
<!-- Stem -->
<path d="M200 126 Q200 145 200 160" fill="none" stroke="#4caf50" stroke-width="2" opacity="0.4"/>
<!-- Small leaves -->
<ellipse cx="195" cy="140" rx="6" ry="3" fill="#81c784" opacity="0.4" transform="rotate(-30 195 140)"/>
<ellipse cx="205" cy="148" rx="6" ry="3" fill="#81c784" opacity="0.4" transform="rotate(30 205 148)"/>
<text x="200" y="260" text-anchor="middle" fill="#880e4f" font-family="Georgia, serif" font-size="14" opacity="0.6">Nail Art — Floral</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<defs>
<linearGradient id="bg7" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ede7f6"/>
<stop offset="100%" stop-color="#b39ddb"/>
</linearGradient>
</defs>
<rect width="400" height="300" fill="url(#bg7)" rx="8"/>
<!-- Ombre nail effect -->
<ellipse cx="200" cy="150" rx="55" ry="95" fill="url(#bg7)" opacity="0.5"/>
<defs>
<linearGradient id="ombre" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#7c4dff" stop-opacity="0.6"/>
<stop offset="40%" stop-color="#7c4dff" stop-opacity="0.3"/>
<stop offset="100%" stop-color="#fff" stop-opacity="0.1"/>
</linearGradient>
</defs>
<ellipse cx="200" cy="130" rx="45" ry="70" fill="url(#ombre)"/>
<!-- Sparkle trail -->
<circle cx="175" cy="100" r="2" fill="#fff" opacity="0.7"/>
<circle cx="185" cy="95" r="1.5" fill="#fff" opacity="0.5"/>
<circle cx="220" cy="105" r="2" fill="#fff" opacity="0.6"/>
<circle cx="210" cy="98" r="1" fill="#fff" opacity="0.4"/>
<circle cx="195" cy="90" r="1.5" fill="#fff" opacity="0.5"/>
<text x="200" y="260" text-anchor="middle" fill="#4527a0" font-family="Georgia, serif" font-size="14" opacity="0.6">Nail Art — Ombre</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<defs>
<linearGradient id="bg8" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#e0f7fa"/>
<stop offset="100%" stop-color="#4dd0e1"/>
</linearGradient>
</defs>
<rect width="400" height="300" fill="url(#bg8)" rx="8"/>
<!-- Chrome/metallic nail effect -->
<ellipse cx="200" cy="150" rx="55" ry="95" fill="#fff" opacity="0.25"/>
<defs>
<linearGradient id="chrome" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#fff" stop-opacity="0.6"/>
<stop offset="30%" stop-color="#80deea" stop-opacity="0.3"/>
<stop offset="60%" stop-color="#26c6da" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#fff" stop-opacity="0.5"/>
</linearGradient>
</defs>
<ellipse cx="200" cy="145" rx="45" ry="80" fill="url(#chrome)"/>
<!-- Reflection line -->
<path d="M175 90 Q185 120 180 150 Q175 180 185 200" fill="none" stroke="#fff" stroke-width="2" opacity="0.5"/>
<path d="M220 85 Q215 115 225 145 Q235 175 220 200" fill="none" stroke="#fff" stroke-width="1" opacity="0.3"/>
<text x="200" y="260" text-anchor="middle" fill="#00838f" font-family="Georgia, serif" font-size="14" opacity="0.6">Nail Art — Chrome</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB