Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
69 changes: 69 additions & 0 deletions src/lib/components/HeroCard.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<script lang="ts">
/**
* The headline card at the top of a planning page: Recurring, Buckets and
* Income.
*
* Text first, as every hero here is: a tracked-out overline names the
* figure, the figure is set large in the display face, and one line under it
* says what it is made of. Whatever a page adds below that (a ribbon, a
* legend, a footnote) goes in `children`.
*
* One component so the three pages can't drift. They used to: Recurring had
* this statement, while Buckets and Income opened on a two-up box of
* small numbers or on nothing at all, so the same tab read three ways.
*
* The wash in the corner takes `tint`: the workspace accent by default, the
* chosen category's colour on Recurring, the approve green on Income.
*/
import type { Snippet } from 'svelte';
import Money from '$lib/components/Money.svelte';

let {
overline,
minor,
currency,
tint = 'var(--ws-accent)',
label,
sign = false,
icon,
line,
children
}: {
overline: string;
minor: bigint;
currency: string;
tint?: string;
/** Names the region for assistive tech. */
label: string;
/** Prefix a + on a positive figure (income). */
sign?: boolean;
/** An emoji ahead of the overline, for a category in focus. */
icon?: string | null;
line?: Snippet;
children?: Snippet;
} = $props();
</script>

<section
class="card-lg grain relative overflow-hidden p-5 pb-4"
style="background: radial-gradient(120% 90% at 100% -20%, color-mix(in oklab, {tint} 22%, transparent), transparent 62%), var(--surface); transition: background var(--dur-slow) var(--ease-out)"
aria-label={label}
>
<p class="section-label flex items-center gap-1.5">
{#if icon}<span class="text-[13px] tracking-normal normal-case">{icon}</span>{/if}
<span>{overline}</span>
</p>
<Money
{minor}
{currency}
{sign}
block
class="mt-2 font-[family-name:var(--font-display)] text-[length:var(--fs-hero)] leading-none font-bold"
/>
{#if line}
<p class="mt-2 text-[14px] leading-snug" style="color: var(--ink-2)">
{@render line()}
</p>
{/if}
{@render children?.()}
</section>
3 changes: 2 additions & 1 deletion src/lib/components/Money.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@

let text = $derived.by(() => {
const body = formatMinor(minor < 0n ? -minor : minor, currency);
const prefix = minor < 0n ? '−' : sign ? '+' : '';
// Zero carries no sign: "+$0.00" under Spending read as money coming in.
const prefix = minor < 0n ? '−' : sign && minor > 0n ? '+' : '';
// The sign goes with the digits when masked: a leading − would announce
// "you're under", which is exactly what the mask is meant to withhold.
return masked ? maskAmount(body) : prefix + body;
Expand Down
66 changes: 66 additions & 0 deletions src/lib/components/PeriodStepper.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<script lang="ts" module>
/** A direction to step in: a link, or a handler for pages that animate it. */
export type Step = { href: string } | { onclick: () => void } | null;
</script>

<script lang="ts">
/**
* Previous · label · next — the stepper for a period or a month.
*
* Activity, the map and the statement each drew their own: 36px circles with
* an inline chevron on two of them, lucide circles on the third, while the
* calendar used the square `.icon-btn`. Per the shape rule on `.icon-btn`, a
* round button closes something and a square one does something, and
* stepping a month does something. So: squares, everywhere, from here.
*
* A direction with nowhere to go leaves an equally sized gap rather than a
* dead button, so the label never shifts as you reach either end.
*/
import { ChevronLeft, ChevronRight } from '@lucide/svelte';

let {
label,
sublabel,
prev,
next,
prevLabel = 'Previous',
nextLabel = 'Next'
}: {
label: string;
/** A quiet second line under the label, like "In progress". */
sublabel?: string | null;
prev: Step;
next: Step;
prevLabel?: string;
nextLabel?: string;
} = $props();
</script>

{#snippet arrow(step: Step, aria: string, dir: 'prev' | 'next')}
{#if step === null}
<span class="h-[38px] w-[38px] shrink-0" aria-hidden="true"></span>
{:else if 'href' in step}
<a href={step.href} class="press icon-btn" aria-label={aria}>
{#if dir === 'prev'}<ChevronLeft class="h-4 w-4" />{:else}<ChevronRight
class="h-4 w-4"
/>{/if}
</a>
{:else}
<button type="button" onclick={step.onclick} class="press icon-btn" aria-label={aria}>
{#if dir === 'prev'}<ChevronLeft class="h-4 w-4" />{:else}<ChevronRight
class="h-4 w-4"
/>{/if}
</button>
{/if}
{/snippet}

<div class="flex items-center justify-between gap-2">
{@render arrow(prev, prevLabel, 'prev')}
<div class="min-w-0 text-center">
<p class="truncate text-[17px] font-semibold" style="color: var(--ink)">{label}</p>
{#if sublabel}
<p class="section-label mt-0.5 text-[10px]">{sublabel}</p>
{/if}
</div>
{@render arrow(next, nextLabel, 'next')}
</div>
64 changes: 64 additions & 0 deletions src/lib/components/PlanHeader.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Segmented from '$lib/components/Segmented.svelte';
import { page } from '$app/state';
import { CalendarDays } from '@lucide/svelte';

/**
* The Plan tab's masthead and its sub-navigation.
*
* Recurring charges and buckets are both money already claimed before
* anything discretionary — a subscription and a standing transfer to the
* travel fund are the same shape from a budgeting view — so they share a tab.
*
* The heading names the tab, "Plan", in the same place every other tab puts
* its own: top left, actions on the right. The switch used to sit *above* the
* heading, which made Plan the one tab whose title moved down the screen and
* whose first thing under the thumb was navigation rather than content. The
* switch now sits under the heading, full width, the way a native large title
* carries a segmented control: one wide target per half, easy one-handed.
*
* Real links rather than a client-side toggle: each keeps its own route, load
* and actions, and stays independently bookmarkable.
*
* The calendar is an action beside the heading rather than a third segment.
* A third segment would say it is a sibling of Recurring and Buckets, and it
* isn't: it's a view *across* both.
*/
let { actions }: { actions?: Snippet } = $props();

let slug = $derived(page.params.workspace);
let current = $derived(page.url.pathname.includes('/buckets') ? 'buckets' : 'recurring');

const items = [
{ key: 'recurring', label: 'Recurring', hint: 'Bills & subscriptions' },
{ key: 'buckets', label: 'Buckets', hint: 'Set aside each month' }
];
</script>

<div class="space-y-3">
<div class="flex items-center justify-between gap-3 px-1 pt-1">
<h1 class="text-[28px]">Plan</h1>
<div class="flex items-center gap-2">
<a
href="/w/{slug}/calendar"
class="press icon-btn"
aria-label="Month calendar"
title="Scheduled, by day"
>
<CalendarDays class="h-4 w-4" />
</a>
{@render actions?.()}
</div>
</div>
<Segmented
options={items.map((i) => ({
value: i.key,
label: i.label,
href: `/w/${slug}/${i.key}`,
title: i.hint
}))}
value={current}
ariaLabel="Plan section"
/>
</div>
49 changes: 0 additions & 49 deletions src/lib/components/PlanTabs.svelte

This file was deleted.

Loading
Loading