v1.2: overwrite confirm, transfer speed/ETA, settings + themes, fused macOS titlebar

- Overwrite confirmation: name-collision detection before enqueue; dialog shows
  source vs destination (size, mtime, 'newer' badge) with per-item Skip/Replace
  and batch Skip all / Replace all; type mismatches auto-skip with a toast;
  cancel aborts the whole operation
- Transfer speed + ETA: UI computes smoothed bytes/sec from progress deltas
  (callback bus makes deltas meaningful); shown in the expanded queue rows and
  the status-bar summary; unit follows the speed-unit setting
- Settings: bun-side SettingsStore (settings.json, 0600, atomic) + get/save RPC;
  SettingsService; gear popover on the tab row (theme segmented control, speed
  unit); persisted across restarts
- Light theme: data-theme on <html> drives token overrides; absent = follow OS
- macOS fused titlebar: hiddenInset + trafficLightOffset, tab row becomes the
  draggable titlebar (app-region drag/no-drag classes); other platforms keep a
  separate titlebar (gated by a platform value injected via window preload)
- README: known-issues note for the upstream Bun/Electrobun FFI crash

Verified: tsc + ui build clean; SettingsStore roundtrip + 0600 headless;
app boots and the UI fetches settings over RPC at startup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jokin
2026-07-11 17:14:50 +02:00
co-authored by Claude Fable 5
parent 262f4de04d
commit 6048d5c814
19 changed files with 571 additions and 14 deletions
+3
View File
@@ -34,6 +34,9 @@
@if (dialogs.chmod(); as req) {
<app-chmod-dialog [request]="req" />
}
@if (dialogs.overwrite(); as req) {
<app-overwrite-dialog [request]="req" />
}
<!-- toasts -->
<div class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2">
+2
View File
@@ -4,6 +4,7 @@ import { BRANDING } from '@shared/branding';
import { ConnectionManager } from './connection-manager/connection-manager';
import { ChmodDialog } from './dialogs/chmod-dialog';
import { ConfirmDialog } from './dialogs/confirm-dialog';
import { OverwriteDialog } from './dialogs/overwrite-dialog';
import { PromptDialog } from './dialogs/prompt-dialog';
import { DialogsService } from './dialogs.service';
import { FilePanel } from './file-panel/file-panel';
@@ -24,6 +25,7 @@ import { TransferBar } from './transfer-bar/transfer-bar';
PromptDialog,
ConfirmDialog,
ChmodDialog,
OverwriteDialog,
TransferBar,
ConnectionManager,
],
+19
View File
@@ -1,4 +1,15 @@
import { Injectable, signal } from '@angular/core';
import type { FileEntry } from '@shared/types';
export type OverwriteChoice = 'overwrite' | 'skip' | 'overwriteAll' | 'skipAll' | 'cancel';
export interface OverwriteRequest {
srcEntry: FileEntry;
dstEntry: FileEntry;
index: number; // 1-based position within the colliding items
total: number; // number of colliding items in this batch
resolve: (choice: OverwriteChoice) => void;
}
export interface PromptRequest {
title: string;
@@ -27,8 +38,16 @@ export class DialogsService {
readonly prompt = signal<PromptRequest | null>(null);
readonly confirm = signal<ConfirmRequest | null>(null);
readonly chmod = signal<ChmodRequest | null>(null);
readonly overwrite = signal<OverwriteRequest | null>(null);
readonly manager = signal(false);
/** Ask what to do about one name collision; resolves with the user's choice. */
askOverwrite(request: Omit<OverwriteRequest, 'resolve'>): Promise<OverwriteChoice> {
return new Promise((resolve) => {
this.overwrite.set({ ...request, resolve });
});
}
get anyOpen(): boolean {
return !!(this.prompt() || this.confirm() || this.chmod());
}
+151
View File
@@ -0,0 +1,151 @@
import {
Component,
ElementRef,
afterNextRender,
computed,
inject,
input,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '../dialogs.service';
import { formatDate, formatSize } from '../format';
import { PanelsService } from '../panels.service';
@Component({
selector: 'app-overwrite-dialog',
imports: [NgIcon],
template: `
<div
class="fixed inset-0 z-40 grid place-items-center bg-black/40"
(keydown.escape)="pick('cancel')"
>
<div
class="w-[26rem] rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
>
<div class="flex items-start justify-between gap-2">
<h2 class="min-w-0 text-sm font-semibold">
{{ isDir() ? 'Merge folder' : 'Replace' }}
<span class="font-mono">"{{ request().srcEntry.name }}"</span>?
</h2>
@if (request().total > 1) {
<span
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground"
>
{{ request().index }} of {{ request().total }}
</span>
}
</div>
<p class="mt-1 text-xs text-muted-foreground">
@if (isDir()) {
The destination already has a folder with this name. Merging may replace files inside
it.
} @else {
The destination already has a file with this name.
}
</p>
<!-- comparison -->
<div class="mt-3 grid grid-cols-2 gap-2">
@for (side of comparison(); track side.label) {
<div class="rounded-md border border-border p-2.5">
<div class="flex items-center justify-between">
<span class="text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
{{ side.label }}
</span>
@if (side.newer) {
<span class="rounded-full bg-success/15 px-1.5 text-[10px] font-medium text-success">
newer
</span>
}
</div>
<div class="mt-1.5 flex items-center gap-1.5 text-xs">
<ng-icon
[name]="isDir() ? 'mynaFolder' : 'mynaFile'"
size="0.9rem"
class="shrink-0 text-muted-foreground"
/>
<span class="font-mono tabular-nums">
{{ isDir() ? '—' : formatSize(side.entry.size) }}
</span>
</div>
<div class="mt-1 font-mono text-[11px] tabular-nums text-muted-foreground">
{{ formatDate(side.entry.mtime) }}
</div>
</div>
}
</div>
<!-- actions -->
<div class="mt-4 flex items-center gap-2">
<button
(click)="pick('cancel')"
class="rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
>
Cancel
</button>
<div class="flex-1"></div>
@if (request().total > 1) {
<button
(click)="pick('skipAll')"
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
Skip all
</button>
<button
(click)="pick('overwriteAll')"
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ isDir() ? 'Merge all' : 'Replace all' }}
</button>
<div class="h-4 w-px bg-border"></div>
}
<button
(click)="pick('skip')"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
Skip
</button>
<button
#primaryBtn
(click)="pick('overwrite')"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{{ isDir() ? 'Merge' : 'Replace' }}
</button>
</div>
</div>
</div>
`,
})
export class OverwriteDialog {
readonly request = input.required<OverwriteRequest>();
readonly formatSize = formatSize;
readonly formatDate = formatDate;
private readonly dialogs = inject(DialogsService);
private readonly panels = inject(PanelsService);
private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('primaryBtn');
constructor() {
afterNextRender(() => this.primaryBtn().nativeElement.focus());
}
readonly isDir = computed(() => this.request().srcEntry.type === 'dir');
readonly comparison = computed(() => {
const { srcEntry, dstEntry } = this.request();
return [
{ label: 'Source', entry: srcEntry, newer: srcEntry.mtime > dstEntry.mtime },
{ label: 'Destination', entry: dstEntry, newer: dstEntry.mtime > srcEntry.mtime },
];
});
pick(choice: OverwriteChoice): void {
const req = this.request();
this.dialogs.overwrite.set(null);
this.panels.refocus();
req.resolve(choice);
}
}
+19
View File
@@ -23,6 +23,25 @@ export function formatDate(epochMs: number): string {
return DATE_FORMAT.format(new Date(epochMs));
}
/** bytes/sec -> "14.2 MB/s" or "113.8 Mbps" depending on the configured unit. */
export function formatSpeed(bytesPerSec: number, unit: 'MBps' | 'Mbps'): string {
if (unit === 'Mbps') {
return `${((bytesPerSec * 8) / 1_000_000).toFixed(1)} Mbps`;
}
return `${formatSize(bytesPerSec)}/s`;
}
/** Seconds -> "0:42" / "3:07" / "1:02:15". */
export function formatEta(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '';
const s = Math.round(seconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const mmss = `${m}:${String(sec).padStart(2, '0')}`;
return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : mmss;
}
/** POSIX-style join for UI-composed paths (both panels use "/" on macOS + remote). */
export function joinPath(dir: string, name: string): string {
return dir.endsWith('/') ? dir + name : `${dir}/${name}`;
+11
View File
@@ -0,0 +1,11 @@
// Injected by the bun process via BrowserWindow preload before any page script.
declare global {
interface Window {
__BUNSCP_PLATFORM?: string;
}
}
export const PLATFORM = typeof window !== 'undefined' ? (window.__BUNSCP_PLATFORM ?? '') : '';
/** True when the window uses macOS hiddenInset chrome (tab bar fused with titlebar). */
export const IS_MAC_CHROME = PLATFORM === 'darwin';
+47
View File
@@ -0,0 +1,47 @@
import { Injectable, inject, signal } from '@angular/core';
import { DEFAULT_SETTINGS, type AppSettings } from '@shared/types';
import { RpcService } from './rpc.service';
@Injectable({ providedIn: 'root' })
export class SettingsService {
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
private readonly rpc = inject(RpcService);
constructor() {
if (!this.rpc.available) return;
void this.rpc.request.getSettings({}).then((res) => {
if (!res.ok) return;
this.theme.set(res.settings.theme);
this.speedUnit.set(res.settings.speedUnit);
this.applyTheme();
});
}
setTheme(theme: AppSettings['theme']): void {
this.theme.set(theme);
this.applyTheme();
void this.persist();
}
setSpeedUnit(unit: AppSettings['speedUnit']): void {
this.speedUnit.set(unit);
void this.persist();
}
private applyTheme(): void {
const theme = this.theme();
if (theme === 'system') {
delete document.documentElement.dataset['theme'];
} else {
document.documentElement.dataset['theme'] = theme;
}
}
private async persist(): Promise<void> {
await this.rpc.request.saveSettings({
settings: { theme: this.theme(), speedUnit: this.speedUnit() },
});
}
}
+9 -1
View File
@@ -1,7 +1,9 @@
import { Component, computed, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import type { TransferSnapshot } from '@shared/types';
import { formatSpeed } from '../format';
import { PanelsService } from '../panels.service';
import { SettingsService } from '../settings.service';
import { TransfersService } from '../transfers.service';
@Component({
@@ -20,7 +22,7 @@ import { TransfersService } from '../transfers.service';
@if (transfers.currentJob(); as job) {
<ng-icon name="mynaSpinner" size="0.8rem" class="shrink-0 animate-spin text-primary" />
<span class="truncate">
Copying {{ job.name }} · {{ percent(job) }}%
Copying {{ job.name }} · {{ percent(job) }}%{{ speedSuffix(job) }}
@if (transfers.queuedCount(); as queued) {
· {{ queued }} queued
}
@@ -57,10 +59,16 @@ import { TransfersService } from '../transfers.service';
export class StatusBar {
readonly transfers = inject(TransfersService);
readonly panels = inject(PanelsService);
readonly settings = inject(SettingsService);
readonly activePanel = computed(() => this.panels.get(this.panels.activeSide()));
percent(job: TransferSnapshot): number {
if (!job.totalBytes) return 0;
return Math.min(100, Math.round((job.doneBytes / job.totalBytes) * 100));
}
speedSuffix(job: TransferSnapshot): string {
const speed = this.transfers.speeds().get(job.id);
return speed ? ` · ${formatSpeed(speed, this.settings.speedUnit())}` : '';
}
}
+79 -4
View File
@@ -1,15 +1,31 @@
import { Component, inject } from '@angular/core';
import { Component, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { PanelsService } from '../panels.service';
import { IS_MAC_CHROME } from '../platform';
import { SettingsService } from '../settings.service';
const THEMES = ['system', 'light', 'dark'] as const;
const SPEED_UNITS = [
{ value: 'MBps', label: 'MB/s' },
{ value: 'Mbps', label: 'Mbps' },
] as const;
@Component({
selector: 'app-tab-bar',
imports: [NgIcon],
template: `
<div class="flex items-center gap-0.5 border-b border-border px-1.5 pt-1">
<!-- On macOS this row IS the titlebar (hiddenInset): taller, padded past the
traffic lights, and draggable. Elsewhere it's a normal compact row. -->
<div
class="relative flex items-center gap-0.5 border-b border-border pr-1.5 electrobun-webkit-app-region-drag"
[class.h-10]="isMacChrome"
[class.pl-20]="isMacChrome"
[class.pl-1.5]="!isMacChrome"
[class.pt-1]="!isMacChrome"
>
@for (ws of panels.workspaces(); track ws.id) {
<div
class="group relative flex max-w-52 items-center rounded-t-md"
class="group relative flex max-w-52 items-center rounded-md electrobun-webkit-app-region-no-drag"
[class.bg-accent]="ws.id === panels.activeWorkspaceId()"
>
<button
@@ -46,15 +62,74 @@ import { PanelsService } from '../panels.service';
<button
(click)="panels.newWorkspace()"
title="New tab (⌘T)"
class="ml-0.5 grid size-6 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
class="ml-0.5 grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaPlus" size="0.9rem" />
</button>
<div class="flex-1"></div>
<!-- global options -->
<button
(click)="settingsOpen.set(!settingsOpen())"
title="Options"
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaCog" size="0.95rem" />
</button>
@if (settingsOpen()) {
<div class="fixed inset-0 z-20" (click)="settingsOpen.set(false)"></div>
<div
class="absolute top-full right-1.5 z-30 mt-1 w-64 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-xl electrobun-webkit-app-region-no-drag"
>
<h3 class="text-xs font-semibold">Options</h3>
<div class="mt-3 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">Theme</span>
<div class="flex rounded-md border border-border p-0.5">
@for (theme of themes; track theme) {
<button
(click)="settings.setTheme(theme)"
class="rounded px-2 py-0.5 text-[11px] capitalize"
[class.bg-accent]="settings.theme() === theme"
[class.text-foreground]="settings.theme() === theme"
[class.text-muted-foreground]="settings.theme() !== theme"
>
{{ theme }}
</button>
}
</div>
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">Speed unit</span>
<div class="flex rounded-md border border-border p-0.5">
@for (unit of speedUnits; track unit.value) {
<button
(click)="settings.setSpeedUnit(unit.value)"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.speedUnit() === unit.value"
[class.text-foreground]="settings.speedUnit() === unit.value"
[class.text-muted-foreground]="settings.speedUnit() !== unit.value"
>
{{ unit.label }}
</button>
}
</div>
</div>
</div>
}
</div>
`,
})
export class TabBar {
readonly panels = inject(PanelsService);
readonly settings = inject(SettingsService);
readonly settingsOpen = signal(false);
readonly isMacChrome = IS_MAC_CHROME;
readonly themes = THEMES;
readonly speedUnits = SPEED_UNITS;
close(event: MouseEvent, id: number): void {
event.stopPropagation();
+17 -2
View File
@@ -1,7 +1,8 @@
import { Component, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import type { TransferSnapshot } from '@shared/types';
import { formatSize } from '../format';
import { formatEta, formatSize, formatSpeed } from '../format';
import { SettingsService } from '../settings.service';
import { TransfersService } from '../transfers.service';
@Component({
@@ -11,8 +12,20 @@ import { TransfersService } from '../transfers.service';
})
export class TransferBar {
readonly transfers = inject(TransfersService);
readonly settings = inject(SettingsService);
readonly formatSize = formatSize;
/** "14.2 MB/s · 0:42 left" while a job is moving bytes. */
speedLabel(job: TransferSnapshot): string {
if (job.state !== 'running') return '';
const speed = this.transfers.speeds().get(job.id);
if (!speed) return '';
const parts = [formatSpeed(speed, this.settings.speedUnit())];
const remaining = job.totalBytes - job.doneBytes;
if (remaining > 0 && speed > 0) parts.push(`${formatEta(remaining / speed)} left`);
return parts.join(' · ');
}
stateIcon(job: TransferSnapshot): string {
switch (job.state) {
case 'completed':
@@ -54,6 +67,8 @@ export class TransferBar {
if (job.state === 'cancelled') return 'Cancelled';
if (job.state === 'completed')
return `${job.doneFiles} file${job.doneFiles === 1 ? '' : 's'} · ${formatSize(job.doneBytes)}`;
return `${job.currentFile} · ${formatSize(job.doneBytes)} / ${formatSize(job.totalBytes)}`;
const speed = this.speedLabel(job);
const base = `${job.currentFile} · ${formatSize(job.doneBytes)} / ${formatSize(job.totalBytes)}`;
return speed ? `${base} · ${speed}` : base;
}
}
+97 -2
View File
@@ -1,19 +1,26 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import type { TransferSnapshot } from '@shared/types';
import type { FileEntry, TransferSnapshot } from '@shared/types';
import { DialogsService } from './dialogs.service';
import { PanelsService, type Side } from './panels.service';
import { RpcService } from './rpc.service';
import { ToastService } from './toast.service';
const AUTO_DISMISS_COMPLETED_MS = 5000;
const ACTIVE_STATES = ['queued', 'scanning', 'running'];
// EMA smoothing for the displayed transfer speed
const SPEED_SMOOTHING = 0.7;
@Injectable({ providedIn: 'root' })
export class TransfersService {
private readonly rpc = inject(RpcService);
private readonly panels = inject(PanelsService);
private readonly toast = inject(ToastService);
private readonly dialogs = inject(DialogsService);
private readonly jobMap = signal<ReadonlyMap<string, TransferSnapshot>>(new Map());
/** Smoothed bytes/sec per running job (UI-computed from progress deltas). */
readonly speeds = signal<ReadonlyMap<string, number>>(new Map());
private speedTrack = new Map<string, { bytes: number; time: number; speed: number }>();
readonly jobs = computed(() => [...this.jobMap().values()]);
readonly hasJobs = computed(() => this.jobMap().size > 0);
readonly activeJobs = computed(() => this.jobs().filter((j) => ACTIVE_STATES.includes(j.state)));
@@ -51,15 +58,75 @@ export class TransfersService {
return;
}
const finalTargets = await this.resolveCollisions(targets, dst.entries());
if (finalTargets === null) return; // user cancelled
if (!finalTargets.length) {
this.toast.info('Nothing to copy — all conflicting items were skipped');
return;
}
const res = await this.rpc.request.enqueueTransfers({
srcSessionId: src.sessionId(),
items: targets.map(({ path, type }) => ({ path, type })),
items: finalTargets.map(({ path, type }) => ({ path, type })),
dstSessionId: dst.sessionId(),
dstDir: dst.cwd(),
});
if (!res.ok) this.toast.error(res.error);
}
/**
* Confirm name collisions with the destination listing one by one.
* Returns the approved target list, or null if the user cancelled.
*/
private async resolveCollisions(
targets: FileEntry[],
dstEntries: FileEntry[],
): Promise<FileEntry[] | null> {
const dstByName = new Map(dstEntries.map((e) => [e.name, e]));
const collisions = targets.filter((t) => {
const dst = dstByName.get(t.name);
return dst && (dst.type === 'dir') === (t.type === 'dir');
});
let mode: 'ask' | 'all' | 'none' = 'ask';
let asked = 0;
const finalTargets: FileEntry[] = [];
for (const target of targets) {
const dstEntry = dstByName.get(target.name);
if (!dstEntry) {
finalTargets.push(target);
continue;
}
if ((dstEntry.type === 'dir') !== (target.type === 'dir')) {
this.toast.info(`Skipped "${target.name}" — a different kind of item is in the way`);
continue;
}
if (mode === 'all') {
finalTargets.push(target);
continue;
}
if (mode === 'none') continue;
const choice = await this.dialogs.askOverwrite({
srcEntry: target,
dstEntry,
index: ++asked,
total: collisions.length,
});
if (choice === 'cancel') return null;
if (choice === 'overwriteAll') {
mode = 'all';
finalTargets.push(target);
} else if (choice === 'skipAll') {
mode = 'none';
} else if (choice === 'overwrite') {
finalTargets.push(target);
}
}
return finalTargets;
}
async cancel(jobId: string): Promise<void> {
await this.rpc.request.cancelTransfer({ jobId });
}
@@ -70,12 +137,19 @@ export class TransfersService {
next.delete(jobId);
return next;
});
this.speedTrack.delete(jobId);
this.speeds.update((prev) => {
const next = new Map(prev);
next.delete(jobId);
return next;
});
await this.rpc.request.dismissTransfer({ jobId });
}
private apply(snapshot: TransferSnapshot): void {
const previous = this.jobMap().get(snapshot.id);
this.jobMap.update((prev) => new Map(prev).set(snapshot.id, snapshot));
this.trackSpeed(snapshot);
const justFinished = previous?.state !== snapshot.state;
if (!justFinished) return;
@@ -97,4 +171,25 @@ export class TransfersService {
if (panel.sessionId() === sessionId) void panel.refresh();
}
}
private trackSpeed(snapshot: TransferSnapshot): void {
if (snapshot.state !== 'running') {
if (snapshot.state !== 'queued' && snapshot.state !== 'scanning') {
this.speedTrack.delete(snapshot.id);
}
return;
}
const now = performance.now();
const prev = this.speedTrack.get(snapshot.id);
if (!prev) {
this.speedTrack.set(snapshot.id, { bytes: snapshot.doneBytes, time: now, speed: 0 });
return;
}
const dt = (now - prev.time) / 1000;
if (dt < 0.2) return; // wait for a meaningful window
const instant = Math.max(0, (snapshot.doneBytes - prev.bytes) / dt);
const speed = prev.speed ? SPEED_SMOOTHING * prev.speed + (1 - SPEED_SMOOTHING) * instant : instant;
this.speedTrack.set(snapshot.id, { bytes: snapshot.doneBytes, time: now, speed });
this.speeds.update((map) => new Map(map).set(snapshot.id, speed));
}
}
+30 -1
View File
@@ -24,8 +24,31 @@
--radius: 0.5rem;
}
/* Dark tokens apply when the user forces dark, or follows a dark system theme.
(data-theme is set by SettingsService; absent = follow the system.) */
:root[data-theme='dark'] {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--success: oklch(0.696 0.17 149.2);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
}
@media (prefers-color-scheme: dark) {
:root {
:root:not([data-theme='light']) {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
@@ -47,6 +70,12 @@
}
}
@media (prefers-color-scheme: dark) {
:root[data-theme='light'] {
color-scheme: light;
}
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);