feat(settings): Open Source Licenses section in System tab (#477)

* feat(licenses): tooling and initial data generation

Adds the maintainer-only generator that produces src/data/licenses.json:
- src-tauri/about.toml + about.hbs: cargo-about config + handlebars template
  for the Rust-side license enumeration
- scripts/generate-licenses.mjs: orchestrator that runs cargo-about and
  license-checker-rseidelsohn (via npx, no devDep), merges the outputs into
  a single per-crate JSON with full license texts, and writes the result to
  src/data/licenses.json

The script is invoked directly with `node scripts/generate-licenses.mjs` —
no npm script wrapper on purpose, since adding one to package.json would
trigger the nix-npm-deps-hash-sync workflow on every push.

Initial generation covers 575 cargo crates + 71 npm packages (646 entries
total, all with full license text bundled, ~1.4 MB JSON).

* feat(licenses): Settings panel UI

Adds a new Open Source Licenses section under Settings → System,
sitting below Contributors. Components:

- LicensesPanel.tsx: search input, curated highlight block of ~10 key
  dependencies (Tauri, React, rodio, symphonia, etc.), TanStack-Virtual
  list of all 600+ entries
- LicenseTextModal.tsx: full-screen-ish modal showing the bundled license
  text plus name/version/license-id badges + repository link
- licensesData.ts: lazy dynamic-import loader (Vite emits the JSON as a
  separate chunk, so the heavy ~1.4 MB payload is only loaded when the
  user actually opens the panel — no runtime fetch, the data is fixed
  into the build artifact)

The panel registers itself in the Settings in-page search index under
the System tab.

* feat(licenses): i18n in 8 locales

Adds the `licenses` namespace (title, intro, highlights, search
placeholder, no-results, loading / load error, no-license-text,
view-source, total line, generated-at) across en, de, fr, nl, zh, nb,
ru, es. License names themselves (MIT, Apache-2.0, GPL-3.0, …) stay
universal and are rendered as-is.

* docs(release): document licenses regeneration step

Adds a Step A.3 to the release SOP describing the maintainer-only
`node scripts/generate-licenses.mjs` workflow, the cargo-about
prerequisite, and explicitly notes why no npm script wrapper exists
(would trigger the nix-npm-deps-hash-sync workflow).
This commit is contained in:
Frank Stellmacher
2026-05-06 14:17:13 +02:00
committed by GitHub
parent d48ea819c1
commit 8692e50603
17 changed files with 963 additions and 1 deletions
+132
View File
@@ -0,0 +1,132 @@
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, ExternalLink } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { open as openExternal } from '@tauri-apps/plugin-shell';
import type { LicenseEntry } from '../utils/licensesData';
interface Props {
entry: LicenseEntry | null;
onClose: () => void;
}
export default function LicenseTextModal({ entry, onClose }: Props) {
const { t } = useTranslation();
useEffect(() => {
if (!entry) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [entry, onClose]);
if (!entry) return null;
const repoLink = entry.repository || entry.homepage;
return createPortal(
<div
className="modal-overlay"
onClick={onClose}
role="dialog"
aria-modal="true"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()}
style={{
maxWidth: '760px',
width: '92vw',
maxHeight: '82vh',
display: 'flex',
flexDirection: 'column',
}}
>
<button className="modal-close" onClick={onClose} aria-label={t('common.close')}>
<X size={18} />
</button>
<div style={{ marginBottom: '0.5rem', paddingRight: '2rem' }}>
<h3 style={{ margin: 0, fontFamily: 'var(--font-display)', fontSize: '1.05rem' }}>
{entry.name} <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}>{entry.version}</span>
</h3>
<div
style={{
display: 'flex',
gap: '8px',
alignItems: 'center',
flexWrap: 'wrap',
marginTop: '0.4rem',
fontSize: '0.78rem',
}}
>
{entry.licenses.map((lid) => (
<span
key={lid}
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: '10px',
padding: '1px 8px',
color: 'var(--text-primary)',
}}
>
{lid}
</span>
))}
<span
style={{
color: 'var(--text-muted)',
textTransform: 'uppercase',
fontSize: '0.7rem',
letterSpacing: '0.04em',
}}
>
{entry.source}
</span>
{repoLink && (
<button
onClick={() => openExternal(repoLink)}
className="btn btn-ghost"
style={{
padding: '2px 8px',
fontSize: '0.78rem',
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
}}
>
<ExternalLink size={12} /> {t('licenses.viewSource')}
</button>
)}
</div>
{entry.description && (
<p style={{ color: 'var(--text-muted)', marginTop: '0.6rem', marginBottom: 0, fontSize: '0.85rem' }}>
{entry.description}
</p>
)}
</div>
<div
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
padding: '12px 14px',
overflow: 'auto',
flex: 1,
fontFamily: 'var(--font-mono, ui-monospace, monospace)',
fontSize: '0.78rem',
whiteSpace: 'pre-wrap',
color: 'var(--text-primary)',
lineHeight: 1.5,
}}
>
{entry.licenseText || t('licenses.noLicenseText')}
</div>
</div>
</div>,
document.body,
);
}
+334
View File
@@ -0,0 +1,334 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Search, ExternalLink } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { open as openExternal } from '@tauri-apps/plugin-shell';
import {
HIGHLIGHTED_DEPENDENCIES,
loadLicensesData,
type LicenseEntry,
type LicensesData,
} from '../utils/licensesData';
import LicenseTextModal from './LicenseTextModal';
const ROW_HEIGHT = 56;
function formatDate(iso: string, locale: string): string {
try {
return new Date(iso).toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
} catch {
return iso;
}
}
function entryKey(e: LicenseEntry): string {
return `${e.source}:${e.name}@${e.version}`;
}
function LicenseRow({
entry,
onSelect,
onOpenRepo,
}: {
entry: LicenseEntry;
onSelect: (e: LicenseEntry) => void;
onOpenRepo: (url: string) => void;
}) {
const repo = entry.repository || entry.homepage;
return (
<button
type="button"
onClick={() => onSelect(entry)}
className="licenses-row"
style={{
width: '100%',
height: ROW_HEIGHT,
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '8px 12px',
background: 'transparent',
border: '1px solid transparent',
borderRadius: 'var(--radius-sm)',
textAlign: 'left',
color: 'var(--text-primary)',
cursor: 'pointer',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: 'flex',
alignItems: 'baseline',
gap: '6px',
fontSize: '0.9rem',
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
<span>{entry.name}</span>
<span style={{ color: 'var(--text-muted)', fontSize: '0.78rem', fontWeight: 400 }}>
{entry.version}
</span>
</div>
<div
style={{
display: 'flex',
gap: '6px',
alignItems: 'center',
marginTop: '2px',
fontSize: '0.72rem',
color: 'var(--text-muted)',
}}
>
<span style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>{entry.source}</span>
<span>·</span>
<span style={{ color: 'var(--text-secondary)' }}>{entry.licenses.join(', ') || '—'}</span>
</div>
</div>
{repo && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onOpenRepo(repo);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onOpenRepo(repo);
}
}}
aria-label="Open repository"
style={{
color: 'var(--text-muted)',
padding: '6px',
borderRadius: 'var(--radius-sm)',
display: 'inline-flex',
cursor: 'pointer',
}}
>
<ExternalLink size={14} />
</span>
)}
</button>
);
}
export default function LicensesPanel() {
const { t, i18n } = useTranslation();
const [data, setData] = useState<LicensesData | null>(null);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<LicenseEntry | null>(null);
useEffect(() => {
let cancelled = false;
loadLicensesData()
.then((d) => {
if (!cancelled) setData(d);
})
.catch((e) => {
if (!cancelled) setError(String(e?.message ?? e));
});
return () => {
cancelled = true;
};
}, []);
const highlights = useMemo(() => {
if (!data) return [];
const byKey = new Map<string, LicenseEntry>();
for (const e of data.entries) {
byKey.set(`${e.source}:${e.name}`, e);
}
return HIGHLIGHTED_DEPENDENCIES.map((h) => byKey.get(`${h.source}:${h.name}`)).filter(
(e): e is LicenseEntry => e != null,
);
}, [data]);
const filtered = useMemo(() => {
if (!data) return [];
const q = query.trim().toLowerCase();
if (!q) return data.entries;
return data.entries.filter((e) => {
if (e.name.toLowerCase().includes(q)) return true;
if (e.version.toLowerCase().includes(q)) return true;
for (const lid of e.licenses) {
if (lid.toLowerCase().includes(q)) return true;
}
if (e.source.toLowerCase().includes(q)) return true;
return false;
});
}, [data, query]);
const scrollParentRef = useRef<HTMLDivElement | null>(null);
const virtualizer = useVirtualizer({
count: filtered.length,
getScrollElement: () => scrollParentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 12,
});
const openRepo = (url: string) => {
openExternal(url).catch(() => {});
};
if (error) {
return (
<div style={{ padding: '12px', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
{t('licenses.loadError')} <span style={{ color: 'var(--danger)' }}>{error}</span>
</div>
);
}
if (!data) {
return (
<div style={{ padding: '12px', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
{t('licenses.loading')}
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div style={{ color: 'var(--text-muted)', fontSize: '0.82rem', lineHeight: 1.55 }}>
{t('licenses.intro')}
</div>
{/* Highlight block */}
{highlights.length > 0 && (
<div>
<div
style={{
fontSize: '0.78rem',
color: 'var(--text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.04em',
marginBottom: '6px',
}}
>
{t('licenses.highlights')}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '6px',
}}
>
{highlights.map((e) => (
<LicenseRow key={entryKey(e)} entry={e} onSelect={setSelected} onOpenRepo={openRepo} />
))}
</div>
</div>
)}
{/* Search */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '6px 10px',
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
}}
>
<Search size={14} style={{ color: 'var(--text-muted)' }} />
<input
type="text"
className="input"
placeholder={t('licenses.searchPlaceholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{
flex: 1,
background: 'transparent',
border: 'none',
outline: 'none',
padding: '2px 0',
color: 'var(--text-primary)',
fontSize: '0.85rem',
}}
/>
</div>
{/* Virtual list */}
<div
ref={scrollParentRef}
style={{
height: '420px',
overflow: 'auto',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
background: 'var(--bg-secondary, var(--bg))',
}}
>
{filtered.length === 0 ? (
<div style={{ padding: '14px', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
{t('licenses.noResults')}
</div>
) : (
<div
style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((vi) => {
const entry = filtered[vi.index];
return (
<div
key={vi.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
height: vi.size,
}}
>
<LicenseRow entry={entry} onSelect={setSelected} onOpenRepo={openRepo} />
</div>
);
})}
</div>
)}
</div>
<div
style={{
fontSize: '0.72rem',
color: 'var(--text-muted)',
display: 'flex',
gap: '8px',
flexWrap: 'wrap',
}}
>
<span>
{t('licenses.totalLine', {
total: data.stats.total,
cargo: data.stats.cargo,
npm: data.stats.npm,
})}
</span>
<span>·</span>
<span>{t('licenses.generatedAt', { date: formatDate(data.generatedAt, i18n.language) })}</span>
</div>
<LicenseTextModal entry={selected} onClose={() => setSelected(null)} />
</div>
);
}
File diff suppressed because one or more lines are too long
+13
View File
@@ -1796,4 +1796,17 @@ export const deTranslation = {
exitPsysonic: 'Psysonic beenden',
nothingPlaying: 'Nichts wird abgespielt',
},
licenses: {
title: 'Open-Source-Lizenzen',
intro: 'Psysonic basiert auf Open-Source-Software. Die untenstehende Liste zeigt jede Abhängigkeit, die mit der App ausgeliefert wird, inklusive Version, Lizenz und vollständigem Lizenztext.',
highlights: 'Wichtige Abhängigkeiten',
searchPlaceholder: 'Suche nach Name, Version oder Lizenz…',
noResults: 'Keine Treffer.',
loading: 'Lizenzen werden geladen…',
loadError: 'Lizenzdaten konnten nicht geladen werden.',
noLicenseText: 'Für diese Abhängigkeit ist kein Lizenztext gebündelt.',
viewSource: 'Quelle anzeigen',
totalLine: '{{total}} Abhängigkeiten — {{cargo}} Cargo, {{npm}} npm',
generatedAt: 'Zuletzt generiert {{date}}',
},
};
+13
View File
@@ -1803,4 +1803,17 @@ export const enTranslation = {
exitPsysonic: 'Exit Psysonic',
nothingPlaying: 'Nothing playing',
},
licenses: {
title: 'Open Source Licenses',
intro: 'Psysonic is built on open-source software. The list below shows every dependency that ships in the app, with its version, license, and full license text.',
highlights: 'Key dependencies',
searchPlaceholder: 'Search by name, version or license…',
noResults: 'No matches.',
loading: 'Loading licenses…',
loadError: 'Could not load license data.',
noLicenseText: 'No license text bundled for this dependency.',
viewSource: 'View source',
totalLine: '{{total}} dependencies — {{cargo}} cargo, {{npm}} npm',
generatedAt: 'Last generated {{date}}',
},
};
+13
View File
@@ -1765,4 +1765,17 @@ export const esTranslation = {
exitPsysonic: 'Salir de Psysonic',
nothingPlaying: 'Nada en reproducción',
},
licenses: {
title: 'Licencias de Código Abierto',
intro: 'Psysonic se basa en software de código abierto. La lista siguiente muestra cada dependencia incluida con la aplicación, con su versión, licencia y el texto completo de la licencia.',
highlights: 'Dependencias principales',
searchPlaceholder: 'Buscar por nombre, versión o licencia…',
noResults: 'Sin resultados.',
loading: 'Cargando licencias…',
loadError: 'No se pudieron cargar los datos de licencia.',
noLicenseText: 'No hay texto de licencia incluido para esta dependencia.',
viewSource: 'Ver fuente',
totalLine: '{{total}} dependencias — {{cargo}} cargo, {{npm}} npm',
generatedAt: 'Última generación {{date}}',
},
};
+13
View File
@@ -1760,4 +1760,17 @@ export const frTranslation = {
exitPsysonic: 'Quitter Psysonic',
nothingPlaying: 'Aucune lecture',
},
licenses: {
title: 'Licences Open Source',
intro: 'Psysonic est construit sur des logiciels open source. La liste ci-dessous montre chaque dépendance livrée avec l\'application, avec sa version, sa licence et le texte de licence complet.',
highlights: 'Dépendances principales',
searchPlaceholder: 'Rechercher par nom, version ou licence…',
noResults: 'Aucun résultat.',
loading: 'Chargement des licences…',
loadError: 'Impossible de charger les données de licence.',
noLicenseText: 'Aucun texte de licence groupé pour cette dépendance.',
viewSource: 'Voir la source',
totalLine: '{{total}} dépendances — {{cargo}} cargo, {{npm}} npm',
generatedAt: 'Dernière génération {{date}}',
},
};
+13
View File
@@ -1759,4 +1759,17 @@ export const nbTranslation = {
exitPsysonic: 'Avslutt Psysonic',
nothingPlaying: 'Ingenting spilles',
},
licenses: {
title: 'Åpen kildekode-lisenser',
intro: 'Psysonic er bygget på åpen kildekode-programvare. Listen nedenfor viser hver avhengighet som leveres med appen, med versjon, lisens og full lisenstekst.',
highlights: 'Sentrale avhengigheter',
searchPlaceholder: 'Søk etter navn, versjon eller lisens…',
noResults: 'Ingen treff.',
loading: 'Laster lisenser…',
loadError: 'Kunne ikke laste inn lisensdata.',
noLicenseText: 'Ingen lisenstekst inkludert for denne avhengigheten.',
viewSource: 'Vis kilde',
totalLine: '{{total}} avhengigheter — {{cargo}} cargo, {{npm}} npm',
generatedAt: 'Sist generert {{date}}',
},
};
+13
View File
@@ -1759,4 +1759,17 @@ export const nlTranslation = {
exitPsysonic: 'Psysonic afsluiten',
nothingPlaying: 'Niets wordt afgespeeld',
},
licenses: {
title: 'Open-source licenties',
intro: 'Psysonic is gebouwd op open-source software. De onderstaande lijst toont elke afhankelijkheid die met de app wordt meegeleverd, met versie, licentie en volledige licentietekst.',
highlights: 'Belangrijke afhankelijkheden',
searchPlaceholder: 'Zoek op naam, versie of licentie…',
noResults: 'Geen resultaten.',
loading: 'Licenties laden…',
loadError: 'Licentiegegevens konden niet worden geladen.',
noLicenseText: 'Geen licentietekst meegeleverd voor deze afhankelijkheid.',
viewSource: 'Bron bekijken',
totalLine: '{{total}} afhankelijkheden — {{cargo}} cargo, {{npm}} npm',
generatedAt: 'Laatst gegenereerd {{date}}',
},
};
+13
View File
@@ -1846,4 +1846,17 @@ export const ruTranslation = {
exitPsysonic: 'Закрыть Psysonic',
nothingPlaying: 'Ничего не воспроизводится',
},
licenses: {
title: 'Лицензии открытого кода',
intro: 'Psysonic построен на программном обеспечении с открытым исходным кодом. Список ниже показывает каждую зависимость, поставляемую с приложением, с версией, лицензией и полным текстом лицензии.',
highlights: 'Ключевые зависимости',
searchPlaceholder: 'Поиск по имени, версии или лицензии…',
noResults: 'Нет совпадений.',
loading: 'Загрузка лицензий…',
loadError: 'Не удалось загрузить данные о лицензиях.',
noLicenseText: 'Текст лицензии для этой зависимости не включён.',
viewSource: 'Открыть исходник',
totalLine: '{{total}} зависимостей — {{cargo}} cargo, {{npm}} npm',
generatedAt: 'Последняя генерация {{date}}',
},
};
+13
View File
@@ -1752,4 +1752,17 @@ export const zhTranslation = {
exitPsysonic: '退出 Psysonic',
nothingPlaying: '当前没有播放',
},
licenses: {
title: '开源许可证',
intro: 'Psysonic 基于开源软件构建。下方列表显示了应用程序中包含的每个依赖项的版本、许可证以及完整的许可证文本。',
highlights: '主要依赖项',
searchPlaceholder: '按名称、版本或许可证搜索…',
noResults: '没有匹配项。',
loading: '加载许可证…',
loadError: '无法加载许可证数据。',
noLicenseText: '此依赖项未包含许可证文本。',
viewSource: '查看源代码',
totalLine: '{{total}} 个依赖项 — {{cargo}} cargo, {{npm}} npm',
generatedAt: '最后生成时间 {{date}}',
},
};
+10 -1
View File
@@ -6,7 +6,7 @@ import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines, User, Lock,
Users, UserPlus, Shield, Wand2, Search
Users, UserPlus, Shield, Wand2, Search, Scale
} from 'lucide-react';
import i18n from '../i18n';
import { exportBackup, importBackup } from '../utils/backup';
@@ -22,6 +22,7 @@ import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, Las
import LastfmIcon from '../components/LastfmIcon';
import CustomSelect from '../components/CustomSelect';
import SettingsSubSection from '../components/SettingsSubSection';
import LicensesPanel from '../components/LicensesPanel';
import { AboutPsysonicBrandHeader } from '../components/AboutPsysonicLol';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
@@ -439,6 +440,7 @@ const SETTINGS_INDEX: SearchIndexEntry[] = [
{ tab: 'system', titleKey: 'settings.loggingTitle', keywords: 'log logs diagnostic debug verbose' },
{ tab: 'system', titleKey: 'settings.aboutTitle', keywords: 'about version update changelog release notes' },
{ tab: 'system', titleKey: 'settings.aboutContributorsLabel', keywords: 'contributors credits maintainers' },
{ tab: 'system', titleKey: 'licenses.title', keywords: 'licenses license open source attribution copyright third party dependencies oss' },
];
// Substring-first, Fuzzy-Fallback (alle Query-Zeichen in Reihenfolge im
@@ -4574,6 +4576,13 @@ export default function Settings() {
</div>
</SettingsSubSection>
<SettingsSubSection
title={t('licenses.title')}
icon={<Scale size={16} />}
>
<LicensesPanel />
</SettingsSubSection>
</>
)}
</>}
+57
View File
@@ -0,0 +1,57 @@
// Lazy loader + types for the bundled `src/data/licenses.json` data.
//
// The JSON is generated by `node scripts/generate-licenses.mjs` (maintainer-only)
// and committed to source. Vite bundles it as a separate JS chunk via dynamic
// import below, so the heavy ~12 MB payload is only loaded when the user
// actually opens the Open Source Licenses panel — no runtime fetch, the data
// lives entirely inside the build artifact.
export interface LicenseEntry {
name: string;
version: string;
source: 'npm' | 'cargo';
licenses: string[];
repository?: string;
homepage?: string;
description?: string;
publisher?: string;
licenseText?: string;
}
export interface LicensesData {
generatedAt: string;
project: { name: string; version: string };
stats: { npm: number; cargo: number; total: number; withFullText: number };
entries: LicenseEntry[];
}
let cache: Promise<LicensesData> | null = null;
export function loadLicensesData(): Promise<LicensesData> {
if (cache) return cache;
cache = import('../data/licenses.json')
.then((mod) => (mod.default ?? mod) as LicensesData)
.catch((e) => {
cache = null;
throw e;
});
return cache;
}
/**
* Curated list of dependency names shown in the highlight block at the top of
* the panel the user-recognisable backbone of the app. Order matters here;
* the panel renders them top-to-bottom as listed.
*/
export const HIGHLIGHTED_DEPENDENCIES: Array<{ source: 'npm' | 'cargo'; name: string }> = [
{ source: 'cargo', name: 'tauri' },
{ source: 'npm', name: 'react' },
{ source: 'npm', name: 'zustand' },
{ source: 'cargo', name: 'rodio' },
{ source: 'cargo', name: 'symphonia' },
{ source: 'cargo', name: 'cpal' },
{ source: 'npm', name: 'axios' },
{ source: 'npm', name: '@tanstack/react-virtual' },
{ source: 'npm', name: 'react-i18next' },
{ source: 'npm', name: 'lucide-react' },
];