feature plus ux rearrangements

This commit is contained in:
JCEEE
2026-08-22 19:49:46 +01:00
parent f14f4e2ac1
commit 42b9a27ce6
44 changed files with 2020 additions and 538 deletions
@@ -2,18 +2,34 @@
import { page } from '$app/state';
import { enhance } from '$app/forms';
import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import { ViewHeader, CardGrid, Card, Button, Accordion, AccordionItem, NoticeDialog } from '$lib/components';
import { COMMON_TIMEZONES } from '@shared/timezone';
import { handleOf } from '@shared/slugify';
import { addMonthsUTC, formatShortDate } from '$lib/format';
import QRCode from 'qrcode';
let { data } = $props();
// fam is sensitive (stripeCustomerId, featureFlags) — never in the
// fam is sensitive (stripeCustomerId, access fields) — never in the
// public famStore stream. It is superadmin-only, fetched server-side by the
// layout load. Writes go through form actions; no live fam subscription.
let fam = $state(data.fam);
let famSlug = $state(page.params.fam);
// Derived so apply/revoke reflect immediately after the action round trip.
let fam = $derived(data.fam);
// Canonical slug from layout load — reactive, never copied into $state.
let famSlug = $derived(page.data.famSlug || page.params.fam);
let accessCodeInput = $state('');
let accessMsg = $state('');
let hasCode = $derived(fam?.paymentMode === 'code' && !!fam?.accessCodeId);
let codeEntryDate = $derived(fam?.accessCodeEnteredAt ? new Date(fam.accessCodeEnteredAt) : null);
const modeLabel: Record<string, string> = {
code: 'Access code',
sub: 'Subscription',
canceled: 'Canceled',
none: 'No plan yet'
};
let addName = $state('');
let rename = $state('');
@@ -21,23 +37,9 @@
let payday = $state(fam?.payday != null ? Number(fam.payday) : 1);
let paydayTime = $state(fam?.paydayTime || '18:00');
let paydayTimes = $state([
'06:00',
'07:00',
'08:00',
'09:00',
'10:00',
'11:00',
'12:00',
'13:00',
'14:00',
'15:00',
'16:00',
'17:00',
'18:00',
'19:00',
'20:00',
'21:00',
'22:00'
'06:00', '07:00', '08:00', '09:00', '10:00', '11:00', '12:00',
'13:00', '14:00', '15:00', '16:00', '17:00', '18:00', '19:00',
'20:00', '21:00', '22:00'
]);
let timezone = $state(fam?.timezone || 'auto');
let timezoneOptions = $state(COMMON_TIMEZONES);
@@ -46,9 +48,7 @@
if (typeof Intl !== 'undefined') {
try {
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
} catch {
detectedTz = '';
}
} catch { detectedTz = ''; }
}
});
let showQR = $state(false);
@@ -60,12 +60,8 @@
let deletingSeason = $state<any>(null);
let issued = $state<{ otp: string; joinUrl: string; name: string } | null>(null);
let invitePath = $derived(
issued ? `${issued.joinUrl}?code=${issued.otp}` : ''
);
let inviteUrl = $derived(
issued ? `${page.url.origin}${invitePath}` : ''
);
let invitePath = $derived(issued ? `${issued.joinUrl}?code=${issued.otp}` : '');
let inviteUrl = $derived(issued ? `${page.url.origin}${invitePath}` : '');
function copy(url: string) {
navigator.clipboard.writeText(url);
@@ -79,304 +75,368 @@
function toggleQR() {
showQR = !showQR;
if (!showQR) {
qrDataUrl = '';
} else {
generateQR(inviteUrl);
}
if (!showQR) qrDataUrl = '';
else generateQR(inviteUrl);
}
function handleParentInvite() {
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail);
}
function formatCountdown(entryDate: Date | null, durationMonths: number): string {
if (!entryDate) return '';
if (durationMonths === 0) return 'Never expires';
const expiry = addMonthsUTC(entryDate, durationMonths);
const now = new Date();
const diffMs = expiry.getTime() - now.getTime();
if (diffMs <= 0) return 'Expired';
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? '' : 's'} left`;
const months = Math.floor(diffDays / 30);
const remDays = diffDays % 30;
return `${months} month${months === 1 ? '' : 's'}${remDays ? ` ${remDays} day${remDays === 1 ? '' : 's'}` : ''} left`;
}
</script>
<ViewHeader title="Settings" hero />
<CardGrid>
<Card title="Family Name">
<p class="hint">
This is the name shown to your family. The address stays at
<code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep
working.
</p>
<form method="POST" action="?/renameFam" use:enhance>
<label class="field-label" for="fam-name">Display name</label>
<input id="fam-name" name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required />
<Button type="submit" size="sm">Rename</Button>
</form>
{#if fam?.slug}
<p class="hint slug-line">
Family page: <code class="slug-inline">/{fam.slug}</code>
</p>
{/if}
</Card>
<Card title="Members ({members.length})" cols={2}>
<div class="members-grid">
<div class="members-add">
<p class="hint">Add a child. They'll pick their own colour after joining.</p>
<form method="POST" action="?/addMember" use:enhance>
<label class="field-label" for="new-child">New child</label>
<input id="new-child" name="name" bind:value={addName} placeholder="Child name" required />
<Button type="submit" size="sm">Add child</Button>
<Accordion>
<!-- Family -->
<AccordionItem title="Family" open>
<CardGrid>
<Card title="Family Name">
<p class="hint">
This is the name shown to your family. The address stays at
<code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep
working.
</p>
<form method="POST" action="?/renameFam" use:enhance>
<label class="field-label" for="fam-name">Display name</label>
<input id="fam-name" name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required />
<Button type="submit" size="sm">Rename</Button>
</form>
</div>
<ul class="members-list">
{#each members as m}
<li>
<span class="member-left">
<span class="member-color" style="background:{m.color}"></span>
<span class="member-info">
<span class="member-name">{m.name}</span>
<span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
</span>
</span>
<span class="member-actions">
<Button href="/{famSlug}/{handleOf(m.username)}" variant="secondary" size="sm">Preview</Button>
<form method="POST" action="?/deleteMember" use:enhance class="inline">
<input type="hidden" name="id" value={m.id} />
<Button
type="submit"
variant="danger"
size="sm"
onclick={() => confirm('Remove {m.name}?')}>Remove</Button
>
</form>
</span>
</li>
{/each}
</ul>
</div>
</Card>
<Card title="Payday" cols={1}>
<form
method="POST"
action="?/updatePayday"
use:enhance={() => {
return async ({ result }) => {
if (result.type === 'error') {
alert(result.error || 'Failed to update payday');
}
// One-way: no invalidation — local state is already correct,
// and the PB subscription handles cross-device sync.
};
}}
class="payday-form"
>
<div class="payday-row">
<label>Day</label>
<select name="payday" bind:value={payday}>
<option value={0}>Sunday</option>
<option value={1}>Monday</option>
<option value={2}>Tuesday</option>
<option value={3}>Wednesday</option>
<option value={4}>Thursday</option>
<option value={5}>Friday</option>
<option value={6}>Saturday</option>
</select>
<label>Time</label>
<select name="paydayTime" bind:value={paydayTime}>
{#each paydayTimes as t}
<option value={t}>{t}</option>
{/each}
</select>
</div>
<div class="payday-row">
<label>Timezone</label>
<select name="timezone" bind:value={timezone}>
<option value="auto">{detectedTz ? `Auto (${detectedTz})` : 'Auto'}</option>
{#each timezoneOptions as tz}
<option value={tz}>{tz}</option>
{/each}
</select>
</div>
<Button type="submit" size="sm">Set payday</Button>
</form>
<p class="hint">
Payday: the week starts on this day and weekly earnings are settled at this time. Auto timezone
follows each device.
</p>
</Card>
<Card title="Invite Children" cols={1}>
<form
method="POST"
action="?/issueAccess"
use:enhance={() => {
return async ({ formData, result }) => {
if (result.type === 'success' && result.data?.ok) {
showQR = false;
qrDataUrl = '';
issued = {
otp: result.data.otp,
joinUrl: result.data.joinUrl,
name: String(formData.get('name') || '')
};
} else if (result.type === 'success' && result.data?.error) {
alert(result.data.error);
}
};
}}
class="invite-form"
>
<label class="field-label" for="invite-child">Child</label>
<select id="invite-child" bind:value={inviteChild} name="name" required>
<option value="">— Select a child —</option>
{#each members as m}
<option value={m.name}>{m.name}</option>
{/each}
</select>
<Button type="submit" size="sm" disabled={!inviteChild}>Issue code</Button>
</form>
<p class="hint">
Generates a 6-digit code valid for 20 minutes. The child enters it at the join link.
</p>
{#if issued?.otp}
<div class="mt-3 rounded-lg border border-indigo-200 bg-indigo-50 p-4">
<p class="text-xs text-slate-500">
Code for {issued.name} (valid 20 min):
</p>
<p class="my-2 text-center text-4xl font-bold tracking-[0.3em] text-indigo-700">
{issued.otp}
</p>
<p class="invite-url">{invitePath}</p>
<div class="actions justify-center">
<Button variant="secondary" size="sm" onclick={() => copy(inviteUrl)}>
{copied ? 'Copied!' : 'Copy URL'}
</Button>
<Button variant="secondary" size="sm" onclick={toggleQR}>
{showQR ? 'Hide QR' : 'Show QR'}
</Button>
</div>
{#if showQR && qrDataUrl}
<div class="qr-wrap">
<img src={qrDataUrl} alt="QR Code" class="qr" />
</div>
{#if fam?.slug}
<p class="hint slug-line">
Family page: <code class="slug-inline">/{fam.slug}</code>
</p>
{/if}
</div>
{/if}
</Card>
</Card>
<Card title="Invite Parent" cols={1}>
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
<div class="invite-form">
<label class="field-label" for="parent-email">Parent email</label>
<input id="parent-email" type="email" bind:value={parentInviteEmail} placeholder="parent@example.com" />
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
</div>
<p class="hint">They will set up their own password on first login.</p>
</Card>
<Card title="Seasons" cols={1}>
<p class="hint">Group chores into seasons. Toggle seasons on/off from the top nav.</p>
<form method="POST" action="?/createSeason" use:enhance class="season-form">
<label class="field-label" for="season-name">New season</label>
<input id="season-name" name="name" placeholder="Season name" required />
<div class="color-row">
<label for="season-color">Colour</label>
<input id="season-color" name="color" type="color" value="#6366f1" class="color-input" />
</div>
<Button type="submit" size="sm">Add</Button>
</form>
<ul>
{#each data.seasons as s}
<li>
<span class="dot" style="background:{s.color}"></span>
{s.name}
<Button variant="danger" size="sm" onclick={() => (deletingSeason = s)}>Remove</Button>
</li>
{/each}
</ul>
</Card>
<!-- Delete Season Modal -->
{#if deletingSeason}
<div class="overlay" onclick={() => (deletingSeason = null)} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Delete "{deletingSeason.name}"?</h3>
<p class="warning">
All chores assigned to this season will also be removed. This cannot be undone.
</p>
<Card title="Payday" cols={1}>
<form
method="POST"
action="?/deleteSeason"
action="?/updatePayday"
use:enhance={() => {
return async ({ result }) => {
if (result.type === 'success') {
deletingSeason = null;
if (result.type === 'error') alert(result.error || 'Failed to update payday');
};
}}
class="payday-form"
>
<div class="payday-row">
<label>Day</label>
<select name="payday" bind:value={payday}>
<option value={0}>Sunday</option>
<option value={1}>Monday</option>
<option value={2}>Tuesday</option>
<option value={3}>Wednesday</option>
<option value={4}>Thursday</option>
<option value={5}>Friday</option>
<option value={6}>Saturday</option>
</select>
<label>Time</label>
<select name="paydayTime" bind:value={paydayTime}>
{#each paydayTimes as t}
<option value={t}>{t}</option>
{/each}
</select>
</div>
<div class="payday-row">
<label>Timezone</label>
<select name="timezone" bind:value={timezone}>
<option value="auto">{detectedTz ? `Auto (${detectedTz})` : 'Auto'}</option>
{#each timezoneOptions as tz}
<option value={tz}>{tz}</option>
{/each}
</select>
</div>
<Button type="submit" size="sm">Set payday</Button>
</form>
<p class="hint">
Payday: the week starts on this day and weekly earnings are settled at this time. Auto timezone
follows each device.
</p>
</Card>
<Card title="Seasons" cols={1}>
<p class="hint">Group chores into seasons. Toggle seasons on/off from the top nav.</p>
<form method="POST" action="?/createSeason" use:enhance class="season-form">
<label class="field-label" for="season-name">New season</label>
<input id="season-name" name="name" placeholder="Season name" required />
<div class="color-row">
<label for="season-color">Colour</label>
<input id="season-color" name="color" type="color" value="#6366f1" class="color-input" />
</div>
<Button type="submit" size="sm">Add</Button>
</form>
<ul>
{#each data.seasons as s}
<li>
<span class="dot" style="background:{s.color}"></span>
{s.name}
<Button variant="danger" size="sm" onclick={() => (deletingSeason = s)}>Remove</Button>
</li>
{/each}
</ul>
</Card>
<!-- Delete Season Modal -->
{#if deletingSeason}
<div class="overlay" onclick={() => (deletingSeason = null)} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Delete "{deletingSeason.name}"?</h3>
<p class="warning">All chores assigned to this season will also be removed. This cannot be undone.</p>
<form method="POST" action="?/deleteSeason" use:enhance={() => {
return async ({ result }) => { if (result.type === 'success') deletingSeason = null; };
}}>
<input type="hidden" name="id" value={deletingSeason.id} />
<div class="modal-actions">
<button type="button" onclick={() => (deletingSeason = null)}>Cancel</button>
<button type="submit" class="danger">Delete Season</button>
</div>
</form>
</div>
</div>
{/if}
</CardGrid>
</AccordionItem>
<AccordionItem title="Invites">
<CardGrid>
<Card title="Members ({members.length})" cols={2}>
<div class="members-grid">
<div class="members-add">
<p class="hint">Add a child. They'll pick their own colour after joining.</p>
<form method="POST" action="?/addMember" use:enhance>
<label class="field-label" for="new-child">New child</label>
<input id="new-child" name="name" bind:value={addName} placeholder="Child name" required />
<Button type="submit" size="sm">Add child</Button>
</form>
</div>
<ul class="members-list">
{#each members as m}
<li>
<span class="member-left">
<span class="member-color" style="background:{m.color}"></span>
<span class="member-info">
<span class="member-name">{m.name}</span>
<span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
</span>
</span>
<span class="member-actions">
<Button href="/{famSlug}/{handleOf(m.username)}" variant="secondary" size="sm">Preview</Button>
<form method="POST" action="?/deleteMember" use:enhance class="inline">
<input type="hidden" name="id" value={m.id} />
<Button type="submit" variant="danger" size="sm" onclick={() => confirm('Remove {m.name}?')}>Remove</Button>
</form>
</span>
</li>
{/each}
</ul>
</div>
</Card>
<Card title="Invite Children" cols={1}>
<form
method="POST"
action="?/issueAccess"
use:enhance={() => {
return async ({ formData, result }) => {
if (result.type === 'success' && result.data?.ok) {
showQR = false;
qrDataUrl = '';
issued = {
otp: result.data.otp,
joinUrl: result.data.joinUrl,
name: String(formData.get('name') || '')
};
} else if (result.type === 'success' && result.data?.error) {
alert(result.data.error);
}
};
}}
class="invite-form"
>
<input type="hidden" name="id" value={deletingSeason.id} />
<div class="modal-actions">
<button type="button" onclick={() => (deletingSeason = null)}>Cancel</button>
<button type="submit" class="danger">Delete Season</button>
<label class="field-label" for="invite-child">Child</label>
<select id="invite-child" bind:value={inviteChild} name="name" required>
<option value="">— Select a child —</option>
{#each members as m}
<option value={m.name}>{m.name}</option>
{/each}
</select>
<Button type="submit" size="sm" disabled={!inviteChild}>Issue code</Button>
</form>
<p class="hint">
Generates a 6-digit code valid for 20 minutes. The child enters it at the join link.
</p>
{#if issued?.otp}
<div class="mt-3 rounded-lg border border-indigo-200 bg-indigo-50 p-4">
<p class="text-xs text-slate-500">Code for {issued.name} (valid 20 min):</p>
<p class="my-2 text-center text-4xl font-bold tracking-[0.3em] text-indigo-700">{issued.otp}</p>
<p class="invite-url">{invitePath}</p>
<div class="actions justify-center">
<Button variant="secondary" size="sm" onclick={() => copy(inviteUrl)}>{copied ? 'Copied!' : 'Copy URL'}</Button>
<Button variant="secondary" size="sm" onclick={toggleQR}>{showQR ? 'Hide QR' : 'Show QR'}</Button>
</div>
{#if showQR && qrDataUrl}
<div class="qr-wrap"><img src={qrDataUrl} alt="QR Code" class="qr" /></div>
{/if}
</div>
</form>
</div>
</div>
{/if}
{/if}
</Card>
<Card title="Billing & Subscription" cols={1}>
<p class="hint">
{data.fam?.active === false
? 'Your subscription is paused — interactions and payments are disabled.'
: 'Manage your plan, billing details, or pause your subscription.'}
</p>
<div class="actions">
<Button href="/account" variant="primary" size="sm">Account & Billing</Button>
<Button href="/subscriptions" variant="secondary" size="sm">Plans</Button>
</div>
</Card>
<Card title="Invite Parent" cols={1}>
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
<div class="invite-form">
<label class="field-label" for="parent-email">Parent email</label>
<input id="parent-email" type="email" bind:value={parentInviteEmail} placeholder="parent@example.com" />
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
</div>
<p class="hint">They will set up their own password on first login.</p>
</Card>
</CardGrid>
</AccordionItem>
<Card title="Data" cols={1}>
<div class="actions">
<Button variant="ghost" size="sm" disabled>Download CSV (coming soon)</Button>
<Button variant="danger" size="sm" disabled>Delete Family (coming soon)</Button>
</div>
</Card>
{#if data.fam?.featureFlags?.debugMode}
<Card title="Debug Tools" cols={1} accent="#f59e0b">
<p class="hint">Debug mode is enabled. These tools are for development and testing only.</p>
<div class="actions">
<form
method="POST"
action="?/completeWeek"
use:enhance={() => {
<AccordionItem title="Account">
<CardGrid>
<!-- Access -->
<Card title="Access" cols={1} accent={hasCode ? '#059669' : '#dc2626'}>
{#if hasCode}
<p class="hint">
Access active via access code
{#if data.accessCode?.duration}
— <strong>{formatCountdown(codeEntryDate, data.accessCode.duration)}</strong>
(expires {formatShortDate(addMonthsUTC(codeEntryDate!, data.accessCode.duration))})
{:else}
— <strong>never expires</strong>
{/if}.
</p>
<form
class="revoke-form"
method="POST"
action="?/revokeCode"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success') accessMsg = 'Access code revoked.';
else if (result.type === 'failure') accessMsg = (result.data as any)?.error || 'Revoke failed.';
await update();
};
}}
>
<Button type="submit" size="sm" variant="danger">Revoke code</Button>
</form>
{:else}
<p class="hint">No access applied. Enter a valid access code to enable your family.</p>
<form method="POST" action="?/applyCode" use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success') alert('Week completed!');
if (result.type === 'success' && result.data) {
const d = result.data as { error?: string; ok?: boolean };
accessMsg = d.error || 'Code applied — access enabled.';
accessCodeInput = '';
}
await update();
};
}}
>
<Button type="submit" size="sm" variant="secondary">Complete Week</Button>
</form>
<form
method="POST"
action="?/generateData"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success') alert('Test data generated!');
await update();
};
}}
>
<input type="hidden" name="days" value="7" />
<Button type="submit" size="sm" variant="secondary">Generate Test Data (7 days)</Button>
</form>
</div>
</Card>
{/if}
</CardGrid>
}}>
<input name="code" bind:value={accessCodeInput} placeholder="Enter access code" autocomplete="off" />
<Button type="submit" size="sm" variant="primary">Apply code</Button>
</form>
{/if}
{#if accessMsg}
<p class="access-msg">{accessMsg}</p>
{/if}
</Card>
<Card title="Subscription" cols={1} accent={fam?.paymentMode === 'sub' ? '#059669' : '#f59e0b'}>
<p class="hint">
{fam?.paymentMode === 'sub' ? 'Current plan: Subscription.' :
fam?.paymentMode === 'code' ? `Current plan: Access code${hasCode ? '' : ' (invalid)'}.` :
fam?.paymentMode === 'canceled' ? 'Your subscription was canceled.' : 'No active plan.'}
</p>
{#if fam?.paymentMode === 'sub'}
<div class="actions">
<Button href="/pricing" variant="primary" size="sm">Change plan</Button>
<form method="POST" action="?/billingPortal" use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success' && (result.data as any)?.portalUrl) {
window.open((result.data as any).portalUrl, '_blank');
} else if (result.type === 'failure') {
accessMsg = (result.data as any)?.error || 'Failed to open billing portal';
}
await update();
};
}}>
<Button type="submit" variant="secondary" size="sm" disabled={!fam?.stripeCustomerId}>Open billing portal</Button>
</form>
</div>
<p class="hint resume-hint">
To pause your plan, cancel in the billing portal — you keep your data and can resubscribe
anytime. Cancelling takes effect at period end.
</p>
{:else if fam?.paymentMode === 'code'}
<div class="actions">
<Button href="/pricing" variant="primary" size="sm">Switch to subscription</Button>
</div>
{:else}
<div class="actions">
<Button href="/pricing" variant="primary" size="sm">Choose a plan</Button>
</div>
{/if}
</Card>
</CardGrid>
</AccordionItem>
<AccordionItem title="App">
<CardGrid>
{#if page.data.platformFlags?.debug}
<Card title="Debug Tools" cols={1} accent="#f59e0b">
<p class="hint">Debug mode is enabled. These tools are for development and testing only.</p>
<div class="actions">
<form method="POST" action="?/completeWeek" use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success') alert('Week completed!');
await update();
};
}}>
<Button type="submit" size="sm" variant="secondary">Complete Week</Button>
</form>
<form method="POST" action="?/generateData" use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success') alert('Test data generated!');
await update();
};
}}>
<input type="hidden" name="days" value="7" />
<Button type="submit" size="sm" variant="secondary">Generate Test Data (7 days)</Button>
</form>
</div>
</Card>
{/if}
<Card title="Data" cols={1}>
<div class="actions">
<Button variant="ghost" size="sm" disabled>Download CSV (coming soon)</Button>
<Button variant="danger" size="sm" disabled>Delete Family (coming soon)</Button>
</div>
</Card>
</CardGrid>
</AccordionItem>
<!-- Invites -->
</Accordion>
<NoticeDialog />
<style>
.hint {
font-size: 0.85rem;
@@ -698,4 +758,21 @@
border-radius: 6px;
margin-bottom: 0.5rem;
}
.access-msg {
margin-top: 0.5rem;
font-size: 0.85rem;
color: #059669;
}
.revoke-form {
margin-top: 0.6rem;
display: flex;
align-items: center;
gap: 0.6rem;
}
.revoke-form .hint {
font-size: 0.78rem;
}
.resume-hint {
margin-top: 0.5rem;
}
</style>