Design pass (all themes + chat UI), model picker, reduce-motion toggle
- Aero: blue gradients back on the active segment and every primary
(Review due, the card's Study button, the composer's send button); a
frosted backing behind the page heading and the All decks label; a
bottom-weighted rail tint; chat header/composer/welcome panels; calmer
orbs; stronger card veil + a stronger blur bake.
- Dark/Light: a dedicated --track token makes progress bars read (the
first attempt shifted --surface-sunken and the pixel scan caught the
sheet-contrast regression); brighter dark secondary text; darker light
segmented labels and difficulty tints; stronger light card border and
shadow.
- Chat UI: friendly model chip ("Gemma 4 31B") with the raw name in a
tooltip, larger chips, plain centered disclaimer, vertically centered
welcome panel, distinct chat-plus icon for new chat.
- Chat models: every chat-capable model from the server's /api/tags is
selectable (favourites first, search field when long, live-list
validation in the route); default CHAT_MODEL_DEFAULT or gemma4:31b.
- Reduce motion: Settings toggle (default off) that parks the scene,
collapses CSS animations and drives framer-motion's calm mode.
- Folders hint, "New" badge on fresh decks.
Contrast: 0 findings, axe clean, mobile 126/126, chat 102/102, wizard
95/95, regression 44/44, i18n 13/13, native 33/33.
Version 0.4.6, SW -> freezmo-v71.
This commit is contained in:
parent
b70b0e83cf
commit
6635b5d405
29 changed files with 468 additions and 90 deletions
|
|
@ -12,11 +12,13 @@ import { cn } from "@/lib/utils";
|
|||
* --text-subtle is the muted helper token and is documented at 5.6:1 (light)
|
||||
* and 8.6:1 (dark), so the note stays readable at this size.
|
||||
*/
|
||||
export function AiDisclaimer({ className }: { className?: string }) {
|
||||
export function AiDisclaimer({ className, plain = false }: { className?: string; plain?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
// hint-line: Aero gives this its own glass chip (it sits on the wallpaper).
|
||||
<p className={cn("hint-line text-center text-[11.5px] leading-snug text-[var(--text-subtle)]", className)}>
|
||||
// Where the note already sits on a glass panel (the chat composer), `plain`
|
||||
// keeps it plain centered text like the other themes.
|
||||
<p className={cn(!plain && "hint-line", "text-center text-[11.5px] leading-snug text-[var(--text-subtle)]", className)}>
|
||||
{t("common.aiDisclaimer")}
|
||||
</p>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -225,9 +225,17 @@ export function DeckCard({
|
|||
) : null}
|
||||
</span>
|
||||
{/* Desktop always shows the same two fields. */}
|
||||
<span className="hidden lg:inline">
|
||||
<span aria-hidden> · </span>
|
||||
{deck.attempts > 0 ? plural(deck.attempts, "deck.runs") : t("deck.notPlayed")}
|
||||
<span className="hidden lg:inline-flex items-center">
|
||||
{deck.attempts > 0 ? (
|
||||
<>
|
||||
<span aria-hidden> · </span>
|
||||
{plural(deck.attempts, "deck.runs")}
|
||||
</>
|
||||
) : (
|
||||
<span className="tint-sky ml-1 rounded-full px-2 py-0.5 text-[10.5px] font-semibold">
|
||||
{t("deck.new")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -343,7 +351,7 @@ export function DeckCard({
|
|||
{t("deck.study")}
|
||||
</Link>
|
||||
<div className="relative z-10 hidden lg:flex lg:justify-end">
|
||||
<Link href={`/deck/${deck.id}`} className="btn-secondary h-9 px-4 text-[13px]">
|
||||
<Link href={`/deck/${deck.id}`} className="btn-primary h-9 px-4 text-[13px]">
|
||||
<PlayIcon className="size-4" />
|
||||
{t("deck.study")}
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -192,12 +192,14 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
{/* page header ---------------------------------------------------- */}
|
||||
<section className="mb-6 space-y-5">
|
||||
<div>
|
||||
<h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[26px]">
|
||||
{t("library.title")}
|
||||
</h1>
|
||||
<p className="mt-1.5 text-[13.5px] text-[var(--text-subtle)]">
|
||||
{t("library.subtitle")}
|
||||
</p>
|
||||
<div className="page-heading">
|
||||
<h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[26px]">
|
||||
{t("library.title")}
|
||||
</h1>
|
||||
<p className="mt-1.5 text-[13.5px] text-[var(--text-subtle)]">
|
||||
{t("library.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop keeps Import above the tabs; phones get a small icon
|
||||
|
|
@ -486,7 +488,7 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
|
||||
{/* One quiet label replaces the redundant single-group heading. */}
|
||||
{!hasFolders ? (
|
||||
<p className="mb-4 hidden text-[12px] font-medium text-[var(--text-subtle)] lg:block">
|
||||
<p className="hint-line mb-4 hidden text-[12px] font-medium text-[var(--text-subtle)] lg:block">
|
||||
{t("common.allDecks")}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
16
components/MotionPreference.tsx
Normal file
16
components/MotionPreference.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { MotionConfig } from "framer-motion";
|
||||
|
||||
import { useReducedMotionPref } from "@/lib/motion-pref";
|
||||
|
||||
/**
|
||||
* Teaches framer-motion about the in-app "reduce motion" setting: with the
|
||||
* toggle on, every motion component runs in its calm mode (`always`), not just
|
||||
* when the OS asks (`user`). Renders nothing itself, so the tree below stays
|
||||
* exactly as it was.
|
||||
*/
|
||||
export function MotionPreference({ children }: { children: React.ReactNode }) {
|
||||
const { reduced } = useReducedMotionPref();
|
||||
return <MotionConfig reducedMotion={reduced ? "always" : "user"}>{children}</MotionConfig>;
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import { ModelErrorActions } from "@/components/ModelErrorActions";
|
|||
import { DataSection } from "@/components/DataSection";
|
||||
import { LearningSection } from "@/components/LearningSection";
|
||||
import { ThemeSection } from "@/components/ThemeSection";
|
||||
import { useReducedMotionPref } from "@/lib/motion-pref";
|
||||
import { SyncSection } from "@/components/SyncSection";
|
||||
import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings";
|
||||
import { useBackClose } from "@/lib/use-back-close";
|
||||
|
|
@ -54,6 +55,7 @@ export function SettingsDialog({
|
|||
focusField?: { field: "key"; nonce: number } | null;
|
||||
}) {
|
||||
const { health, loading, error, refresh, apiKey, checkedAt } = useHealth();
|
||||
const { reduced: reducedMotion, setReduced: setReducedMotion } = useReducedMotionPref();
|
||||
const installAvailable = useInstallAvailable();
|
||||
const { locale, setLocale, t, plural } = useI18n();
|
||||
const version = useVersion();
|
||||
|
|
@ -411,6 +413,19 @@ export function SettingsDialog({
|
|||
{/* ------------------------------------------- theme */}
|
||||
<ThemeSection />
|
||||
|
||||
{/* --------------------------------------- motion */}
|
||||
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||
<ToggleRow
|
||||
label={t("settings.reduceMotion")}
|
||||
note={t("settings.reduceMotionNote")}
|
||||
checked={reducedMotion}
|
||||
onChange={() => {
|
||||
setReducedMotion(!reducedMotion);
|
||||
haptic("toggle");
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* ----------------------------------------- learning */}
|
||||
<LearningSection level={3} />
|
||||
|
||||
|
|
@ -773,6 +788,19 @@ export function SettingsDialog({
|
|||
{/* ------------------------------------------- theme */}
|
||||
<ThemeSection />
|
||||
|
||||
{/* --------------------------------------- motion */}
|
||||
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||
<ToggleRow
|
||||
label={t("settings.reduceMotion")}
|
||||
note={t("settings.reduceMotionNote")}
|
||||
checked={reducedMotion}
|
||||
onChange={() => {
|
||||
setReducedMotion(!reducedMotion);
|
||||
haptic("toggle");
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* ----------------------------------------- learning */}
|
||||
<LearningSection level={3} />
|
||||
|
||||
|
|
|
|||
|
|
@ -209,6 +209,12 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{sortedFolders.length === 0 && !collapsed ? (
|
||||
<p className="px-2.5 pb-1 pt-0.5 text-[11.5px] leading-snug text-[var(--text-subtle)]">
|
||||
{t("common.foldersHint")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{sortedFolders.map((folder) => (
|
||||
<button
|
||||
key={folder.id}
|
||||
|
|
|
|||
|
|
@ -689,7 +689,7 @@ export function UploadScreen() {
|
|||
aria-valuemin={1}
|
||||
aria-valuemax={3}
|
||||
aria-valuenow={step}
|
||||
className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--surface-sunken)]"
|
||||
className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--track)]"
|
||||
>
|
||||
<div
|
||||
className="grad-ice h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none"
|
||||
|
|
|
|||
|
|
@ -10,13 +10,14 @@ import { ContextPicker } from "./ContextPicker";
|
|||
import { HistorySheet } from "./HistoryPanel";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { ModelPicker } from "./ModelPicker";
|
||||
import { ChatIcon, ChevronDownIcon, ChevronLeftIcon, HistoryIcon, PlusIcon, RefreshIcon } from "@/components/icons";
|
||||
import { ChatIcon, ChatPlusIcon, ChevronDownIcon, ChevronLeftIcon, HistoryIcon, RefreshIcon } from "@/components/icons";
|
||||
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { Button } from "@/components/ui";
|
||||
import { ApiError } from "@/lib/client-api";
|
||||
import { extractChatAttachment, streamChat } from "@/lib/chat-client";
|
||||
import { buildChatContext, deckHasNotes } from "@/lib/chat-context";
|
||||
import { cn, prettyModelName } from "@/lib/utils";
|
||||
import { takeChatSeed, type ChatSeed } from "@/lib/chat-seed";
|
||||
import { chatActive } from "@/lib/chat-active";
|
||||
import { chatHistoryStore, conversationMessages, deleteConversation, ensureChatsHydrated, getConversation, renameConversation, saveConversation } from "@/lib/chat-store";
|
||||
|
|
@ -167,7 +168,10 @@ export function ChatScreen() {
|
|||
|
||||
const activeModel = useMemo(() => {
|
||||
if (model && chatModels.some((entry) => entry.name === model)) return model;
|
||||
return chatModels[0]?.name ?? model;
|
||||
// The server puts the default first, but a different server may not have
|
||||
// it — fall back to the first model that is actually available.
|
||||
const firstAvailable = chatModels.find((entry) => entry.available !== false);
|
||||
return (firstAvailable ?? chatModels[0])?.name ?? model;
|
||||
}, [model, chatModels]);
|
||||
|
||||
const scopeText = useMemo(() => {
|
||||
|
|
@ -524,7 +528,7 @@ export function ChatScreen() {
|
|||
<div className="flex h-full min-h-0 w-full">
|
||||
{/* Desktop's conversation list lives in the main sidebar (on /chat). */}
|
||||
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}>
|
||||
<header className="glass sticky top-0 z-30 flex h-14 shrink-0 items-center gap-2 px-2 sm:px-4">
|
||||
<header className="glass chat-header sticky top-0 z-30 flex h-14 shrink-0 items-center gap-2 px-2 sm:px-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (window.history.length > 1 ? router.back() : router.push("/"))}
|
||||
|
|
@ -563,16 +567,21 @@ export function ChatScreen() {
|
|||
title={t("chat.new")}
|
||||
className="grid size-11 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<PlusIcon className="size-5" />
|
||||
<ChatPlusIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div ref={scrollRef} onScroll={onScroll} className="chat-scroll h-full overflow-y-auto overscroll-contain">
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-3xl space-y-4 px-4 py-4",
|
||||
messages.length === 0 && "flex min-h-full flex-col justify-center",
|
||||
)}
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div className="space-y-5 py-6">
|
||||
<div className="chat-empty space-y-5 py-2">
|
||||
<div className="text-center">
|
||||
<span className="mx-auto mb-4 grid size-12 place-items-center rounded-2xl border border-[var(--hairline)] bg-[var(--surface-strong)] text-accent-600 dark:text-accent-200">
|
||||
<ChatIcon className="size-6" />
|
||||
|
|
@ -671,7 +680,7 @@ export function ChatScreen() {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="relative shrink-0">
|
||||
<div className="chat-composer relative shrink-0">
|
||||
<Composer
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
|
|
@ -680,7 +689,8 @@ export function ChatScreen() {
|
|||
busy={busy}
|
||||
online={online}
|
||||
scopeLabel={scopeText}
|
||||
modelLabel={activeModel || t("chat.modelTitle")}
|
||||
modelLabel={activeModel ? prettyModelName(activeModel) : t("chat.modelTitle")}
|
||||
modelTitle={activeModel || undefined}
|
||||
scopeOpen={scopeOpen}
|
||||
modelOpen={modelOpen}
|
||||
// The chips toggle their own popover (and open one at a time), so a
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export function Composer({
|
|||
online,
|
||||
scopeLabel,
|
||||
modelLabel,
|
||||
modelTitle,
|
||||
scopeOpen,
|
||||
modelOpen,
|
||||
onOpenScope,
|
||||
|
|
@ -43,6 +44,8 @@ export function Composer({
|
|||
online: boolean;
|
||||
scopeLabel: string;
|
||||
modelLabel: string;
|
||||
/** The raw model name, shown as the chip's tooltip. */
|
||||
modelTitle?: string;
|
||||
scopeOpen: boolean;
|
||||
modelOpen: boolean;
|
||||
onOpenScope: () => void;
|
||||
|
|
@ -168,8 +171,8 @@ export function Composer({
|
|||
onClick={onOpenScope}
|
||||
className={pickerChipClass(scopeOpen)}
|
||||
>
|
||||
<LayersIcon className="size-3.5 shrink-0" />
|
||||
<span className="max-w-[170px] truncate">{scopeLabel}</span>
|
||||
<LayersIcon className="size-4 shrink-0" />
|
||||
<span className="max-w-[190px] truncate">{scopeLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -177,10 +180,11 @@ export function Composer({
|
|||
aria-haspopup="dialog"
|
||||
aria-expanded={modelOpen}
|
||||
onClick={onOpenModel}
|
||||
title={modelTitle ?? modelLabel}
|
||||
className={pickerChipClass(modelOpen)}
|
||||
>
|
||||
<ChatIcon className="size-3.5 shrink-0" />
|
||||
<span className="max-w-[170px] truncate">{modelLabel}</span>
|
||||
<ChatIcon className="size-4 shrink-0" />
|
||||
<span className="max-w-[190px] truncate">{modelLabel}</span>
|
||||
</button>
|
||||
{!online ? (
|
||||
<span role="status" className="ml-auto shrink-0 text-[11.5px] text-amber-600 dark:text-amber-300">
|
||||
|
|
@ -189,7 +193,7 @@ export function Composer({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
<AiDisclaimer className="mt-1.5" />
|
||||
<AiDisclaimer plain className="mt-1.5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -200,9 +204,9 @@ export function Composer({
|
|||
*/
|
||||
function pickerChipClass(open: boolean): string {
|
||||
return cn(
|
||||
"flex min-w-0 items-center gap-1.5 rounded-xl border px-2.5 py-1.5 text-[12px] font-medium transition",
|
||||
"flex min-w-0 items-center gap-1.5 rounded-xl border px-3 py-2 text-[12.5px] font-medium transition",
|
||||
open
|
||||
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)] text-[var(--text)]"
|
||||
: "border-[var(--hairline-soft)] bg-[var(--surface-sunken)] text-[var(--text-muted)] hover:text-[var(--text)]",
|
||||
: "border-[var(--hairline)] bg-[var(--surface-sunken)] text-[var(--text)] hover:bg-[var(--hover)]",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ export function ContextPicker({
|
|||
"flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition",
|
||||
scope.kind === value
|
||||
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]"
|
||||
: "border-[var(--hairline-soft)] bg-[var(--surface-bg)] hover:bg-[var(--hover)]",
|
||||
: "border-[var(--hairline-soft)] bg-[var(--surface-strong)] hover:bg-[var(--hover)]",
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 text-[var(--text-muted)]">{icon}</span>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
import { CheckCircleIcon } from "@/components/icons";
|
||||
|
|
@ -32,6 +32,15 @@ export function ModelPicker({
|
|||
const isPhone = useIsPhone();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
/* Every model the server lists is offered, so the list can be long — filter
|
||||
it. Cleared each time the picker opens. */
|
||||
useEffect(() => {
|
||||
if (open) setQuery("");
|
||||
}, [open]);
|
||||
const needle = query.trim().toLowerCase();
|
||||
const visible = needle ? models.filter((model) => model.name.toLowerCase().includes(needle)) : models;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -61,8 +70,23 @@ export function ModelPicker({
|
|||
model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText");
|
||||
|
||||
const rows = (
|
||||
<>
|
||||
{models.length > 8 ? (
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("chat.modelSearch")}
|
||||
aria-label={t("chat.modelSearch")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="mb-2 w-full rounded-xl border border-[var(--hairline)] bg-[var(--surface-sunken)] px-3 py-2 text-[13px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)] focus:border-accent-400"
|
||||
/>
|
||||
) : null}
|
||||
<ul className="max-h-[60dvh] space-y-1 overflow-y-auto overscroll-contain">
|
||||
{models.map((model) => {
|
||||
{visible.length === 0 ? (
|
||||
<li className="px-3 py-2 text-[12.5px] text-[var(--text-subtle)]">{t("chat.modelSearchEmpty")}</li>
|
||||
) : null}
|
||||
{visible.map((model) => {
|
||||
const selected = model.name === value;
|
||||
const unavailable = model.available === false;
|
||||
return (
|
||||
|
|
@ -79,7 +103,7 @@ export function ModelPicker({
|
|||
"flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition",
|
||||
selected
|
||||
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]"
|
||||
: "border-[var(--hairline-soft)] bg-[var(--surface-bg)] hover:bg-[var(--hover)]",
|
||||
: "border-[var(--hairline-soft)] bg-[var(--surface-strong)] hover:bg-[var(--hover)]",
|
||||
unavailable && "opacity-55",
|
||||
)}
|
||||
>
|
||||
|
|
@ -100,6 +124,7 @@ export function ModelPicker({
|
|||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isPhone) {
|
||||
|
|
|
|||
|
|
@ -440,7 +440,7 @@ export function HomeworkInput({
|
|||
aria-valuemin={1}
|
||||
aria-valuemax={2}
|
||||
aria-valuenow={step}
|
||||
className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--surface-sunken)]"
|
||||
className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--track)]"
|
||||
>
|
||||
<div
|
||||
className="grad-ice h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none"
|
||||
|
|
|
|||
|
|
@ -197,6 +197,17 @@ export function PlusIcon({ className }: IconProps) {
|
|||
);
|
||||
}
|
||||
|
||||
/** A chat bubble with a plus: "new chat" — distinct from the plain plus the
|
||||
* rail uses for "new deck" (two identical + buttons read as the same thing). */
|
||||
export function ChatPlusIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5Z" />
|
||||
<path d="M12 8.5v5M9.5 11h5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShareIcon({ className }: IconProps) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ export function ProgressBar({
|
|||
const percent = max <= 0 ? 0 : Math.min(100, Math.max(0, (value / max) * 100));
|
||||
return (
|
||||
<div
|
||||
className={cn("h-1 w-full overflow-hidden rounded-full bg-[var(--surface-sunken)]", className)}
|
||||
className={cn("h-1 w-full overflow-hidden rounded-full bg-[var(--track)]", className)}
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(percent)}
|
||||
aria-valuemin={0}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue