Add pomodoro, microbreaks, breathing guide, screen dimming, presentation mode, goals, multi-monitor, and activity manager
Major feature release (v0.1.3) adding 15 new features to the break timer: Backend (Rust): - Pomodoro cycle tracking with configurable short/long break pattern - Microbreak scheduling (20-20-20 rule) with independent timer - Screen dimming events with gradual opacity progression - Presentation mode detection (fullscreen app deferral) - Smart break detection (natural idle breaks counting toward goals) - Daily goal tracking and streak milestone events - Multi-monitor break overlay support - Working hours enforcement with per-day schedules - Weekly summary and natural break stats queries - Config expanded to 71 validated fields Frontend (Svelte): - 6 new components: BreathingGuide, ActivityManager, BreakOverlay, MicrobreakOverlay, DimOverlay, Celebration - Breathing guide with 5 patterns and animated pulsing halo - Activity manager with favorites, custom activities, momentum scroll - Confetti celebrations on milestones and goal completion - Dashboard indicators (pomodoro/microbreak/goal) moved inside ring - Settings reorganized into 18 logical cards - Breathing pattern selector redesigned with timing descriptions - Break activities expanded from 40 to 71 curated exercises - Sound presets expanded from 4 to 8 - Stats view with weekly summary and natural break tracking Also: version bump to 0.1.3, CHANGELOG, README and CLAUDE.md updates
This commit is contained in:
581
src/lib/components/ActivityManager.svelte
Normal file
581
src/lib/components/ActivityManager.svelte
Normal file
@@ -0,0 +1,581 @@
|
||||
<script lang="ts">
|
||||
import { config, autoSave } from "../stores/config";
|
||||
import { breakActivities, getCategoryLabel } from "../utils/activities";
|
||||
import ToggleSwitch from "./ToggleSwitch.svelte";
|
||||
import { pressable } from "../utils/animate";
|
||||
|
||||
const categories = ["eyes", "stretch", "breathing", "movement"] as const;
|
||||
let expandedCategory = $state<string | null>(null);
|
||||
let newActivityText = $state("");
|
||||
let newActivityCategory = $state<string>("eyes");
|
||||
let dropdownOpen = $state(false);
|
||||
let dropdownRef = $state<HTMLElement>(undefined!);
|
||||
let dropdownTriggerRef = $state<HTMLElement>(undefined!);
|
||||
let focusedOptionIndex = $state(-1);
|
||||
|
||||
// Close dropdown on outside click
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
|
||||
dropdownOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (dropdownOpen) {
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
// Set initial focused option to current selection
|
||||
focusedOptionIndex = categories.indexOf(newActivityCategory as typeof categories[number]);
|
||||
return () => document.removeEventListener("mousedown", handleOutsideClick);
|
||||
}
|
||||
});
|
||||
|
||||
// Focus the highlighted option when index changes
|
||||
$effect(() => {
|
||||
if (dropdownOpen && focusedOptionIndex >= 0) {
|
||||
const options = dropdownRef?.querySelectorAll<HTMLElement>('[role="option"]');
|
||||
options?.[focusedOptionIndex]?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
function handleDropdownKeydown(e: KeyboardEvent) {
|
||||
if (!dropdownOpen) {
|
||||
// Open on ArrowDown/Up/Enter/Space when closed
|
||||
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
dropdownOpen = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
dropdownOpen = false;
|
||||
dropdownTriggerRef?.focus();
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
focusedOptionIndex = Math.min(focusedOptionIndex + 1, categories.length - 1);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
focusedOptionIndex = Math.max(focusedOptionIndex - 1, 0);
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
focusedOptionIndex = 0;
|
||||
break;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
focusedOptionIndex = categories.length - 1;
|
||||
break;
|
||||
case "Enter":
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
if (focusedOptionIndex >= 0) {
|
||||
newActivityCategory = categories[focusedOptionIndex];
|
||||
dropdownOpen = false;
|
||||
dropdownTriggerRef?.focus();
|
||||
}
|
||||
break;
|
||||
case "Tab":
|
||||
dropdownOpen = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function markChanged() {
|
||||
autoSave();
|
||||
}
|
||||
|
||||
function toggleCategory(cat: string) {
|
||||
expandedCategory = expandedCategory === cat ? null : cat;
|
||||
}
|
||||
|
||||
function isBuiltinDisabled(text: string): boolean {
|
||||
return $config.disabled_builtin_activities.includes(text);
|
||||
}
|
||||
|
||||
function isBuiltinFavorite(text: string): boolean {
|
||||
return $config.favorite_builtin_activities.includes(text);
|
||||
}
|
||||
|
||||
function toggleBuiltinEnabled(text: string) {
|
||||
if (isBuiltinDisabled(text)) {
|
||||
$config.disabled_builtin_activities = $config.disabled_builtin_activities.filter((t) => t !== text);
|
||||
} else {
|
||||
$config.disabled_builtin_activities = [...$config.disabled_builtin_activities, text];
|
||||
}
|
||||
markChanged();
|
||||
}
|
||||
|
||||
function toggleBuiltinFavorite(text: string) {
|
||||
if (isBuiltinFavorite(text)) {
|
||||
$config.favorite_builtin_activities = $config.favorite_builtin_activities.filter((t) => t !== text);
|
||||
} else {
|
||||
$config.favorite_builtin_activities = [...$config.favorite_builtin_activities, text];
|
||||
}
|
||||
markChanged();
|
||||
}
|
||||
|
||||
function addCustomActivity() {
|
||||
const trimmed = newActivityText.trim();
|
||||
if (!trimmed || trimmed.length > 500) return;
|
||||
if ($config.custom_activities.length >= 100) return;
|
||||
|
||||
const id = `custom-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
$config.custom_activities = [
|
||||
...$config.custom_activities,
|
||||
{ id, category: newActivityCategory, text: trimmed, is_favorite: false, enabled: true },
|
||||
];
|
||||
newActivityText = "";
|
||||
markChanged();
|
||||
}
|
||||
|
||||
function removeCustomActivity(id: string) {
|
||||
$config.custom_activities = $config.custom_activities.filter((a) => a.id !== id);
|
||||
markChanged();
|
||||
}
|
||||
|
||||
function toggleCustomEnabled(id: string) {
|
||||
$config.custom_activities = $config.custom_activities.map((a) =>
|
||||
a.id === id ? { ...a, enabled: !a.enabled } : a,
|
||||
);
|
||||
markChanged();
|
||||
}
|
||||
|
||||
function toggleCustomFavorite(id: string) {
|
||||
$config.custom_activities = $config.custom_activities.map((a) =>
|
||||
a.id === id ? { ...a, is_favorite: !a.is_favorite } : a,
|
||||
);
|
||||
markChanged();
|
||||
}
|
||||
|
||||
// Get counts per category
|
||||
function builtinCount(cat: string): number {
|
||||
return breakActivities.filter((a) => a.category === cat).length;
|
||||
}
|
||||
function customCount(cat: string): number {
|
||||
return $config.custom_activities.filter((a) => a.category === cat).length;
|
||||
}
|
||||
function disabledCount(cat: string): number {
|
||||
return breakActivities
|
||||
.filter((a) => a.category === cat)
|
||||
.filter((a) => isBuiltinDisabled(a.text)).length
|
||||
+ $config.custom_activities
|
||||
.filter((a) => a.category === cat)
|
||||
.filter((a) => !a.enabled).length;
|
||||
}
|
||||
|
||||
// ── Svelte action: blocks mousedown from reaching parent dragScroll ──
|
||||
// Uses native addEventListener (not Svelte delegation) so stopPropagation
|
||||
// fires BEFORE the parent's node-level mousedown handler in bubble phase.
|
||||
function blockParentDrag(node: HTMLElement) {
|
||||
function stop(e: MouseEvent) { e.stopPropagation(); }
|
||||
node.addEventListener("mousedown", stop);
|
||||
return { destroy() { node.removeEventListener("mousedown", stop); } };
|
||||
}
|
||||
|
||||
// ── Svelte action: inner drag-scroll with momentum, overscroll, custom scrollbar ──
|
||||
const prefersReducedMotion = typeof window !== "undefined"
|
||||
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
: false;
|
||||
|
||||
function innerDragScroll(node: HTMLElement) {
|
||||
const scrollEl = node.querySelector(".activity-scroll") as HTMLElement;
|
||||
const content = scrollEl?.querySelector(".scroll-content") as HTMLElement;
|
||||
if (!scrollEl || !content) return { destroy() {} };
|
||||
|
||||
// Reduced motion: skip custom scroll physics, allow normal scroll
|
||||
if (prefersReducedMotion) {
|
||||
// Still block wheel propagation for isolation
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.stopPropagation();
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollEl;
|
||||
const atTop = scrollTop <= 0 && e.deltaY < 0;
|
||||
const atBottom = scrollTop + clientHeight >= scrollHeight && e.deltaY > 0;
|
||||
if (!atTop && !atBottom) {
|
||||
e.preventDefault();
|
||||
scrollEl.scrollTop += e.deltaY;
|
||||
}
|
||||
}
|
||||
node.addEventListener("wheel", onWheel, { passive: false });
|
||||
return { destroy() { node.removeEventListener("wheel", onWheel); } };
|
||||
}
|
||||
|
||||
// Create custom scrollbar thumb
|
||||
const thumb = document.createElement("div");
|
||||
thumb.style.cssText =
|
||||
"position:absolute;right:2px;top:0;width:3px;border-radius:1.5px;" +
|
||||
"background:rgba(255,255,255,0.15);opacity:0;transition:opacity 0.3s ease;" +
|
||||
"z-index:10;pointer-events:none;";
|
||||
node.appendChild(thumb);
|
||||
|
||||
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let coastFrame = 0;
|
||||
let overscroll = 0;
|
||||
|
||||
function getMaxScroll() {
|
||||
return scrollEl.scrollHeight - scrollEl.clientHeight;
|
||||
}
|
||||
|
||||
function showThumb() {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollEl;
|
||||
if (scrollHeight <= clientHeight) { thumb.style.opacity = "0"; return; }
|
||||
const h = Math.max(20, (clientHeight / scrollHeight) * clientHeight);
|
||||
const t = (scrollTop / (scrollHeight - clientHeight)) * (clientHeight - h);
|
||||
thumb.style.height = `${h}px`;
|
||||
thumb.style.transform = `translateY(${t}px)`;
|
||||
thumb.style.opacity = "1";
|
||||
if (hideTimer) clearTimeout(hideTimer);
|
||||
hideTimer = setTimeout(() => { thumb.style.opacity = "0"; }, 1000);
|
||||
}
|
||||
|
||||
function setOverscroll(amount: number) {
|
||||
overscroll = amount;
|
||||
if (Math.abs(amount) < 0.5) {
|
||||
content.style.transform = "";
|
||||
overscroll = 0;
|
||||
} else {
|
||||
content.style.transform = `translateY(${-amount}px)`;
|
||||
}
|
||||
}
|
||||
|
||||
function springBack() {
|
||||
const from = overscroll;
|
||||
if (Math.abs(from) < 0.5) { setOverscroll(0); return; }
|
||||
const start = performance.now();
|
||||
function frame() {
|
||||
const t = Math.min(1, (performance.now() - start) / 500);
|
||||
const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out
|
||||
setOverscroll(from * (1 - ease));
|
||||
showThumb();
|
||||
if (t < 1) coastFrame = requestAnimationFrame(frame);
|
||||
else { content.style.transform = ""; overscroll = 0; }
|
||||
}
|
||||
coastFrame = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function forceReset() {
|
||||
cancelAnimationFrame(coastFrame);
|
||||
content.style.transform = "";
|
||||
overscroll = 0;
|
||||
}
|
||||
|
||||
function onDown(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (e.button !== 0) return;
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (["BUTTON", "INPUT", "LABEL", "SELECT"].includes(tag)) return;
|
||||
|
||||
forceReset();
|
||||
const startY = e.clientY;
|
||||
const startScroll = scrollEl.scrollTop;
|
||||
let lastY = e.clientY;
|
||||
let lastTime = Date.now();
|
||||
const vSamples: number[] = [];
|
||||
|
||||
scrollEl.style.cursor = "grabbing";
|
||||
showThumb();
|
||||
|
||||
function onMove(ev: MouseEvent) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const y = ev.clientY;
|
||||
const now = Date.now();
|
||||
const dt = now - lastTime;
|
||||
if (dt > 0) {
|
||||
vSamples.push((lastY - y) / dt);
|
||||
if (vSamples.length > 5) vSamples.shift();
|
||||
}
|
||||
lastY = y;
|
||||
lastTime = now;
|
||||
|
||||
const desired = startScroll - (y - startY);
|
||||
const max = getMaxScroll();
|
||||
|
||||
if (desired < 0) {
|
||||
scrollEl.scrollTop = 0;
|
||||
setOverscroll(desired * 0.3);
|
||||
} else if (desired > max) {
|
||||
scrollEl.scrollTop = max;
|
||||
setOverscroll((desired - max) * 0.3);
|
||||
} else {
|
||||
scrollEl.scrollTop = desired;
|
||||
if (overscroll !== 0) setOverscroll(0);
|
||||
}
|
||||
showThumb();
|
||||
}
|
||||
|
||||
function onUp(ev: MouseEvent) {
|
||||
ev.stopPropagation();
|
||||
scrollEl.style.cursor = "";
|
||||
window.removeEventListener("mousemove", onMove, true);
|
||||
window.removeEventListener("mouseup", onUp, true);
|
||||
|
||||
if (overscroll !== 0) { springBack(); return; }
|
||||
|
||||
// Momentum coast
|
||||
const avgV = vSamples.length > 0
|
||||
? vSamples.reduce((a, b) => a + b, 0) / vSamples.length : 0;
|
||||
const v0 = Math.max(-4, Math.min(4, avgV));
|
||||
if (Math.abs(v0) < 0.005) return;
|
||||
|
||||
const tau = 300;
|
||||
const coastStart = performance.now();
|
||||
const scrollStart2 = scrollEl.scrollTop;
|
||||
const totalDist = v0 * tau;
|
||||
|
||||
function coast() {
|
||||
const t = performance.now() - coastStart;
|
||||
const decay = Math.exp(-t / tau);
|
||||
const offset = totalDist * (1 - decay);
|
||||
const target = scrollStart2 + offset;
|
||||
const max = getMaxScroll();
|
||||
|
||||
if (target < 0) {
|
||||
scrollEl.scrollTop = 0;
|
||||
const bounce = Math.min(30, Math.abs(v0 * decay) * 40);
|
||||
setOverscroll(-bounce);
|
||||
springBack();
|
||||
return;
|
||||
}
|
||||
if (target > max) {
|
||||
scrollEl.scrollTop = max;
|
||||
const bounce = Math.min(30, Math.abs(v0 * decay) * 40);
|
||||
setOverscroll(bounce);
|
||||
springBack();
|
||||
return;
|
||||
}
|
||||
|
||||
scrollEl.scrollTop = target;
|
||||
showThumb();
|
||||
if (Math.abs(v0 * decay) > 0.0005) {
|
||||
coastFrame = requestAnimationFrame(coast);
|
||||
}
|
||||
}
|
||||
coastFrame = requestAnimationFrame(coast);
|
||||
}
|
||||
|
||||
window.addEventListener("mousemove", onMove, true);
|
||||
window.addEventListener("mouseup", onUp, true);
|
||||
}
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.stopPropagation();
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollEl;
|
||||
const atTop = scrollTop <= 0 && e.deltaY < 0;
|
||||
const atBottom = scrollTop + clientHeight >= scrollHeight && e.deltaY > 0;
|
||||
if (!atTop && !atBottom) {
|
||||
e.preventDefault();
|
||||
scrollEl.scrollTop += e.deltaY;
|
||||
showThumb();
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener("mousedown", onDown);
|
||||
node.addEventListener("wheel", onWheel, { passive: false });
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener("mousedown", onDown);
|
||||
node.removeEventListener("wheel", onWheel);
|
||||
forceReset();
|
||||
if (hideTimer) clearTimeout(hideTimer);
|
||||
thumb.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Add custom activity -->
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add custom activity..."
|
||||
aria-label="New activity text"
|
||||
maxlength={500}
|
||||
class="w-full rounded-xl border border-[#161616] bg-black px-3 py-2.5 text-[13px]
|
||||
text-white outline-none placeholder:text-[#2a2a2a] focus:border-[#333]"
|
||||
bind:value={newActivityText}
|
||||
onkeydown={(e) => { if (e.key === "Enter") addCustomActivity(); }}
|
||||
/>
|
||||
</div>
|
||||
<!-- Custom category dropdown -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="relative" bind:this={dropdownRef} onkeydown={handleDropdownKeydown} role="presentation">
|
||||
<button
|
||||
bind:this={dropdownTriggerRef}
|
||||
use:pressable
|
||||
class="flex items-center gap-1.5 rounded-xl border border-[#161616] bg-black px-3 py-2.5 text-[13px] text-[#8a8a8a]
|
||||
hover:border-[#333] hover:text-white transition-colors"
|
||||
onclick={() => { dropdownOpen = !dropdownOpen; }}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={dropdownOpen}
|
||||
aria-label="Category: {getCategoryLabel(newActivityCategory)}"
|
||||
>
|
||||
<span>{getCategoryLabel(newActivityCategory)}</span>
|
||||
<svg aria-hidden="true" class="w-3 h-3 opacity-50 transition-transform duration-200 {dropdownOpen ? 'rotate-180' : ''}" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if dropdownOpen}
|
||||
<div class="absolute top-full left-0 mt-1 z-50 min-w-[140px] rounded-xl border border-[#222] bg-[#111] shadow-xl shadow-black/50 overflow-hidden"
|
||||
role="listbox" aria-label="Activity category">
|
||||
{#each categories as cat, i}
|
||||
<button
|
||||
class="w-full text-left px-3.5 py-2.5 text-[13px] transition-colors outline-none
|
||||
{newActivityCategory === cat ? 'text-white bg-[#1a1a1a]' : 'text-[#8a8a8a] hover:bg-[#1a1a1a] hover:text-white'}
|
||||
{focusedOptionIndex === i ? 'bg-[#1a1a1a] text-white' : ''}"
|
||||
role="option"
|
||||
aria-selected={newActivityCategory === cat}
|
||||
tabindex={focusedOptionIndex === i ? 0 : -1}
|
||||
onclick={() => { newActivityCategory = cat; dropdownOpen = false; dropdownTriggerRef?.focus(); }}
|
||||
>
|
||||
{getCategoryLabel(cat)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
use:pressable
|
||||
class="flex items-center justify-center w-10 h-10 rounded-xl border border-[#161616] text-[18px] text-[#8a8a8a]
|
||||
hover:border-[#333] hover:text-white transition-colors disabled:opacity-30"
|
||||
onclick={addCustomActivity}
|
||||
disabled={!newActivityText.trim()}
|
||||
aria-label="Add custom activity"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Category buttons -->
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each categories as cat}
|
||||
{@const total = builtinCount(cat) + customCount(cat)}
|
||||
{@const disabled = disabledCount(cat)}
|
||||
{@const isExpanded = expandedCategory === cat}
|
||||
<button
|
||||
use:pressable
|
||||
class="rounded-xl px-3 py-2 text-[11px] tracking-wider transition-all duration-200
|
||||
{isExpanded
|
||||
? 'bg-[#1a1a1a] text-white border border-[#333]'
|
||||
: 'bg-[#0a0a0a] text-[#8a8a8a] border border-[#161616] hover:border-[#333] hover:text-white'}"
|
||||
onclick={() => toggleCategory(cat)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls="activity-panel-{cat}"
|
||||
>
|
||||
<span class="uppercase">{getCategoryLabel(cat)}</span>
|
||||
<span class="ml-1.5 text-[10px] {isExpanded ? 'text-[#8a8a8a]' : 'text-[#8a8a8a] opacity-60'}">
|
||||
{total - disabled}/{total}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Animated accordion for activity list -->
|
||||
{#each categories as cat}
|
||||
{@const isExpanded = expandedCategory === cat}
|
||||
{@const catBuiltins = breakActivities.filter((a) => a.category === cat)}
|
||||
{@const catCustoms = $config.custom_activities.filter((a) => a.category === cat)}
|
||||
<div class="accordion-wrapper" class:accordion-open={isExpanded} id="activity-panel-{cat}" role="region" aria-label="{getCategoryLabel(cat)} activities">
|
||||
<div class="accordion-inner">
|
||||
<div class="rounded-xl border border-[#161616] bg-[#0a0a0a] overflow-hidden" use:blockParentDrag>
|
||||
{#if catCustoms.length > 0}
|
||||
<div class="px-3 pt-2.5 pb-1">
|
||||
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">Custom</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="relative" use:innerDragScroll>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div class="activity-scroll max-h-[200px] overflow-y-auto overflow-x-hidden" tabindex="0" role="group" aria-label="{getCategoryLabel(cat)} activity list">
|
||||
<div class="scroll-content">
|
||||
{#each catCustoms as activity (activity.id)}
|
||||
<div class="flex items-center gap-1 px-1.5 py-0.5 group hover:bg-[#111]">
|
||||
<button
|
||||
class="flex items-center justify-center min-w-[32px] min-h-[32px] text-[13px] transition-opacity {activity.is_favorite ? 'opacity-100' : 'opacity-30 hover:opacity-60'}"
|
||||
onclick={() => toggleCustomFavorite(activity.id)}
|
||||
aria-label="{activity.is_favorite ? 'Remove from favorites' : 'Add to favorites'}"
|
||||
>
|
||||
★
|
||||
</button>
|
||||
<span class="flex-1 text-[12px] {activity.enabled ? 'text-white' : 'text-[#8a8a8a] line-through'}">
|
||||
{activity.text}
|
||||
</span>
|
||||
<button
|
||||
class="flex items-center justify-center min-w-[32px] min-h-[32px] text-[#8a8a8a] hover:text-[#f85149] opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-all text-[11px]"
|
||||
onclick={() => removeCustomActivity(activity.id)}
|
||||
aria-label="Remove {activity.text}"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<ToggleSwitch
|
||||
checked={activity.enabled}
|
||||
label="Enable {activity.text}"
|
||||
onchange={() => toggleCustomEnabled(activity.id)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if catCustoms.length > 0 && catBuiltins.length > 0}
|
||||
<div class="px-3 pt-2 pb-1">
|
||||
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">Built-in</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each catBuiltins as activity (activity.text)}
|
||||
<div class="flex items-center gap-1 px-1.5 py-0.5 hover:bg-[#111]">
|
||||
<button
|
||||
class="flex items-center justify-center min-w-[32px] min-h-[32px] text-[13px] transition-opacity {isBuiltinFavorite(activity.text) ? 'opacity-100' : 'opacity-30 hover:opacity-60'}"
|
||||
onclick={() => toggleBuiltinFavorite(activity.text)}
|
||||
aria-label="{isBuiltinFavorite(activity.text) ? 'Remove from favorites' : 'Add to favorites'}"
|
||||
>
|
||||
★
|
||||
</button>
|
||||
<span class="flex-1 text-[12px] {!isBuiltinDisabled(activity.text) ? 'text-[#8a8a8a]' : 'text-[#8a8a8a] line-through opacity-60'}">
|
||||
{activity.text}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
checked={!isBuiltinDisabled(activity.text)}
|
||||
label="Enable {activity.text}"
|
||||
onchange={() => toggleBuiltinEnabled(activity.text)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Accordion: CSS grid row transition for height animation */
|
||||
.accordion-wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s cubic-bezier(0.22, 0.03, 0.26, 1);
|
||||
}
|
||||
.accordion-wrapper.accordion-open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
.accordion-inner {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Hide native scrollbar completely */
|
||||
.activity-scroll {
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
.activity-scroll::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Edge */
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user