Files
Lumotia/src/lib/components/EnergyChip.svelte
Jake fc3a56c0dc feat(ux): B3.6 — editable energy labels (global + per-profile) + first-tap prompt
Settings → Energy: edit global energy labels (label + description per
level) with reset-to-defaults. Per-profile override toggle in the
Profiles section ("Customise for this profile" / "Use global").
EnergyChip and Tasks-page energy controls now resolve labels via
resolveEnergyLabels(profile, settings) — global by default, profile
override when present.

First-time prompt: on first energy-chip tap of the day (per profile)
or first-ever tap to Zero, an inline non-modal prompt asks "What does
Zero mean for you today?" with example chips. Per-day gate via
energyPromptLastShownDate; one-shot first-Zero gate via
energyPromptZeroSeen; Settings escape resets both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:30:06 +01:00

127 lines
4.5 KiB
Svelte

<script lang="ts">
// Phase 3 — Energy tag chip. Cycles a task's energy level through
// the spec's four states: unset → High → Medium → Zero → unset.
//
// Visual discipline: when energy is unset, the chip renders at a
// muted but still legible opacity so users can discover the affordance
// without first hovering the row. The previous "0% until hover"
// behaviour hid the control entirely, which read as broken on first
// contact. Once set, the chip is always fully visible because the
// colour IS the signal for the match-my-energy sort.
//
// Colour choices borrow the existing design tokens:
// High → accent (warm, on-brand, attention-ready)
// Medium → warning (amber, unforced)
// Zero → text-tertiary (low-energy grey, not danger red — the
// brief is explicit that this state must not feel
// pathologised; the internal enum value remains
// `brain_dead` to avoid a DB migration churn)
//
// Callers pass the task's current energy and a setter. This component
// owns no state — the task store is the source of truth.
//
// B3.6: the displayed string per level now comes from
// resolveEnergyLabels(activeProfile, settings) rather than being
// hardcoded, so users can rename "Zero" to "Battery dead" without
// touching code.
import type { EnergyLevel } from "$lib/types/app";
import { Zap } from "lucide-svelte";
import { settings, profiles, page } from "$lib/stores/page.svelte.js";
import { resolveEnergyLabels } from "$lib/utils/energyLabels";
let {
energy = null as EnergyLevel | null,
onSelect,
size = "sm",
reduceMotion = false,
}: {
energy: EnergyLevel | null;
onSelect: (next: EnergyLevel | null) => void;
size?: "sm" | "md";
reduceMotion?: boolean;
} = $props();
// Cycle order lives here so the chip is the single authority on what
// "next" means. Tap once to tag, tap again to move up, tap past
// Zero to clear. Keyboard-equivalent via the <button> element.
const CYCLE: (EnergyLevel | null)[] = [null, "high", "medium", "brain_dead"];
function next(): EnergyLevel | null {
const idx = CYCLE.indexOf(energy);
return CYCLE[(idx + 1) % CYCLE.length];
}
// Active profile lookup against the localStorage `profiles` array.
// page.activeProfile is "None" or a profile name; we resolve to the
// Profile entry (or null) so the resolver can read its override.
let activeProfile = $derived(
page.activeProfile === "None"
? null
: profiles.find((p) => p.name === page.activeProfile) ?? null,
);
let labels = $derived(resolveEnergyLabels(activeProfile, settings));
function labelFor(level: EnergyLevel | null): string {
switch (level) {
case "high": return labels.high.label;
case "medium": return labels.medium.label;
case "brain_dead": return labels.brain_dead.label;
default: return "No energy set";
}
}
let tooltip = $derived(
energy === null
? "Tag energy (click to set)"
: `Energy: ${labelFor(energy)} — click to change`
);
// Icon dimensions. `md` is the one used on the Tasks-page main rows;
// `sm` is for the compact WIP list rows and micro-step children.
let iconSize = $derived(size === "md" ? 13 : 10);
let chipSize = $derived(size === "md" ? "h-5" : "h-4");
</script>
<button
type="button"
class="energy-chip inline-flex items-center justify-center rounded-md border px-1 {chipSize} text-[10px] font-medium
{energy === null
? 'opacity-60 group-hover:opacity-100 text-text-tertiary border-border-subtle hover:border-accent hover:text-text-secondary'
: ''}
{energy === 'high'
? 'text-accent border-accent bg-accent/10'
: ''}
{energy === 'medium'
? 'text-warning border-warning bg-warning/10'
: ''}
{energy === 'brain_dead'
? 'text-text-tertiary border-border bg-hover'
: ''}"
onclick={() => onSelect(next())}
aria-label={tooltip}
title={tooltip}
data-energy={energy ?? 'unset'}
style={reduceMotion
? ''
: 'transition: opacity var(--duration-ui), color var(--duration-ui), border-color var(--duration-ui), background-color var(--duration-ui)'}
>
<Zap size={iconSize} aria-hidden="true" />
{#if energy !== null && size === "md"}
<span class="ml-1">{labelFor(energy)}</span>
{/if}
</button>
<style>
.energy-chip {
cursor: pointer;
line-height: 1;
font-family: var(--font-family-body);
}
.energy-chip:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 1px;
}
</style>