diff --git a/webui/src/components/form/form.module.css b/webui/src/components/form/form.module.css
new file mode 100644
index 00000000..8f7a4e50
--- /dev/null
+++ b/webui/src/components/form/form.module.css
@@ -0,0 +1,191 @@
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.fieldHeader {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.fieldLabel {
+ color: #fff;
+ font-size: 13px;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+}
+
+.fieldHelper {
+ color: rgba(255, 255, 255, 0.45);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.fieldControl {
+ display: flex;
+ flex-direction: column;
+}
+
+.textInput,
+.textArea {
+ width: 100%;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.05);
+ color: #fff;
+ font: inherit;
+ font-size: 14px;
+ line-height: 1.5;
+ padding: 12px 14px;
+ transition:
+ border-color 0.18s ease,
+ background 0.18s ease,
+ box-shadow 0.18s ease,
+ transform 0.18s ease;
+}
+
+.textInput::placeholder,
+.textArea::placeholder {
+ color: rgba(255, 255, 255, 0.38);
+}
+
+.textInput:hover,
+.textArea:hover {
+ border-color: rgba(255, 255, 255, 0.16);
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.textInput:focus,
+.textArea:focus {
+ outline: none;
+ border-color: rgba(var(--accent-light-rgb), 0.55);
+ background: rgba(255, 255, 255, 0.07);
+ box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
+}
+
+.textArea {
+ min-height: 128px;
+ resize: vertical;
+}
+
+.optionCardGroup {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
+ gap: 12px;
+}
+
+.optionCard {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ width: 100%;
+ padding: 14px 16px;
+ text-align: left;
+ border-radius: 14px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.03)),
+ rgba(255, 255, 255, 0.03);
+ color: #fff;
+ cursor: pointer;
+ transition:
+ transform 0.18s ease,
+ border-color 0.18s ease,
+ box-shadow 0.18s ease,
+ background 0.18s ease;
+}
+
+.optionCard:hover {
+ transform: translateY(-1px);
+ border-color: rgba(255, 255, 255, 0.14);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.04)),
+ rgba(255, 255, 255, 0.04);
+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22);
+}
+
+.optionCardSelected {
+ border-color: rgba(var(--accent-light-rgb), 0.45);
+ background:
+ linear-gradient(180deg, rgba(var(--accent-rgb), 0.18), rgba(255, 255, 255, 0.04)),
+ rgba(255, 255, 255, 0.05);
+ box-shadow: 0 0 0 1px rgba(var(--accent-light-rgb), 0.1), 0 14px 32px rgba(0, 0, 0, 0.26);
+}
+
+.optionCardIcon {
+ flex-shrink: 0;
+ font-size: 18px;
+ line-height: 1;
+ margin-top: 1px;
+}
+
+.optionCardBody {
+ display: flex;
+ min-width: 0;
+ flex: 1;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.optionCardTitle {
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1.3;
+}
+
+.optionCardDescription {
+ color: rgba(255, 255, 255, 0.48);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.optionButtonGroup {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.optionButton {
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.05);
+ color: #fff;
+ cursor: pointer;
+ font: inherit;
+ font-size: 13px;
+ font-weight: 600;
+ min-width: 80px;
+ padding: 10px 14px;
+ transition:
+ transform 0.18s ease,
+ border-color 0.18s ease,
+ box-shadow 0.18s ease,
+ background 0.18s ease;
+}
+
+.optionButton:hover {
+ transform: translateY(-1px);
+ border-color: rgba(255, 255, 255, 0.18);
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.optionButtonSelected {
+ border-color: rgba(var(--accent-light-rgb), 0.5);
+ background: rgba(var(--accent-rgb), 0.18);
+ box-shadow: 0 0 0 1px rgba(var(--accent-light-rgb), 0.08);
+}
+
+.formError {
+ color: #ff8d8d;
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.formActions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ flex-wrap: wrap;
+}
diff --git a/webui/src/components/form/form.test.tsx b/webui/src/components/form/form.test.tsx
new file mode 100644
index 00000000..70336ad2
--- /dev/null
+++ b/webui/src/components/form/form.test.tsx
@@ -0,0 +1,106 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { useState } from 'react';
+import { describe, expect, it } from 'vitest';
+
+import {
+ FormActions,
+ FormError,
+ FormField,
+ OptionButton,
+ OptionButtonGroup,
+ OptionCard,
+ OptionCardGroup,
+ TextArea,
+ TextInput,
+} from './form';
+
+function FormDemo() {
+ const [title, setTitle] = useState('');
+ const [details, setDetails] = useState('');
+ const [category, setCategory] = useState<'wrong_cover' | 'wrong_metadata'>('wrong_cover');
+ const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal');
+
+ return (
+
+ );
+}
+
+describe('form primitives', () => {
+ it('render accessible controls and support selection state', () => {
+ render();
+
+ expect(screen.getByLabelText('Title')).toHaveAttribute('placeholder', 'Enter title');
+ expect(screen.getByLabelText('Details')).toHaveAttribute('placeholder', 'Enter details');
+ expect(screen.getByText('Short summary')).toBeInTheDocument();
+ expect(screen.getByRole('alert')).toHaveTextContent('Validation failed');
+
+ const wrongCover = screen.getByRole('button', { name: /wrong cover/i });
+ const wrongMetadata = screen.getByRole('button', { name: /wrong metadata/i });
+ expect(wrongCover).toHaveAttribute('aria-pressed', 'true');
+ expect(wrongMetadata).toHaveAttribute('aria-pressed', 'false');
+ fireEvent.click(wrongMetadata);
+ expect(wrongCover).toHaveAttribute('aria-pressed', 'false');
+ expect(wrongMetadata).toHaveAttribute('aria-pressed', 'true');
+
+ const highPriority = screen.getByRole('button', { name: 'High' });
+ expect(highPriority).toHaveAttribute('aria-pressed', 'false');
+ fireEvent.click(highPriority);
+ expect(highPriority).toHaveAttribute('aria-pressed', 'true');
+ });
+});
diff --git a/webui/src/components/form/form.tsx b/webui/src/components/form/form.tsx
new file mode 100644
index 00000000..71cef56c
--- /dev/null
+++ b/webui/src/components/form/form.tsx
@@ -0,0 +1,168 @@
+import {
+ forwardRef,
+ type ButtonHTMLAttributes,
+ type InputHTMLAttributes,
+ type ReactNode,
+ type TextareaHTMLAttributes,
+} from 'react';
+
+import styles from './form.module.css';
+
+function mergeClassNames(...classNames: Array) {
+ return classNames.filter(Boolean).join(' ');
+}
+
+export interface FormFieldProps {
+ children: ReactNode;
+ className?: string;
+ error?: ReactNode;
+ helperText?: ReactNode;
+ htmlFor?: string;
+ label: ReactNode;
+}
+
+export function FormField({
+ children,
+ className,
+ error,
+ helperText,
+ htmlFor,
+ label,
+}: FormFieldProps) {
+ return (
+
+
+ {htmlFor ? (
+
+ ) : (
+
{label}
+ )}
+ {helperText ?
{helperText}
: null}
+
+
{children}
+ {error ?
: null}
+
+ );
+}
+
+export type TextInputProps = InputHTMLAttributes;
+
+export const TextInput = forwardRef(function TextInput(
+ { className, ...props },
+ ref,
+) {
+ return ;
+});
+
+export type TextAreaProps = TextareaHTMLAttributes;
+
+export const TextArea = forwardRef(function TextArea(
+ { className, ...props },
+ ref,
+) {
+ return ;
+});
+
+export function OptionCardGroup({
+ className,
+ children,
+}: {
+ children: ReactNode;
+ className?: string;
+}) {
+ return {children}
;
+}
+
+export interface OptionCardProps
+ extends Omit, 'title'> {
+ description?: ReactNode;
+ icon?: ReactNode;
+ selected?: boolean;
+ title?: ReactNode;
+}
+
+export const OptionCard = forwardRef(function OptionCard(
+ { className, children, description, icon, selected = false, title, type = 'button', ...props },
+ ref,
+) {
+ return (
+
+ );
+});
+
+export function OptionButtonGroup({
+ className,
+ children,
+}: {
+ children: ReactNode;
+ className?: string;
+}) {
+ return {children}
;
+}
+
+export interface OptionButtonProps extends ButtonHTMLAttributes {
+ selected?: boolean;
+}
+
+export const OptionButton = forwardRef(function OptionButton(
+ { className, children, selected = false, type = 'button', ...props },
+ ref,
+) {
+ return (
+
+ );
+});
+
+export function FormError({ className, message }: { className?: string; message?: ReactNode }) {
+ if (!message) return null;
+
+ return (
+
+ {message}
+
+ );
+}
+
+export function FormActions({
+ className,
+ children,
+}: {
+ children: ReactNode;
+ className?: string;
+}) {
+ return {children}
;
+}
diff --git a/webui/src/components/form/index.ts b/webui/src/components/form/index.ts
new file mode 100644
index 00000000..4b8e42d7
--- /dev/null
+++ b/webui/src/components/form/index.ts
@@ -0,0 +1,11 @@
+export {
+ FormActions,
+ FormError,
+ FormField,
+ OptionButton,
+ OptionButtonGroup,
+ OptionCard,
+ OptionCardGroup,
+ TextArea,
+ TextInput,
+} from './form';
diff --git a/webui/src/routes/issues/-route.test.tsx b/webui/src/routes/issues/-route.test.tsx
index 62ce8116..b5997910 100644
--- a/webui/src/routes/issues/-route.test.tsx
+++ b/webui/src/routes/issues/-route.test.tsx
@@ -180,10 +180,26 @@ describe('issues route', () => {
fireEvent.click(await screen.findByRole('button', { name: /wrong cover art/i }));
const titleInput = screen.getByLabelText(/title/i);
+ const descriptionInput = screen.getByLabelText(/details/i);
+ const submitButton = screen.getByRole('button', { name: /submit issue/i });
+ const form = submitButton.closest('form');
+
expect(titleInput).toHaveValue('Wrong Cover Art: Album Name');
+ fireEvent.change(titleInput, { target: { value: '' } });
+ expect(submitButton).toBeDisabled();
+ fireEvent.submit(form!);
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toHaveTextContent('Please provide a title for the issue');
+ });
+
fireEvent.change(titleInput, { target: { value: 'Custom report title' } });
- fireEvent.click(screen.getByRole('button', { name: /missing tracks/i }));
+ fireEvent.blur(titleInput);
+ fireEvent.change(descriptionInput, { target: { value: 'Detailed reproduction notes' } });
+ fireEvent.click(screen.getByRole('button', { name: /high/i }));
+ fireEvent.click(screen.getByRole('button', { name: /wrong metadata/i }));
expect(titleInput).toHaveValue('Custom report title');
+ expect(descriptionInput).toHaveValue('Detailed reproduction notes');
+ expect(screen.getByRole('button', { name: /high/i })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: /submit issue/i }));
await waitFor(() => {
diff --git a/webui/src/routes/issues/-ui/issue-detail-modal.module.css b/webui/src/routes/issues/-ui/issue-detail-modal.module.css
index 00c303ea..75ff731b 100644
--- a/webui/src/routes/issues/-ui/issue-detail-modal.module.css
+++ b/webui/src/routes/issues/-ui/issue-detail-modal.module.css
@@ -828,92 +828,6 @@
font-size: 13px;
}
-.reportIssueCategoryGrid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
- gap: 10px;
-}
-
-.reportIssueCategoryCard {
- display: grid;
- gap: 5px;
- min-height: 122px;
- text-align: left;
- padding: 12px;
- border-radius: 12px;
- border: 1px solid rgba(255, 255, 255, 0.08);
- background: rgba(255, 255, 255, 0.04);
- color: #fff;
- cursor: pointer;
- font-family: inherit;
-}
-
-.reportIssueCategoryCard:hover,
-.reportIssueCategoryCardSelected {
- border-color: rgba(var(--accent-light-rgb), 0.45);
- background: rgba(var(--accent-light-rgb), 0.1);
-}
-
-.reportIssueCategoryIcon {
- font-size: 18px;
-}
-
-.reportIssueCategoryLabel {
- font-weight: 700;
- font-size: 13px;
-}
-
-.reportIssueCategoryDesc {
- color: rgba(255, 255, 255, 0.48);
- font-size: 12px;
- line-height: 1.4;
-}
-
-.reportIssueInput {
- width: 100%;
- box-sizing: border-box;
- margin-bottom: 12px;
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid rgba(255, 255, 255, 0.1);
- color: #fff;
- padding: 10px 12px;
- border-radius: 8px;
- font-size: 13px;
- outline: none;
- font-family: inherit;
-}
-
-.reportIssuePriorityRow {
- display: flex;
- gap: 8px;
- margin-top: 12px;
-}
-
-.reportIssuePriorityButton {
- padding: 8px 12px;
- border-radius: 999px;
- border: 1px solid rgba(255, 255, 255, 0.08);
- background: rgba(255, 255, 255, 0.05);
- color: rgba(255, 255, 255, 0.72);
- cursor: pointer;
- font-family: inherit;
-}
-
-.reportIssuePriorityButtonSelected {
- border-color: rgba(var(--accent-light-rgb), 0.45);
- background: rgba(var(--accent-light-rgb), 0.14);
- color: #fff;
-}
-
-.reportIssueError {
- color: #ffd0d0;
- border: 1px solid rgba(239, 68, 68, 0.25);
- background: rgba(239, 68, 68, 0.1);
- border-radius: 10px;
- padding: 10px 12px;
- font-size: 13px;
-}
-
.modalFooter {
display: flex;
justify-content: flex-end;
@@ -1065,6 +979,11 @@
justify-content: space-between;
}
+ .issueDetailInfoLeft,
+ .issueDetailInfoRight {
+ width: 100%;
+ }
+
.modal {
max-height: calc(100vh - 24px);
border-radius: 22px;
@@ -1075,18 +994,40 @@
}
.modalFooter {
+ flex-direction: column;
+ align-items: stretch;
padding: 0 18px 18px;
}
.issueDetailMetaGrid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-template-columns: repeat(1, minmax(0, 1fr));
}
.issueMetaValue {
- max-width: 80px;
+ max-width: none;
}
.issueActionButtons {
+ flex-direction: column;
+ align-items: stretch;
+ justify-content: center;
+ }
+
+ .issueActionButton,
+ .modalButton {
+ width: 100%;
+ }
+
+ .issueActionButton {
+ justify-content: center;
+ }
+
+ .issueExternalLinks {
+ justify-content: center;
+ }
+
+ .issueExternalLink {
+ width: 100%;
justify-content: center;
}
}
diff --git a/webui/src/routes/issues/-ui/issue-domain-host.tsx b/webui/src/routes/issues/-ui/issue-domain-host.tsx
index 4a2e8fe2..4dac9433 100644
--- a/webui/src/routes/issues/-ui/issue-domain-host.tsx
+++ b/webui/src/routes/issues/-ui/issue-domain-host.tsx
@@ -1,12 +1,23 @@
-import { useForm } from '@tanstack/react-form';
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { useEffect, useMemo, useState } from 'react';
-import { createPortal } from 'react-dom';
+import { useForm } from "@tanstack/react-form";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect, useMemo, useState } from "react";
+import { createPortal } from "react-dom";
-import { getShellProfileContext } from '@/platform/shell/bridge';
-import { useShellBridge } from '@/platform/shell/route-controllers';
+import { getShellProfileContext } from "@/platform/shell/bridge";
+import {
+ FormActions,
+ FormError,
+ FormField,
+ OptionButton,
+ OptionButtonGroup,
+ OptionCard,
+ OptionCardGroup,
+ TextArea,
+ TextInput,
+} from "@/components/form";
+import { useShellBridge } from "@/platform/shell/route-controllers";
-import type { IssuePriority, IssueReportPayload } from '../-issues.types';
+import type { IssuePriority, IssueReportPayload } from "../-issues.types";
import {
REFRESH_EVENT,
@@ -14,10 +25,10 @@ import {
createIssue,
getIssueCategoriesForEntity,
issueCountsQueryOptions,
-} from '../-issues.helpers';
-import styles from './issue-detail-modal.module.css';
+} from "../-issues.helpers";
+import styles from "./issue-detail-modal.module.css";
-const ISSUE_DOMAIN_QUERY_KEY = ['issues'] as const;
+const ISSUE_DOMAIN_QUERY_KEY = ["issues"] as const;
interface ReportIssueFormValues {
category: string;
@@ -27,10 +38,10 @@ interface ReportIssueFormValues {
}
const DEFAULT_REPORT_ISSUE_VALUES: ReportIssueFormValues = {
- category: '',
- description: '',
- priority: 'normal',
- title: '',
+ category: "",
+ description: "",
+ priority: "normal",
+ title: "",
};
export function IssueDomainHost() {
@@ -115,7 +126,7 @@ function ReportIssueModal({
[payload.entityType],
);
const entityLabel =
- payload.entityType === 'track' ? 'Track' : payload.entityType === 'album' ? 'Album' : 'Artist';
+ payload.entityType === "track" ? "Track" : payload.entityType === "album" ? "Album" : "Artist";
const createMutation = useMutation({
mutationFn: async (values: ReportIssueFormValues) => {
@@ -129,7 +140,7 @@ function ReportIssueModal({
});
},
onSuccess: () => {
- notify('Issue reported successfully', 'success');
+ notify("Issue reported successfully", "success");
onSubmitted();
},
});
@@ -149,9 +160,9 @@ function ReportIssueModal({
await createMutation.mutateAsync(normalizedValues);
} catch (mutationError) {
const message =
- mutationError instanceof Error ? mutationError.message : 'Failed to submit issue';
+ mutationError instanceof Error ? mutationError.message : "Failed to submit issue";
formApi.setErrorMap({ onSubmit: message });
- notify(message, 'error');
+ notify(message, "error");
throw mutationError;
}
},
@@ -194,63 +205,56 @@ function ReportIssueModal({
{payload.artistName ? (
{payload.artistName}
- {payload.albumTitle ? ` - ${payload.albumTitle}` : ''}
+ {payload.albumTitle ? ` - ${payload.albumTitle}` : ""}
) : null}
-
-
-
-
- {(field) =>
- categories.map(([category, meta]) => (
-
- ))
- }
-
-
-
+ selected={field.state.value === category}
+ title={meta.label}
+ />
+ ))}
+
+ )}
+
+
state.values.category}>
{(selectedCategory) =>
selectedCategory ? (
-
+ <>
{(field) => (
- <>
-
-
+
- >
+
)}
+
{(field) => (
- <>
-
-
+
{(field) => (
-
- {(['low', 'normal', 'high'] as const).map((priority) => (
-
- ))}
-
+
+
+ {(["low", "normal", "high"] as const).map((priority) => (
+ field.handleChange(priority)}
+ selected={field.state.value === priority}
+ >
+ {priority[0].toUpperCase()}
+ {priority.slice(1)}
+
+ ))}
+
+
)}
-
+ >
) : null
}
@@ -316,12 +319,12 @@ function ReportIssueModal({
state.errors}>
{(errors) => {
const error = getReportIssueFormError(errors);
- return error ? {error}
: null;
+ return ;
}}
-
+
);
}}
-
+
);
@@ -369,27 +372,27 @@ function validateReportIssueForm(
values: ReportIssueFormValues,
): string | undefined {
const normalizedValues = normalizeReportIssueFormValues(values);
- if (!profileId) return 'Profile is still loading';
- if (!normalizedValues.category) return 'Please select an issue category';
- if (!normalizedValues.title) return 'Please provide a title for the issue';
+ if (!profileId) return "Profile is still loading";
+ if (!normalizedValues.category) return "Please select an issue category";
+ if (!normalizedValues.title) return "Please provide a title for the issue";
return undefined;
}
function getReportIssueFormError(errors: Array): string {
const error = errors.find(Boolean);
- if (!error) return '';
- if (typeof error === 'string') return error;
+ if (!error) return "";
+ if (typeof error === "string") return error;
if (error instanceof Error) return error.message;
return String(error);
}
-function notify(message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info') {
+function notify(message: string, type: "success" | "error" | "warning" | "info" = "info") {
window.showToast?.(message, type);
}
function updateBadge(openCount: number) {
- const badge = document.getElementById('issues-nav-badge');
+ const badge = document.getElementById("issues-nav-badge");
if (!badge) return;
badge.textContent = String(openCount || 0);
- badge.classList.toggle('hidden', !openCount);
+ badge.classList.toggle("hidden", !openCount);
}
diff --git a/webui/src/routes/issues/-ui/issues-page.module.css b/webui/src/routes/issues/-ui/issues-page.module.css
index 742a9230..8f6621c8 100644
--- a/webui/src/routes/issues/-ui/issues-page.module.css
+++ b/webui/src/routes/issues/-ui/issues-page.module.css
@@ -828,6 +828,14 @@
padding: 18px;
}
+ .issuesHeaderRight {
+ width: 100%;
+ }
+
+ .issuesFilters {
+ width: 100%;
+ }
+
.issuesFilterSelect {
min-width: 100%;
width: 100%;