From 3e45f3316d442d986bfd9d0ea7fb38a2a1a6e701 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Wed, 22 Jul 2026 14:57:07 +0800 Subject: [PATCH 1/6] feat(browser): meeting type badge + Type facet + place-led card headline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MeetingType is a first-class concept in the gem (Edoxen::Enums::MEETING_TYPE, 17 values) but the browser was rendering it raw as a plain string on the detail page and ignoring it entirely on cards + the search island. Data: MeetingListItem now carries type; MeetingListFacets exposes types[]. /data/meetings.json carries the enum value per item. Config + i18n: terminology.meetingTypes override (per-locale). Built-in meeting.type.* strings for all 17 values, English + French. meetingTypeLabel(type, locale, terminology) helper resolves override -> i18n -> humanize. UI: MeetingCard meta row gains a small-caps type badge (label via helper). MeetingDetail uses the helper instead of raw {meeting.type}. Search island adds a Type facet between Year and Location, with hash round-trip (#types=plenary,working_group). Meeting result rows in the island also gain the type badge for consistency with the card. Card headline: composed from place (flag + City, Country or 🌐 Virtual) instead of the entity title. The title previously restated dates + committee the meta row already carried and produced 15-word headlines. Full title now rides as the link title attribute (tooltip) + remains the detail page h1 + JSON-LD + sitemap value. Falls back to title when no place data. Drift guardrail: src/i18n/meeting-types.spec.ts asserts every canonical MeetingType value has an English meeting.type.* entry + a few required French translations + helper behavior (override, fallback, nullish). 171 unit + 51 e2e tests green (was 171 + 51, +6 new i18n spec tests). --- .changeset/meeting-card-place-headline.md | 14 ++++ packages/browser/e2e/smoke.spec.ts | 28 +++++-- .../src/astro/components/MeetingCard.astro | 57 +++++++------ .../src/astro/components/MeetingDetail.astro | 3 +- packages/browser/src/config/schema.spec.ts | 1 + packages/browser/src/config/schema.ts | 7 ++ packages/browser/src/data/prepare.ts | 5 ++ packages/browser/src/i18n/index.ts | 1 + .../browser/src/i18n/meeting-types.spec.ts | 80 +++++++++++++++++++ packages/browser/src/i18n/ui.ts | 64 +++++++++++++++ .../src/islands/search-filter-core.spec.ts | 3 + .../browser/src/islands/search-filter-core.ts | 17 +++- packages/browser/src/islands/search-filter.ts | 42 +++++++--- 13 files changed, 276 insertions(+), 46 deletions(-) create mode 100644 .changeset/meeting-card-place-headline.md create mode 100644 packages/browser/src/i18n/meeting-types.spec.ts diff --git a/.changeset/meeting-card-place-headline.md b/.changeset/meeting-card-place-headline.md new file mode 100644 index 0000000..64541b2 --- /dev/null +++ b/.changeset/meeting-card-place-headline.md @@ -0,0 +1,14 @@ +--- +'@edoxen/browser': minor +--- + +Expose Meeting.type as an i18n'd badge + filterable Type facet. + +- `MeetingListItem` carries `type` (MeetingType enum) and `MeetingListFacets` exposes a `types[]` list. `/data/meetings.json` now includes the type per item. +- `MeetingCard` renders a type badge in the meta row (small caps muted label), resolved through the new `meetingTypeLabel()` helper. +- `MeetingDetail` badge row uses the helper instead of the raw enum value. +- The search island adds a Type facet (between Year and Location, meetings mode) — chip labels are humanized enum values; selection round-trips via `#types=plenary,working_group` in the URL hash. The facet text is also part of the search haystack so typing "plenary" filters meetings. +- Card headline is composed from place (flag + "City, Country" or "🌐 Virtual"), with the entity title carried as the link's `title` attribute tooltip. Detail h1, JSON-LD, sitemaps, and `` continue to use the entity title. Falls back to the title when no place data is present. +- New config knob: `terminology.meetingTypes` — per-locale overrides (`{ eng: { plenary: 'Plénière CIML' } }`). Resolution order: consumer override → built-in i18n table (`meeting.type.<value>`) → humanized enum value. The built-in table ships English + French for all 17 MeetingType values; other locales fall back to English until they're translated. + +`@edoxen/edoxen` (separate package) gains a symmetric `meetingTypeLabel(type)` helper + `MEETING_TYPE_LABELS` constant, mirroring the existing `actionTypeLabel` pattern. diff --git a/packages/browser/e2e/smoke.spec.ts b/packages/browser/e2e/smoke.spec.ts index 4379b36..1bb4e4d 100644 --- a/packages/browser/e2e/smoke.spec.ts +++ b/packages/browser/e2e/smoke.spec.ts @@ -210,6 +210,7 @@ test.describe('fixture site — home, lists and data endpoints', () => { await page.goto('/meetings') await expect(page.locator('section#decade-2020')).toBeVisible() const card = page.locator('.edoxen-meeting-card', { has: page.locator('a[href="/meetings/urn:test:meeting:2025"]') }) + await expect(card.locator('.edoxen-meeting-card__type')).toHaveText('Plenary') await expect(card.locator('.edoxen-meeting-card__committee')).toHaveText('sc-1') await expect(card.locator('.edoxen-meeting-card__count')).toContainText('1 Resolutions') await expect(page.locator('.edoxen-decade-timeline__link').first()).toHaveAttribute('href', '#decade-2020') @@ -280,6 +281,9 @@ test.describe('fixture site — home, lists and data endpoints', () => { // Facets are grouped under labels — years never mix with locations. await expect(island.locator('.edoxen-search-filter__facet-label', { hasText: 'Year' })).toBeVisible() await expect(island.locator('.edoxen-search-filter__facet-label', { hasText: 'Location' })).toBeVisible() + await expect(island.locator('.edoxen-search-filter__facet-label', { hasText: 'Type' })).toBeVisible() + // Meeting-type facet chips carry the humanized enum value. + await expect(island.locator('.edoxen-search-filter__facet--meeting-type', { hasText: 'Plenary' })).toContainText('(3)') // Country chips carry flag + localized country name; Virtual is last. await expect(island.locator('.edoxen-search-filter__facet--country', { hasText: '🇩🇪 Germany' })).toContainText('(1)') await expect(island.locator('.edoxen-search-filter__facet--country', { hasText: '🇨🇭 Switzerland' })).toContainText('(1)') @@ -295,10 +299,11 @@ test.describe('fixture site — home, lists and data endpoints', () => { const input = island.locator('input[type="search"]') await input.fill('sc-1') await expect(results).toHaveCount(1) - await expect(results.first()).toContainText('2025 Refs Plenary') + // Headline is composed from place — full entity title rides along as a tooltip. + await expect(results.first().locator('a.edoxen-search-filter__result-title')) + .toHaveAttribute('title', '2025 Refs Plenary') await expect(results.first().locator('a.edoxen-search-filter__result-title')) .toHaveAttribute('href', '/meetings/urn:test:meeting:2025') - // UN/LOCODE resolved to a place name on the result card. await expect(results.first()).toContainText('Berlin, Germany') await expect(island).toHaveAttribute('data-filtering', 'true') await expect(page.locator('section#decade-2020')).toBeHidden() @@ -310,7 +315,8 @@ test.describe('fixture site — home, lists and data endpoints', () => { const chChip = island.locator('.edoxen-search-filter__facet--country', { hasText: 'Switzerland' }) await chChip.click() await expect(results).toHaveCount(1) - await expect(results.first()).toContainText('2026 Register Refs Plenary') + await expect(results.first().locator('a.edoxen-search-filter__result-title')) + .toHaveAttribute('title', '2026 Register Refs Plenary') await expect(results.first()).toContainText('Geneva, Switzerland') await expect(page).toHaveURL(/#.*countries=CH/) await chChip.click() @@ -319,7 +325,8 @@ test.describe('fixture site — home, lists and data endpoints', () => { // The Virtual chip matches the online meeting (no city/country). await virtualChip.click() await expect(results).toHaveCount(1) - await expect(results.first()).toContainText('2027 Virtual Plenary') + await expect(results.first().locator('a.edoxen-search-filter__result-title')) + .toHaveAttribute('title', '2027 Virtual Plenary') await expect(results.first()).toContainText('🌐 Virtual') await expect(page).toHaveURL(/#.*countries=virtual/) await virtualChip.click() @@ -329,8 +336,16 @@ test.describe('fixture site — home, lists and data endpoints', () => { await expect(y2026).toContainText('(1)') await y2026.click() await expect(results).toHaveCount(1) - await expect(results.first()).toContainText('2026 Register Refs Plenary') + await expect(results.first()).toContainText('Geneva, Switzerland') await y2026.click() + + // Meeting-type facet narrows by enum value and round-trips via hash. + // All three fixture meetings are type=plenary, so the chip matches all 3. + const plenaryChip = island.locator('.edoxen-search-filter__facet--meeting-type', { hasText: 'Plenary' }) + await plenaryChip.click() + await expect(results).toHaveCount(3) + await expect(page).toHaveURL(/#.*types=plenary/) + await plenaryChip.click() }) test('JSON data endpoints are served from the built site', async ({ request }) => { @@ -352,11 +367,12 @@ test.describe('fixture site — home, lists and data endpoints', () => { expect(meetings.ok()).toBeTruthy() const meetingsBody = (await meetings.json()) as { items: Array<Record<string, unknown>> } const m2025 = meetingsBody.items.find((i) => i['urn'] === 'urn:test:meeting:2025') - // The island searches the flattened title + committee code + city. + // The island searches the flattened title + committee code + city + type. expect(typeof m2025?.['title']).toBe('string') expect(m2025?.['committeeCode']).toBe('sc-1') expect(m2025?.['city']).toBe('DEBER') expect(m2025?.['countryCode']).toBe('DE') + expect(m2025?.['type']).toBe('plenary') const registers = await request.get('/data/registers.json') expect(registers.ok()).toBeTruthy() diff --git a/packages/browser/src/astro/components/MeetingCard.astro b/packages/browser/src/astro/components/MeetingCard.astro index 4903140..fa2d0d9 100644 --- a/packages/browser/src/astro/components/MeetingCard.astro +++ b/packages/browser/src/astro/components/MeetingCard.astro @@ -1,7 +1,7 @@ --- import type { MeetingListItem } from '../../data/index.js' import { isVirtualMeeting } from '../../data/index.js' -import { pickLocalizedValue, formatDateRange, regionName, t } from '../../i18n/index.js' +import { pickLocalizedValue, formatDateRange, regionName, t, meetingTypeLabel } from '../../i18n/index.js' import { urnToPath } from '../../urn.js' import { countryFlag } from '@edoxen/edoxen' import cfg from 'virtual:edoxen-config' @@ -12,7 +12,7 @@ interface Props { urlPrefix?: string } const { item: m, lang = 'en', urlPrefix: prefix = '/' } = Astro.props -const title = pickLocalizedValue(m.title, lang, m.urn) +const fullTitle = pickLocalizedValue(m.title, lang, m.urn) const dateLabel = m.startDate ? formatDateRange(m.startDate, m.endDate, lang) : '' const virtual = isVirtualMeeting(m) const flag = !virtual && m.countryCode ? countryFlag(m.countryCode) : '' @@ -20,6 +20,14 @@ const flag = !virtual && m.countryCode ? countryFlag(m.countryCode) : '' // the country renders as a localized name, not the raw alpha-2 code. const city = m.cityNames?.[lang] ?? m.cityNames?.[lang.slice(0, 2)] ?? m.cityNames?.en ?? m.city ?? '' const country = !virtual && m.countryCode ? regionName(m.countryCode, lang) : '' +// Browse views (index, home) compose the headline from place — matches the +// original venue+year browse model and avoids restating dates/committee the +// meta row already carries. Fall back to the entity title when no place. +const placeLabel = virtual + ? t('meeting.virtual', lang, cfg.uiStrings, cfg.terminology) + : [city, country].filter(Boolean).join(', ') +const headline = placeLabel || fullTitle +const typeLabel = meetingTypeLabel(m.type, lang, cfg.terminology, cfg.uiStrings) --- <li class="edoxen-meeting-card edoxen-card"> @@ -29,6 +37,9 @@ const country = !virtual && m.countryCode ? regionName(m.countryCode, lang) : '' <time datetime={m.startDate}>{dateLabel}</time> </span> )} + {typeLabel && ( + <span class="edoxen-meeting-card__type">{typeLabel}</span> + )} {m.status && ( <span class="edoxen-badge edoxen-badge--status" data-status={m.status}>{m.status}</span> )} @@ -41,18 +52,11 @@ const country = !virtual && m.countryCode ? regionName(m.countryCode, lang) : '' </span> )} </div> - <a class="edoxen-meeting-card__title" href={`${prefix}meetings/${urnToPath(m.urn)}`}>{title}</a> - {virtual ? ( - <p class="edoxen-meeting-card__place"> - <span class="edoxen-meeting-card__flag" aria-hidden="true">🌐</span> - <span>{t('meeting.virtual', lang, cfg.uiStrings, cfg.terminology)}</span> - </p> - ) : (city || country) && ( - <p class="edoxen-meeting-card__place"> - {flag && <span class="edoxen-meeting-card__flag" aria-hidden="true">{flag}</span>} - <span>{[city, country].filter(Boolean).join(', ')}</span> - </p> - )} + <a class="edoxen-meeting-card__title" href={`${prefix}meetings/${urnToPath(m.urn)}`} title={fullTitle}> + {virtual && <span class="edoxen-meeting-card__flag" aria-hidden="true">🌐</span>} + {!virtual && flag && <span class="edoxen-meeting-card__flag" aria-hidden="true">{flag}</span>} + <span class="edoxen-meeting-card__headline">{headline}</span> + </a> </li> <style> @@ -71,6 +75,14 @@ const country = !virtual && m.countryCode ? regionName(m.countryCode, lang) : '' font-variant-numeric: tabular-nums; white-space: nowrap; } +.edoxen-meeting-card__type { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--edoxen-color-muted); + white-space: nowrap; +} .edoxen-meeting-card__committee { font-family: var(--edoxen-font-mono, ui-monospace, "SF Mono", Menlo, monospace); font-size: 0.75rem; @@ -89,6 +101,9 @@ const country = !virtual && m.countryCode ? regionName(m.countryCode, lang) : '' white-space: nowrap; } .edoxen-meeting-card__title { + display: flex; + align-items: baseline; + gap: 0.375rem; font-family: var(--edoxen-font-display-resolved, "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, "Times New Roman", serif); font-size: 1.125rem; @@ -102,20 +117,12 @@ const country = !virtual && m.countryCode ? regionName(m.countryCode, lang) : '' color: var(--edoxen-color-accent); text-decoration: none; } -.edoxen-meeting-card__place { - margin: 0; - display: flex; - align-items: center; - gap: 0.375rem; - font-size: 0.9375rem; - color: var(--edoxen-color-muted); +.edoxen-meeting-card__headline { + flex: 1 1 auto; } .edoxen-meeting-card__flag { + flex: 0 0 auto; font-size: 1.1em; line-height: 1; } -.edoxen-meeting-card__country { - font-family: var(--edoxen-font-mono, ui-monospace, monospace); - font-size: 0.8125rem; -} </style> diff --git a/packages/browser/src/astro/components/MeetingDetail.astro b/packages/browser/src/astro/components/MeetingDetail.astro index 81a6083..7cffe17 100644 --- a/packages/browser/src/astro/components/MeetingDetail.astro +++ b/packages/browser/src/astro/components/MeetingDetail.astro @@ -18,6 +18,7 @@ import { formatDate, formatDateRange, humanizeVerb, + meetingTypeLabel, regionName, t, urlPrefix, @@ -121,7 +122,7 @@ function hostLabel(h: HostRef): string { <header class="edoxen-meeting__header edoxen-enter" style="--nth: 2"> <div class="edoxen-meta-row"> - {meeting.type && <span class="edoxen-badge">{meeting.type}</span>} + {meeting.type && <span class="edoxen-badge">{meetingTypeLabel(meeting.type, locale, cfg.terminology, cfg.uiStrings)}</span>} {meeting.status && ( <span class="edoxen-badge edoxen-badge--status" data-status={meeting.status}>{meeting.status}</span> )} diff --git a/packages/browser/src/config/schema.spec.ts b/packages/browser/src/config/schema.spec.ts index 0bb995c..4235b35 100644 --- a/packages/browser/src/config/schema.spec.ts +++ b/packages/browser/src/config/schema.spec.ts @@ -270,6 +270,7 @@ describe('EdoxenConfigSchema', () => { decisions: 'decisions', meeting: 'meeting', meetings: 'meetings', + meetingTypes: {}, }) }) diff --git a/packages/browser/src/config/schema.ts b/packages/browser/src/config/schema.ts index d5d116d..405031e 100644 --- a/packages/browser/src/config/schema.ts +++ b/packages/browser/src/config/schema.ts @@ -116,11 +116,18 @@ export type NavItem = z.infer<typeof NavItemSchema> // English string (nav, page titles, section headings, stat strip, // breadcrumbs, empty states, search placeholder) follows — see t() in // src/i18n/ui.ts for the resolution order. +// +// `meetingTypes` overrides the display label for a MeetingType enum value +// (plenary, working_group, …) per locale. Keys are UI locale codes +// (eng, fra, …); values map the enum value → display label. Unlisted +// locales or enum values fall back to the built-in i18n table, then to a +// humanized form of the enum value. export const TerminologySchema = z.object({ decision: z.string().min(1).default('decision'), decisions: z.string().min(1).default('decisions'), meeting: z.string().min(1).default('meeting'), meetings: z.string().min(1).default('meetings'), + meetingTypes: z.record(z.string(), z.record(z.string(), z.string())).default({}), }) export type Terminology = z.infer<typeof TerminologySchema> diff --git a/packages/browser/src/data/prepare.ts b/packages/browser/src/data/prepare.ts index 6f4f62d..a5b75d3 100644 --- a/packages/browser/src/data/prepare.ts +++ b/packages/browser/src/data/prepare.ts @@ -80,6 +80,8 @@ export interface MeetingListItem { readonly endDate?: string readonly year?: number readonly bodyType?: string + /** MeetingType enum value (plenary, working_group, …) — drives the type badge + facet. */ + readonly type?: string readonly city?: string readonly countryCode?: string readonly status?: string @@ -95,6 +97,7 @@ export interface MeetingListFacets { readonly decades: readonly number[] readonly bodies: readonly string[] readonly countries: readonly string[] + readonly types: readonly string[] } export interface MeetingListPayload { @@ -238,6 +241,7 @@ function toMeetingListItem(m: Meeting, unlocodes?: UnlocodeNames): MeetingListIt endDate: m.scheduled_date_range?.end, year: year ?? undefined, bodyType: m.body_type, + type: m.type, city: m.city, countryCode: m.country_code, status: m.status, @@ -280,6 +284,7 @@ export function prepareMeetingsList(project: EdoxenProject, unlocodes?: Unlocode decades: uniqSortedNumbers(decadesSet), bodies: uniqSorted(project.meetings.map((m) => m.body_type ?? '')), countries: uniqSorted(project.meetings.map((m) => m.country_code ?? '')), + types: uniqSorted(project.meetings.map((m) => m.type ?? '')), }, } } diff --git a/packages/browser/src/i18n/index.ts b/packages/browser/src/i18n/index.ts index e45bb41..1345f0f 100644 --- a/packages/browser/src/i18n/index.ts +++ b/packages/browser/src/i18n/index.ts @@ -87,6 +87,7 @@ export { isRtl, availableUiLocales, applyTerminology, + meetingTypeLabel, DEFAULT_TERMINOLOGY, SUPPORTED_UI_LOCALES, LOCALE_LABELS, diff --git a/packages/browser/src/i18n/meeting-types.spec.ts b/packages/browser/src/i18n/meeting-types.spec.ts new file mode 100644 index 0000000..a15f638 --- /dev/null +++ b/packages/browser/src/i18n/meeting-types.spec.ts @@ -0,0 +1,80 @@ +// Drift detection: every canonical MeetingType enum value (mirrored from +// Edoxen::Enums::MEETING_TYPE) must have a `meeting.type.<value>` entry +// in the built-in English i18n table. When the gem adds a new value, +// this spec fails until the i18n table catches up. +// +// The canonical list lives in +// /Users/mulgogi/src/edoxen/edoxen-model/models/meeting_type.lutaml and +// propagates: gem (lib/edoxen/enums.rb) → schema ($defs/MeetingType) → +// generated TS type (MeetingType, in @edoxen/edoxen). The i18n table +// here is hand-maintained; this spec is the guardrail. + +import { describe, expect, it } from 'vitest' + +import { t, meetingTypeLabel } from './ui.js' + +const CANONICAL_MEETING_TYPES = [ + 'plenary', + 'working_group', + 'task_group', + 'ad_hoc', + 'joint', + 'general_assembly', + 'committee', + 'subcommittee', + 'conference', + 'workshop', + 'seminar', + 'webinar', + 'hearing', + 'markup', + 'board_meeting', + 'annual_general_meeting', + 'other', +] as const + +describe('MeetingType i18n coverage', () => { + it('every canonical enum value has an English meeting.type.* string', () => { + const missing = CANONICAL_MEETING_TYPES.filter((type) => { + const label = t(`meeting.type.${type}`, 'eng') + return !label || label === `meeting.type.${type}` + }) + expect(missing, `Missing English i18n for: ${missing.join(', ')}`).toEqual([]) + }) + + it('French locale has at least the common subset translated', () => { + const requiredFra = ['plenary', 'committee', 'subcommittee', 'other'] + const missing = requiredFra.filter((type) => { + const label = t(`meeting.type.${type}`, 'fra') + return !label || label === `meeting.type.${type}` || label === t(`meeting.type.${type}`, 'eng') + }) + expect(missing, `Missing French translations for: ${missing.join(', ')}`).toEqual([]) + }) +}) + +describe('meetingTypeLabel resolution', () => { + it('returns the curated English label for known values', () => { + expect(meetingTypeLabel('plenary', 'eng')).toBe('Plenary') + expect(meetingTypeLabel('annual_general_meeting', 'eng')).toBe('Annual General Meeting') + }) + + it('returns empty string for nullish input', () => { + expect(meetingTypeLabel(undefined, 'eng')).toBe('') + expect(meetingTypeLabel('', 'eng')).toBe('') + }) + + it('falls back to humanize for unknown values', () => { + expect(meetingTypeLabel('some_future_type', 'eng')).toBe('Some Future Type') + }) + + it('honors terminology.meetingTypes overrides per locale', () => { + const terminology = { + meetingTypes: { + eng: { plenary: 'Plénière CIML' }, + }, + } + expect(meetingTypeLabel('plenary', 'eng', terminology)).toBe('Plénière CIML') + // Non-overridden values still resolve normally. + expect(meetingTypeLabel('committee', 'eng', terminology)).toBe('Committee') + }) +}) diff --git a/packages/browser/src/i18n/ui.ts b/packages/browser/src/i18n/ui.ts index 512d7a3..040936a 100644 --- a/packages/browser/src/i18n/ui.ts +++ b/packages/browser/src/i18n/ui.ts @@ -88,6 +88,24 @@ const STRINGS: Readonly<Record<string, Readonly<Record<string, string>>>> = { 'search.groupBody': 'Body', 'meeting.virtual': 'Virtual', + 'meeting.type.plenary': 'Plenary', + 'meeting.type.working_group': 'Working Group', + 'meeting.type.task_group': 'Task Group', + 'meeting.type.ad_hoc': 'Ad Hoc', + 'meeting.type.joint': 'Joint', + 'meeting.type.general_assembly': 'General Assembly', + 'meeting.type.committee': 'Committee', + 'meeting.type.subcommittee': 'Subcommittee', + 'meeting.type.conference': 'Conference', + 'meeting.type.workshop': 'Workshop', + 'meeting.type.seminar': 'Seminar', + 'meeting.type.webinar': 'Webinar', + 'meeting.type.hearing': 'Hearing', + 'meeting.type.markup': 'Markup', + 'meeting.type.board_meeting': 'Board Meeting', + 'meeting.type.annual_general_meeting': 'Annual General Meeting', + 'meeting.type.other': 'Other', + 'nav.prev': '← Previous', 'nav.next': 'Next →', @@ -194,6 +212,24 @@ const STRINGS: Readonly<Record<string, Readonly<Record<string, string>>>> = { 'search.groupBody': 'Organe', 'meeting.virtual': 'Virtuel', + 'meeting.type.plenary': 'Plénière', + 'meeting.type.working_group': 'Groupe de travail', + 'meeting.type.task_group': 'Groupe de travail ad hoc', + 'meeting.type.ad_hoc': 'Ad hoc', + 'meeting.type.joint': 'Conjointe', + 'meeting.type.general_assembly': 'Assemblée générale', + 'meeting.type.committee': 'Comité', + 'meeting.type.subcommittee': 'Sous-comité', + 'meeting.type.conference': 'Conférence', + 'meeting.type.workshop': 'Atelier', + 'meeting.type.seminar': 'Séminaire', + 'meeting.type.webinar': 'Webinaire', + 'meeting.type.hearing': 'Audience', + 'meeting.type.markup': 'Markup', + 'meeting.type.board_meeting': 'Réunion du conseil', + 'meeting.type.annual_general_meeting': 'Assemblée générale annuelle', + 'meeting.type.other': 'Autre', + 'nav.prev': '← Précédent', 'nav.next': 'Suivant →', @@ -566,6 +602,7 @@ export const DEFAULT_TERMINOLOGY: Terminology = { decisions: 'decisions', meeting: 'meeting', meetings: 'meetings', + meetingTypes: {}, } function capitalize(s: string): string { @@ -647,3 +684,30 @@ export function t( export function availableUiLocales(configuredLocales: readonly { code: string }[]): string[] { return configuredLocales.map((l) => normalizeUiLocale(l.code)) } + +// Display label for a MeetingType enum value (plenary, working_group, …). +// Resolution order: terminology.meetingTypes[locale][type] → built-in +// `meeting.type.<value>` i18n string → humanized enum value. Consumers +// override per-locale via terminology.meetingTypes when their committee +// uses a different word (e.g. CIML meetings styled as "Plénière CIML"). +export function meetingTypeLabel( + type: string | undefined, + locale: string, + terminology?: Partial<Terminology>, + customStrings?: CustomUiStrings, +): string { + if (!type) return '' + const code = normalizeUiLocale(locale) + const override = terminology?.meetingTypes?.[code]?.[type] + if (override) return override + const i18n = t(`meeting.type.${type}`, locale, customStrings, terminology) + if (i18n && i18n !== `meeting.type.${type}`) return i18n + return humanizeEnum(type) +} + +function humanizeEnum(value: string): string { + return value + .split('_') + .map((part) => (part.length === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))) + .join(' ') +} diff --git a/packages/browser/src/islands/search-filter-core.spec.ts b/packages/browser/src/islands/search-filter-core.spec.ts index f7744b6..83d6a82 100644 --- a/packages/browser/src/islands/search-filter-core.spec.ts +++ b/packages/browser/src/islands/search-filter-core.spec.ts @@ -206,6 +206,7 @@ describe('encodeState / decodeState', () => { actions: new Set(['approves']), decades: new Set([2020]), countries: new Set(['DE', 'CH']), + types: new Set(['plenary']), dateFrom: '2023', dateTo: '2024-12-31', } @@ -218,6 +219,7 @@ describe('encodeState / decodeState', () => { expect([...restored.actions]).toEqual(['approves']) expect([...restored.decades]).toEqual([2020]) expect([...restored.countries].sort()).toEqual(['CH', 'DE']) + expect([...restored.types]).toEqual(['plenary']) expect(restored.dateFrom).toBe('2023') expect(restored.dateTo).toBe('2024-12-31') }) @@ -228,5 +230,6 @@ describe('encodeState / decodeState', () => { expect(restored.bodies.size).toBe(0) expect(restored.decades.size).toBe(0) expect(restored.countries.size).toBe(0) + expect(restored.types.size).toBe(0) }) }) diff --git a/packages/browser/src/islands/search-filter-core.ts b/packages/browser/src/islands/search-filter-core.ts index 869eba8..0ae3330 100644 --- a/packages/browser/src/islands/search-filter-core.ts +++ b/packages/browser/src/islands/search-filter-core.ts @@ -20,6 +20,8 @@ export interface SearchableItem { /** Meeting start (or only) ISO date. */ readonly startDate?: string readonly endDate?: string + /** MeetingType enum value (plenary, working_group, …). */ + readonly type?: string readonly city?: string readonly countryCode?: string /** Localized names for the UN/LOCODE in `city` (from data.unlocodes). */ @@ -37,6 +39,8 @@ export interface FilterState { readonly actions: ReadonlySet<string> readonly decades: ReadonlySet<number> readonly countries: ReadonlySet<string> + /** MeetingType enum values (plenary, working_group, …). */ + readonly types: ReadonlySet<string> /** Inclusive range bounds: an ISO date ('2024-06-15') or a bare year ('2024'). Either side may be omitted. */ readonly dateFrom?: string @@ -51,6 +55,7 @@ export const EMPTY_STATE: FilterState = { actions: new Set(), decades: new Set(), countries: new Set(), + types: new Set(), } export function decadeOfYear(year: number): number { @@ -109,9 +114,10 @@ export function filterItems<T extends SearchableItem>( if (state.decades.size > 0 && !state.decades.has(typeof item.year === 'number' ? decadeOfYear(item.year) : -1)) return false // Items with no country code (online meetings) match the Virtual chip. if (state.countries.size > 0 && !state.countries.has(item.countryCode ?? VIRTUAL_COUNTRY)) return false + if (state.types.size > 0 && !state.types.has(item.type ?? '')) return false if (!inDateRange(item, state)) return false if (q) { - const hay = `${item.title} ${item.urn} ${item.identifier ?? ''} ${item.snippet ?? ''} ${item.committeeCode ?? ''} ${item.city ?? ''}`.toLowerCase() + const hay = `${item.title} ${item.urn} ${item.identifier ?? ''} ${item.snippet ?? ''} ${item.committeeCode ?? ''} ${item.city ?? ''} ${item.type ?? ''}`.toLowerCase() if (!hay.includes(q)) return false } return true @@ -125,6 +131,7 @@ export interface FacetCounts { readonly actions: ReadonlyMap<string, number> readonly decades: ReadonlyMap<number, number> readonly countries: ReadonlyMap<string, number> + readonly types: ReadonlyMap<string, number> } export function countFacets<T extends SearchableItem>(items: readonly T[]): FacetCounts { @@ -134,6 +141,7 @@ export function countFacets<T extends SearchableItem>(items: readonly T[]): Face const actions = new Map<string, number>() const decades = new Map<number, number>() const countries = new Map<string, number>() + const types = new Map<string, number>() for (const item of items) { if (item.bodyType) bodies.set(item.bodyType, (bodies.get(item.bodyType) ?? 0) + 1) if (item.kind) kinds.set(item.kind, (kinds.get(item.kind) ?? 0) + 1) @@ -144,9 +152,10 @@ export function countFacets<T extends SearchableItem>(items: readonly T[]): Face } const country = item.countryCode ?? (item.startDate ? VIRTUAL_COUNTRY : undefined) if (country) countries.set(country, (countries.get(country) ?? 0) + 1) + if (item.type) types.set(item.type, (types.get(item.type) ?? 0) + 1) for (const a of item.actionTypes ?? []) actions.set(a, (actions.get(a) ?? 0) + 1) } - return { bodies, kinds, years, actions, decades, countries } + return { bodies, kinds, years, actions, decades, countries, types } } export function toggle<K>(set: ReadonlySet<K>, key: K): Set<K> { @@ -165,6 +174,7 @@ export function encodeState(state: FilterState): string { if (state.actions.size > 0) params.set('actions', [...state.actions].sort().join(',')) if (state.decades.size > 0) params.set('decades', [...state.decades].sort().map(String).join(',')) if (state.countries.size > 0) params.set('countries', [...state.countries].sort().join(',')) + if (state.types.size > 0) params.set('types', [...state.types].sort().join(',')) if (state.dateFrom) params.set('from', state.dateFrom) if (state.dateTo) params.set('to', state.dateTo) const s = params.toString() @@ -181,6 +191,7 @@ export function decodeState(hash: string): FilterState { const actions = (params.get('actions') ?? '').split(',').filter(Boolean) const decades = (params.get('decades') ?? '').split(',').filter(Boolean).map(Number).filter(Number.isFinite) const countries = (params.get('countries') ?? '').split(',').filter(Boolean) + const types = (params.get('types') ?? '').split(',').filter(Boolean) return { query: params.get('q') ?? '', bodies: new Set(bodies), @@ -189,6 +200,7 @@ export function decodeState(hash: string): FilterState { actions: new Set(actions), decades: new Set(decades), countries: new Set(countries), + types: new Set(types), dateFrom: params.get('from') ?? undefined, dateTo: params.get('to') ?? undefined, } @@ -203,5 +215,6 @@ function freshEmptyState(): FilterState { actions: new Set(), decades: new Set(), countries: new Set(), + types: new Set(), } } diff --git a/packages/browser/src/islands/search-filter.ts b/packages/browser/src/islands/search-filter.ts index 8960f92..3510d4c 100644 --- a/packages/browser/src/islands/search-filter.ts +++ b/packages/browser/src/islands/search-filter.ts @@ -155,27 +155,31 @@ function buildMeetingListItem( if (item.identifier) meta.appendChild(makeBadge(item.identifier, 'edoxen-search-filter__result-id')) const dates = formatRange(item.startDate, item.endDate, lang) if (dates) meta.appendChild(makeBadge(dates, 'edoxen-search-filter__result-date')) + if (item.type) meta.appendChild(makeBadge(humanize(item.type), 'edoxen-search-filter__result-meeting-type')) if (item.committeeCode) meta.appendChild(makeBadge(item.committeeCode, 'edoxen-badge')) - // Online meeting (no city/country): globe + localized "Virtual"; - // otherwise flag + "City, Country" with UN/LOCODE resolved to a name. - if (!item.city && !item.countryCode) { - meta.appendChild(makeBadge(`\u{1F310} ${virtualLabel}`, 'edoxen-search-filter__result-date')) - } else { - const place = [cityName(item, lang), item.countryCode ? regionName(item.countryCode, lang) : ''] - .filter(Boolean) - .join(', ') - const flag = item.countryCode ? flagEmoji(item.countryCode) : '' - if (place) meta.appendChild(makeBadge(`${flag ? `${flag} ` : ''}${place}`, 'edoxen-search-filter__result-date')) - } if (item.decisionCount != null && item.decisionCount > 0) { meta.appendChild(makeBadge(`${item.decisionCount} ${decisionsLabel}`, 'edoxen-badge')) } if (meta.childElementCount > 0) main.appendChild(meta) + // Headline mirrors MeetingCard: place (City, Country) when known, else + // localized "Virtual", else the entity title. The meta row already shows + // dates and committee — don't repeat them in the headline. const link = document.createElement('a') link.className = 'edoxen-search-filter__result-title' link.href = `${basePath}/${urnToPath(item.urn)}` - link.textContent = item.title || item.urn + const city = cityName(item, lang) + const country = item.countryCode ? regionName(item.countryCode, lang) : '' + const place = [city, country].filter(Boolean).join(', ') + const flag = item.countryCode ? flagEmoji(item.countryCode) : '' + if (!item.city && !item.countryCode) { + link.textContent = `\u{1F310} ${virtualLabel}` + } else if (place) { + link.textContent = flag ? `${flag} ${place}` : place + } else { + link.textContent = item.title || item.urn + } + if (item.title) link.title = item.title main.appendChild(link) li.appendChild(main) @@ -372,6 +376,7 @@ class SearchFilter extends HTMLElement { const actions = new Map<string, number>() const years = new Map<number, number>() const countries = new Map<string, number>() + const types = new Map<string, number>() for (const item of this.items) { if (item.bodyType) bodies.set(item.bodyType, (bodies.get(item.bodyType) ?? 0) + 1) if (item.kind) kinds.set(item.kind, (kinds.get(item.kind) ?? 0) + 1) @@ -380,6 +385,7 @@ class SearchFilter extends HTMLElement { // Meetings without a country code are online — the Virtual chip. const country = item.countryCode ?? (this.mode === 'meetings' ? VIRTUAL_COUNTRY : undefined) if (country) countries.set(country, (countries.get(country) ?? 0) + 1) + if (item.type) types.set(item.type, (types.get(item.type) ?? 0) + 1) } const lang = document.documentElement.lang || 'en' const groups: HTMLElement[] = [] @@ -395,6 +401,16 @@ class SearchFilter extends HTMLElement { chip.classList.add('edoxen-search-filter__facet--year') return chip }) + // Meeting-type chips: humanize the enum value (plenary → Plenary). + const typeChips = [...types.keys()].sort().map((type) => { + const chip = makeFacetChip(humanize(type), types.get(type) ?? 0, this.state.types.has(type), () => { + this.state = { ...this.state, types: toggle(this.state.types, type) } + this.syncHash() + this.render() + }) + chip.classList.add('edoxen-search-filter__facet--meeting-type') + return chip + }) // Location chips: flag + country name, "🌐 Virtual" sorted last. const countryChips = [...countries.keys()] .sort((a, b) => (a === VIRTUAL_COUNTRY ? 1 : b === VIRTUAL_COUNTRY ? -1 : regionName(a, lang).localeCompare(regionName(b, lang)))) @@ -412,8 +428,10 @@ class SearchFilter extends HTMLElement { return chip }) const yearGroup = this.makeFacetGroup(this.groupYearLabel, yearChips) + const typeGroup = this.makeFacetGroup(this.groupTypeLabel, typeChips) const locationGroup = this.makeFacetGroup(this.groupLocationLabel, countryChips) if (yearGroup) groups.push(yearGroup) + if (typeGroup) groups.push(typeGroup) if (locationGroup) groups.push(locationGroup) } From 797352f0eaa769973057a4f9391addb674541a1f Mon Sep 17 00:00:00 2001 From: Ronald Tse <ronald.tse@ribose.com> Date: Wed, 22 Jul 2026 15:55:02 +0800 Subject: [PATCH 2/6] feat(browser): Edoxen brand mark in footer attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Powered by Edoxen' line now shows the Edoxen wordmark instead of plain text. Both theme variants are inlined (dark ink for light backgrounds, light ink for dark backgrounds) and CSS toggles visibility based on data-theme on <html>. No asset-path juggling — works on consumer basePaths out of the box. Also updates the link target from edoxen.github.io to www.edoxen.org (the canonical site). New EdoxenBrandMark.astro component wraps the dual SVGs with proper aria-label. e2e assertion locks in: link points to edoxen.org, light variant visible by default, dark variant hidden until theme flips. --- packages/browser/e2e/smoke.spec.ts | 6 +++ .../src/astro/assets/edoxen-logo-dark.svg | 1 + .../src/astro/assets/edoxen-logo-light.svg | 1 + .../astro/components/EdoxenBrandMark.astro | 54 +++++++++++++++++++ .../src/astro/layouts/BaseLayout.astro | 5 +- 5 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 packages/browser/src/astro/assets/edoxen-logo-dark.svg create mode 100644 packages/browser/src/astro/assets/edoxen-logo-light.svg create mode 100644 packages/browser/src/astro/components/EdoxenBrandMark.astro diff --git a/packages/browser/e2e/smoke.spec.ts b/packages/browser/e2e/smoke.spec.ts index 1bb4e4d..1141412 100644 --- a/packages/browser/e2e/smoke.spec.ts +++ b/packages/browser/e2e/smoke.spec.ts @@ -483,5 +483,11 @@ test.describe('fixture site — home, lists and data endpoints', () => { await expect(footer.locator('.edoxen-footer__links a', { hasText: 'Committee home' })).toHaveAttribute('href', 'https://example.org/committee') // Bottom bar await expect(footer.locator('.edoxen-footer__copy')).toContainText(`© ${new Date().getFullYear()} TEST/TC 1`) + // Edoxen attribution carries the brand mark (both theme variants inlined, + // CSS toggles visibility) and links to edoxen.org. + const edoxenLink = footer.locator('a.edoxen-footer__edoxen-link') + await expect(edoxenLink).toHaveAttribute('href', 'https://www.edoxen.org') + await expect(edoxenLink.locator('.edoxen-brand-mark__svg--light svg')).toBeVisible() + await expect(edoxenLink.locator('.edoxen-brand-mark__svg--dark svg')).toBeHidden() }) }) diff --git a/packages/browser/src/astro/assets/edoxen-logo-dark.svg b/packages/browser/src/astro/assets/edoxen-logo-dark.svg new file mode 100644 index 0000000..f0def02 --- /dev/null +++ b/packages/browser/src/astro/assets/edoxen-logo-dark.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-fe64e4c5-fd17-4ec2-899b-c282a2dc72d4" xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><path d="m32.95,14.17v67.65M63.05,14.17v67.65M2.87,48h90.27m-3-33.83H5.87c-1.66,0-3,1.34-3,3v61.65c0,1.66,1.34,3,3,3h84.27c1.66,0,3-1.34,3-3V17.17c0-1.66-1.34-3-3-3Z" style="fill:none; stroke:#bae6fd; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><g style="isolation:isolate;"><path d="m14.6,21.22h3.32c2.04,0,4.2-.02,6.47-.05l.08.11-.79,4.22-.55.1-.85-3.16h-6.32v7.52h1.24c.95,0,2,0,3.17-.03.24,0,.39-.13.46-.36l.48-1.85.74-.03c-.03,1.28-.04,2.22-.04,2.83s.01,1.52.04,2.79l-.74.05-.49-1.87c-.07-.24-.21-.36-.42-.37-1.19-.02-2.26-.03-3.21-.03h-1.23v4.98c0,1.01,0,2.01.03,2.99h6.56l1.67-3.47.62-.11-.3,4.76-.1.1c-2.36-.03-4.54-.04-6.54-.04h-3.25c-1.1,0-2.23.01-3.39.04l-.05-.59,1.68-.51c.23-.06.35-.21.37-.42,0-.36.02-.77.02-1.22,0-.45,0-.95,0-1.5v-11.09c0-.46,0-.88,0-1.26,0-.37-.01-.71-.02-1.01,0-.24-.12-.39-.33-.46l-1.7-.53-.03-.59c1.18.04,2.31.05,3.39.05Z" style="fill:#bae6fd; stroke-width:0px;"/></g><g style="isolation:isolate;"><path d="m55.99,40.35c-1.49-.03-2.81-.04-3.96-.04h-8.56c-1.04,0-2.24.01-3.6.04l-.05-.59,7.83-18.68,1.39-.31,6.89,18.99.05.59Zm-8.3-15.89l-5.46,13.81h10.34l-4.88-13.81Z" style="fill:#bae6fd; stroke-width:0px;"/></g><g style="isolation:isolate;"><path d="m87.89,30.33c0,3.15-.9,5.69-2.68,7.61-1.79,1.92-4.29,2.88-7.51,2.88s-5.41-.89-7.08-2.66c-1.67-1.77-2.51-4.17-2.51-7.2s.9-5.65,2.7-7.49,4.29-2.75,7.48-2.75,5.43.86,7.1,2.58c1.67,1.72,2.51,4.06,2.51,7.03Zm-16.79.18c0,2.97.6,5.25,1.8,6.83s2.97,2.37,5.29,2.37,3.98-.72,5.07-2.15c1.09-1.44,1.63-3.69,1.63-6.76,0-2.88-.58-5.09-1.74-6.64-1.16-1.55-2.94-2.32-5.36-2.32s-4.04.72-5.11,2.15c-1.06,1.44-1.59,3.61-1.59,6.53Z" style="fill:#bae6fd; stroke-width:0px;"/></g><g style="isolation:isolate;"><path d="m25.38,71.07l.27,4.89-.1.1c-2.24-.03-4.39-.04-6.44-.04h-2.21c-2.04,0-4.2.01-6.47.04l-.08-.1.72-4.81.55-.08.55,2.71h11.91l.75-2.62.55-.08Zm-14.22-9.21l-.23-4.87.1-.11c2.24.04,4.38.05,6.43.05h1.07c2.05,0,4.21-.02,6.48-.05l.08.11-.71,4.77-.53.1-.6-2.8h-10.73l-.79,2.71-.55.1Zm6.84,5.41c-.82,0-1.67,0-2.56.03-.24,0-.39.13-.45.36l-.31,1.31-.74.04c.03-1.21.04-2.14.04-2.79s-.01-1.51-.04-2.65l.74-.07.31,1.27c.06.24.2.36.41.37.9.02,1.77.03,2.61.03s1.68,0,2.56-.03c.24,0,.39-.13.46-.36l.31-1.23.72-.04c-.03,1.15-.04,2.05-.04,2.71s.01,1.54.04,2.73l-.72.07-.33-1.35c-.07-.24-.21-.36-.42-.37-.89-.02-1.76-.03-2.6-.03Z" style="fill:#bae6fd; stroke-width:0px;"/></g><g style="isolation:isolate;"><path d="m44.6,56.94h3.32c2.04,0,4.2-.02,6.47-.05l.08.11-.79,4.22-.55.1-.85-3.16h-6.32v7.52h1.24c.95,0,2,0,3.17-.03.24,0,.39-.13.46-.36l.48-1.85.74-.03c-.03,1.28-.04,2.22-.04,2.83s.01,1.52.04,2.79l-.74.05-.49-1.87c-.07-.24-.21-.36-.42-.37-1.19-.02-2.26-.03-3.21-.03h-1.23v4.98c0,1.01,0,2.01.03,2.99h6.56l1.67-3.47.62-.11-.3,4.76-.1.1c-2.36-.03-4.54-.04-6.54-.04h-3.25c-1.1,0-2.23.01-3.39.04l-.05-.59,1.68-.51c.23-.06.35-.21.37-.42,0-.36.02-.77.02-1.22,0-.45,0-.95,0-1.5v-11.09c0-.46,0-.88,0-1.26,0-.37-.01-.71-.02-1.01,0-.24-.12-.39-.33-.46l-1.7-.53-.03-.59c1.18.04,2.31.05,3.39.05Z" style="fill:#bae6fd; stroke-width:0px;"/></g><g style="isolation:isolate;"><path d="m72.18,60.78v11.05c0,1.08,0,1.97.03,2.68,0,.25.12.41.34.46l1.63.48.03.62c-1.14-.03-2.07-.04-2.8-.04s-1.65.01-2.83.04l-.05-.59,1.68-.51c.24-.07.36-.21.37-.42.02-.74.03-1.65.03-2.72v-11.09c0-.91,0-1.67-.03-2.27,0-.24-.12-.39-.33-.46l-1.7-.53-.03-.59c1.18.04,2.11.05,2.79.05.41,0,.82-.02,1.24-.05l11.48,14.34v-10.54c0-.88,0-1.65-.03-2.28,0-.21-.13-.35-.36-.42l-1.61-.46-.03-.63c1.17.04,2.11.05,2.83.05s1.66-.02,2.8-.05l.05.59-1.72.53c-.21.07-.32.23-.33.46-.02.6-.03,1.36-.03,2.27v15.29l-1.15.11-12.29-15.35Z" style="fill:#bae6fd; stroke-width:0px;"/></g></svg> \ No newline at end of file diff --git a/packages/browser/src/astro/assets/edoxen-logo-light.svg b/packages/browser/src/astro/assets/edoxen-logo-light.svg new file mode 100644 index 0000000..3c3c283 --- /dev/null +++ b/packages/browser/src/astro/assets/edoxen-logo-light.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-fe64e4c5-fd17-4ec2-899b-c282a2dc72d4" xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><path d="m32.95,14.17v67.65M63.05,14.17v67.65M2.87,48h90.27m-3-33.83H5.87c-1.66,0-3,1.34-3,3v61.65c0,1.66,1.34,3,3,3h84.27c1.66,0,3-1.34,3-3V17.17c0-1.66-1.34-3-3-3Z" style="fill:none; stroke:#0c4a6e; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><g style="isolation:isolate;"><path d="m14.6,21.22h3.32c2.04,0,4.2-.02,6.47-.05l.08.11-.79,4.22-.55.1-.85-3.16h-6.32v7.52h1.24c.95,0,2,0,3.17-.03.24,0,.39-.13.46-.36l.48-1.85.74-.03c-.03,1.28-.04,2.22-.04,2.83s.01,1.52.04,2.79l-.74.05-.49-1.87c-.07-.24-.21-.36-.42-.37-1.19-.02-2.26-.03-3.21-.03h-1.23v4.98c0,1.01,0,2.01.03,2.99h6.56l1.67-3.47.62-.11-.3,4.76-.1.1c-2.36-.03-4.54-.04-6.54-.04h-3.25c-1.1,0-2.23.01-3.39.04l-.05-.59,1.68-.51c.23-.06.35-.21.37-.42,0-.36.02-.77.02-1.22,0-.45,0-.95,0-1.5v-11.09c0-.46,0-.88,0-1.26,0-.37-.01-.71-.02-1.01,0-.24-.12-.39-.33-.46l-1.7-.53-.03-.59c1.18.04,2.31.05,3.39.05Z" style="fill:#0c4a6e; stroke-width:0px;"/></g><g style="isolation:isolate;"><path d="m55.99,40.35c-1.49-.03-2.81-.04-3.96-.04h-8.56c-1.04,0-2.24.01-3.6.04l-.05-.59,7.83-18.68,1.39-.31,6.89,18.99.05.59Zm-8.3-15.89l-5.46,13.81h10.34l-4.88-13.81Z" style="fill:#0c4a6e; stroke-width:0px;"/></g><g style="isolation:isolate;"><path d="m87.89,30.33c0,3.15-.9,5.69-2.68,7.61-1.79,1.92-4.29,2.88-7.51,2.88s-5.41-.89-7.08-2.66c-1.67-1.77-2.51-4.17-2.51-7.2s.9-5.65,2.7-7.49,4.29-2.75,7.48-2.75,5.43.86,7.1,2.58c1.67,1.72,2.51,4.06,2.51,7.03Zm-16.79.18c0,2.97.6,5.25,1.8,6.83s2.97,2.37,5.29,2.37,3.98-.72,5.07-2.15c1.09-1.44,1.63-3.69,1.63-6.76,0-2.88-.58-5.09-1.74-6.64-1.16-1.55-2.94-2.32-5.36-2.32s-4.04.72-5.11,2.15c-1.06,1.44-1.59,3.61-1.59,6.53Z" style="fill:#0c4a6e; stroke-width:0px;"/></g><g style="isolation:isolate;"><path d="m25.38,71.07l.27,4.89-.1.1c-2.24-.03-4.39-.04-6.44-.04h-2.21c-2.04,0-4.2.01-6.47.04l-.08-.1.72-4.81.55-.08.55,2.71h11.91l.75-2.62.55-.08Zm-14.22-9.21l-.23-4.87.1-.11c2.24.04,4.38.05,6.43.05h1.07c2.05,0,4.21-.02,6.48-.05l.08.11-.71,4.77-.53.1-.6-2.8h-10.73l-.79,2.71-.55.1Zm6.84,5.41c-.82,0-1.67,0-2.56.03-.24,0-.39.13-.45.36l-.31,1.31-.74.04c.03-1.21.04-2.14.04-2.79s-.01-1.51-.04-2.65l.74-.07.31,1.27c.06.24.2.36.41.37.9.02,1.77.03,2.61.03s1.68,0,2.56-.03c.24,0,.39-.13.46-.36l.31-1.23.72-.04c-.03,1.15-.04,2.05-.04,2.71s.01,1.54.04,2.73l-.72.07-.33-1.35c-.07-.24-.21-.36-.42-.37-.89-.02-1.76-.03-2.6-.03Z" style="fill:#0c4a6e; stroke-width:0px;"/></g><g style="isolation:isolate;"><path d="m44.6,56.94h3.32c2.04,0,4.2-.02,6.47-.05l.08.11-.79,4.22-.55.1-.85-3.16h-6.32v7.52h1.24c.95,0,2,0,3.17-.03.24,0,.39-.13.46-.36l.48-1.85.74-.03c-.03,1.28-.04,2.22-.04,2.83s.01,1.52.04,2.79l-.74.05-.49-1.87c-.07-.24-.21-.36-.42-.37-1.19-.02-2.26-.03-3.21-.03h-1.23v4.98c0,1.01,0,2.01.03,2.99h6.56l1.67-3.47.62-.11-.3,4.76-.1.1c-2.36-.03-4.54-.04-6.54-.04h-3.25c-1.1,0-2.23.01-3.39.04l-.05-.59,1.68-.51c.23-.06.35-.21.37-.42,0-.36.02-.77.02-1.22,0-.45,0-.95,0-1.5v-11.09c0-.46,0-.88,0-1.26,0-.37-.01-.71-.02-1.01,0-.24-.12-.39-.33-.46l-1.7-.53-.03-.59c1.18.04,2.31.05,3.39.05Z" style="fill:#0c4a6e; stroke-width:0px;"/></g><g style="isolation:isolate;"><path d="m72.18,60.78v11.05c0,1.08,0,1.97.03,2.68,0,.25.12.41.34.46l1.63.48.03.62c-1.14-.03-2.07-.04-2.8-.04s-1.65.01-2.83.04l-.05-.59,1.68-.51c.24-.07.36-.21.37-.42.02-.74.03-1.65.03-2.72v-11.09c0-.91,0-1.67-.03-2.27,0-.24-.12-.39-.33-.46l-1.7-.53-.03-.59c1.18.04,2.11.05,2.79.05.41,0,.82-.02,1.24-.05l11.48,14.34v-10.54c0-.88,0-1.65-.03-2.28,0-.21-.13-.35-.36-.42l-1.61-.46-.03-.63c1.17.04,2.11.05,2.83.05s1.66-.02,2.8-.05l.05.59-1.72.53c-.21.07-.32.23-.33.46-.02.6-.03,1.36-.03,2.27v15.29l-1.15.11-12.29-15.35Z" style="fill:#0c4a6e; stroke-width:0px;"/></g></svg> \ No newline at end of file diff --git a/packages/browser/src/astro/components/EdoxenBrandMark.astro b/packages/browser/src/astro/components/EdoxenBrandMark.astro new file mode 100644 index 0000000..a62f220 --- /dev/null +++ b/packages/browser/src/astro/components/EdoxenBrandMark.astro @@ -0,0 +1,54 @@ +--- +// The Edoxen brand mark — small wordmark used in the footer attribution. +// Two variants are inlined and toggled by `data-theme` on <html>: the +// light variant (dark ink, for light backgrounds) shows in light mode; +// the dark variant (light ink, for dark backgrounds) shows in dark mode. +// Both inline so the mark works on subpath hosts without asset-path +// juggling and survives consumer basePath changes. +import lightSvg from '../assets/edoxen-logo-light.svg?raw' +import darkSvg from '../assets/edoxen-logo-dark.svg?raw' + +interface Props { + /** Optional alt text for screen readers; defaults to "Edoxen". */ + alt?: string +} +const { alt = 'Edoxen' } = Astro.props +--- + +<span class="edoxen-brand-mark" role="img" aria-label={alt}> + <span class="edoxen-brand-mark__svg edoxen-brand-mark__svg--light" set:html={lightSvg} aria-hidden="true"></span> + <span class="edoxen-brand-mark__svg edoxen-brand-mark__svg--dark" set:html={darkSvg} aria-hidden="true"></span> +</span> + +<style> +.edoxen-brand-mark { + display: inline-flex; + align-items: middle; + vertical-align: middle; +} +.edoxen-brand-mark__svg { + display: none; + width: 1.5em; + height: 1.5em; + line-height: 0; +} +.edoxen-brand-mark__svg :global(svg) { + width: 100%; + height: 100%; + display: block; +} +/* Default (no data-theme or data-theme=auto → light): show light-on-dark variant */ +.edoxen-brand-mark__svg--light { + display: inline-block; +} +.edoxen-brand-mark__svg--dark { + display: none; +} +/* Dark theme: flip the visible variant */ +:global([data-theme="dark"]) .edoxen-brand-mark__svg--light { + display: none; +} +:global([data-theme="dark"]) .edoxen-brand-mark__svg--dark { + display: inline-block; +} +</style> diff --git a/packages/browser/src/astro/layouts/BaseLayout.astro b/packages/browser/src/astro/layouts/BaseLayout.astro index eb45715..89d6a63 100644 --- a/packages/browser/src/astro/layouts/BaseLayout.astro +++ b/packages/browser/src/astro/layouts/BaseLayout.astro @@ -4,6 +4,7 @@ import { generateCssTokens, resolveFooter } from '../../config/index.js' import { urlPrefix, pickLocalizedValue } from '../../i18n/index.js' import payloads from 'virtual:edoxen-payloads' import '../../../styles/base.css' +import EdoxenBrandMark from '../components/EdoxenBrandMark.astro' // Print styles are gated on features.printStyles: imported as a raw // string and inlined only when the flag is on, so `false` omits the // print CSS from the output entirely. @@ -212,7 +213,7 @@ const customCssImports = (config as EdoxenConfig & { customCssImports?: string[] <p class="edoxen-footer__copy">© {year} {committeeName}</p> {footer.showEdoxenAttribution && ( <p class="edoxen-footer__attribution"> - Powered by <a href="https://edoxen.github.io">Edoxen</a>. + Powered by <a href="https://www.edoxen.org" class="edoxen-footer__edoxen-link"><EdoxenBrandMark /></a> </p> )} </div> @@ -231,7 +232,7 @@ const customCssImports = (config as EdoxenConfig & { customCssImports?: string[] <p class="edoxen-footer__copyright">{footer.copyright}</p> {footer.showEdoxenAttribution && ( <p class="edoxen-footer__attribution"> - Powered by <a href="https://edoxen.github.io">Edoxen</a>. + Powered by <a href="https://www.edoxen.org" class="edoxen-footer__edoxen-link"><EdoxenBrandMark /></a> </p> )} </> From 03c240e5738e6971fb3bc83128e5dbf3f18030ad Mon Sep 17 00:00:00 2001 From: Ronald Tse <ronald.tse@ribose.com> Date: Wed, 22 Jul 2026 16:12:24 +0800 Subject: [PATCH 3/6] fix(browser): project facetTypes on /data/meetings.json wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The meetings JSON endpoint was projecting facetDecades, facetBodies, and facetCountries but not facetTypes — inconsistent with the FetchResponse interface declaration and with the new Type facet. The island currently recomputes facets client-side so it is not user-visible today, but the inconsistency is the kind of thing that bites later (e.g. when someone tries to optimize the island to use the precomputed arrays). integration.ts now projects facetTypes alongside the other three. FetchResponse interface updated. e2e locks the wire shape: facetTypes === ['plenary'] for the fixture. --- packages/browser/e2e/smoke.spec.ts | 11 ++++++++++- packages/browser/src/integration.ts | 1 + packages/browser/src/islands/search-filter.ts | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/browser/e2e/smoke.spec.ts b/packages/browser/e2e/smoke.spec.ts index 1141412..895e628 100644 --- a/packages/browser/e2e/smoke.spec.ts +++ b/packages/browser/e2e/smoke.spec.ts @@ -365,7 +365,13 @@ test.describe('fixture site — home, lists and data endpoints', () => { const meetings = await request.get('/data/meetings.json') expect(meetings.ok()).toBeTruthy() - const meetingsBody = (await meetings.json()) as { items: Array<Record<string, unknown>> } + const meetingsBody = (await meetings.json()) as { + items: Array<Record<string, unknown>> + facetDecades?: number[] + facetBodies?: string[] + facetCountries?: string[] + facetTypes?: string[] + } const m2025 = meetingsBody.items.find((i) => i['urn'] === 'urn:test:meeting:2025') // The island searches the flattened title + committee code + city + type. expect(typeof m2025?.['title']).toBe('string') @@ -373,6 +379,9 @@ test.describe('fixture site — home, lists and data endpoints', () => { expect(m2025?.['city']).toBe('DEBER') expect(m2025?.['countryCode']).toBe('DE') expect(m2025?.['type']).toBe('plenary') + // Wire shape stays consistent: every projected facet array on the model + // is mirrored on the wire, including the new Type facet. + expect(meetingsBody.facetTypes).toEqual(['plenary']) const registers = await request.get('/data/registers.json') expect(registers.ok()).toBeTruthy() diff --git a/packages/browser/src/integration.ts b/packages/browser/src/integration.ts index 0592c46..e15d35e 100644 --- a/packages/browser/src/integration.ts +++ b/packages/browser/src/integration.ts @@ -246,6 +246,7 @@ function dataEndpointPayload(cache: IntegrationCache, name: DataEndpointName): s facetDecades: [...cache.payloads.meetingsList.facets.decades], facetBodies: [...cache.payloads.meetingsList.facets.bodies], facetCountries: [...cache.payloads.meetingsList.facets.countries], + facetTypes: [...cache.payloads.meetingsList.facets.types], }) } diff --git a/packages/browser/src/islands/search-filter.ts b/packages/browser/src/islands/search-filter.ts index 3510d4c..69d156b 100644 --- a/packages/browser/src/islands/search-filter.ts +++ b/packages/browser/src/islands/search-filter.ts @@ -17,6 +17,7 @@ interface FetchResponse { facetActions?: string[] facetDecades?: number[] facetCountries?: string[] + facetTypes?: string[] } type SearchMode = 'decisions' | 'meetings' From 531b23e3c6d40f8660ec3ac7b4d7834597cb9e03 Mon Sep 17 00:00:00 2001 From: Ronald Tse <ronald.tse@ribose.com> Date: Wed, 22 Jul 2026 17:09:32 +0800 Subject: [PATCH 4/6] refactor(browser): drop dead facet* wire arrays from data endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /data/decisions.json and /data/meetings.json endpoints were projecting facetBodies/Kinds/Years/Actions/Statuses and facetDecades/Bodies/Countries/Types respectively — but the search-filter island never read them. It computes chip counts client-side from items[] (single source of truth, supports live-filter recount, no drift). The wire arrays were: incomplete (no counts, just unique values), inconsistent (decisions had different keys from meetings), drift bait (adding a model facet meant remembering three projection points), and actively misleading (empty facetBodies for a consumer without body_type set). Removed from integration.ts, FetchResponse interface, and e2e. Kept the model-level DecisionListFacets and MeetingListFacets (still used for SSR: decade sections, About-page facts, JSON-LD). Added a comment on each clarifying the boundary. Wire is now {items: [...]} for both endpoints. --- packages/browser/e2e/smoke.spec.ts | 12 ++---------- packages/browser/src/data/prepare.ts | 5 +++++ packages/browser/src/integration.ts | 14 +++++--------- packages/browser/src/islands/search-filter.ts | 7 ------- 4 files changed, 12 insertions(+), 26 deletions(-) diff --git a/packages/browser/e2e/smoke.spec.ts b/packages/browser/e2e/smoke.spec.ts index 895e628..85786f2 100644 --- a/packages/browser/e2e/smoke.spec.ts +++ b/packages/browser/e2e/smoke.spec.ts @@ -353,7 +353,6 @@ test.describe('fixture site — home, lists and data endpoints', () => { expect(decisions.ok()).toBeTruthy() const decisionsBody = (await decisions.json()) as { items: Array<Record<string, unknown>> - facetActions: string[] } expect(decisionsBody.items.length).toBeGreaterThan(0) const first = decisionsBody.items.find((i) => i['urn'] === 'urn:test:resolution:1') @@ -361,27 +360,20 @@ test.describe('fixture site — home, lists and data endpoints', () => { expect(first?.['date']).toBe('2024-06-15') expect(first?.['snippet']).toContain('Publishes the test standard') expect(first?.['meetingUrn']).toBe('urn:test:meeting:2025') - expect(decisionsBody.facetActions).toContain('publishes') const meetings = await request.get('/data/meetings.json') expect(meetings.ok()).toBeTruthy() const meetingsBody = (await meetings.json()) as { items: Array<Record<string, unknown>> - facetDecades?: number[] - facetBodies?: string[] - facetCountries?: string[] - facetTypes?: string[] } const m2025 = meetingsBody.items.find((i) => i['urn'] === 'urn:test:meeting:2025') - // The island searches the flattened title + committee code + city + type. + // The island searches the flattened title + committee code + city + type + // and computes all facet counts client-side from items[]. expect(typeof m2025?.['title']).toBe('string') expect(m2025?.['committeeCode']).toBe('sc-1') expect(m2025?.['city']).toBe('DEBER') expect(m2025?.['countryCode']).toBe('DE') expect(m2025?.['type']).toBe('plenary') - // Wire shape stays consistent: every projected facet array on the model - // is mirrored on the wire, including the new Type facet. - expect(meetingsBody.facetTypes).toEqual(['plenary']) const registers = await request.get('/data/registers.json') expect(registers.ok()).toBeTruthy() diff --git a/packages/browser/src/data/prepare.ts b/packages/browser/src/data/prepare.ts index a5b75d3..304fbb2 100644 --- a/packages/browser/src/data/prepare.ts +++ b/packages/browser/src/data/prepare.ts @@ -58,6 +58,7 @@ export interface DecisionListItem { readonly meetingPageUrn?: string } +// Model-level facets — see MeetingListFacets comment above. export interface DecisionListFacets { readonly years: readonly number[] readonly kinds: readonly string[] @@ -93,6 +94,10 @@ export interface MeetingListItem { readonly cityNames?: Readonly<Record<string, string>> } +// Model-level facets — used for SSR (decade sections on /meetings, the +// About-page committee facts, JSON-LD, etc.). The /data/*.json endpoints +// do NOT project these on the wire; the search island derives its chip +// counts client-side from items[] (single source of truth, no drift). export interface MeetingListFacets { readonly decades: readonly number[] readonly bodies: readonly string[] diff --git a/packages/browser/src/integration.ts b/packages/browser/src/integration.ts index e15d35e..acf2447 100644 --- a/packages/browser/src/integration.ts +++ b/packages/browser/src/integration.ts @@ -208,6 +208,11 @@ function dataEndpointPayload(cache: IntegrationCache, name: DataEndpointName): s // filters on. `subject` is NOT sent (the island's result card uses // title+snippet; the subject is on the server-rendered card and // the detail page). Snippets are clamped server-side. + // + // The island computes all facet counts client-side from items[] + // (single source of truth — see renderFacets in search-filter.ts). + // Do NOT add `facet<Foo>` projection arrays here without wiring + // them into the island; half-baked facets mislead callers. items: cache.payloads.decisionsList.items.map((d) => ({ urn: d.urn, identifier: d.identifier, @@ -224,11 +229,6 @@ function dataEndpointPayload(cache: IntegrationCache, name: DataEndpointName): s // without one there is no meeting page to link to. meetingUrn: d.meetingPageUrn ?? undefined, })), - facetBodies: [...cache.payloads.decisionsList.facets.bodies], - facetKinds: [...cache.payloads.decisionsList.facets.kinds], - facetYears: [...cache.payloads.decisionsList.facets.years], - facetActions: [...cache.payloads.decisionsList.facets.actionTypes], - facetStatuses: [...cache.payloads.decisionsList.facets.statuses], }) } if (name === 'registers') { @@ -243,10 +243,6 @@ function dataEndpointPayload(cache: IntegrationCache, name: DataEndpointName): s ...m, title: pickLocalizedValue(m.title, locale), })), - facetDecades: [...cache.payloads.meetingsList.facets.decades], - facetBodies: [...cache.payloads.meetingsList.facets.bodies], - facetCountries: [...cache.payloads.meetingsList.facets.countries], - facetTypes: [...cache.payloads.meetingsList.facets.types], }) } diff --git a/packages/browser/src/islands/search-filter.ts b/packages/browser/src/islands/search-filter.ts index 69d156b..b42d855 100644 --- a/packages/browser/src/islands/search-filter.ts +++ b/packages/browser/src/islands/search-filter.ts @@ -11,13 +11,6 @@ import { interface FetchResponse { items: SearchableItem[] - facetBodies?: string[] - facetKinds?: string[] - facetYears?: number[] - facetActions?: string[] - facetDecades?: number[] - facetCountries?: string[] - facetTypes?: string[] } type SearchMode = 'decisions' | 'meetings' From 291f2dac5b917bd8bf879d3cdbf2deec1c8f59f1 Mon Sep 17 00:00:00 2001 From: Ronald Tse <ronald.tse@ribose.com> Date: Wed, 22 Jul 2026 17:14:16 +0800 Subject: [PATCH 5/6] docs(browser): terminology.meetingTypes override + i18n keys catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README §Terminology gains a meetingTypes subsection showing the per-locale override API, the three-tier resolution order (override -> i18n -> humanize), an example for a committee styling meetings as 'Plénière CIML', and a pointer to the drift guardrail spec. i18n-keys.yaml catalogs all 17 meeting.type.* keys plus meeting.virtual so consumers translating into other locales know exactly what to provide. Per the user's directive: built-in translations stay English + French; other locales are consumer-config-driven and documented. --- packages/browser/README.md | 52 ++++++++++++++++++++++++++++ packages/browser/docs/i18n-keys.yaml | 26 ++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/packages/browser/README.md b/packages/browser/README.md index 9682ce0..dae396a 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -284,6 +284,58 @@ When `nav` is not configured, the default nav (Meetings / Decisions / About) is derived from `terminology` + `decisionsSlug` — the example above yields Meetings / Resolutions / About with a `/resolutions` link. +#### `terminology.meetingTypes` — translating meeting type labels + +Every meeting carries a `type` from the +[`MeetingType` enum](https://github.com/edoxen/edoxen-model/blob/main/models/meeting_type.lutaml) +(`plenary`, `working_group`, `task_group`, …, 17 values total). The +browser renders this as a small-caps badge on meeting cards + the detail +page header, and as a `Type` facet chip in the search island. + +Built-in labels cover **English + French**. Other locales (中文, Español, +العربية, Русский) fall back to English until you translate them. + +Translate or override per-locale via `terminology.meetingTypes`: + +```ts +terminology: { + // …decision / decisions / meeting / meetings as above… + meetingTypes: { + eng: { + plenary: 'Plenary', // override an English label + working_group: 'Working Group', // committee-specific phrasing + }, + fra: { + plenary: 'Plénière CIML', // French committee style + working_group: 'Groupe de travail CIML', + }, + zho: { + plenary: '全体会议', + working_group: '工作组', + // …translate as many of the 17 values as your committee uses + }, + }, +}, +``` + +Resolution order for each type value: + +1. **`terminology.meetingTypes[locale][type]`** — your per-locale override +2. **Built-in `meeting.type.<value>` string** — English + French ship + out of the box; partial coverage for other locales +3. **Humanized enum value** — `'working_group'` → `'Working Group'`, + never throws on data the gem hasn't categorized yet + +So a committee whose meetings are commonly styled "Plenary Session" can +override just that one value and inherit the rest. The +[`src/i18n/meeting-types.spec.ts`](packages/browser/src/i18n/meeting-types.spec.ts) +drift guardrail fails the build if the gem adds a `MeetingType` value +that neither your override nor the built-in table covers. + +The same data drives the `Type` facet in the search island (chip labels +humanize on the client; pass localized labels to the island via +`data-*` attributes if you need them in non-English UIs). + ## Theming The default look is **elegant professional warm**: a warm paper canvas diff --git a/packages/browser/docs/i18n-keys.yaml b/packages/browser/docs/i18n-keys.yaml index aa0813e..9108abc 100644 --- a/packages/browser/docs/i18n-keys.yaml +++ b/packages/browser/docs/i18n-keys.yaml @@ -90,6 +90,32 @@ 'decisions.empty': No decisions. # Shown when the decisions list is empty 'meetings.empty': No meetings. # Shown when the meetings list is empty +# ── Meetings — virtual + type labels ──────────────────────────────── +# `meeting.virtual` is the "Virtual" badge shown on cards/detail for +# meetings with no physical venue. The `meeting.type.*` keys map each +# MeetingType enum value (plenary, working_group, …, 17 total) to a +# display label — see README §Terminology › meetingTypes for the +# per-locale override API. Built-in English + French values ship; the +# humanize fallback covers anything missing. +'meeting.virtual': Virtual # Online / no-place meeting +'meeting.type.plenary': Plenary +'meeting.type.working_group': Working Group +'meeting.type.task_group': Task Group +'meeting.type.ad_hoc': Ad Hoc +'meeting.type.joint': Joint +'meeting.type.general_assembly': General Assembly +'meeting.type.committee': Committee +'meeting.type.subcommittee': Subcommittee +'meeting.type.conference': Conference +'meeting.type.workshop': Workshop +'meeting.type.seminar': Seminar +'meeting.type.webinar': Webinar +'meeting.type.hearing': Hearing +'meeting.type.markup': Markup +'meeting.type.board_meeting': Board Meeting +'meeting.type.annual_general_meeting': Annual General Meeting +'meeting.type.other': Other + # ── Decade timeline ───────────────────────────────────────────────── 'decade.browseLabel': Browse by decade # Accessible label for the decade nav From 892d99ccc4341970890c313253841be985d0e21c Mon Sep 17 00:00:00 2001 From: Ronald Tse <ronald.tse@ribose.com> Date: Wed, 22 Jul 2026 18:24:33 +0800 Subject: [PATCH 6/6] docs(browser): add Traditional Chinese (zho-Hant) alongside Simplified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README meetingTypes example now shows both Simplified (全体会议) and Traditional (全體會議) Chinese blocks. Notes the current limitation: normalizeUiLocale truncates codes >3 chars so both currently collapse into the same 'zho' bucket — serving them as separate routed locales needs a small helper change. --- packages/browser/README.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/browser/README.md b/packages/browser/README.md index dae396a..a400b20 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -309,11 +309,25 @@ terminology: { plenary: 'Plénière CIML', // French committee style working_group: 'Groupe de travail CIML', }, - zho: { + zho: { // Simplified Chinese (简体中文) plenary: '全体会议', working_group: '工作组', + subcommittee: '分技术委员会', + task_group: '任务组', + ad_hoc: '特设组', + joint: '联席会议', + other: '其他', // …translate as many of the 17 values as your committee uses }, + 'zho-Hant': { // Traditional Chinese (繁體中文) + plenary: '全體會議', + working_group: '工作組', + subcommittee: '分技術委員會', + task_group: '任務組', + ad_hoc: '特設組', + joint: '聯席會議', + other: '其他', + }, }, }, ``` @@ -332,6 +346,16 @@ override just that one value and inherit the rest. The drift guardrail fails the build if the gem adds a `MeetingType` value that neither your override nor the built-in table covers. +> **Script variants note (e.g. 简体中文 vs 繁體中文)**: the built-in UI +> locale table uses ISO 639-3 codes and `normalizeUiLocale` currently +> truncates any code longer than 3 chars to its first 3 — so `zho-Hant` +> resolves into the same `zho` bucket as Simplified. The translation +> blocks above illustrate the labels you'd supply; serving both +> simultaneously as distinct routed locales needs a small change to +> `normalizeUiLocale` (preserve the script subtag) plus a `UiLocale` +> type widening. File a feature request if you need both served as +> separate `/zho-Hans/…` and `/zho-Hant/…` routes. + The same data drives the `Type` facet in the search island (chip labels humanize on the client; pass localized labels to the island via `data-*` attributes if you need them in non-English UIs).