diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bb939df..68a754c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: CI on: + workflow_dispatch: pull_request: push: branches: [main] @@ -16,8 +17,8 @@ jobs: browser-contracts: runs-on: ubuntu-latest steps: - # actions/checkout v7.0.0, verified upstream 2026-06-18. - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + # actions/checkout v7.0.1, verified upstream 2026-07-31. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - run: npm run check php: @@ -27,7 +28,7 @@ jobs: matrix: php: ['8.2', '8.3', '8.4', '8.5'] steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # shivammathur/setup-php 2.37.2, verified upstream 2026-06-08. - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 with: @@ -52,7 +53,7 @@ jobs: module-contract: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 with: php-version: '8.4' @@ -66,3 +67,20 @@ jobs: unzip -q "$RUNNER_TEMP/omeka-s.zip" -d "$RUNNER_TEMP" - name: Module classes declare against Omeka S run: php scripts/check-module-contract.php "$RUNNER_TEMP/omeka-s" + + embeddings: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + # actions/setup-python v6.3.0, verified upstream 2026-07-31. + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: tools/embeddings/requirements.txt + - name: Install pinned embedding dependencies + run: python -m pip install -r tools/embeddings/requirements.txt + - name: Validate profile and embedding contracts + run: | + python tools/embeddings/build_embeddings.py --validate-profile + python -m unittest discover -s tools/embeddings/tests -v diff --git a/.github/workflows/embeddings.yml b/.github/workflows/embeddings.yml new file mode 100644 index 00000000..72e8857a --- /dev/null +++ b/.github/workflows/embeddings.yml @@ -0,0 +1,117 @@ +name: Build semantic embeddings + +# Manual because the Gemini call is a paid, secret-backed precompute. Ordinary +# pull-request CI tests every pure contract without a key; this workflow fetches +# only the unauthenticated public Omeka API, incrementally refreshes vectors, +# opens a bot PR for the compact artifacts, and publishes full vectors as a +# versioned GitHub Release rather than committing them. +on: + workflow_dispatch: + inputs: + scope: + description: Embedding scope + required: true + type: choice + default: missing + options: + - missing + - all + publish_vectors: + description: Publish the full-vector GitHub Release + required: true + type: boolean + default: true + +permissions: + contents: write + pull-requests: write + +concurrency: + group: semantic-embeddings + cancel-in-progress: false + +env: + GEMINI_EMBEDDING_MODEL: gemini-embedding-2 + GEMINI_EMBEDDING_DIMS: '768' + +jobs: + build: + runs-on: ubuntu-latest + steps: + # actions/checkout v7.0.1, verified upstream 2026-07-31. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + + # actions/setup-python v6.3.0, verified upstream 2026-07-31. + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: tools/embeddings/requirements.txt + + # actions/cache v6.1.0, verified upstream 2026-07-31. A unique write key + # restores the newest compatible prefix and persists the refreshed cache. + - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: asset/data/embeddings/cache.json + key: embeddings-${{ runner.os }}-${{ env.GEMINI_EMBEDDING_MODEL }}-${{ env.GEMINI_EMBEDDING_DIMS }}-${{ github.run_id }} + restore-keys: | + embeddings-${{ runner.os }}-${{ env.GEMINI_EMBEDDING_MODEL }}-${{ env.GEMINI_EMBEDDING_DIMS }}- + + - name: Install pinned dependencies + run: python -m pip install -r tools/embeddings/requirements.txt + + - name: Run embedding contract tests + run: | + python tools/embeddings/build_embeddings.py --validate-profile + python -m unittest discover -s tools/embeddings/tests -v + + - name: Build public semantic artifacts + env: + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + run: python tools/embeddings/build_embeddings.py --scope "${{ inputs.scope }}" + + - name: Validate generated artifact set + run: python tools/embeddings/build_embeddings.py --validate-artifacts + + # Save even when a later API batch fails: build_embeddings.py flushes each + # paid successful batch, so the next run can resume instead of rebilling it. + - name: Persist the incremental embedding cache + if: always() + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: asset/data/embeddings/cache.json + key: embeddings-${{ runner.os }}-${{ env.GEMINI_EMBEDDING_MODEL }}-${{ env.GEMINI_EMBEDDING_DIMS }}-${{ github.run_id }} + + - name: Open bot PR for derived map and recommendations + env: + GH_TOKEN: ${{ github.token }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add asset/data/embeddings/map.json asset/data/embeddings/similar.json asset/data/embeddings/report.json + if git diff --staged --quiet; then + echo "No semantic artifact changes." + else + branch="automation/embeddings-${GITHUB_RUN_ID}" + git switch -c "$branch" + git commit -m "Semantic embeddings: refresh public derived artifacts" + git push --set-upstream origin "$branch" + gh pr create --base main --head "$branch" \ + --title "Semantic embeddings: refresh public artifacts" \ + --body "Automated public-only semantic-map and recommendation refresh from workflow run ${GITHUB_RUN_ID}." + fi + + - name: Publish versioned full-vector release + if: ${{ inputs.publish_vectors }} + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="semantic-embeddings-v1-${GITHUB_RUN_NUMBER}" + gh release create "$tag" \ + asset/data/embeddings/release/vectors.f32 \ + asset/data/embeddings/release/ids.json \ + asset/data/embeddings/release/manifest.json \ + --target "$GITHUB_SHA" \ + --title "AMIRA semantic embeddings v1 · build ${GITHUB_RUN_NUMBER}" \ + --notes "Public AMIRA vectors. Read manifest.json before indexing; embedding spaces are model-specific." \ + --latest=false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aefc8782..aa8427b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,8 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + # actions/checkout v7.0.1, verified upstream 2026-07-31. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: fetch-depth: 0 - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 diff --git a/.github/workflows/wordclouds.yml b/.github/workflows/wordclouds.yml index c2c21882..3d365cd7 100644 --- a/.github/workflows/wordclouds.yml +++ b/.github/workflows/wordclouds.yml @@ -19,8 +19,8 @@ jobs: build: runs-on: ubuntu-latest steps: - # actions/checkout v7.0.0, verified upstream 2026-06-18. - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + # actions/checkout v7.0.1, verified upstream 2026-07-31. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/setup-python v6.3.0, verified upstream 2026-06-24. - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 diff --git a/.gitignore b/.gitignore index eaa2c006..f2389605 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules/ .idea/ .vscode/ __pycache__/ +.codex/ # Per-item knowledge-graph JSON (~6,000 files, ~188 MB) is regenerated in-Omeka # via the admin "Regenerate now" job — not committed. The front-end falls back to @@ -33,3 +34,8 @@ asset/data/communities/ # Additional install-specific outputs from the same precompute job. asset/data/network-explorer.json asset/data/featured-collections/ + +# Semantic embeddings: compact public map/recommendation/report JSON is +# committed, while the full-vector cache and release payload stay out of Git. +asset/data/embeddings/cache.json +asset/data/embeddings/release/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b912a58..c3f432ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ All notable changes are documented here. Versions follow Semantic Versioning. +## 2.24.0 — 2026-07-31 + +### Added + +- **One multilingual semantic space across six public corpora.** The manual, + secret-backed embedding workflow creates uniformly bounded cards for podcasts, + YouTube videos, publications, projects, research sections, and research items; + incrementally embeds changed cards with `gemini-embedding-2`; projects them with + deterministic UMAP; reports quality/low-signal coverage; commits the compact + map and recommendation contracts; and publishes normalized 768-dimensional + float32 vectors as a versioned GitHub Release. +- **Semantic Map** site-page block and **Similar Items** resource-page block. + The map supports title search, resource-type/cluster colouring, accessible + controls, lazy ECharts loading, and embedding. Recommendations are progressive + enhancement and never surface low-signal records. +- CI now validates the six-corpus profile, card construction, public filtering, + incremental cache behaviour, recommendation eligibility, and vector release + schema alongside the PHP and browser-contract matrices. + +### Changed + +- Podcasts now include linked subjects, subject trends/co-occurrence, locations, + and an items-by-country choropleth. Transcript word clouds expose translated, + accessible language controls, while the PHP fallback performs a curated layer + of English/French inflection folding when the spaCy-built input is unavailable. +- Project and installation metadata use the canonical **DRE-Visualizations** + repository name throughout. + ## 2.23.0 — 2026-07-30 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f645958..cc34fe13 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,7 @@ the point; a static network is cheaper as an ECharts series. npm run check # design-token contract + JS syntax sweep + registry/layout/embed contracts docker run --rm -v "$PWD:/m" php:8.4-cli php /m/tests/AggregatorsTest.php # aggregator regressions docker run --rm -v "$PWD:/m" php:8.4-cli php /m/tests/KnowledgeGraphsTest.php # graph builder regressions +python -m unittest discover -s tools/embeddings/tests -v # embedding contracts ``` The aggregators are dependency-free and unit-tested — add a mock-data case for @@ -139,24 +140,28 @@ All visualizations load from precomputed JSON under `asset/data/`: asset/data/ ├── geo/countries.geojson # Natural Earth boundaries (choropleth) — committed INPUT ├── wordclouds/ # Lemmatised frequencies from the CI Action — committed INPUT +├── embeddings/ # Committed map/recommendations/report; vectors are release-only ├── communities/ # Multi-entity co-occurrence network (baked FA2 positions) ├── knowledge-graphs/ # One per item — gitignored, regenerated in-Omeka ├── photo-galleries/ # One per image-bearing item set — gitignored └── item-dashboards/ # Dashboards + {type}-index.json + publications/podcasts/youtube/… ``` -**Everything** regenerates inside Omeka via the admin **"Regenerate now"** button — +Installation-specific dashboard data regenerates inside Omeka via the admin **"Regenerate now"** button — a pure-PHP engine under `src/Precompute/` (`DataLoader` → `Aggregators` / -`KnowledgeGraphs` → `Runner`) that reuses Omeka's own database connection. No -Python, shell access, or extra credentials — the module ships **zero** Python. The +`KnowledgeGraphs` → `Runner`) that reuses Omeka's own database connection. It needs no +Python, shell access, or extra credentials at runtime. The knowledge-graph JSON (~6,000 files) is **not committed**; until the first run the front-end falls back to a lighter live REST-API graph. -Two static **inputs** are the exception, produced outside Omeka and committed like +Static **inputs** are the exception, produced outside Omeka and committed like `countries.geojson`: the `wordclouds/` frequencies come from the **Build word clouds** GitHub Action (`tools/wordclouds/build_wordclouds.py`, spaCy lemmatisation — PHP can't do it), read back by `Runner::wordCloudInput()` with an -in-PHP tokeniser fallback. +in-PHP tokeniser fallback. The **Build semantic embeddings** Action runs the +secret-backed Gemini precompute: only `map.json`, `similar.json`, and `report.json` +are committed, while full vectors and their manifest are published as a versioned +release. Its consumer contract is documented in `docs/SEMANTIC_EMBEDDINGS.md`. The JS is modular — one vanilla-JS IIFE per concern (chart builders, controllers, the `window.RV` core, registry, layouts). The authoritative per-file tree lives in diff --git a/Module.php b/Module.php index 91a768ea..2691df7a 100644 --- a/Module.php +++ b/Module.php @@ -269,6 +269,28 @@ public static function clientTranslations($view): array 'fullscreen' => $view->translate('Fullscreen'), 'exitFullscreen' => $view->translate('Exit fullscreen'), 'item' => $view->translate('item'), + 'language' => $view->translate('Language'), + 'words' => $view->translate('Words'), + 'langAll' => $view->translate('All'), + 'langEnglish' => $view->translate('English'), + 'langFrench' => $view->translate('French'), + 'langGerman' => $view->translate('German'), + 'langPortuguese' => $view->translate('Portuguese'), + 'semanticColorBy' => $view->translate('Colour by'), + 'semanticType' => $view->translate('Resource type'), + 'semanticCluster' => $view->translate('Semantic cluster'), + 'semanticSearch' => $view->translate('Find a record on the map'), + 'semanticSearchPlaceholder' => $view->translate('Search titles'), + 'semanticMapAria' => $view->translate('Semantic map of public collection records. Nearby points have similar metadata and descriptions.'), + 'semanticLowSignal' => $view->translate('Faint points have too little descriptive metadata for recommendations.'), + 'semanticLoadError' => $view->translate('The semantic map is not available yet. Run the embeddings workflow and try again.'), + 'semanticNoSearchResults' => $view->translate('No matching records.'), + 'semanticSimilarity' => $view->translate('similar'), + 'semanticMapTitle' => $view->translate('Semantic map'), + 'semanticMapIntro' => $view->translate('Nearby records use similar language, subjects, places, and descriptions. The map joins every resource type in one multilingual space.'), + 'semanticSharedSpace' => $view->translate('Shared semantic space'), + 'semanticRecords' => $view->translate('records'), + 'semanticLowSignalCount' => $view->translate('low-signal'), ]; } diff --git a/README.md b/README.md index 649218c5..bdc86984 100644 --- a/README.md +++ b/README.md @@ -183,8 +183,9 @@ YouTube videos carry no `dcterms:type` of their own, so they don't appear in the Analytics for the cluster's curated **podcast episodes** — the manually-catalogued **Podcasts** item set configured as `itemSets.podcasts` in `config/amira-profile.json` (`fabio:AudioDocument`). Added as a **site-page block** (Admin > Sites > [site] > Pages), it loads `asset/data/item-dashboards/podcasts.json` and shows: - **summary stat cards** — episodes, series, distinct **speakers** (`marcrel:spk`), total **hours of audio** (with the average length), and the languages — the same reusable component as the Collection Overview; -- **transcript word cloud** — the headline chart, from the episodes' AI-generated transcripts (`bibo:content`), with audio cues (`[music]`), `Speaker N:` labels and numbers stripped. **Lemmatised** when the [Word clouds](#word-clouds-lemmatised) Action has run; the in-PHP tokeniser (`Aggregators::buildTranscriptWordCloud`, a tunable EN+FR stop-word/filler list) is the fallback; -- **speakers & hosts** (`marcrel:spk` / `hst` / `sde`), the **episode-length** distribution (`dcterms:extent`, ISO-8601, bucketed into bands by `Aggregators::buildDurationHistogram`), **episodes by year** (`dcterms:date`), and **episodes by series** (`dcterms:isPartOf`, clickable through to each series). +- **transcript word cloud** — the headline chart, from the episodes' AI-generated transcripts (`bibo:content`), with audio cues (`[music]`), `Speaker N:` labels and numbers stripped. **Lemmatised** when the [Word clouds](#word-clouds-lemmatised) Action has run, with an accessible **All / English / French / German / Portuguese** language switch. The in-PHP fallback also collapses common English and French inflections in addition to its EN+FR stop-word/filler filtering; +- **speakers & hosts** (`marcrel:spk` / `hst` / `sde`), the **episode-length** distribution (`dcterms:extent`, ISO-8601, bucketed into bands by `Aggregators::buildDurationHistogram`), **episodes by year** (`dcterms:date`), and **episodes by series** (`dcterms:isPartOf`, clickable through to each series); +- **subjects and places** — ranked facets, subject trends, a subject co-occurrence chord, and an items-by-country choropleth using the same linked-value and geocoding rules as the other collection dashboards. Podcasts carry no `dcterms:type` of their own, so (like YouTube videos) they don't appear in the resource-type pie *here*; instead they fold into the **Collection Overview** under a single synthetic **Podcast** type (see above). Speakers and series are clickable through to their Omeka pages. @@ -197,6 +198,12 @@ The text word clouds (Podcasts transcripts, Publications abstracts, YouTube capt - These are committed **static inputs** — like `geo/countries.geojson`, *not* the git-ignored generated dashboards. The precompute reads them via `Runner::wordCloudInput()` and folds the combined (`all`) frequencies into the dashboard; when a file is absent it **falls back** to the in-PHP tokeniser, so the clouds always render — just unlemmatised until the Action has run. - **Reusable:** add a corpus under `wordcloudCorpora` in `config/amira-profile.json` (item-set key + text property). The Python builder and PHP precompute share that profile, and the per-language buckets feed the word cloud's **language toggle** (shipped in v2.16.0). +### Semantic Map & Similar Items + +The **Semantic Map** site-page block (Admin > Sites > [site] > Pages) places public podcasts, YouTube videos, publications, projects, research sections, and research items in one multilingual Gemini embedding space. Its UMAP scatter can be coloured by resource type or semantic cluster, searched by title, zoomed, and embedded like the other site blocks. Low-signal records remain visible as faint context but do not produce recommendations. + +The **Similar Items** resource-page block (Admin > Sites > [site] > Theme > Configure resource pages) adds up to six cross-type neighbours to an item page and stays hidden when no reliable recommendation exists. Both components read compact, committed public-only JSON from `asset/data/embeddings/`; the full 768-dimensional float32 vectors are kept out of Git and published as a versioned GitHub Release for downstream search systems. See [Semantic embeddings](docs/SEMANTIC_EMBEDDINGS.md) for the shared card, schema, refresh, and compatibility contract. + ### What's New A recent-additions feed with a **3 / 6 / 12-month** window selector and a "most active projects" bar. Added as a **site-page block** (Admin > Sites > [site] > Pages), it loads `asset/data/item-dashboards/whats-new.json`. "Now" is the latest item-creation date in the corpus, so it stays meaningful regardless of when the data was imported. @@ -261,12 +268,12 @@ Every embed shows a small **source** link back to the site, and the endpoint sen Download via Omeka S CLI: ```bash -docker compose exec php omeka-s-cli module:download --base-path /var/www/html https://github.com/AM-Digital-Research-Environment/ResourceVisualizations/releases/latest/download/DreVisualizations.zip +docker compose exec php omeka-s-cli module:download --base-path /var/www/html https://github.com/AM-Digital-Research-Environment/DRE-Visualizations/releases/latest/download/DreVisualizations.zip ``` Then activate in **Admin > Modules**. -> **Module folder name.** Omeka loads this module from a directory named `DreVisualizations`, matching the PHP namespace. Official release archives already contain that top-level directory. For development installs, clone the `ResourceVisualizations` repository explicitly into it: `git clone https://github.com/AM-Digital-Research-Environment/ResourceVisualizations.git modules/DreVisualizations`. +> **Module folder name.** Omeka loads this module from a directory named `DreVisualizations`, matching the PHP namespace. Official release archives already contain that top-level directory. For development installs, clone the `DRE-Visualizations` repository explicitly into it: `git clone https://github.com/AM-Digital-Research-Environment/DRE-Visualizations.git modules/DreVisualizations`. ### Configure Resource Pages @@ -302,7 +309,7 @@ Watch progress and any errors at **Admin → Jobs → the job's log**. Re-run af To pull a new module **release** into the container: ```bash -docker compose exec php omeka-s-cli module:download --base-path /var/www/html --force https://github.com/AM-Digital-Research-Environment/ResourceVisualizations/releases/latest/download/DreVisualizations.zip +docker compose exec php omeka-s-cli module:download --base-path /var/www/html --force https://github.com/AM-Digital-Research-Environment/DRE-Visualizations/releases/latest/download/DreVisualizations.zip docker compose restart php ``` diff --git a/asset/css/dre-visualizations.css b/asset/css/dre-visualizations.css index e0098827..9300e745 100644 --- a/asset/css/dre-visualizations.css +++ b/asset/css/dre-visualizations.css @@ -98,6 +98,273 @@ body { --rv-lift-sm: var(--lift-sm, -0.25rem); /* -4px */ } +/* ------------------------------------------------------------------ */ +/* Semantic map + cross-type recommendations */ +/* ------------------------------------------------------------------ */ + +.semantic-map-intro, +.semantic-similar-intro { + max-width: 72ch; + margin: 0 0 var(--rv-space-5); + color: var(--rv-text-color); +} + +.semantic-map-toolbar { + position: relative; + z-index: 2; + display: grid; + grid-template-columns: minmax(0, auto) minmax(16rem, 28rem); + align-items: end; + justify-content: space-between; + gap: var(--rv-space-4); + margin-bottom: var(--rv-space-4); +} + +.semantic-map-mode { + display: inline-flex; + width: fit-content; + padding: var(--rv-space-1); + border: 1px solid var(--rv-border); + border-radius: var(--rv-radius-full); + background: var(--rv-bg-sunken); +} + +button.semantic-map-mode__button { + min-height: 2.5rem; + padding: var(--rv-space-2) var(--rv-space-4); + border: 0; + border-radius: var(--rv-radius-full); + background: transparent; + box-shadow: none; + color: var(--rv-text-color); + font: inherit; + font-size: var(--rv-text-sm); + font-weight: 700; + cursor: pointer; + transition: background var(--rv-transition-fast), color var(--rv-transition-fast), box-shadow var(--rv-transition-fast); +} + +button.semantic-map-mode__button:hover { + color: var(--rv-text-strong); +} + +button.semantic-map-mode__button.is-active { + background: var(--rv-bg-raised); + box-shadow: var(--rv-shadow-sm); + color: var(--rv-accent); +} + +button.semantic-map-mode__button:focus-visible { + outline: none; + box-shadow: var(--rv-focus-ring); +} + +.semantic-map-search { + position: relative; +} + +.semantic-map-search__label { + display: grid; + gap: var(--rv-space-1); + color: var(--rv-text-strong); + font-size: var(--rv-text-xs); + font-weight: 700; +} + +.semantic-map-search__input { + width: 100%; + min-height: 2.75rem; + padding: var(--rv-space-2) var(--rv-space-3); + border: 1px solid var(--rv-border-strong); + border-radius: var(--rv-radius-sm); + background: var(--rv-bg); + color: var(--rv-text-strong); + font: inherit; + font-size: var(--rv-text-sm); +} + +.semantic-map-search__input:focus-visible { + outline: none; + border-color: var(--rv-accent); + box-shadow: var(--rv-focus-ring); +} + +.semantic-map-search__results { + position: absolute; + top: calc(100% + var(--rv-space-1)); + right: 0; + left: 0; + z-index: 20; + max-height: 20rem; + overflow-y: auto; + margin: 0; + padding: var(--rv-space-1); + list-style: none; + border: 1px solid var(--rv-border); + border-radius: var(--rv-radius-sm); + background: var(--rv-bg-raised); + box-shadow: var(--rv-shadow); +} + +.semantic-map-search__result { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: baseline; + gap: var(--rv-space-3); + border-radius: var(--rv-radius-sm); +} + +.semantic-map-search__result a { + padding: var(--rv-space-2) var(--rv-space-3); + color: var(--rv-text-strong); + font-size: var(--rv-text-sm); + font-weight: 650; + text-decoration: none; +} + +.semantic-map-search__result:has(a:hover), +.semantic-map-search__result:has(a:focus-visible) { + background: var(--rv-bg-sunken); +} + +.semantic-map-search__result a:focus-visible { + outline: none; + box-shadow: var(--rv-focus-ring); +} + +.semantic-map-search__type { + padding-right: var(--rv-space-3); + color: var(--rv-text-color); + font-size: var(--rv-text-2xs); + white-space: nowrap; +} + +.semantic-map-search__empty { + padding: var(--rv-space-3); + color: var(--rv-text-color); + font-size: var(--rv-text-sm); +} + +.semantic-map-panel { + min-width: 0; +} + +.chart-container.semantic-map-chart { + height: 38rem; + min-height: 28rem; +} + +.semantic-map-status, +.semantic-map-note { + margin: var(--rv-space-2) 0 0; + color: var(--rv-text-color); + font-size: var(--rv-text-xs); +} + +.semantic-map-status { + color: var(--rv-text-strong); + font-weight: 700; +} + +.semantic-similar-list { + margin: 0; + padding: 0; + list-style: none; + border-top: 1px solid var(--rv-border); +} + +.semantic-similar-item { + border-bottom: 1px solid var(--rv-border-light); +} + +.semantic-similar-link { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--rv-space-4); + padding: var(--rv-space-3) var(--rv-space-2); + border-radius: var(--rv-radius-sm); + color: var(--rv-text-strong); + text-decoration: none; + transition: background var(--rv-transition-fast), color var(--rv-transition-fast); +} + +.semantic-similar-link:hover { + background: var(--rv-bg-sunken); + color: var(--rv-accent); +} + +.semantic-similar-link:focus-visible { + outline: none; + box-shadow: var(--rv-focus-ring); +} + +.semantic-similar-copy { + display: grid; + justify-items: start; + gap: var(--rv-space-1); + min-width: 0; +} + +.semantic-similar-type { + display: inline-block; + padding: 0.15em 0.65em; + border-radius: var(--rv-radius-full); + background: var(--rv-bg-sunken); + color: var(--rv-text-color); + font-size: var(--rv-text-2xs); + font-weight: 750; + line-height: 1.45; +} + +.semantic-similar-title { + overflow-wrap: anywhere; + font-size: var(--rv-text-base); + font-weight: 700; + line-height: 1.35; +} + +.semantic-similar-score { + min-width: 3.4rem; + color: var(--rv-accent); + font-variant-numeric: tabular-nums; + font-weight: 800; + text-align: right; +} + +@media (max-width: 700px) { + .semantic-map-toolbar { + grid-template-columns: minmax(0, 1fr); + align-items: stretch; + } + + .semantic-map-mode { + width: 100%; + } + + button.semantic-map-mode__button { + flex: 1; + padding-inline: var(--rv-space-2); + } + + .chart-container.semantic-map-chart { + height: 29rem; + min-height: 24rem; + } + + .semantic-similar-link { + gap: var(--rv-space-2); + padding-inline: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + button.semantic-map-mode__button, + .semantic-similar-link { + transition: none; + } +} + /* * Dark fallbacks for NON-DRE host themes only. When the DRE theme is active it * already supplies dark values for --surface / --border / --ink-* / --primary, diff --git a/asset/js/dashboard-charts-wordcloud.js b/asset/js/dashboard-charts-wordcloud.js index b38af299..98d6ceca 100644 --- a/asset/js/dashboard-charts-wordcloud.js +++ b/asset/js/dashboard-charts-wordcloud.js @@ -21,7 +21,13 @@ ns.charts = ns.charts || {}; - var LANG_NAMES = { en: 'English', fr: 'French', de: 'German', pt: 'Portuguese' }; + var LANG_NAMES = { + all: ['langAll', 'All'], + en: ['langEnglish', 'English'], + fr: ['langFrench', 'French'], + de: ['langGerman', 'German'], + pt: ['langPortuguese', 'Portuguese'] + }; var _wordCloudOk = null; function isWordCloudAvailable() { @@ -117,17 +123,25 @@ if (multi && langs.length > 1) { var langBar = document.createElement('div'); langBar.className = 'rv-word-langs'; + langBar.setAttribute('role', 'group'); + langBar.setAttribute('aria-label', ns.t('language', 'Language')); langs.forEach(function (code) { var b = document.createElement('button'); b.type = 'button'; b.className = 'rv-word-lang' + (code === curLang ? ' is-active' : ''); - b.textContent = LANG_NAMES[code] || code.toUpperCase(); + b.setAttribute('aria-pressed', code === curLang ? 'true' : 'false'); + var langName = LANG_NAMES[code]; + b.textContent = langName ? ns.t(langName[0], langName[1]) : code.toUpperCase(); b.addEventListener('click', function () { if (code === curLang) return; curLang = code; entries = toEntries(rawFor()); - langBar.querySelectorAll('.rv-word-lang').forEach(function (x) { x.classList.remove('is-active'); }); + langBar.querySelectorAll('.rv-word-lang').forEach(function (x) { + x.classList.remove('is-active'); + x.setAttribute('aria-pressed', 'false'); + }); b.classList.add('is-active'); + b.setAttribute('aria-pressed', 'true'); if (sliderInput) { sliderInput.max = String(entries.length); var n = Math.min(parseInt(sliderInput.value, 10), entries.length); @@ -147,7 +161,7 @@ var dc = defaultCount(); var slider = document.createElement('div'); slider.className = 'rv-word-slider'; - slider.innerHTML = '