diff --git a/CHANGELOG.md b/CHANGELOG.md index a4e0fd2..2a2fb2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,40 @@ Keep a Changelog format. ## [Unreleased] +### πŸ” One box for finding anything, and doing anything + +`Ctrl`/`⌘` + `K` opens a command palette from anywhere in the app β€” including +from inside a text field, since half of wanting it is being mid-sentence. It +replaces the header search box, which searched less and could do nothing. + +**Search now sees the half of the library it was blind to.** A recording's +summary, its action items and its translations are all LLM output living in one +column that nothing indexed, so the words a user is most likely to remember +reading were the words search could not find. A new FTS5 index covers them, and +`/api/search` answers from five places at once β€” transcripts, LLM output, file +names and aliases, tags and folders. A recording that matches in several places +is still one result, labelled with every place it was found in, and ranked by +the strongest of them. + +**The palette is also the app's command list.** Typing `/` filters commands β€” +go to a tab, ask your library, pick files, export a backup, open the console, +clear filters. `@` searches recordings, `#` searches folders and tags, and +anything else searches all of it at once. Commands, folders and tags are matched +in the browser and appear as you type; recordings come from the search endpoint, +debounced. The prefixes are the ones the terminal UI already uses, and the +matching is the same algorithm, ported. + +Smaller things that came with it: + +- **A recording renamed with an alias can now be found by that name.** Search + looked only at the original file name β€” the one name the user had chosen was + the one it ignored. +- **A search snippet is escaped before it is displayed.** Snippets carry + `` around the match and are otherwise raw transcript text, which the old + dropdown assigned straight to `innerHTML`. +- **`offset` walks the whole result list.** It used to be applied per source and + then merged, so paging skipped and repeated rows. + ### 🧹 A structural pass over the whole codebase A review of the repository against clean-architecture layering and clean-code diff --git a/README.md b/README.md index c41368e..6f14f86 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,9 @@ AmicoScript keeps everything local. OpenRouter and other hosted providers are supported too, behind an explicit opt-in - πŸ—£οΈ Speaker diarization (who said what) - 🌍 Real-time translation to English -- πŸ” Global search across transcripts +- πŸ” Command palette (`Ctrl`/`⌘` + `K`) β€” one box that searches transcripts, + LLM summaries, names, tags and folders at once, and runs any command in the + app. A recording appears once, labelled with where it matched - πŸ’¬ Ask your library β€” a question answered from every transcript at once, with citations that open the recording at the second it was said. Works on keyword search out of the box; name an embedding model and it searches by meaning diff --git a/backend/api/routes/folders_tags.py b/backend/api/routes/folders_tags.py index 466d5a0..676cf15 100644 --- a/backend/api/routes/folders_tags.py +++ b/backend/api/routes/folders_tags.py @@ -1,4 +1,4 @@ -"""Folder, tag, and search endpoints.""" +"""Folder and tag endpoints.""" import os import threading @@ -12,10 +12,9 @@ from fastapi import APIRouter, Depends, Form, HTTPException, Request from llm_providers import refusal_reason from models import Analysis, Folder, Recording, RecordingTag, Tag, Transcript -from search_query import build_fts_match from settings import get_llm_settings -from sqlalchemy import func, text -from sqlalchemy.exc import IntegrityError, OperationalError +from sqlalchemy import func +from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select router = APIRouter() @@ -315,110 +314,6 @@ def remove_recording_tag(recording_id: str, tag_id: str, session: Session = Depe return {"ok": True} -@router.get("/api/search") -def search_library(q: str = "", limit: int = 20, offset: int = 0, session: Session = Depends(get_session)) -> list: - if not q.strip(): - return [] - - - - safe_limit = max(1, min(limit, 100)) - - # Escape LIKE wildcards so % and _ in the query are treated literally - q_like = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%" - - # Never hand raw user input to MATCH β€” see backend/search_query.py. - fts_expr = build_fts_match(q) - - try: - fts_rows = [] - if fts_expr: - fts_rows = session.exec( - text( - """ - SELECT t.recording_id, - snippet(transcript_fts, 0, '', '', '…', 20) AS snippet - FROM transcript_fts - JOIN transcript t ON transcript_fts.rowid = t.rowid - WHERE transcript_fts MATCH :q - ORDER BY rank - LIMIT :lim OFFSET :off - """ - ), - params={"q": fts_expr, "lim": safe_limit, "off": offset}, - ).all() - - meta_rows = session.exec( - text( - """ - SELECT DISTINCT r.id as recording_id, - CASE - WHEN f.name LIKE :ql ESCAPE '\\' THEN 'Folder: ' || f.name - WHEN t.name LIKE :ql ESCAPE '\\' THEN 'Tag: ' || t.name - ELSE 'Title: ' || r.filename - END as snippet - FROM recording r - LEFT JOIN folder f ON r.folder_id = f.id - LEFT JOIN recordingtag rt ON r.id = rt.recording_id - LEFT JOIN tag t ON rt.tag_id = t.id - WHERE r.filename LIKE :ql ESCAPE '\\' - OR f.name LIKE :ql ESCAPE '\\' - OR t.name LIKE :ql ESCAPE '\\' - ORDER BY r.filename - LIMIT :lim OFFSET :off - """ - ), - params={"ql": q_like, "lim": safe_limit, "off": offset}, - ).all() - - fts_ids = {r.recording_id: r.snippet for r in fts_rows} - ordered = list(fts_rows) - for r in meta_rows: - if r.recording_id not in fts_ids: - ordered.append(r) - rows = ordered[:safe_limit] - except OperationalError: - rows = session.exec( - text( - """ - SELECT DISTINCT r.id AS recording_id, - CASE - WHEN f.name LIKE :ql ESCAPE '\\' THEN 'Folder: ' || f.name - WHEN t.name LIKE :ql ESCAPE '\\' THEN 'Tag: ' || t.name - WHEN r.filename LIKE :ql ESCAPE '\\' THEN 'Title: ' || r.filename - ELSE COALESCE(substr(tr.full_text, 1, 100), 'Metadata match') - END AS snippet - FROM recording r - LEFT JOIN transcript tr ON r.id = tr.recording_id - LEFT JOIN folder f ON r.folder_id = f.id - LEFT JOIN recordingtag rt ON r.id = rt.recording_id - LEFT JOIN tag t ON rt.tag_id = t.id - WHERE r.filename LIKE :ql ESCAPE '\\' - OR tr.full_text LIKE :ql ESCAPE '\\' - OR f.name LIKE :ql ESCAPE '\\' - OR t.name LIKE :ql ESCAPE '\\' - ORDER BY r.filename - LIMIT :lim OFFSET :off - """ - ), - params={"ql": q_like, "lim": safe_limit, "off": offset}, - ).all() - - results = [] - for row in rows: - rec = session.get(Recording, row.recording_id) - if rec: - results.append( - { - "recording_id": row.recording_id, - "filename": rec.filename, - "duration": rec.duration, - "snippet": row.snippet, - } - ) - return results - - @router.post("/api/exit") async def api_exit(request: Request, token: str = ""): try: diff --git a/backend/api/routes/search.py b/backend/api/routes/search.py new file mode 100644 index 0000000..5262463 --- /dev/null +++ b/backend/api/routes/search.py @@ -0,0 +1,18 @@ +"""Global search β€” one query across transcripts, LLM output and metadata. + +The ranking and the SQL live in core/search.py; this is only the door. +""" + +from core.search import search_library +from db import get_session +from fastapi import APIRouter, Depends +from sqlmodel import Session + +router = APIRouter() + + +@router.get("/api/search") +def search( + q: str = "", limit: int = 20, offset: int = 0, session: Session = Depends(get_session) +) -> list: + return search_library(session, q, limit=limit, offset=offset) diff --git a/backend/core/search.py b/backend/core/search.py new file mode 100644 index 0000000..47410e9 --- /dev/null +++ b/backend/core/search.py @@ -0,0 +1,318 @@ +"""One query over everything the library knows about a recording. + +The search box used to see two things: the words in a transcript, and the +names of files, folders and tags. Everything the LLM produced β€” the summary +of a two-hour meeting, its action items, its translation β€” was invisible to +it, even though that is the part a user actually remembers reading. + +This module answers a query from five places at once: + +=========== ========================================================== +transcript the spoken words, FTS5 over ``transcript.full_text`` +summary LLM output, FTS5 over ``analysis.result_text`` +title the file name, or the alias the user renamed it to +tag the name of a tag on the recording, LLM-suggested or not +folder the name of the folder holding it +=========== ========================================================== + +A recording that matches in several places is still **one** result: the +caller gets a single row per recording, showing the strongest match, with +``matched_in`` listing every place the query was found. Hits from different +places therefore have to be ranked against each other, which is what +``_KIND_WEIGHT`` below is for. + +The ranking is deliberately dumb and explainable. Ordering within one place +is left to whatever produced it (FTS5's ``rank``, name order for the rest), +and a place never outranks a better one: matching in three places lifts a +result within its band, never above it. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from models import Recording +from search_query import build_fts_match +from sqlalchemy import text +from sqlalchemy.exc import OperationalError +from sqlmodel import Session + +from utils.logging_utils import get_logger + +logger = get_logger("amicoscript.search") + +TRANSCRIPT = "transcript" +SUMMARY = "summary" +TITLE = "title" +TAG = "tag" +FOLDER = "folder" + +# Highest first: this is both the ranking order and the order the sources are +# consulted in, so the first place to claim a recording is the strongest one +# and gets to supply the snippet. +_KIND_WEIGHT = {TITLE: 5, TRANSCRIPT: 4, SUMMARY: 3, TAG: 2, FOLDER: 1} + +# One band per kind. A position inside a band is worth one point and a second +# matching place is worth _MULTI_BONUS, both far below _BAND so neither can +# push a result out of the band its best match earned. +_BAND = 10_000 +_MULTI_BONUS = 200 + +MAX_LIMIT = 100 +# How deep each source is read before merging. Deduplication means the merged +# list is shorter than the sum of its parts, so each source is read past what +# the caller asked for β€” but not without bound, since a one-letter query +# matches most of the library. +_MAX_SCAN = 500 + + +@dataclass +class _Hit: + """One recording, and every place this query was found in it.""" + + recording_id: str + kind: str + snippet: str + score: int + matched_in: list[str] = field(default_factory=list) + + +def _like_pattern(query: str) -> str: + """A LIKE pattern matching *query* literally, wildcards and all.""" + escaped = query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + return f"%{escaped}%" + + +def _rows(session: Session, sql: str, **params) -> list: + """Run *sql*, or return nothing if the table it needs is not there. + + An FTS index can be missing on a library whose migration was skipped (the + step logs and moves on rather than refusing to open the database). Search + degrading to the sources that do exist beats a 500. + """ + try: + return session.exec(text(sql), params=params).all() + except OperationalError as exc: + logger.warning("Search source unavailable, skipping it: %s", exc) + return [] + + +# --------------------------------------------------------------------------- +# The five sources +# --------------------------------------------------------------------------- +# Each returns rows of (recording_id, snippet), already in the order it wants +# to be ranked in. + + +def _transcript_hits(session: Session, expr: str, scan: int) -> list: + if not expr: + return [] + return _rows( + session, + """ + SELECT t.recording_id AS recording_id, + snippet(transcript_fts, 0, '', '', '…', 20) AS snippet + FROM transcript_fts + JOIN transcript t ON transcript_fts.rowid = t.rowid + WHERE transcript_fts MATCH :q + ORDER BY rank + LIMIT :scan + """, + q=expr, + scan=scan, + ) + + +def _summary_hits(session: Session, expr: str, scan: int) -> list: + """Matches in LLM output β€” summaries, action items, translations. + + Rows whose analysis has not answered yet hold an empty ``result_text`` and + match nothing, so no filter on status is needed here. + """ + if not expr: + return [] + return _rows( + session, + """ + SELECT a.recording_id AS recording_id, + snippet(analysis_fts, 0, '', '', '…', 20) AS snippet + FROM analysis_fts + JOIN analysis a ON analysis_fts.rowid = a.rowid + WHERE analysis_fts MATCH :q + ORDER BY rank + LIMIT :scan + """, + q=expr, + scan=scan, + ) + + +def _title_hits(session: Session, like: str, scan: int) -> list: + """File names, and the aliases users rename them to. + + The alias is what the library shows once it is set, so searching the + original file name alone would miss the only name the user has seen. + """ + return _rows( + session, + r""" + SELECT r.id AS recording_id, + CASE WHEN r.alias LIKE :ql ESCAPE '\' THEN r.alias ELSE r.filename END AS snippet + FROM recording r + WHERE r.filename LIKE :ql ESCAPE '\' OR r.alias LIKE :ql ESCAPE '\' + ORDER BY r.created_at DESC + LIMIT :scan + """, + ql=like, + scan=scan, + ) + + +def _tag_hits(session: Session, like: str, scan: int) -> list: + return _rows( + session, + r""" + SELECT rt.recording_id AS recording_id, t.name AS snippet + FROM tag t + JOIN recordingtag rt ON rt.tag_id = t.id + WHERE t.name LIKE :ql ESCAPE '\' + ORDER BY t.name + LIMIT :scan + """, + ql=like, + scan=scan, + ) + + +def _folder_hits(session: Session, like: str, scan: int) -> list: + return _rows( + session, + r""" + SELECT r.id AS recording_id, f.name AS snippet + FROM folder f + JOIN recording r ON r.folder_id = f.id + WHERE f.name LIKE :ql ESCAPE '\' + ORDER BY f.name + LIMIT :scan + """, + ql=like, + scan=scan, + ) + + +def _text_like_hits(session: Session, query: str, scan: int, table: str, column: str) -> list: + """Substring fallback for a text source whose FTS expression is empty. + + ``build_fts_match`` returns "" for a query FTS5 cannot express β€” one made + only of punctuation, say. LIKE has no such trouble, and finding ":-)" by + scanning is better than the search box going blank on it. The snippet is + cut around the match by hand, since there is no FTS index here to ask. + """ + return _rows( + session, + rf""" + SELECT s.recording_id AS recording_id, + substr(s.{column}, MAX(1, INSTR(LOWER(s.{column}), LOWER(:needle)) - 40), 120) AS snippet + FROM {table} s + WHERE s.{column} LIKE :ql ESCAPE '\' + LIMIT :scan + """, + ql=_like_pattern(query), + needle=query, + scan=scan, + ) + + +# --------------------------------------------------------------------------- +# Merge +# --------------------------------------------------------------------------- + + +def _collect(sources: list[tuple[str, list]]) -> list[_Hit]: + """Fold per-source rows into one hit per recording, ranked. + + *sources* arrives in ``_KIND_WEIGHT`` order, so the first source to see a + recording is its strongest match and the one that supplies the snippet. + """ + hits: dict[str, _Hit] = {} + for kind, rows in sources: + for position, row in enumerate(rows): + existing = hits.get(row.recording_id) + if existing is None: + hits[row.recording_id] = _Hit( + recording_id=row.recording_id, + kind=kind, + snippet=row.snippet or "", + score=_KIND_WEIGHT[kind] * _BAND - position, + matched_in=[kind], + ) + elif kind not in existing.matched_in: + existing.matched_in.append(kind) + existing.score += _MULTI_BONUS + + return sorted(hits.values(), key=lambda h: h.score, reverse=True) + + +def search_library( + session: Session, query: str, limit: int = 20, offset: int = 0 +) -> list[dict]: + """Search transcripts, LLM output and metadata for *query*. + + Returns one row per recording, strongest match first. ``snippet`` may + contain ```` around the matching words and is otherwise raw library + text, so callers must treat it as untrusted and escape it before display. + """ + query = (query or "").strip() + if not query: + return [] + + safe_limit = max(1, min(limit, MAX_LIMIT)) + safe_offset = max(0, offset) + scan = min(safe_limit + safe_offset, _MAX_SCAN) + + # Never hand raw user input to MATCH β€” see backend/search_query.py. + expr = build_fts_match(query) + like = _like_pattern(query) + + if expr: + transcripts = _transcript_hits(session, expr, scan) + summaries = _summary_hits(session, expr, scan) + else: + transcripts = _text_like_hits(session, query, scan, "transcript", "full_text") + summaries = _text_like_hits(session, query, scan, "analysis", "result_text") + + ranked = _collect([ + (TITLE, _title_hits(session, like, scan)), + (TRANSCRIPT, transcripts), + (SUMMARY, summaries), + (TAG, _tag_hits(session, like, scan)), + (FOLDER, _folder_hits(session, like, scan)), + ]) + + results = [] + for hit in ranked[safe_offset : safe_offset + safe_limit]: + recording = session.get(Recording, hit.recording_id) + if not recording: + continue + results.append( + { + "recording_id": hit.recording_id, + "filename": recording.filename, + "alias": recording.alias, + "duration": recording.duration, + "status": recording.status, + "kind": hit.kind, + "matched_in": hit.matched_in, + "snippet": _label(hit), + } + ) + return results + + +# The snippet of a metadata match is just a name, which says nothing on its +# own about *which* name it is. The labels predate this module and the TUI +# search screen still reads them, so they are kept exactly as they were. +_LABELS = {TITLE: "Title: ", TAG: "Tag: ", FOLDER: "Folder: "} + + +def _label(hit: _Hit) -> str: + return _LABELS.get(hit.kind, "") + hit.snippet diff --git a/backend/main.py b/backend/main.py index 67e927b..7f07b10 100644 --- a/backend/main.py +++ b/backend/main.py @@ -53,6 +53,7 @@ def _ensure_standard_streams() -> None: from api.routes.library_chat import router as library_chat_router from api.routes.llm import router as llm_router from api.routes.releases import router as releases_router +from api.routes.search import router as search_router from api.routes.settings import router as settings_router from api.routes.transcription import router as transcription_router from core.job_lifecycle import cleanup_loop, recover_interrupted_jobs @@ -132,6 +133,7 @@ async def _require_auth(request, call_next): app.include_router(library_router) app.include_router(library_chat_router) app.include_router(folders_tags_router) +app.include_router(search_router) app.include_router(benchmark_router) app.include_router(backup_router) diff --git a/backend/migrations.py b/backend/migrations.py index aba7634..4f5e128 100644 --- a/backend/migrations.py +++ b/backend/migrations.py @@ -193,6 +193,52 @@ def _m008_transcript_chunks(conn: Connection) -> None: conn.execute(text("INSERT INTO chunk_fts(chunk_fts) VALUES ('rebuild')")) +def _m009_analysis_fts(conn: Connection) -> None: + """FTS5 index over analysis.result_text, so search reaches LLM output. + + Everything the LLM writes about a recording β€” the summary, the action + items, a translation β€” lands in this one column. Without an index over it + the summary of a meeting was the one place the search box could not look, + which is backwards: the summary is usually what the user remembers. + """ + if "analysis" not in _tables(conn) or "result_text" not in _columns(conn, "analysis"): + logger.warning("Skipping analysis index: analysis.result_text does not exist") + return + + conn.execute(text(""" + CREATE VIRTUAL TABLE IF NOT EXISTS analysis_fts + USING fts5(result_text, content='analysis', content_rowid='rowid') + """)) + conn.execute(text(""" + CREATE TRIGGER IF NOT EXISTS analysis_ai + AFTER INSERT ON analysis BEGIN + INSERT INTO analysis_fts(rowid, result_text) + VALUES (new.rowid, new.result_text); + END + """)) + conn.execute(text(""" + CREATE TRIGGER IF NOT EXISTS analysis_ad + AFTER DELETE ON analysis BEGIN + INSERT INTO analysis_fts(analysis_fts, rowid, result_text) + VALUES ('delete', old.rowid, old.result_text); + END + """)) + # An analysis row is inserted empty and filled in when the LLM answers, so + # the update trigger is the one that indexes almost every summary. + conn.execute(text(""" + CREATE TRIGGER IF NOT EXISTS analysis_au + AFTER UPDATE ON analysis BEGIN + INSERT INTO analysis_fts(analysis_fts, rowid, result_text) + VALUES ('delete', old.rowid, old.result_text); + INSERT INTO analysis_fts(rowid, result_text) + VALUES (new.rowid, new.result_text); + END + """)) + # Every analysis in an existing library predates the triggers; without a + # rebuild none of them would be searchable until they were edited. + conn.execute(text("INSERT INTO analysis_fts(analysis_fts) VALUES ('rebuild')")) + + MIGRATIONS: list[tuple[int, str, Callable[[Connection], None]]] = [ (1, "folder.color_code", _m001_folder_color_code), (2, "recording.alias", _m002_recording_alias), @@ -202,6 +248,7 @@ def _m008_transcript_chunks(conn: Connection) -> None: (6, "analysis.auto_generated", _m006_analysis_auto_generated), (7, "recording.status_detail", _m007_recording_interrupted_reason), (8, "transcript_chunks", _m008_transcript_chunks), + (9, "analysis_fts", _m009_analysis_fts), ] SCHEMA_VERSION = MIGRATIONS[-1][0] diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 267ea23..22e1f4a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -18,6 +18,9 @@ The live roadmap is tracked on the **[GitHub Project board](https://github.com/u ### Shipped +- Unified global search & command palette β€” `Ctrl`/`⌘` + `K` searches + transcripts, LLM summaries, names, tags and folders in one box, and runs any + command in the app - Chat with your library β€” one question answered across every transcript, with citations that open the recording at the timestamp. Keyword retrieval works with no setup; naming an embedding model adds semantic search diff --git a/docs/doc.md b/docs/doc.md index 7f41cc7..5f35daa 100644 --- a/docs/doc.md +++ b/docs/doc.md @@ -143,6 +143,64 @@ top of a file is frontmatter. --- +### Global search & the command palette + +**GET /api/search?q=&limit=20&offset=0** + +One query, answered from everything the library knows about a recording: + +| `kind` | where the match was found | +| ------------ | -------------------------------------------------------- | +| `transcript` | the spoken words β€” FTS5 over `transcript.full_text` | +| `summary` | LLM output β€” FTS5 over `analysis.result_text` | +| `title` | the file name, or the alias it was renamed to | +| `tag` | the name of a tag on the recording | +| `folder` | the name of the folder holding it | + +```json +[ + { + "recording_id": "…", "filename": "standup.mp3", "alias": "Monday standup", + "duration": 743.0, "status": "done", + "kind": "summary", "matched_in": ["transcript", "summary"], + "snippet": "The team agreed to postpone the Helsinki launch." + } +] +``` + +A recording is returned **once** no matter how many places matched; `kind` is +the strongest of them and supplies the snippet, and `matched_in` lists them +all. Ranking is by kind in the order of the table above β€” a match in a better +place always outranks one in a worse place, and matching in several lifts a +result only within its own band. The ranking itself lives in +`backend/core/search.py`. + +`snippet` is library text with `` around the match. It is **not** escaped +β€” a transcript can contain anything β€” so a caller putting it in a page must +escape it and restore only those two tags, as `frontend/js/command-palette.js` +does. + +The query is never handed to FTS5 raw (see `backend/search_query.py`); a query +FTS5 cannot express at all, like `:-)`, falls back to a substring scan rather +than returning nothing. + +In the browser this is the **command palette**, opened with `Ctrl`/`⌘` + `K` +from anywhere, including from inside a text field. It searches as you type and +also runs any command in the app. A leading character narrows it, the same +prefixes the terminal UI uses: + +| prefix | shows | +| ------ | ------------------------------------------ | +| `/` | commands only | +| `@` | recordings only | +| `#` | folders and tags only | + +Commands, folders and tags are matched in the browser and appear instantly; +recordings come from `/api/search`, debounced. `Enter` on the last row opens +every result in the library rather than just the first few. + +--- + ### Library portability **GET /api/library/export?include_audio=true&ids=** diff --git a/frontend/index.html b/frontend/index.html index b37e443..74fe6f7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -322,33 +322,190 @@ box-shadow: 0 2px 8px 0 rgba(108, 99, 255, .08); } - /* global search dropdown */ - .search-dropdown { - position: absolute; - top: calc(100% + 4px); - left: 0; - right: 0; + /* command palette */ + #palette-overlay { + position: fixed; + inset: 0; + z-index: 9992; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 10vh 16px 16px; + background: rgba(15, 23, 42, 0.4); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + } + + #palette-overlay.hidden { + display: none; + } + + #palette-card { + width: 100%; + max-width: 640px; background: #fff; + border-radius: 16px; border: 1px solid #e2e8f0; - border-radius: 10px; - box-shadow: 0 8px 24px rgba(0, 0, 0, .10); - z-index: 100; - max-height: 320px; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, .3); + overflow: hidden; + animation: overlayIn 0.18s cubic-bezier(0.16, 1, 0.3, 1); + } + + #palette-input-row { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-bottom: 1px solid #f1f5f9; + } + + #palette-input { + flex: 1; + border: none; + outline: none; + font-size: 0.95rem; + color: #0f172a; + background: transparent; + } + + #palette-status { + font-size: 11px; + color: #94a3b8; + white-space: nowrap; + } + + #palette-results { + max-height: 56vh; overflow-y: auto; + padding: 6px 0; + margin: 0; + list-style: none; + } + + .palette-section { + padding: 8px 16px 4px; + font-size: 10px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; + color: #94a3b8; } - .search-dropdown-item { - padding: 10px 14px; + .palette-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 16px; cursor: pointer; - border-bottom: 1px solid #f1f5f9; + border-left: 2px solid transparent; } - .search-dropdown-item:last-child { - border-bottom: none; + .palette-row.active { + background: #f5f4ff; + border-left-color: #6c63ff; } - .search-dropdown-item:hover { - background: #f8f7ff; + .palette-row.disabled { + opacity: .45; + cursor: default; + } + + .palette-icon { + width: 20px; + text-align: center; + color: #94a3b8; + font-size: 12px; + flex-shrink: 0; + } + + .palette-swatch { + width: 10px; + height: 10px; + border-radius: 3px; + margin: 0 5px; + flex-shrink: 0; + } + + .palette-text { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + } + + .palette-title { + font-size: 0.8125rem; + font-weight: 600; + color: #334155; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .palette-subtitle { + font-size: 11px; + color: #94a3b8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .palette-subtitle mark { + background: #fef3c7; + color: #92400e; + border-radius: 2px; + } + + .palette-meta { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + } + + .palette-badge { + font-size: 9px; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; + color: #6c63ff; + background: #ede9ff; + border-radius: 999px; + padding: 2px 7px; + } + + .palette-dur { + font-size: 11px; + color: #94a3b8; + font-variant-numeric: tabular-nums; + } + + .palette-empty { + padding: 20px 16px; + text-align: center; + font-size: 0.8125rem; + color: #94a3b8; + } + + #palette-footer { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 8px 16px; + border-top: 1px solid #f1f5f9; + background: #f8fafc; + font-size: 10px; + color: #94a3b8; + } + + #palette-footer kbd { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 4px; + padding: 1px 5px; + margin-right: 3px; + color: #6c63ff; } /* Entity edit modal styles */ @@ -1549,15 +1706,18 @@

Tags - @@ -2079,6 +2239,31 @@

P + + +