added color/ theme settings, new config window

This commit is contained in:
yonlu committed 2026-02-20 14:43:35 -05:00
1 parent fa6506ed5d
commit 88c3736110
43 files changed
+2314 -1207

No files matched your search

@@ -58,11 +58,11 @@ export class PlayerControls extends LitElement {
}
button:hover {
color: #ffd43b;
color: var(--yj-accent, #ffd43b);
}
.active {
color: #ffd43b;
color: var(--yj-accent, #ffd43b);
}
.repeat-one {
@@ -22,23 +22,23 @@ export class SeekBar extends LitElement {
--track-size: 6px;
flex: 1;
margin: 0 1em;
--wa-tooltip-background-color: #343a40;
--wa-tooltip-content-color: white;
--wa-tooltip-border-color: #343a40;
--wa-tooltip-background-color: var(--yj-bg-elevated, #343a40);
--wa-tooltip-content-color: var(--yj-text-primary, white);
--wa-tooltip-border-color: var(--yj-bg-elevated, #343a40);
--wa-tooltip-border-radius: 4px;
--wa-tooltip-font-size: 0.875em;
}
wa-slider::part(track) {
background: white;
background: var(--yj-text-primary, white);
}
wa-slider::part(indicator) {
background: yellow;
background: var(--yj-accent, yellow);
}
wa-slider::part(thumb) {
background: black;
background: var(--yj-bg-base, black);
}
#seek-bar-container {
@@ -35,8 +35,8 @@ export class VolumeControl extends LitElement {
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: #1a1a1a;
border: 1px solid #333;
background: var(--yj-bg-surface, #1a1a1a);
border: 1px solid var(--yj-border-subtle, #333);
border-radius: 8px;
padding: 1em 0.5em;
margin-bottom: 0.5em;
@@ -52,16 +52,16 @@ export class VolumeControl extends LitElement {
}
wa-slider::part(track) {
background: white;
background: var(--yj-text-primary, white);
height: 120px;
}
wa-slider::part(indicator) {
background: yellow;
background: var(--yj-accent, yellow);
}
wa-slider::part(thumb) {
background: black;
background: var(--yj-bg-base, black);
}
`;
@@ -0,0 +1,410 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
/**
* Schema describing a single config field.
*
* The `type` controls which input widget is rendered:
* - `text` – plain text input
* - `select` – dropdown with `options`
* - `color` – colour picker swatch
* - `directory` – readonly text + browse button
* - `number` – numeric input
* - `toggle` – on/off switch
*/
export interface ConfigFieldSchema {
key: string;
label: string;
description?: string;
type:
| 'text'
| 'select'
| 'color'
| 'directory'
| 'number'
| 'toggle';
options?: { value: string; label: string }[];
disabled?: boolean;
}
export interface ConfigFieldChangeEvent {
key: string;
value: unknown;
}
@customElement('config-field')
export class ConfigField extends LitElement {
static override styles = css`
:host {
display: block;
margin-bottom: 1em;
color-scheme: inherit;
}
.field {
display: flex;
flex-direction: column;
gap: 0.35em;
}
label {
font-weight: 600;
font-size: 0.85em;
color: var(--yj-text-primary, #fff);
}
.description {
font-size: 0.75em;
color: var(--yj-text-tertiary, #888);
margin: 0;
}
.input-row {
display: flex;
align-items: center;
gap: 0.5em;
}
input[type='text'],
input[type='number'] {
background: var(--yj-bg-elevated, #343a40);
color: var(--yj-text-primary, #fff);
border: 1px solid var(--yj-border-subtle, #333);
border-radius: 4px;
padding: 0.4em 0.6em;
font-size: 0.85em;
font-family: inherit;
min-width: 0;
flex: 1;
}
input:focus {
outline: 1px solid var(--yj-accent, #ffd43b);
border-color: var(--yj-accent, #ffd43b);
}
input[readonly] {
opacity: 0.8;
cursor: default;
}
select {
background: var(--yj-bg-elevated, #343a40);
color: var(--yj-text-primary, #fff);
border: 1px solid var(--yj-border-subtle, #333);
border-radius: 4px;
padding: 0.4em 0.6em;
font-size: 0.85em;
font-family: inherit;
cursor: pointer;
flex: 1;
}
select:focus {
outline: 1px solid var(--yj-accent, #ffd43b);
border-color: var(--yj-accent, #ffd43b);
}
select option {
background: var(--yj-bg-elevated, #343a40);
color: var(--yj-text-primary, #fff);
}
button {
background: var(--yj-info, #4263eb);
color: #fff;
border: none;
border-radius: 4px;
padding: 0.4em 0.8em;
font-size: 0.85em;
cursor: pointer;
white-space: nowrap;
}
button:hover {
background: var(--yj-info-hover, #3b5bdb);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Colour picker */
.color-wrapper {
display: flex;
align-items: center;
gap: 0.75em;
}
input[type='color'] {
width: 2.5em;
height: 2.5em;
border: 2px solid var(--yj-border, #444);
border-radius: 4px;
padding: 0;
cursor: pointer;
background: none;
}
input[type='color']::-webkit-color-swatch-wrapper {
padding: 2px;
}
input[type='color']::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
.color-hex {
font-family: monospace;
font-size: 0.85em;
color: var(--yj-text-secondary, #b3b3b3);
}
/* Toggle */
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.toggle-switch {
position: relative;
width: 2.5em;
height: 1.4em;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
inset: 0;
background: var(--yj-bg-overlay, #495057);
border-radius: 1em;
transition: background 0.2s;
}
.toggle-slider::before {
content: '';
position: absolute;
height: 1em;
width: 1em;
left: 0.2em;
bottom: 0.2em;
background: white;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle-switch input:checked + .toggle-slider {
background: var(--yj-accent, #ffd43b);
}
.toggle-switch input:checked + .toggle-slider::before {
transform: translateX(1.1em);
}
`;
@property({ attribute: false })
schema!: ConfigFieldSchema;
@property({ attribute: false })
value: unknown = '';
override render() {
if (!this.schema) return nothing;
return html`
<div class="field">
${this.schema.type === 'toggle'
? this.renderToggle()
: html`
<label>${this.schema.label}</label>
${this.renderInput()}
`}
${this.schema.description
? html`<p class="description">
${this.schema.description}
</p>`
: nothing}
</div>
`;
}
private renderInput() {
switch (this.schema.type) {
case 'text':
return this.renderText();
case 'number':
return this.renderNumber();
case 'select':
return this.renderSelect();
case 'color':
return this.renderColor();
case 'directory':
return this.renderDirectory();
default:
return html`<p>Unsupported field type</p>`;
}
}
private renderText() {
return html`
<input
type="text"
.value=${String(this.value ?? '')}
?disabled=${this.schema.disabled}
@change=${this.onTextChange}
/>
`;
}
private renderNumber() {
return html`
<input
type="number"
.value=${String(this.value ?? '')}
?disabled=${this.schema.disabled}
@change=${this.onTextChange}
/>
`;
}
private renderSelect() {
const current = String(this.value ?? '');
return html`
<select
?disabled=${this.schema.disabled}
@change=${this.onSelectChange}
>
${(this.schema.options ?? []).map(
(opt) => html`
<option
value=${opt.value}
?selected=${opt.value === current}
>
${opt.label}
</option>
`,
)}
</select>
`;
}
private renderColor() {
const hex = String(this.value ?? '#ffffff');
return html`
<div class="color-wrapper">
<input
type="color"
.value=${hex}
?disabled=${this.schema.disabled}
@input=${this.onColorInput}
/>
<span class="color-hex">${hex}</span>
</div>
`;
}
private renderDirectory() {
return html`
<div class="input-row">
<input
type="text"
.value=${String(this.value ?? '')}
readonly
/>
<button
?disabled=${this.schema.disabled}
@click=${this.onBrowseClick}
>
Browse
</button>
</div>
`;
}
private renderToggle() {
const checked = Boolean(this.value);
return html`
<div class="toggle-row">
<label>${this.schema.label}</label>
<label class="toggle-switch">
<input
type="checkbox"
?checked=${checked}
?disabled=${this.schema.disabled}
@change=${this.onToggleChange}
/>
<span class="toggle-slider"></span>
</label>
</div>
`;
}
// ===================================================================
// EVENT DISPATCHERS
// ===================================================================
private emitChange(value: unknown): void {
this.dispatchEvent(
new CustomEvent<ConfigFieldChangeEvent>(
'config-change',
{
detail: {
key: this.schema.key,
value,
},
bubbles: true,
composed: true,
},
),
);
}
private onTextChange = (e: Event) => {
const input = e.target as HTMLInputElement;
this.emitChange(input.value);
};
private onSelectChange = (e: Event) => {
const select = e.target as HTMLSelectElement;
this.emitChange(select.value);
};
private onColorInput = (e: Event) => {
const input = e.target as HTMLInputElement;
this.value = input.value;
this.emitChange(input.value);
};
private onBrowseClick = () => {
this.dispatchEvent(
new CustomEvent('config-browse', {
detail: { key: this.schema.key },
bubbles: true,
composed: true,
}),
);
};
private onToggleChange = (e: Event) => {
const input = e.target as HTMLInputElement;
this.emitChange(input.checked);
};
}
declare global {
interface HTMLElementTagNameMap {
'config-field': ConfigField;
}
}
@@ -0,0 +1,1000 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { EventsOn } from '@runtime/runtime';
import { Scan, FullRescan } from '@go/library/Library';
import {
GetLibraryDirectory,
SetLibraryDirectory,
GetScanConcurrency,
SetScanConcurrency,
} from '@go/config/Config';
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
import { ThemeController } from '@store/controllers/theme-controller';
import { Events } from '../../events';
import type { ConfigFieldChangeEvent } from './config-field';
import type { BackgroundShade } from '@store/theme-store';
import './config-field';
import './config-section';
// ===================================================================
// Scan metrics types and helpers (carried over from library-manager)
// ===================================================================
const NS_PER_MS = 1_000_000;
interface ScanMetrics {
total: number;
loadExisting: number;
walkDuration: number;
extractionWallClock: number;
dbWritesWallClock: number;
orphanCleanup: number;
postScanVariants: number;
formatExtraction: Record<string, number>;
formatCount: Record<string, number>;
tagExtraction: number;
durationExtraction: number;
batchCommits: number;
coverArtSave: number;
thumbnailWallClock: number;
thumbnailGeneration: number;
thumbnailSmall: number;
thumbnailMedium: number;
thumbnailLarge: number;
clearQueue: number;
clearDatabase: number;
clearCoverFiles: number;
added: number;
updated: number;
skipped: number;
removed: number;
}
function fmtNs(ns: number): string {
if (ns <= 0) return '<1ms';
const ms = ns / NS_PER_MS;
if (ms < 1) return '<1ms';
if (ms < 1000) return `${ms.toFixed(0)}ms`;
const s = ms / 1000;
if (s < 60) return `${s.toFixed(2)}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
return `${m}m ${rem.toFixed(1)}s`;
}
function fmtMs(ms: number): string {
if (ms <= 0) return '<1ms';
if (ms < 1000) return `${ms.toFixed(0)}ms`;
const s = ms / 1000;
if (s < 60) return `${s.toFixed(2)}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
return `${m}m ${rem.toFixed(1)}s`;
}
function formatMetricsText(m: ScanMetrics): string {
const line = (
label: string,
value: string,
indent = 0,
) => `${' '.repeat(indent)}${label}: ${value}`;
const lines: string[] = [
'=== YellowJacket Scan Results ===',
'',
line('Total', fmtNs(m.total)),
'',
'-- File Counts --',
line('Added', String(m.added), 1),
line('Updated', String(m.updated), 1),
line('Skipped', String(m.skipped), 1),
line('Removed', String(m.removed), 1),
];
if (
m.clearQueue > 0 ||
m.clearDatabase > 0 ||
m.clearCoverFiles > 0
) {
lines.push(
'',
'-- Clear Phases --',
line('Clear Queue', fmtNs(m.clearQueue), 1),
line(
'Clear Database',
fmtNs(m.clearDatabase),
1,
),
line(
'Clear Cover Files',
fmtNs(m.clearCoverFiles),
1,
),
);
}
lines.push(
'',
'-- Scan Phases --',
line(
'Load Existing',
fmtNs(m.loadExisting),
1,
),
line(
'Directory Walk',
fmtNs(m.walkDuration),
1,
),
line(
'Metadata Extraction (wall)',
fmtNs(m.extractionWallClock),
1,
),
line(
'DB Writes (wall)',
fmtNs(m.dbWritesWallClock),
1,
),
line(
'Thumbnail Generation (wall)',
fmtNs(m.thumbnailWallClock),
1,
),
line(
'Orphan Cleanup',
fmtNs(m.orphanCleanup),
1,
),
line(
'Post-Scan Variants',
fmtNs(m.postScanVariants),
1,
),
);
return lines.join('\n');
}
// ===================================================================
// Config page component
// ===================================================================
@customElement('config-page')
export class ConfigPage extends LitElement {
// --- Theme controller for reading/writing theme state ---
private themeCtrl = new ThemeController(this);
// --- Library state ---
@state() private libraryDirectory = '';
@state() private selectedDirectory = '';
@state() private scanning = false;
@state() private statusMessage = '';
@state() private metrics: ScanMetrics | null = null;
@state() private copied = false;
@state() private concurrencyMode = 'auto';
private cancelScanStarted?: () => void;
private cancelScanComplete?: () => void;
static override styles = css`
:host {
display: block;
padding: 1.5em;
color: var(--yj-text-primary, #fff);
font-family: system-ui, -apple-system, sans-serif;
overflow-y: auto;
}
h2 {
margin: 0 0 1em;
font-size: 1.4em;
font-weight: 600;
}
/* Button styles */
button {
padding: 0.5em 1.25em;
border: none;
border-radius: 4px;
font-size: 0.85em;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s ease;
white-space: nowrap;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-warning {
background: var(--yj-warning, #e8590c);
color: #fff;
}
.btn-warning:hover:not(:disabled) {
background: var(--yj-warning-hover, #d9480f);
}
.btn-danger {
background: var(--yj-error, #e03131);
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background: var(--yj-error-hover, #c92a2a);
}
.btn-success {
background: var(--yj-success, #2f9e44);
color: #fff;
}
.btn-success:hover:not(:disabled) {
background: var(--yj-success-hover, #2b8a3e);
}
.btn-ghost {
background: transparent;
color: var(--yj-text-tertiary, #868e96);
padding: 0.3em 0.75em;
font-size: 0.75em;
border: 1px solid var(--yj-border, #444);
}
.btn-ghost:hover:not(:disabled) {
background: var(--yj-bg-overlay, #495057);
color: var(--yj-text-primary, #fff);
}
.btn-ghost.copied {
border-color: var(--yj-success, #2f9e44);
color: var(--yj-success, #2f9e44);
}
/* Scan actions */
.scan-actions {
display: flex;
gap: 0.75em;
flex-wrap: wrap;
}
.save-row {
display: flex;
gap: 0.5em;
margin-top: 0.5em;
}
/* Status bar */
.status-bar {
margin-top: 1em;
padding: 0.75em 1em;
background: var(--yj-bg-elevated, #343a40);
border-radius: 4px;
font-size: 0.85em;
color: var(--yj-text-tertiary, #868e96);
min-height: 1.2em;
}
.status-bar.active {
color: var(--yj-accent, #ffd43b);
}
/* Metrics tree */
.metrics-wrapper {
margin-top: 1em;
}
.metrics-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5em;
}
.metrics-title {
margin: 0;
font-size: 0.95em;
font-weight: 600;
color: var(--yj-text-primary, #fff);
}
details {
margin-left: 1em;
}
details.root {
margin-left: 0;
}
summary {
cursor: pointer;
padding: 0.25em 0;
font-size: 0.85em;
color: var(--yj-text-secondary, #b3b3b3);
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
summary::before {
content: '\\25B6';
display: inline-block;
width: 1em;
font-size: 0.6em;
vertical-align: middle;
transition: transform 0.15s ease;
margin-right: 0.35em;
}
details[open] > summary::before {
transform: rotate(90deg);
}
.metric-row {
display: flex;
justify-content: space-between;
padding: 0.2em 0;
padding-left: 1.35em;
font-size: 0.85em;
}
.metric-label {
color: var(--yj-text-secondary, #adb5bd);
}
.metric-value {
color: var(--yj-text-primary, #e9ecef);
font-family: monospace;
font-weight: 500;
}
.metric-value.highlight {
color: var(--yj-accent, #ffd43b);
}
.metric-note {
color: var(--yj-text-tertiary, #868e96);
font-size: 0.75em;
font-style: italic;
padding-left: 1.35em;
}
.counts-grid {
display: grid;
grid-template-columns: repeat(4, auto);
gap: 0.25em 1.5em;
padding-left: 1.35em;
font-size: 0.85em;
}
.count-label {
color: var(--yj-text-secondary, #adb5bd);
}
.count-value {
color: var(--yj-text-primary, #e9ecef);
font-family: monospace;
}
/* Theme preview */
.color-preview {
display: flex;
gap: 0.5em;
margin-top: 0.75em;
flex-wrap: wrap;
}
.swatch {
width: 2em;
height: 2em;
border-radius: 4px;
border: 1px solid var(--yj-border, #444);
}
.swatch-label {
font-size: 0.7em;
text-align: center;
color: var(--yj-text-tertiary, #888);
margin-top: 0.2em;
}
.swatch-group {
display: flex;
flex-direction: column;
align-items: center;
}
`;
// ===================================================================
// LIFECYCLE
// ===================================================================
override connectedCallback(): void {
super.connectedCallback();
this.loadLibraryConfig();
this.cancelScanStarted = EventsOn(
Events.LibraryScanStarted,
this.handleScanStarted,
);
this.cancelScanComplete = EventsOn(
Events.LibraryScanComplete,
this.handleScanComplete,
);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.cancelScanStarted?.();
this.cancelScanComplete?.();
}
private async loadLibraryConfig(): Promise<void> {
try {
const [dir, mode] = await Promise.all([
GetLibraryDirectory(),
GetScanConcurrency(),
]);
this.libraryDirectory = dir;
this.selectedDirectory = dir;
this.concurrencyMode = mode;
} catch (err) {
console.error(
'Failed to load library config:',
err,
);
}
}
// ===================================================================
// LIBRARY HANDLERS
// ===================================================================
private handleScanStarted = (): void => {
this.scanning = true;
this.statusMessage = 'Scanning...';
this.metrics = null;
this.copied = false;
};
private handleScanComplete = (
metrics?: ScanMetrics,
): void => {
this.scanning = false;
this.statusMessage = 'Scan complete.';
if (metrics) {
this.metrics = metrics;
}
};
private handleDirectoryBrowse = async (): Promise<void> => {
try {
const dir = await DirectoryPicker();
if (dir) {
this.selectedDirectory = dir;
}
} catch (err) {
console.error(
'Directory picker failed:',
err,
);
}
};
private handleSaveDirectory = async (): Promise<void> => {
if (!this.selectedDirectory) return;
try {
await SetLibraryDirectory(
this.selectedDirectory,
);
this.libraryDirectory =
this.selectedDirectory;
this.statusMessage =
'Library directory saved. A scan will start automatically if the directory changed.';
} catch (err) {
this.statusMessage = `Failed to save directory: ${err}`;
}
};
private handleConcurrencyChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
const mode = String(e.detail.value);
SetScanConcurrency(mode)
.then(() => {
this.concurrencyMode = mode;
this.statusMessage =
'Storage type saved. Takes effect on next scan.';
})
.catch((err: unknown) => {
this.statusMessage = `Failed to save storage type: ${err}`;
});
};
private handleSoftScan = async (): Promise<void> => {
try {
await Scan();
} catch (err) {
this.statusMessage = `Scan failed: ${err}`;
}
};
private handleFullRescan = async (): Promise<void> => {
const confirmed = confirm(
'This will delete ALL library data including cover art and re-scan from scratch. Continue?',
);
if (!confirmed) return;
try {
await FullRescan();
} catch (err) {
this.statusMessage = `Full rescan failed: ${err}`;
}
};
private handleCopyMetrics = async (): Promise<void> => {
if (!this.metrics) return;
try {
await navigator.clipboard.writeText(
formatMetricsText(this.metrics),
);
this.copied = true;
setTimeout(() => {
this.copied = false;
}, 2000);
} catch (err) {
console.error(
'Failed to copy metrics:',
err,
);
}
};
// ===================================================================
// THEME HANDLERS
// ===================================================================
private handleAccentChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
this.themeCtrl
.setAccentColor(String(e.detail.value))
.catch((err: unknown) => {
console.error(
'Failed to set accent color:',
err,
);
});
};
private handleShadeChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
this.themeCtrl
.setBackgroundShade(
String(
e.detail.value,
) as BackgroundShade,
)
.catch((err: unknown) => {
console.error(
'Failed to set background shade:',
err,
);
});
};
// ===================================================================
// COMPUTED
// ===================================================================
private get directoryChanged(): boolean {
return (
this.selectedDirectory !==
this.libraryDirectory
);
}
private get hasRescanPhases(): boolean {
if (!this.metrics) return false;
const m = this.metrics;
return (
m.clearQueue > 0 ||
m.clearDatabase > 0 ||
m.clearCoverFiles > 0
);
}
// ===================================================================
// RENDER
// ===================================================================
override render() {
return html`
<h2>Settings</h2>
${this.renderThemeSection()}
${this.renderLibrarySection()}
`;
}
// --- Theme section ---
private renderThemeSection() {
return html`
<config-section
heading="Theme"
description="Customise the app's colour scheme."
>
<config-field
.schema=${{
key: 'accentColor',
label: 'Accent Colour',
description:
'The primary highlight colour used for active items, buttons, and indicators.',
type: 'color' as const,
}}
.value=${this.themeCtrl.accentColor}
@config-change=${this.handleAccentChange}
></config-field>
<config-field
.schema=${{
key: 'backgroundShade',
label: 'Background Shade',
description:
'Controls the overall brightness of the interface.',
type: 'select' as const,
options: [
{
value: 'darker',
label: 'Darker (OLED)',
},
{
value: 'dark',
label: 'Dark (Default)',
},
{
value: 'light',
label: 'Light',
},
],
}}
.value=${this.themeCtrl
.backgroundShade}
@config-change=${this.handleShadeChange}
></config-field>
<div class="color-preview">
${this.renderSwatches()}
</div>
</config-section>
`;
}
private renderSwatches() {
const swatches = [
{ label: 'Base', var: '--yj-bg-base' },
{
label: 'Surface',
var: '--yj-bg-surface',
},
{
label: 'Elevated',
var: '--yj-bg-elevated',
},
{
label: 'Overlay',
var: '--yj-bg-overlay',
},
{ label: 'Accent', var: '--yj-accent' },
];
return swatches.map(
(s) => html`
<div class="swatch-group">
<div
class="swatch"
style="background: var(${s.var})"
></div>
<span class="swatch-label">
${s.label}
</span>
</div>
`,
);
}
// --- Library section ---
private renderLibrarySection() {
return html`
<config-section
heading="Library"
description="Configure your music library location and scanning behaviour."
>
<config-field
.schema=${{
key: 'libraryDirectory',
label: 'Library Directory',
description:
'The root directory containing your music files.',
type: 'directory' as const,
}}
.value=${this.selectedDirectory}
@config-browse=${this.handleDirectoryBrowse}
></config-field>
${this.directoryChanged
? html`
<div class="save-row">
<button
class="btn-success"
?disabled=${this.scanning}
@click=${this.handleSaveDirectory}
>
Save Directory
</button>
</div>
`
: nothing}
<config-field
.schema=${{
key: 'concurrencyMode',
label: 'Storage Type',
description:
'Controls parallel workers during scanning. Auto-detect reads the disk type automatically.',
type: 'select' as const,
options: [
{
value: 'auto',
label: 'Auto-detect',
},
{
value: 'ssd',
label: 'SSD (max parallelism)',
},
{
value: 'hdd',
label: 'HDD (reduced I/O)',
},
],
}}
.value=${this.concurrencyMode}
@config-change=${this.handleConcurrencyChange}
></config-field>
<div class="scan-actions">
<button
class="btn-warning"
?disabled=${this.scanning}
@click=${this.handleSoftScan}
>
${this.scanning
? 'Scanning...'
: 'Soft Scan'}
</button>
<button
class="btn-danger"
?disabled=${this.scanning}
@click=${this.handleFullRescan}
>
${this.scanning
? 'Scanning...'
: 'Full Rescan'}
</button>
</div>
<div
class="status-bar ${this.scanning ? 'active' : ''}"
>
${this.statusMessage || 'Ready.'}
</div>
${this.renderMetrics()}
</config-section>
`;
}
// --- Metrics ---
private renderMetricRow(
label: string,
value: string,
highlight = false,
) {
return html`
<div class="metric-row">
<span class="metric-label">${label}</span>
<span
class="metric-value ${highlight ? 'highlight' : ''}"
>${value}</span
>
</div>
`;
}
private renderMetrics() {
const m = this.metrics;
if (!m) return nothing;
const formatEntries = Object.entries(
m.formatExtraction ?? {},
).sort(([, a], [, b]) => b - a);
const pureDb = Math.max(
0,
m.batchCommits - m.coverArtSave,
);
return html`
<div class="metrics-wrapper">
<div class="metrics-header">
<p class="metrics-title">
Scan Results
</p>
<button
class="btn-ghost ${this.copied ? 'copied' : ''}"
@click=${this.handleCopyMetrics}
>
${this.copied
? 'Copied!'
: 'Copy'}
</button>
</div>
${this.renderMetricRow('Total', fmtNs(m.total), true)}
<details class="root" open>
<summary>File Counts</summary>
<div class="counts-grid">
<span class="count-label"
>Added</span
>
<span class="count-value"
>${m.added}</span
>
<span class="count-label"
>Updated</span
>
<span class="count-value"
>${m.updated}</span
>
<span class="count-label"
>Skipped</span
>
<span class="count-value"
>${m.skipped}</span
>
<span class="count-label"
>Removed</span
>
<span class="count-value"
>${m.removed}</span
>
</div>
</details>
${this.hasRescanPhases
? html`
<details class="root" open>
<summary>
Clear Phases
</summary>
${this.renderMetricRow('Clear Queue', fmtNs(m.clearQueue))}
${this.renderMetricRow('Clear Database', fmtNs(m.clearDatabase))}
${this.renderMetricRow('Clear Cover Files', fmtNs(m.clearCoverFiles))}
</details>
`
: nothing}
${this.renderMetricRow('Load Existing Files', fmtNs(m.loadExisting))}
${this.renderMetricRow('Directory Walk', fmtNs(m.walkDuration))}
<details class="root" open>
<summary>
Metadata Extraction &mdash;
${fmtNs(m.extractionWallClock)}
wall-clock
</summary>
<p class="metric-note">
Per-format and per-operation times
are cumulative across
${Object.values(
m.formatCount ?? {},
).reduce((a, b) => a + b, 0)}
files
</p>
${formatEntries.length > 0
? html`
<details open>
<summary>
By Format
</summary>
${formatEntries.map(
([ext, ms]) =>
this.renderMetricRow(
`${ext} (${m.formatCount?.[ext] ?? 0} files)`,
fmtMs(
ms,
),
),
)}
</details>
`
: nothing}
<details>
<summary>By Operation</summary>
${this.renderMetricRow('Tag Extraction', fmtNs(m.tagExtraction))}
${this.renderMetricRow('Duration Extraction', fmtNs(m.durationExtraction))}
</details>
</details>
<details class="root" open>
<summary>
Database Writes &mdash;
${fmtNs(m.dbWritesWallClock)}
wall-clock
</summary>
${this.renderMetricRow('Batch Commits', fmtNs(m.batchCommits))}
${this.renderMetricRow('Pure DB Operations', fmtNs(pureDb))}
${this.renderMetricRow('Save Cover Originals', fmtNs(m.coverArtSave))}
</details>
<details class="root" open>
<summary>
Thumbnail Generation &mdash;
${fmtNs(m.thumbnailWallClock)}
wall-clock
</summary>
<p class="metric-note">
Generated concurrently; cumulative
CPU time may exceed wall-clock
</p>
${this.renderMetricRow('Cumulative CPU Time', fmtNs(m.thumbnailGeneration))}
${this.renderMetricRow('Small (_sm)', fmtNs(m.thumbnailSmall))}
${this.renderMetricRow('Medium (_md)', fmtNs(m.thumbnailMedium))}
${this.renderMetricRow('Large (_lg)', fmtNs(m.thumbnailLarge))}
</details>
${this.renderMetricRow('Orphan Cleanup', fmtNs(m.orphanCleanup))}
${this.renderMetricRow('Post-Scan Variants', fmtNs(m.postScanVariants))}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'config-page': ConfigPage;
}
}
@@ -0,0 +1,69 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
/**
* A visual grouping wrapper for config fields.
* Renders a heading, optional description, and a slot for fields.
*/
@customElement('config-section')
export class ConfigSection extends LitElement {
static override styles = css`
:host {
display: block;
margin-bottom: 1.5em;
}
.section {
background: var(--yj-bg-surface, #212529);
border: 1px solid var(--yj-border-subtle, #333);
border-radius: 6px;
padding: 1.25em;
}
h3 {
margin: 0 0 0.25em;
font-size: 1em;
font-weight: 700;
color: var(--yj-text-primary, #fff);
}
.description {
font-size: 0.8em;
color: var(--yj-text-tertiary, #888);
margin: 0 0 1em;
}
.fields {
display: flex;
flex-direction: column;
}
`;
@property({ type: String })
heading = '';
@property({ type: String })
description = '';
override render() {
return html`
<div class="section">
<h3>${this.heading}</h3>
${this.description
? html`<p class="description">
${this.description}
</p>`
: nothing}
<div class="fields">
<slot></slot>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'config-section': ConfigSection;
}
}
@@ -67,7 +67,7 @@ export class AlbumDropdown extends LitElement {
}
.album-dropdown {
background-color: #343a40;
background-color: var(--yj-bg-elevated, #343a40);
border-radius: 0 0 4px 4px;
padding: 12px 16px;
box-sizing: border-box;
@@ -81,7 +81,7 @@ export class AlbumDropdown extends LitElement {
height: 0;
border-left: 9px solid transparent;
border-right: 9px solid transparent;
border-bottom: 8px solid #343a40;
border-bottom: 8px solid var(--yj-bg-elevated, #343a40);
}
.dropdown-tracks {
@@ -103,11 +103,9 @@ export class AlbumDropdown extends LitElement {
}
.track-row:hover {
background-color: rgba(
255,
255,
255,
0.05
background-color: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.05)
);
}
@@ -121,13 +119,11 @@ export class AlbumDropdown extends LitElement {
}
.track-row.active {
background-color: rgba(
255,
212,
59,
0.1
background-color: var(
--yj-accent-bg,
rgba(255, 212, 59, 0.1)
);
color: #ffd43b;
color: var(--yj-accent, #ffd43b);
}
.track-row.selected.active {
@@ -140,14 +136,14 @@ export class AlbumDropdown extends LitElement {
}
.track-number {
color: #888;
color: var(--yj-text-tertiary, #888);
min-width: 22px;
text-align: right;
flex-shrink: 0;
}
.track-title {
color: #fff;
color: var(--yj-text-primary, #fff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -156,7 +152,7 @@ export class AlbumDropdown extends LitElement {
}
.track-duration {
color: #888;
color: var(--yj-text-tertiary, #888);
flex-shrink: 0;
margin-left: auto;
}
@@ -203,21 +203,21 @@ export class CoverGrid extends LitElement {
}
.album-card:hover {
background-color: rgba(255, 255, 255, 0.1);
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.1));
}
.album-card.selected {
outline: 2px solid #ffd43b;
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
.album-card.expanded {
background-color: #343a40;
background-color: var(--yj-bg-elevated, #343a40);
border-radius: 8px 8px 0 0;
}
.album-card:focus-visible {
outline: 2px solid #ffd43b;
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
@@ -227,7 +227,7 @@ export class CoverGrid extends LitElement {
aspect-ratio: 1;
border-radius: 4px;
overflow: hidden;
background-color: #282828;
background-color: var(--yj-bg-surface, #282828);
transition: scale 0.15s ease;
}
@@ -263,8 +263,8 @@ export class CoverGrid extends LitElement {
.select-toggle.checked {
opacity: 1;
background-color: #ffd43b;
border-color: #ffd43b;
background-color: var(--yj-accent, #ffd43b);
border-color: var(--yj-accent, #ffd43b);
}
.check-icon {
@@ -289,10 +289,10 @@ export class CoverGrid extends LitElement {
justify-content: center;
background: linear-gradient(
135deg,
#404040 0%,
#282828 100%
var(--yj-bg-overlay, #404040) 0%,
var(--yj-bg-surface, #282828) 100%
);
color: #b3b3b3;
color: var(--yj-text-secondary, #b3b3b3);
font-size: var(--placeholder-font, 48px);
}
@@ -310,7 +310,7 @@ export class CoverGrid extends LitElement {
.album-name {
font-size: var(--album-name-font, 14px);
font-weight: 400;
color: #fff;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -318,7 +318,7 @@ export class CoverGrid extends LitElement {
.artist-name {
font-size: var(--artist-name-font, 12px);
color: #b3b3b3;
color: var(--yj-text-secondary, #b3b3b3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -326,7 +326,7 @@ export class CoverGrid extends LitElement {
}
.album-year {
color: #888;
color: var(--yj-text-tertiary, #888);
}
/* ========================================
@@ -338,7 +338,7 @@ export class CoverGrid extends LitElement {
justify-content: center;
align-items: center;
padding: 32px;
color: #b3b3b3;
color: var(--yj-text-secondary, #b3b3b3);
}
.empty-state {
@@ -347,7 +347,7 @@ export class CoverGrid extends LitElement {
justify-content: center;
align-items: center;
padding: 48px;
color: #b3b3b3;
color: var(--yj-text-secondary, #b3b3b3);
text-align: center;
}
@@ -364,8 +364,8 @@ export class CoverGrid extends LitElement {
}
.context-menu-panel {
background-color: #343a40;
border: 1px solid #444;
background-color: var(--yj-bg-elevated, #343a40);
border: 1px solid var(--yj-border, #444);
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
@@ -377,16 +377,14 @@ export class CoverGrid extends LitElement {
}
.context-menu-panel wa-dropdown-item {
--wa-color-text-normal: #fff;
--wa-color-text-normal: var(--yj-text-primary, #fff);
font-size: 13px;
}
.context-menu-panel wa-dropdown-item:hover {
background-color: rgba(
255,
255,
255,
0.1
background-color: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.1)
);
}
@@ -242,7 +242,7 @@ export class LibraryManager extends LitElement {
:host {
display: block;
padding: 1.5em;
color: #e9ecef;
color: var(--yj-text-primary, #e9ecef);
font-family: system-ui, -apple-system, sans-serif;
overflow-y: auto;
}
@@ -251,13 +251,13 @@ export class LibraryManager extends LitElement {
margin: 0 0 1em 0;
font-size: 1.4em;
font-weight: 600;
color: #f8f9fa;
color: var(--yj-text-primary, #f8f9fa);
}
.section {
margin-bottom: 2em;
padding: 1.25em;
background: #2b3035;
background: var(--yj-bg-surface, #2b3035);
border-radius: 8px;
}
@@ -265,7 +265,7 @@ export class LibraryManager extends LitElement {
margin: 0 0 0.75em 0;
font-size: 1em;
font-weight: 600;
color: #dee2e6;
color: var(--yj-text-primary, #dee2e6);
}
.section-header {
@@ -282,7 +282,7 @@ export class LibraryManager extends LitElement {
.section-description {
margin: 0 0 1em 0;
font-size: 0.85em;
color: #868e96;
color: var(--yj-text-tertiary, #868e96);
line-height: 1.4;
}
@@ -296,10 +296,10 @@ export class LibraryManager extends LitElement {
.directory-path {
flex: 1;
padding: 0.5em 0.75em;
background: #1a1d20;
border: 1px solid #495057;
background: var(--yj-bg-elevated, #1a1d20);
border: 1px solid var(--yj-bg-overlay, #495057);
border-radius: 4px;
color: #adb5bd;
color: var(--yj-text-secondary, #adb5bd);
font-size: 0.85em;
font-family: monospace;
overflow: hidden;
@@ -309,7 +309,7 @@ export class LibraryManager extends LitElement {
}
.directory-path.has-value {
color: #e9ecef;
color: var(--yj-text-primary, #e9ecef);
}
button {
@@ -329,57 +329,57 @@ export class LibraryManager extends LitElement {
}
.btn-primary {
background: #4263eb;
background: var(--yj-info, #4263eb);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #3b5bdb;
background: var(--yj-info-hover, #3b5bdb);
}
.btn-success {
background: #2f9e44;
background: var(--yj-success, #2f9e44);
color: white;
}
.btn-success:hover:not(:disabled) {
background: #2b8a3e;
background: var(--yj-success-hover, #2b8a3e);
}
.btn-warning {
background: #e8590c;
background: var(--yj-warning, #e8590c);
color: white;
}
.btn-warning:hover:not(:disabled) {
background: #d9480f;
background: var(--yj-warning-hover, #d9480f);
}
.btn-danger {
background: #e03131;
background: var(--yj-error, #e03131);
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #c92a2a;
background: var(--yj-error-hover, #c92a2a);
}
.btn-ghost {
background: transparent;
color: #868e96;
color: var(--yj-text-tertiary, #868e96);
padding: 0.3em 0.75em;
font-size: 0.75em;
border: 1px solid #495057;
border: 1px solid var(--yj-bg-overlay, #495057);
}
.btn-ghost:hover:not(:disabled) {
background: #495057;
color: #e9ecef;
background: var(--yj-bg-overlay, #495057);
color: var(--yj-text-primary, #e9ecef);
}
.btn-ghost.copied {
border-color: #2f9e44;
color: #2f9e44;
border-color: var(--yj-success, #2f9e44);
color: var(--yj-success, #2f9e44);
}
.setting-row {
@@ -390,16 +390,16 @@ export class LibraryManager extends LitElement {
}
.setting-row label {
color: #adb5bd;
color: var(--yj-text-secondary, #adb5bd);
min-width: 8em;
}
.setting-row select {
padding: 0.4em 0.6em;
background: #1a1d20;
border: 1px solid #495057;
background: var(--yj-bg-elevated, #1a1d20);
border: 1px solid var(--yj-bg-overlay, #495057);
border-radius: 4px;
color: #e9ecef;
color: var(--yj-text-primary, #e9ecef);
font-size: 1em;
font-family: inherit;
cursor: pointer;
@@ -408,12 +408,12 @@ export class LibraryManager extends LitElement {
.setting-row select:focus {
outline: none;
border-color: #4263eb;
border-color: var(--yj-info, #4263eb);
}
.setting-row select option {
background: #2b3035;
color: #e9ecef;
background: var(--yj-bg-surface, #2b3035);
color: var(--yj-text-primary, #e9ecef);
}
.scan-actions {
@@ -425,15 +425,15 @@ export class LibraryManager extends LitElement {
.status-bar {
margin-top: 1.5em;
padding: 0.75em 1em;
background: #1a1d20;
background: var(--yj-bg-elevated, #1a1d20);
border-radius: 4px;
font-size: 0.85em;
color: #868e96;
color: var(--yj-text-tertiary, #868e96);
min-height: 1.2em;
}
.status-bar.active {
color: #ffd43b;
color: var(--yj-accent, #ffd43b);
}
/* --- Metrics tree --- */
@@ -453,7 +453,7 @@ export class LibraryManager extends LitElement {
cursor: pointer;
padding: 0.25em 0;
font-size: 0.85em;
color: #ced4da;
color: var(--yj-text-secondary, #ced4da);
list-style: none;
}
@@ -484,21 +484,21 @@ export class LibraryManager extends LitElement {
}
.metric-label {
color: #adb5bd;
color: var(--yj-text-secondary, #adb5bd);
}
.metric-value {
color: #e9ecef;
color: var(--yj-text-primary, #e9ecef);
font-family: monospace;
font-weight: 500;
}
.metric-value.highlight {
color: #ffd43b;
color: var(--yj-accent, #ffd43b);
}
.metric-note {
color: #868e96;
color: var(--yj-text-tertiary, #868e96);
font-size: 0.75em;
font-style: italic;
padding-left: 1.35em;
@@ -513,11 +513,11 @@ export class LibraryManager extends LitElement {
}
.count-label {
color: #adb5bd;
color: var(--yj-text-secondary, #adb5bd);
}
.count-value {
color: #e9ecef;
color: var(--yj-text-primary, #e9ecef);
font-family: monospace;
}
`;
@@ -48,14 +48,14 @@ export class NowPlaying extends LitElement {
.cover-placeholder {
width: 100%;
height: 100%;
background-color: #000;
background-color: var(--yj-bg-base, #000);
display: flex;
align-items: center;
justify-content: center;
}
.cover-placeholder wa-icon {
color: #fff;
color: var(--yj-text-primary, #fff);
font-size: 24px;
}
@@ -76,7 +76,7 @@ export class NowPlaying extends LitElement {
.track-artist {
font-size: 12px;
color: #666;
color: var(--yj-text-tertiary, #666);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -96,7 +96,7 @@ export class NowPlaying extends LitElement {
.resize-handle:hover,
.resize-handle.dragging {
background-color: #6c757d;
background-color: var(--yj-text-tertiary, #6c757d);
}
`;
@@ -37,8 +37,8 @@ export class PlaylistPicker extends LitElement {
}
.picker-panel {
background-color: #343a40;
border: 1px solid #444;
background-color: var(--yj-bg-elevated, #343a40);
border: 1px solid var(--yj-border, #444);
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
@@ -49,17 +49,17 @@ export class PlaylistPicker extends LitElement {
.picker-panel wa-dropdown-item {
cursor: pointer;
--wa-color-text-normal: #fff;
--wa-color-text-normal: var(--yj-text-primary, #fff);
font-size: 13px;
}
.picker-panel wa-dropdown-item:hover {
background-color: rgba(255, 255, 255, 0.1);
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
.separator {
height: 1px;
background: #555;
background: var(--yj-border-subtle, #555);
margin: 4px 0;
}
@@ -71,10 +71,10 @@ export class PlaylistPicker extends LitElement {
}
.create-form input {
background: #2a2d30;
border: 1px solid #555;
background: var(--yj-bg-surface, #2b3035);
border: 1px solid var(--yj-border-subtle, #555);
border-radius: 4px;
color: #fff;
color: var(--yj-text-primary, #fff);
padding: 6px 8px;
font-size: 13px;
outline: none;
@@ -82,11 +82,11 @@ export class PlaylistPicker extends LitElement {
}
.create-form input:focus {
border-color: #ffd43b;
border-color: var(--yj-accent, #ffd43b);
}
.create-form input::placeholder {
color: #888;
color: var(--yj-text-tertiary, #888);
}
.button-row {
@@ -96,10 +96,10 @@ export class PlaylistPicker extends LitElement {
}
.button-row button {
background: #495057;
background: var(--yj-bg-overlay, #495057);
border: none;
border-radius: 4px;
color: #fff;
color: var(--yj-text-primary, #fff);
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
@@ -111,23 +111,23 @@ export class PlaylistPicker extends LitElement {
}
.button-row button.primary {
background: #ffd43b;
background: var(--yj-accent, #ffd43b);
color: #000;
}
.button-row button.primary:hover {
background: #ffe066;
background: var(--yj-accent-hover, #ffe066);
}
.button-row button.primary:disabled {
background: #665a1e;
color: #888;
background: var(--yj-accent-muted, #665a1e);
color: var(--yj-text-tertiary, #888);
cursor: not-allowed;
}
.empty-message {
padding: 8px 12px;
color: #888;
color: var(--yj-text-tertiary, #888);
font-size: 13px;
}
`;
@@ -205,21 +205,21 @@ export class PlaylistView
align-items: center;
padding: 16px;
flex-shrink: 0;
border-bottom: 1px solid #333;
border-bottom: 1px solid var(--yj-border-subtle, #333);
}
.header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #fff;
color: var(--yj-text-primary, #fff);
}
.new-playlist-button {
background: none;
border: 1px solid #555;
border: 1px solid var(--yj-border-subtle, #555);
border-radius: 4px;
color: #fff;
color: var(--yj-text-primary, #fff);
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
@@ -230,8 +230,8 @@ export class PlaylistView
}
.new-playlist-button:hover {
border-color: #ffd43b;
color: #ffd43b;
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent, #ffd43b);
}
.create-form {
@@ -239,16 +239,16 @@ export class PlaylistView
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid #333;
border-bottom: 1px solid var(--yj-border-subtle, #333);
flex-shrink: 0;
}
.create-form input {
flex: 1;
background: #2a2d30;
border: 1px solid #555;
background: var(--yj-bg-surface, #2b3035);
border: 1px solid var(--yj-border-subtle, #555);
border-radius: 4px;
color: #fff;
color: var(--yj-text-primary, #fff);
padding: 6px 10px;
font-size: 13px;
outline: none;
@@ -256,18 +256,18 @@ export class PlaylistView
}
.create-form input:focus {
border-color: #ffd43b;
border-color: var(--yj-accent, #ffd43b);
}
.create-form input::placeholder {
color: #888;
color: var(--yj-text-tertiary, #888);
}
.create-form button {
background: #495057;
background: var(--yj-bg-overlay, #495057);
border: none;
border-radius: 4px;
color: #fff;
color: var(--yj-text-primary, #fff);
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
@@ -275,21 +275,21 @@ export class PlaylistView
}
.create-form button:hover {
background: #5a6268;
background: var(--yj-bg-overlay, #5a6268);
}
.create-form button.primary {
background: #ffd43b;
background: var(--yj-accent, #ffd43b);
color: #000;
}
.create-form button.primary:hover {
background: #ffe066;
background: var(--yj-accent-hover, #ffe066);
}
.create-form button.primary:disabled {
background: #665a1e;
color: #888;
background: var(--yj-accent-muted, #665a1e);
color: var(--yj-text-tertiary, #888);
cursor: not-allowed;
}
@@ -303,7 +303,7 @@ export class PlaylistView
.playlist-item {
border-bottom: 1px solid
rgba(255, 255, 255, 0.05);
var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
.playlist-header {
@@ -316,18 +316,18 @@ export class PlaylistView
}
.playlist-header:hover {
background-color: rgba(255, 255, 255, 0.05);
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
.playlist-item.drag-over > .playlist-header {
background-color: rgba(255, 212, 59, 0.15);
outline: 1px dashed #ffd43b;
background-color: var(--yj-accent-bg-strong, rgba(255, 212, 59, 0.15));
outline: 1px dashed var(--yj-accent, #ffd43b);
outline-offset: -1px;
}
.chevron {
font-size: 14px;
color: #888;
color: var(--yj-text-tertiary, #888);
flex-shrink: 0;
transition: transform 0.15s ease;
}
@@ -338,13 +338,13 @@ export class PlaylistView
.playlist-icon {
font-size: 18px;
color: #888;
color: var(--yj-text-tertiary, #888);
flex-shrink: 0;
}
.playlist-name {
font-size: 14px;
color: #fff;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -353,7 +353,7 @@ export class PlaylistView
.track-count {
font-size: 11px;
color: #666;
color: var(--yj-text-tertiary, #666);
flex-shrink: 0;
}
@@ -370,9 +370,9 @@ export class PlaylistView
.play-all-button {
background: none;
border: 1px solid #555;
border: 1px solid var(--yj-border-subtle, #555);
border-radius: 4px;
color: #fff;
color: var(--yj-text-primary, #fff);
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
@@ -383,8 +383,8 @@ export class PlaylistView
}
.play-all-button:hover {
border-color: #ffd43b;
color: #ffd43b;
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent, #ffd43b);
}
.track-item {
@@ -396,20 +396,20 @@ export class PlaylistView
}
.track-item:hover {
background-color: rgba(255, 255, 255, 0.05);
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
.track-item.selected {
background-color: rgba(100, 160, 255, 0.15);
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
}
.track-item.active {
background-color: rgba(255, 212, 59, 0.1);
color: #ffd43b;
background-color: var(--yj-accent-bg, rgba(255, 212, 59, 0.1));
color: var(--yj-accent, #ffd43b);
}
.track-item.selected.active {
background-color: rgba(100, 160, 255, 0.15);
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
}
.track-item.phantom {
@@ -424,7 +424,7 @@ export class PlaylistView
.phantom-badge {
display: inline-block;
font-size: 10px;
color: #e67700;
color: var(--yj-warning, #e67700);
background: rgba(230, 119, 0, 0.15);
padding: 1px 6px;
border-radius: 3px;
@@ -438,7 +438,7 @@ export class PlaylistView
.tracks-empty {
padding: 12px 0;
color: #666;
color: var(--yj-text-tertiary, #666);
font-size: 12px;
}
@@ -447,7 +447,7 @@ export class PlaylistView
justify-content: center;
align-items: center;
padding: 32px;
color: #b3b3b3;
color: var(--yj-text-secondary, #b3b3b3);
}
.empty-state {
@@ -456,7 +456,7 @@ export class PlaylistView
align-items: center;
justify-content: center;
padding: 48px 20px;
color: #b3b3b3;
color: var(--yj-text-secondary, #b3b3b3);
text-align: center;
gap: 8px;
}
@@ -474,8 +474,8 @@ export class PlaylistView
}
.context-menu-panel {
background-color: #343a40;
border: 1px solid #444;
background-color: var(--yj-bg-elevated, #343a40);
border: 1px solid var(--yj-border, #444);
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
@@ -484,7 +484,7 @@ export class PlaylistView
.context-menu-panel wa-dropdown-item {
cursor: pointer;
--wa-color-text-normal: #fff;
--wa-color-text-normal: var(--yj-text-primary, #fff);
font-size: 13px;
}
@@ -512,10 +512,10 @@ export class PlaylistView
.rename-input {
flex: 1;
background: #2a2d30;
border: 1px solid #ffd43b;
background: var(--yj-bg-surface, #2b3035);
border: 1px solid var(--yj-accent, #ffd43b);
border-radius: 4px;
color: #fff;
color: var(--yj-text-primary, #fff);
padding: 4px 8px;
font-size: 14px;
outline: none;
@@ -525,9 +525,9 @@ export class PlaylistView
.import-button {
background: none;
border: 1px solid #555;
border: 1px solid var(--yj-border-subtle, #555);
border-radius: 4px;
color: #fff;
color: var(--yj-text-primary, #fff);
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
@@ -538,8 +538,8 @@ export class PlaylistView
}
.import-button:hover {
border-color: #ffd43b;
color: #ffd43b;
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent, #ffd43b);
}
`;
@@ -153,7 +153,7 @@ export class QueuePanel
flex-shrink: 0;
width: 0;
overflow: hidden;
background-color: #212529;
background-color: var(--yj-bg-surface, #212529);
display: flex;
flex-direction: row;
}
@@ -163,7 +163,7 @@ export class QueuePanel
--queue-width,
${unsafeCSS(DEFAULT_WIDTH)}px
);
border-left: 1px solid #333;
border-left: 1px solid var(--yj-border-subtle, #333);
}
.resize-handle {
@@ -180,7 +180,7 @@ export class QueuePanel
.resize-handle:hover,
.resize-handle.dragging {
background-color: #6c757d;
background-color: var(--yj-text-tertiary, #6c757d);
}
.panel-content {
@@ -196,7 +196,7 @@ export class QueuePanel
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #333;
border-bottom: 1px solid var(--yj-border-subtle, #333);
flex-shrink: 0;
}
@@ -223,11 +223,11 @@ export class QueuePanel
}
.header-action-button:hover {
color: #ffd43b;
color: var(--yj-accent, #ffd43b);
}
.header-action-button:disabled {
color: #555;
color: var(--yj-border-subtle, #555);
cursor: not-allowed;
}
@@ -247,7 +247,7 @@ export class QueuePanel
padding: 8px 16px;
gap: 12px;
border-bottom: 1px solid
rgba(255, 255, 255, 0.05);
var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
cursor: default;
user-select: none;
width: 100%;
@@ -255,30 +255,30 @@ export class QueuePanel
}
.track-item:hover {
background-color: rgba(255, 255, 255, 0.05);
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
.track-item.selected {
background-color: rgba(100, 160, 255, 0.15);
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
}
.track-item.active {
background-color: rgba(255, 212, 59, 0.1);
background-color: var(--yj-accent-bg, rgba(255, 212, 59, 0.1));
}
.track-item.selected.active {
background-color: rgba(100, 160, 255, 0.15);
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
}
.track-position {
font-size: 12px;
color: #888;
color: var(--yj-text-tertiary, #888);
min-width: 20px;
text-align: right;
}
.track-item.active .track-position {
color: #ffd43b;
color: var(--yj-accent, #ffd43b);
}
.track-details {
@@ -297,12 +297,12 @@ export class QueuePanel
}
.track-item.active .track-title {
color: #ffd43b;
color: var(--yj-accent, #ffd43b);
}
.track-artist {
font-size: 11px;
color: #b3b3b3;
color: var(--yj-text-secondary, #b3b3b3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -311,7 +311,7 @@ export class QueuePanel
.remove-button {
background: none;
border: none;
color: #888;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
padding: 4px;
display: flex;
@@ -325,11 +325,11 @@ export class QueuePanel
}
.remove-button:hover {
color: #ff6b6b;
color: var(--yj-error, #ff6b6b);
}
.panel-content.drag-over {
outline: 2px dashed #ffd43b;
outline: 2px dashed var(--yj-accent, #ffd43b);
outline-offset: -2px;
}
@@ -338,7 +338,7 @@ export class QueuePanel
padding: 12px 16px;
text-align: center;
font-size: 12px;
color: #ffd43b;
color: var(--yj-accent, #ffd43b);
border-bottom: 1px solid
rgba(255, 212, 59, 0.2);
}
@@ -355,7 +355,7 @@ export class QueuePanel
left: 8px;
right: 8px;
height: 2px;
background: #ffd43b;
background: var(--yj-accent, #ffd43b);
border-radius: 1px;
z-index: 5;
pointer-events: none;
@@ -368,7 +368,7 @@ export class QueuePanel
left: 8px;
right: 8px;
height: 2px;
background: #ffd43b;
background: var(--yj-accent, #ffd43b);
border-radius: 1px;
z-index: 5;
pointer-events: none;
@@ -380,7 +380,7 @@ export class QueuePanel
align-items: center;
justify-content: center;
padding: 40px 20px;
color: #b3b3b3;
color: var(--yj-text-secondary, #b3b3b3);
text-align: center;
gap: 8px;
}
@@ -394,8 +394,8 @@ export class QueuePanel
}
.context-menu-panel {
background-color: #343a40;
border: 1px solid #444;
background-color: var(--yj-bg-elevated, #343a40);
border: 1px solid var(--yj-border, #444);
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
@@ -404,7 +404,7 @@ export class QueuePanel
.context-menu-panel wa-dropdown-item {
cursor: pointer;
--wa-color-text-normal: #fff;
--wa-color-text-normal: var(--yj-text-primary, #fff);
font-size: 13px;
}
+29 -14
View File
@@ -1,13 +1,15 @@
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import type { DragActiveDetail } from '@utils/drag-controller';
type View = 'home' | 'libraries' | 'playlists' | 'artists' | 'albums' | 'tracks';
type View = 'home' | 'libraries' | 'playlists' | 'artists' | 'albums' | 'tracks' | 'settings';
interface NavItem {
id: View;
label: string;
icon: string;
}
const MIN_WIDTH = 120;
@@ -21,7 +23,7 @@ export class AppSidebar extends LitElement {
display: block;
position: relative;
height: 100%;
background-color: #212529;
background-color: var(--yj-bg-surface, #212529);
min-width: ${MIN_WIDTH}px;
max-width: ${MAX_WIDTH}px;
}
@@ -40,7 +42,7 @@ export class AppSidebar extends LitElement {
.resize-handle:hover,
.resize-handle.dragging {
background-color: #6c757d;
background-color: var(--yj-text-tertiary, #6c757d);
}
ul {
@@ -50,19 +52,28 @@ export class AppSidebar extends LitElement {
}
li {
text-align: left;
display: flex;
align-items: center;
gap: 0.6em;
border-radius: 5px;
padding: 0.5em;
cursor: pointer;
transition: background-color 0.15s ease;
}
li wa-icon {
font-size: 0.9em;
flex-shrink: 0;
width: 1.2em;
text-align: center;
}
li:hover {
background-color: #343a40;
background-color: var(--yj-bg-elevated, #343a40);
}
li.active {
background-color: #495057;
background-color: var(--yj-bg-overlay, #495057);
}
li p {
@@ -73,8 +84,8 @@ export class AppSidebar extends LitElement {
}
li.drag-hover {
background-color: rgba(255, 212, 59, 0.15);
outline: 1px dashed #ffd43b;
background-color: var(--yj-accent-bg-strong, rgba(255, 212, 59, 0.15));
outline: 1px dashed var(--yj-accent, #ffd43b);
outline-offset: -1px;
}
`;
@@ -101,12 +112,13 @@ export class AppSidebar extends LitElement {
> | null = null;
private navItems: NavItem[] = [
{ id: 'home', label: 'Home' },
{ id: 'libraries', label: 'Libraries' },
{ id: 'playlists', label: 'Playlists' },
{ id: 'artists', label: 'Artists' },
{ id: 'albums', label: 'Albums' },
{ id: 'tracks', label: 'Tracks' },
{ id: 'home', label: 'Home', icon: 'house' },
{ id: 'libraries', label: 'Libraries', icon: 'folder-open' },
{ id: 'playlists', label: 'Playlists', icon: 'list' },
{ id: 'artists', label: 'Artists', icon: 'user-group' },
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
{ id: 'tracks', label: 'Tracks', icon: 'music' },
{ id: 'settings', label: 'Settings', icon: 'gear' },
];
override connectedCallback() {
@@ -179,6 +191,9 @@ export class AppSidebar extends LitElement {
@drop=${(e: DragEvent) =>
this.onNavDrop(e)}
>
<wa-icon
name=${item.icon}
></wa-icon>
<p>${item.label}</p>
</li>
`;
@@ -63,14 +63,14 @@ export class TrackInfo extends LitElement {
.cover-placeholder {
width: 100%;
height: 100%;
background-color: #2a2d30;
background-color: var(--yj-bg-elevated, #2a2d30);
display: flex;
align-items: center;
justify-content: center;
}
.cover-placeholder wa-icon {
color: #666;
color: var(--yj-text-tertiary, #666);
font-size: 18px;
}
@@ -85,7 +85,7 @@ export class TrackInfo extends LitElement {
.title {
font-size: 13px;
font-weight: 500;
color: #fff;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -93,7 +93,7 @@ export class TrackInfo extends LitElement {
.secondary {
font-size: 11px;
color: #888;
color: var(--yj-text-tertiary, #888);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -101,7 +101,7 @@ export class TrackInfo extends LitElement {
.duration {
font-size: 12px;
color: #888;
color: var(--yj-text-tertiary, #888);
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
@@ -252,8 +252,8 @@ export class TrackList extends LitElement implements SelectionHost {
grid-template-columns: var(--grid-cols, 1fr 1fr 80px);
padding: 8px;
font-weight: bold;
color: #fff;
border-bottom: 1px solid #666;
color: var(--yj-text-primary, #fff);
border-bottom: 1px solid var(--yj-text-tertiary, #666);
flex-shrink: 0;
}
@@ -277,7 +277,7 @@ export class TrackList extends LitElement implements SelectionHost {
width: 1px;
cursor: col-resize;
pointer-events: auto;
background-color: #444;
background-color: var(--yj-border, #444);
transition: background-color 0.15s ease;
}
@@ -292,7 +292,7 @@ export class TrackList extends LitElement implements SelectionHost {
.col-resize-handle:hover,
.col-resize-handle.active {
background-color: #6c757d;
background-color: var(--yj-text-tertiary, #6c757d);
}
lit-virtualizer {
@@ -307,7 +307,7 @@ export class TrackList extends LitElement implements SelectionHost {
grid-template-columns: var(--grid-cols, 1fr 1fr 80px);
font-size: 12px;
padding: 8px;
border-bottom: 1px solid #333;
border-bottom: 1px solid var(--yj-border-subtle, #333);
align-items: center;
width: 100%;
cursor: default;
@@ -324,23 +324,23 @@ export class TrackList extends LitElement implements SelectionHost {
}
.track-row:hover {
background-color: rgba(255, 255, 255, 0.05);
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
.track-row.selected {
background-color: rgba(100, 160, 255, 0.15);
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
}
.track-row.active {
background-color: rgba(255, 212, 59, 0.1);
background-color: var(--yj-accent-bg, rgba(255, 212, 59, 0.1));
}
.track-row.active {
color: #ffd43b;
color: var(--yj-accent, #ffd43b);
}
.track-row.selected.active {
background-color: rgba(100, 160, 255, 0.15);
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
}
.track-name {
@@ -363,8 +363,8 @@ export class TrackList extends LitElement implements SelectionHost {
}
.context-menu-panel {
background-color: #343a40;
border: 1px solid #444;
background-color: var(--yj-bg-elevated, #343a40);
border: 1px solid var(--yj-border, #444);
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
@@ -376,12 +376,12 @@ export class TrackList extends LitElement implements SelectionHost {
}
.context-menu-panel wa-dropdown-item {
--wa-color-text-normal: #fff;
--wa-color-text-normal: var(--yj-text-primary, #fff);
font-size: 13px;
}
.context-menu-panel wa-dropdown-item:hover {
background-color: rgba(255, 255, 255, 0.1);
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.1));
}
.submenu-item {
+3
View File
@@ -48,6 +48,9 @@ export const Events = {
PlaylistTracksChanged: "PlaylistTracksChanged",
PlaylistsRestored: "PlaylistsRestored",
// Config events
ThemeConfigChanged: "ThemeConfigChanged",
// Library events
LibraryScanStarted: "LibraryScanStarted",
LibraryScanComplete: "LibraryScanComplete",
-11
View File
@@ -1,11 +0,0 @@
*,
*::before,
*::after {
-webkit-user-select: none;
user-select: none;
}
body {
background-color: black;
color: white;
}
-42
View File
@@ -1,42 +0,0 @@
import 'htmx.org/dist/htmx.js'
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
import { Scan } from '@go/library/Library';
declare global {
interface Window {
DirectoryPicker: typeof DirectoryPicker;
Scan: typeof Scan;
selectLibraryDirectory: (pElement: HTMLInputElement) => void;
scanLibrary: (button: HTMLButtonElement) => void;
}
}
window.DirectoryPicker = DirectoryPicker;
window.Scan = Scan;
window.selectLibraryDirectory = (pElement: HTMLInputElement) => {
window.DirectoryPicker()
.then((result) => {
if (result.length !== 0) {
pElement.value = result;
}
})
.catch((err: unknown) => {
console.error('error with directory picker: ' + err);
});
};
window.scanLibrary = (button: HTMLButtonElement) => {
button.disabled = true;
button.textContent = 'Scanning...';
window.Scan()
.then(() => {
button.textContent = 'Scan Library';
button.disabled = false;
})
.catch((err: unknown) => {
console.error('error scanning library: ' + err);
button.textContent = 'Scan Library';
button.disabled = false;
});
};
-26
View File
@@ -1,26 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<link href="./config.css" rel="stylesheet" />
<title>yellowjacket - config</title>
</head>
<script src="./config.ts" type="module"></script>
<body>
<header class="container">
<hgroup>
<a href="/index.html">
<h1>YellowJacket</h1>
</a>
</hgroup>
</header>
<hr />
<main class="container">
<div hx-get="/config" hx-trigger="load"></div>
</main>
</body>
</html>
@@ -0,0 +1,65 @@
import type { ReactiveController, ReactiveControllerHost } from 'lit';
import type { ThemeState, BackgroundShade } from '../theme-store';
import { themeStore } from '../theme-store';
/**
* ThemeController connects a Lit component to the ThemeStore.
*
* Most components do not need this because CSS custom properties
* cascade into Shadow DOM automatically. Use this controller only
* in components that need to read or change theme values (e.g. the
* config page colour picker).
*/
export class ThemeController implements ReactiveController {
private host: ReactiveControllerHost;
private unsubscribe?: () => void;
constructor(host: ReactiveControllerHost) {
this.host = host;
host.addController(this);
}
// ===================================================================
// LIFECYCLE HOOKS
// ===================================================================
hostConnected(): void {
this.unsubscribe = themeStore.subscribe(() => {
this.host.requestUpdate();
});
}
hostDisconnected(): void {
this.unsubscribe?.();
}
// ===================================================================
// STATE ACCESSORS
// ===================================================================
get state(): Readonly<ThemeState> {
return themeStore.getState();
}
get accentColor(): string {
return this.state.accentColor;
}
get backgroundShade(): BackgroundShade {
return this.state.backgroundShade;
}
// ===================================================================
// ACTIONS
// ===================================================================
async setAccentColor(color: string): Promise<void> {
await themeStore.setAccentColor(color);
}
async setBackgroundShade(
shade: BackgroundShade,
): Promise<void> {
await themeStore.setBackgroundShade(shade);
}
}
+3
View File
@@ -4,3 +4,6 @@ export { PlayerController } from './controllers/player-controller';
export { queueStore } from './queue-store';
export type { QueueState, QueueTrack, RepeatMode } from './queue-store';
export { QueueController } from './controllers/queue-controller';
export { themeStore } from './theme-store';
export type { ThemeState, BackgroundShade } from './theme-store';
export { ThemeController } from './controllers/theme-controller';
+291
View File
@@ -0,0 +1,291 @@
import { EventsOn } from '@runtime/runtime';
import {
GetThemeAccentColor,
GetThemeBackgroundShade,
SetThemeAccentColor,
SetThemeBackgroundShade,
} from '@go/config/Config';
import { Events } from '../events';
export type BackgroundShade = 'darker' | 'dark' | 'light';
export interface ThemeState {
accentColor: string;
backgroundShade: BackgroundShade;
}
type Subscriber = () => void;
/**
* Shade palettes keyed by BackgroundShade.
* Each defines the base grayscale ramp used throughout the UI.
*/
interface ShadePalette {
bgBase: string;
bgSurface: string;
bgElevated: string;
bgOverlay: string;
textPrimary: string;
textSecondary: string;
textTertiary: string;
border: string;
borderSubtle: string;
hoverOverlay: string;
selectionBg: string;
}
const SHADE_PALETTES: Record<BackgroundShade, ShadePalette> = {
darker: {
bgBase: '#000000',
bgSurface: '#121212',
bgElevated: '#1e1e1e',
bgOverlay: '#2a2a2a',
textPrimary: '#ffffff',
textSecondary: '#b3b3b3',
textTertiary: '#888888',
border: '#333333',
borderSubtle: '#222222',
hoverOverlay: 'rgba(255, 255, 255, 0.05)',
selectionBg: 'rgba(100, 160, 255, 0.15)',
},
dark: {
bgBase: '#000000',
bgSurface: '#212529',
bgElevated: '#343a40',
bgOverlay: '#495057',
textPrimary: '#ffffff',
textSecondary: '#b3b3b3',
textTertiary: '#888888',
border: '#444444',
borderSubtle: '#333333',
hoverOverlay: 'rgba(255, 255, 255, 0.05)',
selectionBg: 'rgba(100, 160, 255, 0.15)',
},
light: {
bgBase: '#ffffff',
bgSurface: '#f8f9fa',
bgElevated: '#e9ecef',
bgOverlay: '#dee2e6',
textPrimary: '#212529',
textSecondary: '#495057',
textTertiary: '#868e96',
border: '#ced4da',
borderSubtle: '#dee2e6',
hoverOverlay: 'rgba(0, 0, 0, 0.05)',
selectionBg: 'rgba(100, 160, 255, 0.15)',
},
};
/** Parse a hex colour string to RGB components. */
function hexToRgb(hex: string): { r: number; g: number; b: number } {
let cleaned = hex.replace('#', '');
if (cleaned.length === 3) {
cleaned =
cleaned[0]! + cleaned[0]! +
cleaned[1]! + cleaned[1]! +
cleaned[2]! + cleaned[2]!;
}
return {
r: parseInt(cleaned.slice(0, 2), 16),
g: parseInt(cleaned.slice(2, 4), 16),
b: parseInt(cleaned.slice(4, 6), 16),
};
}
/** Mix a colour towards white by a fraction (0..1). */
function lighten(hex: string, amount: number): string {
const { r, g, b } = hexToRgb(hex);
const lr = Math.round(r + (255 - r) * amount);
const lg = Math.round(g + (255 - g) * amount);
const lb = Math.round(b + (255 - b) * amount);
return `#${lr.toString(16).padStart(2, '0')}${lg.toString(16).padStart(2, '0')}${lb.toString(16).padStart(2, '0')}`;
}
/** Mix a colour towards black by a fraction (0..1). */
function darken(hex: string, amount: number): string {
const { r, g, b } = hexToRgb(hex);
const dr = Math.round(r * (1 - amount));
const dg = Math.round(g * (1 - amount));
const db = Math.round(b * (1 - amount));
return `#${dr.toString(16).padStart(2, '0')}${dg.toString(16).padStart(2, '0')}${db.toString(16).padStart(2, '0')}`;
}
/**
* Derive the full set of CSS custom properties from accent + shade.
*/
function deriveThemeVariables(
accent: string,
shade: BackgroundShade,
): Record<string, string> {
const palette = SHADE_PALETTES[shade];
const { r, g, b } = hexToRgb(accent);
return {
// Background ramp
'--yj-bg-base': palette.bgBase,
'--yj-bg-surface': palette.bgSurface,
'--yj-bg-elevated': palette.bgElevated,
'--yj-bg-overlay': palette.bgOverlay,
// Accent ramp
'--yj-accent': accent,
'--yj-accent-hover': lighten(accent, 0.15),
'--yj-accent-muted': darken(accent, 0.5),
'--yj-accent-bg': `rgba(${r}, ${g}, ${b}, 0.1)`,
'--yj-accent-bg-strong': `rgba(${r}, ${g}, ${b}, 0.15)`,
// Text
'--yj-text-primary': palette.textPrimary,
'--yj-text-secondary': palette.textSecondary,
'--yj-text-tertiary': palette.textTertiary,
// Borders
'--yj-border': palette.border,
'--yj-border-subtle': palette.borderSubtle,
// Interactive overlays
'--yj-hover-overlay': palette.hoverOverlay,
'--yj-selection-bg': palette.selectionBg,
// Semantic colours (fixed across themes)
'--yj-success': '#2f9e44',
'--yj-success-hover': '#2b8a3e',
'--yj-warning': '#e8590c',
'--yj-warning-hover': '#d9480f',
'--yj-error': '#e03131',
'--yj-error-hover': '#c92a2a',
'--yj-info': '#4263eb',
'--yj-info-hover': '#3b5bdb',
};
}
class ThemeStore {
private state: ThemeState = {
accentColor: '#ffd43b',
backgroundShade: 'dark',
};
private subscribers = new Set<Subscriber>();
private initialized = false;
constructor() {
this.initializeEventListeners();
this.loadFromBackend();
}
// ===================================================================
// WAILS EVENT BRIDGE
// ===================================================================
private initializeEventListeners(): void {
EventsOn(
Events.ThemeConfigChanged,
(data: {
AccentColor: string;
BackgroundShade: string;
}) => {
this.update({
accentColor: data.AccentColor,
backgroundShade:
data.BackgroundShade as BackgroundShade,
});
},
);
}
private async loadFromBackend(): Promise<void> {
try {
const [accent, shade] = await Promise.all([
GetThemeAccentColor(),
GetThemeBackgroundShade(),
]);
this.update({
accentColor: accent,
backgroundShade: shade as BackgroundShade,
});
this.initialized = true;
} catch {
// Use defaults on failure, apply them so the UI has variables.
this.applyVariables();
this.initialized = true;
}
}
// ===================================================================
// STATE ACCESS
// ===================================================================
getState(): Readonly<ThemeState> {
return this.state;
}
isInitialized(): boolean {
return this.initialized;
}
// ===================================================================
// ACTIONS
// ===================================================================
async setAccentColor(color: string): Promise<void> {
await SetThemeAccentColor(color);
}
async setBackgroundShade(
shade: BackgroundShade,
): Promise<void> {
await SetThemeBackgroundShade(shade);
}
// ===================================================================
// SUBSCRIPTION SYSTEM
// ===================================================================
subscribe(callback: Subscriber): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
private update(partial: Partial<ThemeState>): void {
this.state = { ...this.state, ...partial };
this.applyVariables();
this.notify();
}
private notify(): void {
this.subscribers.forEach((callback) => callback());
}
/**
* Apply the full set of CSS custom properties to :root so every
* component (including Shadow DOM) inherits them automatically.
*/
private applyVariables(): void {
const vars = deriveThemeVariables(
this.state.accentColor,
this.state.backgroundShade,
);
const root = document.documentElement;
for (const [prop, value] of Object.entries(vars)) {
root.style.setProperty(prop, value);
}
// Set the document color-scheme so native form controls
// (select dropdowns, scrollbars, etc.) match the theme.
root.style.colorScheme =
this.state.backgroundShade === 'light'
? 'light'
: 'dark';
}
}
// Singleton instance.
export const themeStore = new ThemeStore();