diff --git a/core/search/sources.py b/core/search/sources.py index e66b9eda..006bf8f0 100644 --- a/core/search/sources.py +++ b/core/search/sources.py @@ -56,6 +56,12 @@ def search_kind(client, query: str, kind: str, source_name: Optional[str] = None "release_date": album.release_date, "total_tracks": album.total_tracks, "album_type": album.album_type, + "format": getattr(album, "format", None), + "country": getattr(album, "country", None), + "status": getattr(album, "status", None), + "label": getattr(album, "label", None), + "disambiguation": getattr(album, "disambiguation", None), + "release_group_id": getattr(album, "release_group_id", None), "external_urls": album.external_urls or {}, }) except Exception as e: diff --git a/tests/search/test_search_sources.py b/tests/search/test_search_sources.py index 48a93620..f6949027 100644 --- a/tests/search/test_search_sources.py +++ b/tests/search/test_search_sources.py @@ -19,7 +19,9 @@ class _Artist: class _Album: def __init__(self, id_, name, artists=None, image_url=None, release_date=None, - total_tracks=10, album_type='album', external_urls=None): + total_tracks=10, album_type='album', external_urls=None, format=None, + country=None, status=None, label=None, disambiguation=None, + release_group_id=None): self.id = id_ self.name = name self.artists = artists or [] @@ -28,6 +30,12 @@ class _Album: self.total_tracks = total_tracks self.album_type = album_type self.external_urls = external_urls + self.format = format + self.country = country + self.status = status + self.label = label + self.disambiguation = disambiguation + self.release_group_id = release_group_id class _Track: @@ -99,6 +107,27 @@ def test_search_kind_albums_handles_no_artists(): assert result[0]['artist'] == 'Unknown Artist' +def test_search_kind_albums_passthrough_release_metadata(): + client = _Client(albums=[_Album( + 'a1', + 'Variant', + artists=['Artist'], + format='CD', + country='US', + status='Official', + label='Fixture Records', + disambiguation='clean', + release_group_id='rg-1', + )]) + result = sources.search_kind(client, 'v', 'albums') + assert result[0]['format'] == 'CD' + assert result[0]['country'] == 'US' + assert result[0]['status'] == 'Official' + assert result[0]['label'] == 'Fixture Records' + assert result[0]['disambiguation'] == 'clean' + assert result[0]['release_group_id'] == 'rg-1' + + def test_search_kind_tracks_returns_full_shape(): client = _Client(tracks=[_Track('t1', 'Money', artists=['Pink Floyd'], album='DSOTM', duration_ms=383000, image_url='m.jpg', diff --git a/tests/test_import_page_album_lookup_pattern.py b/tests/test_import_page_album_lookup_pattern.py deleted file mode 100644 index 5bf4ab9d..00000000 --- a/tests/test_import_page_album_lookup_pattern.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Pin the import-page album-lookup cache pattern in -``webui/static/stats-automations.js`` — github issue #524 regression -guard at the source-text level. - -Why a structural test instead of a behavioral JS test: - -``stats-automations.js`` is a ~7k-line file with a lot of global state -+ inline DOM rendering. Loading it into a sandboxed Node `vm` context -(the pattern used in `tests/static/test_discover_section_controller.mjs`) -would require stubbing dozens of unrelated dependencies. The file -needs to be modularized before behavioral tests are practical for -arbitrary functions in it. - -Until then, this test fails the suite if the critical pattern from -the #524 fix gets removed: - -1. The album cache (``_albumLookup`` field on ``importPageState``) -2. Card renderers populating the cache before emitting the onclick -3. The match-POST builder reading source/name/artist from the cache - -If anyone deletes the cache, the click handler, or the cache writes, -this test catches it before the regression ships. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - - -_REPO_ROOT = Path(__file__).resolve().parents[1] -_SOURCE = _REPO_ROOT / "webui" / "static" / "stats-automations.js" - - -@pytest.fixture(scope="module") -def js_source() -> str: - return _SOURCE.read_text(encoding="utf-8") - - -def test_album_lookup_cache_field_exists_on_state(js_source: str): - """importPageState must have an `_albumLookup` field. Without it, - card renderers have nowhere to stash source/name/artist for the - click handler to read.""" - assert "_albumLookup:" in js_source, ( - "importPageState._albumLookup field missing — the album cache " - "that backs the source-routing fix for issue #524 has been " - "removed. The click handler will fall back to passing only " - "album_id and the backend will silently misroute lookups again." - ) - - -def test_select_album_handler_reads_cache(js_source: str): - """importPageSelectAlbum must read source / name / artist from - the cache and include them in the match POST body. The whole - point of the fix.""" - # Find the function body - match = re.search( - r"async function importPageSelectAlbum\([^)]*\) \{(.*?)^\}", - js_source, re.DOTALL | re.MULTILINE, - ) - assert match, "importPageSelectAlbum function not found" - body = match.group(1) - - # Must read from the lookup cache - assert "_albumLookup[" in body, ( - "importPageSelectAlbum no longer reads from " - "importPageState._albumLookup — match POST will drop source " - "again, see issue #524." - ) - - # Must build a matchBody that includes source + album_name + album_artist - for required_field in ("source:", "album_name:", "album_artist:"): - assert required_field in body, ( - f"matchBody missing required field {required_field!r}. " - "Backend's get_artist_album_tracks needs source to route " - "the lookup to the correct metadata client. Without it, " - "cross-source album_ids fall through to the failure-fallback " - "dict (Unknown Artist / album_id-as-title / 0 tracks). " - "See issue #524 for the original symptom." - ) - - -def test_card_renderer_populates_cache_before_onclick(js_source: str): - """The shared card-renderer ``_renderSuggestionCard`` must write to - ``_albumLookup`` before emitting the onclick — otherwise the click - handler reads an empty cache for newly-displayed albums. - - Originally this test required >=2 cache writes (one per inline - renderer), but the search-results inline render was consolidated - into a single ``_renderSuggestionCard`` call as part of the #681 - fix. The invariant now is: the shared renderer populates the cache, - and every render call site goes through it (no inline duplicates).""" - # 1. The shared renderer must contain the cache write. - match = re.search( - r"function _renderSuggestionCard\([^)]*\) \{(.*?)^\}", - js_source, re.DOTALL | re.MULTILINE, - ) - assert match, "_renderSuggestionCard function not found" - body = match.group(1) - assert re.search(r"_albumLookup\[a\.id\]\s*=\s*\{", body), ( - "_renderSuggestionCard no longer writes to _albumLookup before " - "emitting the onclick — every card rendered through this helper " - "would have an empty cache on click, regressing issue #524." - ) - - # 2. No inline card render allowed outside the shared helper. - # A second `_albumLookup[a.id] = {` write means a caller is - # re-implementing the renderer instead of calling the helper — - # that's exactly the duplication the #524 fix consolidated away. - cache_writes = re.findall(r"_albumLookup\[a\.id\]\s*=\s*\{", js_source) - assert len(cache_writes) == 1, ( - f"Expected exactly 1 _albumLookup write (inside _renderSuggestionCard), " - f"found {len(cache_writes)}. A new inline card-render site has " - "duplicated the cache-write logic — route the new caller through " - "_renderSuggestionCard(a, primarySource) instead." - ) - - -def test_cache_entry_carries_source_field(js_source: str): - """The cache must store `source:` per entry — not just id/name/artist.""" - write_blocks = re.findall( - r"_albumLookup\[a\.id\]\s*=\s*\{[^}]*\}", - js_source, - ) - assert write_blocks, "no _albumLookup writes found" - assert any("source:" in block for block in write_blocks), ( - "_albumLookup cache entries must include `source` — that's the " - "field the click handler forwards to /api/import/album/match " - "to route the lookup to the correct provider." - ) diff --git a/webui/docs/migration/README.md b/webui/docs/migration/README.md index a7a9d5e3..f54442a1 100644 --- a/webui/docs/migration/README.md +++ b/webui/docs/migration/README.md @@ -16,6 +16,9 @@ This folder is the home for React migration planning work inside `webui`. - cross-route risk assessment - [stats-migration-plan.md](./stats-migration-plan.md) - route-specific migration plan for `stats` +- [import-migration-plan.md](./import-migration-plan.md) + - route-specific migration plan for `import` + - implementation status and follow-up cleanup notes ## Naming Guidance diff --git a/webui/docs/migration/import-migration-plan.md b/webui/docs/migration/import-migration-plan.md new file mode 100644 index 00000000..1ef64f72 --- /dev/null +++ b/webui/docs/migration/import-migration-plan.md @@ -0,0 +1,350 @@ +# WebUI Import Migration Plan + +Snapshot date: 2026-05-24 + +## Status + +- Initial implementation completed on 2026-05-15. +- `import` is now React-owned in the shell route manifest. +- The legacy import page DOM has been removed from `webui/index.html`. +- Legacy import activation has been removed from `webui/static/init.js`. +- A React route subtree now owns import rendering, nested route state, album matching, singles matching, auto-import controls, and the client-side processing queue. +- The old `tab=` URL contract has been replaced by `/import/album`, `/import/singles`, and `/import/auto`, with `/import` redirecting to `/import/album`. +- Route-local workflow state lives in `webui/src/routes/import/-import.store.ts`, which keeps draft matching, selection, and queue state alive while navigating within the import route. +- The old import-page-specific functions have already been removed from `webui/static/stats-automations.js`; any remaining `import` references there belong to the broader automation feature set, not this page migration. +- Backend routes are already grouped around `/api/import/*` and `/api/auto-import/*`. + +## Goal + +- Migrate `import` into a React-owned route without changing the user workflow. +- Preserve manual album matching, singles matching, auto-import review, and the processing queue. +- Keep staging-folder and import-processing behavior backed by the existing API routes. +- Use the completed `issues` and `stats` routes as the structural reference for route slices, API helpers, shell gating, and tests. + +## Why `import` Was The Right Next Route + +- It is the safest remaining route after excluding `help` and `hydrabase`. +- It has real workflows, so it gives the migration program more signal than another mostly-static page. +- The backend API boundary is already clearer than the broad dashboard, library, discover, sync, or settings surfaces. +- The page is important enough to validate mutation, polling, and route-local reducer patterns before larger operational pages. +- It does not need a visual redesign or a new shell abstraction to migrate cleanly. + +## Current Legacy Shape + +Page surface in `webui/index.html`: + +- Header + - import folder path + - file count and total size + - refresh action +- Processing queue + - per-job progress + - partial error display + - clear-finished action +- Tabs + - auto-import + - albums + - singles +- Auto-import tab + - enable toggle + - status text + - confidence and interval settings + - scan-now action + - live scan progress + - result filters + - approve, reject, approve-all, and clear-completed actions +- Albums tab + - auto-group suggestions from staging + - album search + - album result cards + - track matching view + - drag/drop and tap-to-assign overrides + - unmatched file pool + - process album action +- Singles tab + - staging file list + - select all / per-file selection + - per-file track search + - manual match selection + - process selected action + +Legacy JS responsibilities in `webui/static/stats-automations.js`: + +- `initializeImportPage` +- staging fetch and refresh +- tab switching +- auto-import polling +- auto-import mutations +- staging group and suggestion rendering +- album search and match POSTs +- drag/drop assignment state +- singles search and selection state +- client-side processing queue +- sequential album/singles processing requests + +Backend endpoints already available: + +- `GET /api/import/staging/files` +- `GET /api/import/staging/groups` +- `GET /api/import/staging/hints` +- `GET /api/import/staging/suggestions` +- `GET /api/import/search/albums` +- `POST /api/import/album/match` +- `POST /api/import/album/process` +- `GET /api/import/search/tracks` +- `POST /api/import/singles/process` +- `GET /api/auto-import/status` +- `POST /api/auto-import/toggle` +- `GET /api/auto-import/settings` +- `POST /api/auto-import/settings` +- `GET /api/auto-import/results` +- `POST /api/auto-import/approve/:id` +- `POST /api/auto-import/reject/:id` +- `POST /api/auto-import/scan-now` +- `POST /api/auto-import/approve-all` +- `POST /api/auto-import/clear-completed` + +## Implemented Route Slice + +```text +webui/src/routes/import/ + route.tsx + index.tsx + album.tsx + auto.tsx + singles.tsx + -import.types.ts + -import.api.ts + -import.helpers.ts + -import.store.ts + -route.test.tsx + -ui/ + import-page.tsx + album-import-tab.tsx + auto-import-tab.tsx + singles-import-tab.tsx + import-shared.tsx +``` + +## Implemented Route Responsibilities + +`route.tsx` + +- declare `/import` +- gate route through `bridge.isPageAllowed('import')` +- preload shell context +- prefetch the staging-files query without blocking on transient fetch failure + +`index.tsx` + +- redirect `/import` to `/import/album` + +`album.tsx` + +- prefetch album staging groups and suggestions + +`auto.tsx` + +- validate the `autoFilter` search param +- keep route-driven filter changes in the URL while leaving the rest of the workflow state local + +`singles.tsx` + +- mount the singles import tab without extra route-level loader work + +`-import.types.ts` + +- search param schema +- API response types +- staging file, staging group, album result, track result, match, auto-import result, and queue item types + +`-import.api.ts` + +- query options for staging, groups, suggestions, auto-import status, auto-import settings, auto-import results, album search, and track search +- mutation helpers for album match, album process, singles process, auto-import actions, and settings writes +- invalidation helpers for broad route refreshes, staging-only refreshes, and auto-import-only refreshes + +`-import.helpers.ts` + +- byte-size formatting +- album and track display labels +- confidence class/label mapping +- staging match normalization +- auto-import result filtering and counters + +`-import.store.ts` + +- album search state, selected album, auto-group file paths, and match overrides +- selected single-file state and manual matches +- queue job state and queue entry updates +- single-search draft state +- draft state survival across nested route remounts + +`-ui/import-page.tsx` + +- page chrome and nested route navigation +- queue summary and queue-item rendering +- nested route outlet for album, singles, and auto views + +## Search Params + +Use nested route paths for durable, shareable tab state: + +- `/import/album` + - default landing route +- `/import/singles` +- `/import/auto` +- `autoFilter` + - values: `all`, `pending`, `imported`, `failed` + - default: `all` + +Keep these local to React state: + +- album search text +- track search text +- selected album +- match overrides +- selected singles +- processing queue jobs + +Reasoning: + +- The tab choice belongs in the path, which keeps deep links simple and avoids an extra `tab=` query param. +- The auto-import filter is still useful after reloads. +- The matching workflow is ephemeral and should not create fragile URLs with file indexes or local staging paths. + +## Query Model + +Critical route-loader data: + +- `importStagingFilesQueryOptions()` + +Useful prefetch data: + +- `importStagingGroupsQueryOptions()` +- `importStagingSuggestionsQueryOptions()` + +Nested-route data: + +- `autoImportStatusQueryOptions()` +- `autoImportSettingsQueryOptions()` +- `autoImportResultsQueryOptions(autoFilter)` + +Lazy search data: + +- `importAlbumSearchQueryOptions(query)` +- `importTrackSearchQueryOptions(query)` + +Mutation-style actions: + +- album match draft +- process one album track +- process one single file +- toggle auto-import +- save auto-import settings +- scan now +- approve/reject auto-import result +- approve all +- clear completed + +Invalidation rules: + +- Processing album or singles files invalidates staging files, staging groups, staging suggestions, auto-import results, and any route-local queue completion summary. +- Auto-import actions invalidate auto-import status and results. +- Auto-import settings writes invalidate settings and status. +- Refresh invalidates staging files, groups, and suggestions. + +## Incremental Migration Order + +Recommended order: + +1. Add route slice, types, API helpers, reducer, and helper tests. +2. Build the React route shell with header, tabs, and staging summary. +3. Port the Albums tab search and suggestions, but keep processing disabled until match rendering is covered. +4. Port the album matching view, including drag/drop and tap assignment. +5. Port the processing queue and album/singles process mutations. +6. Port the Singles tab. +7. Port the Auto tab and polling behavior. +8. Flip `import` from `legacy` to `react` in the shell route manifest. +9. Remove the legacy `import-page` DOM from `webui/index.html`. +10. Remove import-specific legacy functions from `webui/static/stats-automations.js`. + +This order gives us a visible React page early while delaying the highest-risk file-processing actions until the state model is tested. + +The implemented route keeps the same overall migration shape, but the final URL contract uses nested route paths instead of a `tab=` search param. + +## Testing Sketch + +Unit tests: + +- route path and filter defaults +- staging summary formatting +- auto-import counters +- confidence labels +- reducer assignment behavior +- reducer queue transitions + +API tests: + +- staging files success and error +- staging groups success and error +- album search success and empty result +- track search success and empty result +- album match success and failure +- album process success and partial error +- singles process success and partial error +- auto-import status/results/settings/actions + +Route / component tests: + +- unauthorized users redirect to profile home +- default route redirects to `/import/album` +- `/import/singles` renders the Singles tab +- `/import/auto?autoFilter=pending` renders pending auto-import results +- refresh invalidates staging queries +- album selection opens the match view +- drag/drop and tap assignment update track matches +- processing queue advances and refreshes staging on completion +- client workflow drafts survive page remounts + +Playwright can wait until after route ownership flips. + +## Risks + +- The processing queue is client-side and long-running. +- Auto-import polling must stop when leaving the auto subroute or route. +- File indexes can become stale after staging refreshes. +- Album matching depends on preserving source, album name, and album artist from search results. +- The page currently shares a large legacy module with stats and automations code, so cleanup should be careful and incremental. + +## Decisions To Keep Simple + +- Keep the current visual language. +- Keep the existing backend endpoints. +- Keep the processing queue client-side for the first migration. +- Keep file matching state local to the route. +- Do not extract shared workflow primitives until a second migrated route needs them. + +## Outcome + +- The route now serves as the first React-owned workflow migration. +- The implementation uses nested route paths plus a validated `autoFilter` search param. +- The route uses TanStack Query for staging data, suggestions, auto-import polling, mutations, and invalidation. +- Tests cover shell ownership, nested route state, album match payload preservation, and auto-import rendering. + +## Recommendation + +Treat remaining work as cleanup and hardening rather than route selection. + +Follow-up work should optimize for: + +- shrinking `stats-automations.js` after cutover +- adding E2E coverage around full album and singles processing +- considering route-level code splitting once more large React routes land + +It should not optimize for: + +- redesign +- backend reshaping +- shared queue abstractions +- migrating `automations` at the same time diff --git a/webui/docs/migration/page-migration-overview.md b/webui/docs/migration/page-migration-overview.md index 01c96a57..16808c62 100644 --- a/webui/docs/migration/page-migration-overview.md +++ b/webui/docs/migration/page-migration-overview.md @@ -1,10 +1,10 @@ # WebUI Page Migration Overview -Snapshot date: 2026-05-14 +Snapshot date: 2026-05-15 ## Summary - The shell route manifest now has 18 page ids. -- `issues` and `stats` are now React-owned routes. +- `issues`, `stats`, and `import` are now React-owned routes. - Since the last snapshot, the biggest changes are: - `downloads` was renamed into `search`. - The live queue became `active-downloads`. @@ -78,9 +78,9 @@ Rollups: | --- | --- | --- | --- | --- | --- | | `issues` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Completed | | `stats` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Completed | +| `import` | React | 3 / 3 / 3 / 2 / 3 | Medium | Medium | Completed | | `help` | Legacy | 3 / 2 / 1 / 1 / 2 | Low | Low | Wave 1 | | `hydrabase` | Legacy | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 1 | -| `import` | Legacy | 3 / 3 / 3 / 2 / 3 | Medium | Medium | Wave 1 | | `search` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 2 | | `watchlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 | | `wishlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 | @@ -130,11 +130,13 @@ Rollups: - Recommendation: low-risk route with a narrow surface. #### `import` -- Current owner: Legacy. -- Primary files: `webui/index.html`, `webui/static/stats-automations.js`, `webui/static/helper.js`. +- Current owner: React. +- Primary files: `webui/src/routes/import/*`, `webui/src/platform/shell/route-manifest.ts`. - Main surface: staging files, album and singles matching, suggestion cards, processing queue. - Key coupling: settings-derived staging path assumptions and downstream library state. -- Recommendation: still bounded enough for an early wave, though more workflow-heavy than `help` or `hydrabase`. +- Recommendation: completed as the next migration after `stats`. The import subtree now uses nested route paths, with `/import` redirecting to `/import/album` and `autoFilter` remaining in the search string; any remaining `import` references in `webui/static/stats-automations.js` belong to the broader automation feature set, not this page migration. +- Route-local workflow state lives in `webui/src/routes/import/-import.store.ts`, which keeps drafts and queue state alive while moving between album, singles, and auto views. +- Route plan: `webui/docs/migration/import-migration-plan.md`. ### Wave 2: Search split @@ -264,9 +266,10 @@ Rollups: - Waves 6-10 defer the broadest, most coupled, or most orchestration-heavy surfaces until the team has the most leverage. ## Final Recommendation -- Keep `issues` and `stats` as the current React reference implementations, and preserve the explicit bridge contract between React routes and legacy shell behavior. +- Keep `issues`, `stats`, and `import` as the current React reference implementations, and preserve the explicit bridge contract between React routes and legacy shell behavior. - Treat `search`, `watchlist`, `wishlist`, `active-downloads`, and `tools` as the current route ids, and keep `downloads` and `artists` only as compatibility history. -- Migrate the remaining safe legacy routes first: `help`, `hydrabase`, and `import`. +- Migrate the remaining safe legacy routes first: `help` and `hydrabase`. +- `import` has already been migrated and should be treated as the first React-owned workflow route. - During each migration, actively look for small reuse opportunities across route slices and shared UI primitives, but only extract once the overlap is clearly real. -- Use `search` as the next meaningful proving ground now that the download queue has been split out. +- Use `search` as the next larger proving ground after `import`, now that the download queue has been split out. - Avoid pulling `settings`, `sync`, `library`, `artist-detail`, or `automations` forward unless there is a separate product priority strong enough to justify the added regression risk. diff --git a/webui/index.html b/webui/index.html index 43b1e265..709c9b9e 100644 --- a/webui/index.html +++ b/webui/index.html @@ -6199,159 +6199,6 @@ - -
-
- -
-
-

Import Music

- -
-
- Import folder: loading... - -
-
- - - - - -
- - - -
- - -
-
-
- - Disabled - -
- - - -
- - - - -
-
-

Enable auto-import to watch your import folder for new music.

-

Drop album folders or single tracks into your import folder and SoulSync will identify, match, and import them automatically.

-
-
-
- - -
- -
-
- -
-
- -
-
- - - - -
- - -
-
-
- - -
-
-
-
Navigate to this page to scan your import folder for audio files.
-
- -
-
-
-
diff --git a/webui/package-lock.json b/webui/package-lock.json index 23b81ab1..3e6bf5a6 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -15,7 +15,8 @@ "react": "^19.2.5", "react-dom": "^19.2.5", "recharts": "^3.8.1", - "zod": "^4.4.2" + "zod": "^4.4.2", + "zustand": "^5.0.13" }, "devDependencies": { "@playwright/test": "^1.59.1", @@ -5391,6 +5392,35 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/webui/package.json b/webui/package.json index 56123f51..ad4cb775 100644 --- a/webui/package.json +++ b/webui/package.json @@ -21,7 +21,8 @@ "react": "^19.2.5", "react-dom": "^19.2.5", "recharts": "^3.8.1", - "zod": "^4.4.2" + "zod": "^4.4.2", + "zustand": "^5.0.13" }, "devDependencies": { "@playwright/test": "^1.59.1", diff --git a/webui/src/components/form/form.module.css b/webui/src/components/form/form.module.css index bf673cac..31eff1a8 100644 --- a/webui/src/components/form/form.module.css +++ b/webui/src/components/form/form.module.css @@ -73,6 +73,7 @@ .select { width: auto; min-width: 130px; + box-sizing: border-box; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 10px; background: @@ -95,6 +96,7 @@ font: inherit; font-size: 13px; line-height: 1.5; + min-height: 36px; padding: 8px 32px 8px 12px; transition: border-color 0.18s ease, @@ -105,28 +107,204 @@ -webkit-appearance: none; color-scheme: dark; cursor: pointer; + &:hover { + border-color: rgba(255, 255, 255, 0.16); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.045)), + rgba(255, 255, 255, 0.06); + } + + &:focus { + outline: none; + border-color: rgba(var(--accent-light-rgb), 0.55); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.05)), + rgba(255, 255, 255, 0.07); + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12); + } + + &[data-size='sm'] { + min-height: 32px; + padding: 6px 30px 6px 10px; + background-position: + 0 0, + 0 0, + right 10px center; + } + + & option, + & optgroup { + background: #1a1a2e; + color: #fff; + } } -.select:hover { - border-color: rgba(255, 255, 255, 0.16); +.checkbox { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + flex-shrink: 0; + box-sizing: border-box; + margin: 0; + border: 2px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + background: transparent; + color: #000; + cursor: pointer; + transition: + border-color 0.18s ease, + background 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; + color-scheme: dark; + &[data-checked] { + border-color: rgb(var(--accent-light-rgb)); + background: rgb(var(--accent-light-rgb)); + } + + &[data-focused] { + outline: none; + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.14); + } + + &[data-checked] .checkboxIndicator { + opacity: 1; + } +} + +.checkboxIndicator { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + opacity: 0; + transition: opacity 0.18s ease; +} + +.checkboxIcon { + color: #000; + font-size: 12px; + font-weight: 700; + line-height: 1; + transform: translateY(-0.5px) scaleX(1.18); +} + +.switch { + position: relative; + display: inline-flex; + align-items: center; + width: 44px; + height: 24px; + flex-shrink: 0; + box-sizing: border-box; + margin: 0; + padding: 0 3px; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 999px; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.045)), + linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.04)), rgba(255, 255, 255, 0.06); + cursor: pointer; + transition: + border-color 0.18s ease, + background 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; + color-scheme: dark; + &[data-checked] { + border-color: rgba(var(--accent-light-rgb), 0.55); + background: + linear-gradient(180deg, rgba(var(--accent-light-rgb), 0.55), rgba(var(--accent-rgb), 0.8)), + rgba(var(--accent-rgb), 0.45); + } + + &[data-focused] { + outline: none; + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.14); + } + + &[data-disabled] { + opacity: 0.55; + cursor: not-allowed; + } + + &[data-checked] .switchThumb { + transform: translateX(20px); + } } -.select:focus { - outline: none; - border-color: rgba(var(--accent-light-rgb), 0.55); +.switchThumb { + display: block; + width: 18px; + height: 18px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + transform: translateX(0); + transition: transform 0.18s ease; +} + +.rangeRoot { + display: inline-flex; + flex: 0 0 160px; + width: 160px; + min-width: 160px; + color-scheme: dark; +} + +.rangeControl { + display: flex; + align-items: center; + width: 100%; + min-height: 28px; + cursor: pointer; +} + +.rangeTrack { + position: relative; + width: 100%; + height: 8px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.04); +} + +.rangeIndicator { + height: 100%; + border-radius: inherit; + background: linear-gradient( + 90deg, + rgba(var(--accent-rgb), 0.95), + rgba(var(--accent-light-rgb), 0.95) + ); +} + +.rangeThumb { + width: 16px; + height: 16px; + border: 1px solid rgba(255, 255, 255, 0.24); + border-radius: 50%; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.05)), - rgba(255, 255, 255, 0.07); - box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12); + radial-gradient(circle at 30% 28%, rgba(255, 255, 255, 0.3), transparent 46%), + rgb(var(--accent-light-rgb)); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28); + transition: + transform 0.18s ease, + box-shadow 0.18s ease; } -.select option, -.select optgroup { - background: #1a1a2e; - color: #fff; +.rangeThumb:hover { + transform: scale(1.04); +} + +.rangeThumb:focus-visible { + box-shadow: + 0 0 0 4px rgba(var(--accent-light-rgb), 0.14), + 0 2px 8px rgba(0, 0, 0, 0.28); } .optionCardGroup { @@ -154,25 +332,24 @@ border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; -} + &: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); + } -.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); + &[data-selected='true'] { + 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 { @@ -206,9 +383,22 @@ display: flex; flex-wrap: wrap; gap: 8px; + &[data-size='sm'] { + gap: 6px; + } + + &[data-size='sm'] .optionButton { + min-width: 0; + padding: 6px 12px; + font-size: 12px; + gap: 6px; + } } .optionButton { + display: inline-flex; + align-items: center; + gap: 8px; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 999px; background: rgba(255, 255, 255, 0.05); @@ -218,24 +408,45 @@ font-size: 13px; font-weight: 600; min-width: 80px; - padding: 10px 14px; + padding: 8px 16px; transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; -} + &:hover { + transform: translateY(-1px); + border-color: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.08); + } -.optionButton:hover { - transform: translateY(-1px); - border-color: rgba(255, 255, 255, 0.18); - background: rgba(255, 255, 255, 0.08); -} + &[data-variant='ghost'] { + border-color: transparent; + background: transparent; + color: rgba(255, 255, 255, 0.58); + } -.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); + &[data-variant='ghost']:hover:not(:disabled) { + transform: none; + border-color: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.85); + } + + &[data-selected='true'] { + color: #fff; + 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); + } + + &[data-selected='true']:hover { + border-color: rgba(var(--accent-light-rgb), 0.62); + background: rgba(var(--accent-rgb), 0.26); + box-shadow: + 0 0 0 1px rgba(var(--accent-light-rgb), 0.12), + 0 10px 24px rgba(0, 0, 0, 0.14); + } } .button { @@ -260,24 +471,118 @@ box-shadow 0.18s ease, background 0.18s ease, color 0.18s ease; -} + &:hover:not(:disabled) { + transform: translateY(-1px); + border-color: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.08); + } -.button:hover:not(:disabled) { - transform: translateY(-1px); - border-color: rgba(255, 255, 255, 0.18); - background: rgba(255, 255, 255, 0.08); -} + &:focus-visible { + outline: none; + border-color: rgba(var(--accent-light-rgb), 0.55); + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12); + } -.button:focus-visible { - outline: none; - border-color: rgba(var(--accent-light-rgb), 0.55); - box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12); -} + &:disabled { + opacity: 0.55; + cursor: not-allowed; + transform: none; + } -.button:disabled { - opacity: 0.55; - cursor: not-allowed; - transform: none; + &[data-size='sm'] { + min-height: 32px; + padding: 6px 10px; + font-size: 13px; + } + + &[data-size='lg'] { + min-height: 40px; + padding: 10px 20px; + font-size: 14px; + } + + &[data-size='icon'] { + width: var(--button-icon-size, 36px); + height: var(--button-icon-size, 36px); + min-height: var(--button-icon-size, 36px); + min-width: var(--button-icon-size, 36px); + padding: 0; + font-size: var(--button-icon-font-size, 15px); + line-height: 1; + } + + &[data-variant='primary'] { + border-color: rgba(var(--accent-light-rgb), 0.45); + background: rgb(var(--accent-light-rgb)); + color: #000; + } + + &[data-variant='primary']:hover:not(:disabled) { + border-color: rgba(var(--accent-light-rgb), 0.55); + background: rgba(var(--accent-light-rgb), 0.95); + color: #000; + } + + &[data-variant='primary']:focus-visible { + border-color: rgba(var(--accent-light-rgb), 0.8); + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.16); + } + + &[data-variant='primary']:disabled { + opacity: 0.62; + background: rgba(var(--accent-light-rgb), 0.45); + color: rgba(0, 0, 0, 0.55); + } + + &[data-variant='primary'] [data-slot='badge'] { + color: #fff; + background: rgba(0, 0, 0, 0.18); + border-color: rgba(0, 0, 0, 0.22); + } + + &[data-variant='secondary'] { + border-color: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.08); + color: #ccc; + } + + &[data-variant='secondary']:hover:not(:disabled) { + border-color: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.14); + color: #fff; + } + + &[data-variant='secondary']:focus-visible { + border-color: rgba(var(--accent-light-rgb), 0.45); + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12); + } + + &[data-variant='secondary']:disabled { + opacity: 0.55; + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.45); + } + + &[data-variant='ghost'] { + border-color: transparent; + background: transparent; + color: rgba(255, 255, 255, 0.55); + } + + &[data-variant='ghost']:hover:not(:disabled) { + border-color: rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.07); + color: #fff; + } + + &[data-variant='ghost']:focus-visible { + border-color: rgba(var(--accent-light-rgb), 0.42); + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12); + } + + &[data-variant='ghost']:disabled { + opacity: 0.55; + } } .formError { diff --git a/webui/src/components/form/form.test.tsx b/webui/src/components/form/form.test.tsx index 3488a2f6..77b7677b 100644 --- a/webui/src/components/form/form.test.tsx +++ b/webui/src/components/form/form.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { Button, + Checkbox, FormActions, FormError, FormField, @@ -11,7 +12,9 @@ import { OptionButtonGroup, OptionCard, OptionCardGroup, + RangeInput, Select, + Switch, TextArea, TextInput, } from './form'; @@ -22,6 +25,9 @@ function FormDemo() { const [category, setCategory] = useState<'wrong_cover' | 'wrong_metadata'>('wrong_cover'); const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal'); const [status, setStatus] = useState('open'); + const [archive, setArchive] = useState(false); + const [enabled, setEnabled] = useState(true); + const [confidence, setConfidence] = useState(90); return (
@@ -90,11 +96,31 @@ function FormDemo() { + + + + + + + + + + + + - + ); @@ -109,9 +135,25 @@ describe('form primitives', () => { expect(screen.getByText('Short summary')).toBeInTheDocument(); expect(screen.getByRole('alert')).toHaveTextContent('Validation failed'); expect(screen.getByLabelText('Status')).toHaveValue('open'); + expect(screen.getByLabelText('Status')).toHaveAttribute('data-size', 'md'); fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'resolved' } }); expect(screen.getByLabelText('Status')).toHaveValue('resolved'); + const archiveCheckbox = screen.getByRole('checkbox', { name: 'Archive' }); + expect(archiveCheckbox).not.toBeChecked(); + fireEvent.click(archiveCheckbox); + expect(archiveCheckbox).toBeChecked(); + + const enabledSwitch = screen.getByRole('switch', { name: 'Enabled' }); + expect(enabledSwitch).toBeChecked(); + fireEvent.click(enabledSwitch); + expect(enabledSwitch).not.toBeChecked(); + + const confidenceSlider = screen.getByLabelText('Confidence', { selector: 'input' }); + expect(confidenceSlider).toHaveValue('90'); + fireEvent.change(confidenceSlider, { target: { value: '75' } }); + expect(confidenceSlider).toHaveValue('75'); + const wrongCover = screen.getByRole('button', { name: /wrong cover/i }); const wrongMetadata = screen.getByRole('button', { name: /wrong metadata/i }); expect(wrongCover).toHaveAttribute('aria-pressed', 'true'); @@ -126,6 +168,32 @@ describe('form primitives', () => { expect(highPriority).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('data-variant', 'primary'); + }); + + it('supports compact option button groups', () => { + const { container } = render( + + All + Pending + , + ); + + expect(container.querySelector('[data-size="sm"]')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Pending' })).toHaveAttribute( + 'data-variant', + 'ghost', + ); + }); + + it('supports compact select sizing', () => { + render( + , + ); + + expect(screen.getByLabelText('Compact')).toHaveAttribute('data-size', 'sm'); }); }); diff --git a/webui/src/components/form/form.tsx b/webui/src/components/form/form.tsx index 15938435..9e894d43 100644 --- a/webui/src/components/form/form.tsx +++ b/webui/src/components/form/form.tsx @@ -1,10 +1,14 @@ import { Button as BaseButton } from '@base-ui/react/button'; +import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox'; import { Field } from '@base-ui/react/field'; import { Input as BaseInput } from '@base-ui/react/input'; +import { Slider } from '@base-ui/react/slider'; +import { Switch as BaseSwitch } from '@base-ui/react/switch'; import { Toggle as BaseToggle } from '@base-ui/react/toggle'; import clsx from 'clsx'; import { forwardRef, + type CSSProperties, type ComponentPropsWithoutRef, type ButtonHTMLAttributes, type SelectHTMLAttributes, @@ -73,13 +77,118 @@ export const TextArea = forwardRef(function return