@museumwnf/viewer-layout 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,44 @@
1
+ This package is Content of the MWNF Website in the sense of the legal notice below, which governs it.
2
+
3
+ Source: https://www.museumwnf.org/about/legal-notice
4
+ Retrieved: 2026-09-08
5
+
6
+ ---
7
+
8
+ # Important Legal Notice
9
+
10
+ ## DEFINITIONS
11
+
12
+ - **'Museum'**: Refers to Museum Ohne Grenzen e.V. (trading as **'Museum With No Frontiers'**, hereinafter **'MWNF'**), an international non-profit association incorporated under Austrian law, and the Consortium.
13
+ - **'Consortium'**: The group of museums, public/private entities, and individuals coordinated by Museum Ohne Grenzen for the development of the [Website](https://museumwnf.org/).
14
+ - **'Website'**: [museumwnf.org](https://museumwnf.org/) and all associated subdomains and services.
15
+ - **'Content'**: All images, texts, data, software, audio-visual files, and metadata displayed on the Website.
16
+ - **'Orphan Work'**: A work protected by copyright for which the rightsholder cannot be identified or located despite a [reasonably diligent search](https://www.euipo.europa.eu/en/observatory/awareness/ip-in-culture/orphan-works-db).
17
+
18
+ ## DISCLAIMER
19
+
20
+ The Website is maintained by MWNF. The European Union funded the initial development of this Website during the realisation of the Discover Islamic Art project. Since EU funding concluded in 2007, the Website has been maintained and further developed solely by MWNF. Content is provided "as is" and constitutes a work in progress. While MWNF makes every effort to ensure accuracy, the Museum is not liable for damages arising from the use of, or reliance on, the Content.
21
+
22
+ ## TERMS AND CONDITIONS FOR USE
23
+
24
+ 1. **Copyright Protection**: The Content is protected by copyright. MWNF retains all rights unless proprietary rights are held by third parties.
25
+ 2. **Permitted Use**: Non-commercial, personal, educational, or scientific use is permitted strictly within the limits of the Austrian Copyright Act (Urheberrechtsgesetz - Urhg) and international treaties.
26
+ 3. **Citations and Attribution**: All citations must include either the URL www.museumwnf.org or the specific 'source-URL' published on the relevant page.
27
+ 4. **Mandatory Reporting**: Users are required to provide MWNF with a full digital or physical copy of any publication that makes use of Website Content
28
+ 5. **Unauthorised Use**: Any reproduction, downloading, printing, or communication to the public of Content from the Website (including text, images, and documentation) for commercial purposes—including commercial publication or personal financial gain—is strictly prohibited without prior written permission from MWNF. Unauthorised use includes, but is not limited to, the redistribution of Content, removing author or source credits, or failing to acknowledge www.museumwnf.org as the source. Furthermore, works identified as belonging to third-party artists or their heirs cannot be copied or downloaded without separate permission from the respective rights holder.
29
+ 6. **Enquiries**: Please submit requests or notifications to: [management@museumwnf.net](mailto:management@museumwnf.net).
30
+ 7. **Artificial Intelligence & Data Mining (AI Opt-Out)**:
31
+ - **Express Prohibition**: Pursuant to Article 4(3) of Directive (EU) 2019/790 and the corresponding implementation in Austrian law, the Museum expressly reserves all rights regarding Text and Data Mining (TDM) and automated data scraping.
32
+ - **AI Training**: The use of any Content for the training, development, or operation of Artificial Intelligence models, machine learning systems, or Large Language Models (LLMs) is strictly prohibited.
33
+ - **Automated Access**: Unauthorised web scraping, crawling, or automated extraction of data for commercial AI purposes constitutes a breach of these terms.
34
+ 8. **Orphan Works**:
35
+ - MWNF displays certain works identified as Orphan Works for cultural and educational purposes, following a diligent search for rightsholders.
36
+ - If you are the rightsholder of a work displayed on this site and wish to assert your rights, please contact MWNF Management.
37
+
38
+ ## PRIVACY POLICY
39
+
40
+ Personal data processing is governed by the EU General Data Protection Regulation (GDPR). MWNF undertakes not to sell or rent personal data provided via the Website or newsletter to any third party.
41
+
42
+ ## MISCELLANEOUS
43
+
44
+ This Legal Notice is governed by the laws of Austria. Any disputes shall be submitted to the exclusive jurisdiction of the courts of Vienna.
package/README.md ADDED
@@ -0,0 +1,433 @@
1
+ # @museumwnf/viewer-layout
2
+
3
+ Page structure for MWNF websites: `<PageShell>` composing seven optional, token-styled sections. No data access, no routing, no theming values — websites own their `theme/tokens.css`.
4
+
5
+ `PageShell` covers the needs of every website from props. A website fills the
6
+ sections it has and leaves the rest empty; a slot is for the *content* of a
7
+ section — the mark in the header, the active view — not for rebuilding the
8
+ section. A website that needs something no prop expresses adds the prop
9
+ here, so the next website has it too.
10
+
11
+ Two companion docs give a more scannable version of who-owns-what and the
12
+ full slot list: [`docs/designer-contract.md`](docs/designer-contract.md) (the
13
+ four roles — components, tokens/CSS, site designer, translator — and the
14
+ token → overrides → slot → own-view decision ladder) and
15
+ [`docs/slot-catalogue.md`](docs/slot-catalogue.md) (every prop and slot of
16
+ `PageShell`, `SiteShell` and the nine composed views, plus a one-line purpose
17
+ for every `/content` component).
18
+
19
+ ## Install
20
+
21
+ Published to npmjs (`registry.npmjs.org`), publicly — no authentication
22
+ needed to install. (Versions up to 2.11.2, published as
23
+ `@metanull/viewer-layout`, remain available on GitHub Packages, frozen; no
24
+ new version is published there.)
25
+
26
+ ```bash
27
+ npm install @museumwnf/viewer-layout
28
+ ```
29
+
30
+ `@museumwnf/viewer-core` (1.14.0 or later) is a peer dependency: the layout
31
+ reads its texts from the application's catalogue through it, and renders the
32
+ one Markdown prop it has (the banner headline) through its pipeline.
33
+
34
+ ## Use
35
+
36
+ ```js
37
+ import { PageShell } from '@museumwnf/viewer-layout'
38
+ import '@museumwnf/viewer-layout/style.css'
39
+ import './theme/tokens.css' // your copy of tokens.reference.css
40
+ ```
41
+
42
+ ```vue
43
+ <PageShell
44
+ header-title="My Museum"
45
+ :nav-links="[{ label: 'Items', href: '#/items', active: true }]"
46
+ :languages="['en', 'fr']"
47
+ language="en"
48
+ footer-text="© MWNF"
49
+ @update:language="switchLanguage"
50
+ >
51
+ <router-view />
52
+ </PageShell>
53
+ ```
54
+
55
+ With `@museumwnf/viewer-core`, a website passes these props from
56
+ `dataset.config.js` (`navigation`) or from a thin shell of its own that
57
+ computes the ones that depend on the route or the language.
58
+
59
+ ## Texts
60
+
61
+ The layout carries no texts of its own. It renders six entries, which reach it
62
+ from the catalogue the website passes to `createViewer`:
63
+
64
+ | Entry | Rendered as |
65
+ | --- | --- |
66
+ | `layout.nav.skipToContent` | the skip link |
67
+ | `layout.nav.label` | the navigation's `aria-label` |
68
+ | `layout.nav.menu` | the menu button on a narrow screen |
69
+ | `layout.language.label` | the label of the language chooser |
70
+ | `layout.hyperlinks.label` | the related-links `aria-label`, unless a `title` is given |
71
+ | `layout.sponsors.label` | the sponsors `aria-label`, unless a `title` is given |
72
+
73
+ They are published in the `layout` namespace of
74
+ [`@museumwnf/viewer-i18n`](https://github.com/metanull/viewer-i18n), which every
75
+ website receives; a website overloads any of them in its own `locales/` file.
76
+ A language that has not translated one falls back to English.
77
+
78
+ Every other text a section shows — a link label, a search placeholder, a
79
+ notice, a sponsor heading — is a prop, and the website passes it through its
80
+ own `t()`.
81
+
82
+ ## PageShell
83
+
84
+ Sections render top to bottom in this fixed order. Every section is optional: it renders nothing unless its slot or driving props are set. The default slot (→ `AppContent`) is where the router-view goes.
85
+
86
+ | Section | Slot | Props (on PageShell) | Events |
87
+ |---|---|---|---|
88
+ | `AppHeader` | `#header`, `#header-brand` | `header-home`, `header-brand`, `header-eyebrow`, `header-title`, `header-title-href`, `header-subtitle`, `header-logos`, `header-logos-title`, `header-links`, `search`, `languages`, `language`, `language-placement`, `language-style` | `search`, `update:language` |
89
+ | `AppBanner` | `#banner` | `banner-variant`, `banner-image`, `banner-image-alt`, `banner-text`, `banner-caption`, `banner-caption-label`, `banner-eyebrow`, `banner-title`, `banner-title-href`, `banner-subtitle`, `banner-headline`, `banner-enter`, `banner-strapline` | — |
90
+ | `AppNavigation` | `#navigation` | `nav-links`, `notice`, `languages`, `language` | `update:language` |
91
+ | `AppContent` | default | — | — |
92
+ | `AppHyperlinks` | `#hyperlinks` | `hyperlinks-variant`, `hyperlinks-title`, `hyperlinks-title-href`, `hyperlinks-subtitle`, `hyperlinks` | — |
93
+ | `AppSponsors` | `#sponsors` | `sponsors-title`, `sponsors`, `sponsor-groups` | — |
94
+ | `AppFooter` | `#footer` | `footer-text`, `footer-links`, `footer-attribution-label`, `footer-attribution-text`, `footer-terms-href`, `footer-terms-label` | — |
95
+
96
+ ### The language switcher
97
+
98
+ `languages` is `['en']` or `[{ code, label }]`, `language` the active code.
99
+ `language-placement` says where the switcher renders: `navigation` (the
100
+ default, a select at the end of the navigation bar) or `header`;
101
+ `language-style` says how, `select` or `buttons`. Whichever renders emits
102
+ `update:language` with the code chosen.
103
+
104
+ ### Links
105
+
106
+ Every link list — `header-links`, `nav-links`, `hyperlinks`, `footer-links`
107
+ — takes `{ label, href, external? }`; `external: true` opens the link in a
108
+ new window. `nav-links` also takes `active: true`, marked with
109
+ `aria-current="page"`. Links are plain `href`s: with viewer-core's hash
110
+ router, `#/items` navigates without any router coupling here.
111
+
112
+ ## Site shell
113
+
114
+ `SiteShell` composes `PageShell` from a website's `dataset.config.js`
115
+ instead of a shell the site writes by hand — the menu, the header/footer
116
+ link lists, the search submit, the banner caption and section-title map,
117
+ and (an exhibition's) logos bucketed into header logos and sponsor groups.
118
+ It reads `config.navigation`, `config.logos` and `config.banner` through
119
+ `@museumwnf/viewer-core`'s `useSiteConfig()`, and the active section through
120
+ its `useSection()`.
121
+
122
+ It reads `@museumwnf/viewer-core` itself, the same reason the composed views
123
+ do (see below): it is exported from `@museumwnf/viewer-layout/components`,
124
+ not from the package root, and a website using it lists
125
+ `@museumwnf/viewer-layout` next to `@museumwnf/viewer-core` in its Vitest
126
+ `server.deps.inline`.
127
+
128
+ ```js
129
+ import { SiteShell } from '@museumwnf/viewer-layout/components'
130
+ ```
131
+
132
+ ```vue
133
+ <SiteShell footer-text="© MWNF 2004–2026">
134
+ <template #brand><span class="mark">MWNF</span></template>
135
+ <router-view />
136
+ </SiteShell>
137
+ ```
138
+
139
+ ```js
140
+ // dataset.config.js
141
+ export default {
142
+ navigation: {
143
+ languages: ['en', 'fr'],
144
+ links: [
145
+ { section: 'home', label: 'core.nav.home', to: { name: 'home' } },
146
+ { section: 'collection', label: 'gallery.nav.collection', to: { name: 'collection' } },
147
+ { label: 'gallery.nav.myCollection', href: mwnfLinks.myCollection, external: true },
148
+ ],
149
+ headerLinks: [{ label: 'core.nav.home', to: { name: 'home' } }],
150
+ footerLinks: [{ label: 'gallery.footer.aboutMwnf', href: mwnfLinks.about, external: true }],
151
+ sectionTitles: { collection: 'gallery.section.collection', partners: 'gallery.section.partners' },
152
+ search: { route: 'search-results', key: 'q', placeholder: 'gallery.search.placeholder', submitLabel: 'catalogue.search.submit', empty: 'all-objects' },
153
+ },
154
+ banner: {
155
+ variant: ({ section }) => (section === 'home' ? 'strip' : 'section'),
156
+ },
157
+ }
158
+ ```
159
+
160
+ A field the config leaves out stays whatever the caller passed `PageShell`
161
+ directly through `$attrs` — a page's own `banner-image`/`banner-caption`,
162
+ say, which only the loaded record knows.
163
+
164
+ | Part | Shape | Notes |
165
+ | --- | --- | --- |
166
+ | `config.navigation.links` | `[{ section, label, to \| href, external?, when? }]` | `label` is an entry name, resolved through `t()`; `to` a route name/location resolved through the router, `href` used as-is; the entry whose `section` equals `useSection()` gets `active: true`; `when(ctx)` (`ctx` is `{ section, locale }`) optionally hides the entry — one whose visibility depends on loaded data closes over its own composable's ref instead, as the DXA shells already did for "hasTimeline" |
167
+ | `config.navigation.headerLinks` / `.footerLinks` | `[{ label, to \| href, external? }]` | no `when`, no `active` |
168
+ | `config.navigation.sectionTitles` | `{ [section]: entryName }` | the banner title over a section page, when `config.banner.title` sets none |
169
+ | `config.navigation.search` | `{ route, key, placeholder, submitLabel, empty }` | `key` defaults to `'q'`; a submit navigates to `route` with `{ [key]: term \|\| empty }` — `empty` is legacy's sentinel for a blank submit (`'all-objects'`) |
170
+ | `config.logos` | `{ header(logo) => boolean, sponsorGroups(logos, t) => [{ title, sponsors }], headerTitle: entryName }` | a predicate and a mapper over the `logos` prop (below); `headerTitle` shows only once `header()` kept at least one |
171
+ | `config.banner` | `{ variant, image, imageAlt, caption, captionLabel, title, subtitle, headline, enter, strapline }` | each value a string (an entry name, resolved through `t()`) or a function of `{ section, locale, t }`; optional field by field |
172
+
173
+ The `logos` prop is the page's own data (an exhibition's logo list) — not
174
+ something a static config can declare — already in `PageShell`'s logo
175
+ shape (`{ image, alt, href? }`) plus whatever extra field (a legacy
176
+ `category_id`, a `visible` flag) the site's own `header`/`sponsorGroups`
177
+ bucket by; turning legacy's `image_url`/`labels` into that shape is the
178
+ site's own job, the same as it already is for `AppSponsors`' shape.
179
+
180
+ Slots: `#brand` is the header lockup — `PageShell`'s `#header-brand`, so the
181
+ computed header links/search/logos still render beside it, unlike a full
182
+ `#header` override; `#banner` replaces `AppBanner`'s content; `#notice`
183
+ renders before the routed content, `#after-content` after it (a dismissible
184
+ sponsor notice next to the router-view, say); `#header`, `#navigation`,
185
+ `#hyperlinks`, `#sponsors`, `#footer` pass straight through to `PageShell`.
186
+
187
+ Once the loaded package's `manifest.rights` names a holder (`useSiteRights()`),
188
+ the footer also renders the attribution sentence — `rights.attribution`, or
189
+ the holder's name when the package carries no separate attribution text —
190
+ labelled `record.source.rightsHolder`, with a link labelled
191
+ `record.source.termsOfUse` to `rights.termsUrl`. A package built before the
192
+ `rights` block existed carries no holder, so the footer renders exactly as
193
+ it did before: `footer-text` alone.
194
+
195
+ ## Sections (standalone use)
196
+
197
+ All sections are also exported individually.
198
+
199
+ | Component | Props | Slot behavior |
200
+ |---|---|---|
201
+ | `AppHeader` | `home`, `brand`, `eyebrow`, `title`, `titleHref`, `subtitle`, `logos: [{ image, alt, href? }]`, `logosTitle`, `links`, `search: { placeholder, submitLabel, submitText? }`, `languages`, `language`, `languageStyle: 'select' \| 'buttons' \| ''`; emits `search(term)`, `update:language` | Default slot replaces the whole header; `brand` slot is the mark |
202
+ | `AppBanner` | `variant: '' \| 'strip' \| 'split' \| 'section'`, `image`, `imageAlt`, `text`, `caption` (a string or `{ name, partner, location, country }`), `captionLabel`, `eyebrow`, `title`, `titleHref`, `subtitle`, `headline` (Markdown), `enter: { label, href, ariaLabel? }`, `strapline` | Slot replaces the banner |
203
+ | `AppNavigation` | `links`, `languages`, `language`, `languageSwitcher` (Boolean), `notice: { title?, text }`; emits `update:language` | Slot replaces the link list; the menu button, the switcher and the notice stay |
204
+ | `AppContent` | — | Default slot only; renders `<main id="mwnf-content">` |
205
+ | `AppHyperlinks` | `variant: '' \| 'tiles'`, `title`, `titleHref`, `subtitle`, `links: [{ label, href, description?, external? }]` | Slot replaces the link list |
206
+ | `AppSponsors` | `title`, `sponsors: [{ name, href?, logo? }]`, `groups: [{ title, sponsors }]` | Slot replaces the sponsor lists |
207
+ | `AppFooter` | `text`, `links` | Slot replaces text and links |
208
+
209
+ ### Banner variants
210
+
211
+ - `''` — an image with a line of text under it.
212
+ - `strip` — a tall image with the site's name over it: `eyebrow` above
213
+ `title`, `enter` rendered after the title as the way in, the `caption` in
214
+ a box that shows on hover. A gallery's home.
215
+ - `split` — the image beside a text column: `title`, `subtitle`, `headline`
216
+ (Markdown, rendered through viewer-core's pipeline), `enter`, `strapline`.
217
+ An exhibition's home.
218
+ - `section` — a narrow strip with `title` over its lower edge and the
219
+ `caption` at the other end. Every other page. The title is data the
220
+ website derives from the route; the component only renders it.
221
+
222
+ The image's `alt` is `imageAlt`, else the caption's name (with
223
+ `captionLabel` before it), else the title.
224
+
225
+ ### Hyperlinks variants
226
+
227
+ - `''` — a titled list of links.
228
+ - `tiles` — a title block (`title`, `subtitle`, linking to `titleHref`) beside
229
+ one tile per link, each with its `label` and `description`. An exhibition's
230
+ bottom banner.
231
+
232
+ ## Content components
233
+
234
+ What renders *inside* a page, from props: the pieces every website's landing
235
+ page, results pages and record page are made of. Behaviour is viewer-core's
236
+ (`useListQuery`, `paginate`, `useFacets`, `useRecordSheet`, `sheetRows`, …);
237
+ what a page holds — which cards, which fields, which filters — is the site's;
238
+ these render it. They are exported from the package and, on their own,
239
+ from `@museumwnf/viewer-layout/content`, which brings none of the shell.
240
+
241
+ ```js
242
+ import { RecordList, Pagination } from '@museumwnf/viewer-layout/content'
243
+ ```
244
+
245
+ Links take an `href` the website's router produced — the same rule as the
246
+ sections. A route location (`to`) is honoured too, through the `RouterLink`
247
+ the application registered, without any router coupling here.
248
+
249
+ | Component | Props | Notes |
250
+ |---|---|---|
251
+ | `SectionCards` | `cards: [{ title, description, action, image?, alt?, number?, children?, href \| to }]`, `variant: 'cards' \| 'rows' \| 'covers' \| 'accordion'` | a landing page's grid of section cards, themed by variant; `title`/`description` are Markdown, rendered inline (no block elements); `number` is formatted by the caller (Roman or decimal); `children` (accordion only) are subsections with their own `to`/`href` |
252
+ | `FeaturedRecord` | `heading`, `image`, `imageAlt`, `eyebrow`, `name` (inline HTML), `meta: [string]`, `action`, `href \| to` | the "item on display" spotlight; the default slot replaces the record |
253
+ | `RecordList` | `records`, `loading`, `loadingText` | records as rows; `#empty` slot |
254
+ | `RecordGrid` | `records`, `loading`, `loadingText`, `actionLabel`, `dateCutoff` (80) | records as tiles with a hover card, shown under the image on a narrow screen; `#empty` slot |
255
+ | `Pagination` | `pageInfo` (viewer-core's `paginate()` result), `window` (5), `jump`, `ends`; emits `navigate(page)` | first / previous / a window of pages / next / last, the position beside the texts |
256
+ | `FacetSelect` | `label`, `options: [{ value, label }]`, `modelValue`, `placeholder`, `anyLabel`, `disabled`, `hideEmpty`; emits `update:modelValue` | one labelled select for one facet |
257
+ | `FilterPanel` | `title`, `mode: 'apply' \| 'immediate'`, `applyLabel`, `resetLabel`, `disabled`; emits `apply`, `reset` | the box the controls (default slot) sit in; `apply` has both buttons, `immediate` only Reset |
258
+ | `ResultsSummary` | `parts: [{ label, count?, value? }]` | each count beside its label, never inside a text; `#actions` slot |
259
+ | `RecordLanguages` | `languages`, `language`; emits `select(code)` | the languages one record carries, as pressed buttons |
260
+ | `RecordSheet` | `rows` (viewer-core's `sheetRows()`), `layout: 'table' \| 'list'`, `dir`, `shortDescription: { html }`, `shortDescriptionAfter`, `shortDescriptionOpen` | a row rendered `custom` or `link` is handed to a slot named after its key |
261
+ | `SheetSection` | `heading`, `html`, `dir` | a headed block under the sheet |
262
+ | `RecordCredits` | `credits: [{ label, value }]`, `workingNumber`, `workingNumberLabel`, `citation`, `heading`, `citationHeading` | who made the sheet, the working number, the citation |
263
+ | `SourceCredit` | `route` (a resolved route, or a raw location; defaults to the current route via `useRoute()`), `labelEntry` ('record.source.label') | the "Source: `<address>`" line — `viewer-core`'s `sourceUrl(route)`; renders nothing until the website declares `site.origin` |
264
+ | `RelatedRecords` | `heading`, `records`, `variant: 'list' \| 'grid'`, `actionLabel` | the same two presentations as a results page; outside references go in the default slot |
265
+ | `MediaGallery` | `images: [{ url, alt, caption, photographer, copyright }]`, `start`, `variant: '' \| 'row'` | the current image, thumbnails, caption, a lightbox (Escape closes, arrows move, focus returns); `row` renders every image in a row |
266
+ | `GlossaryPopover` | `term: { word, spelling, definition }`, `html`, `dir`; emits `close` | the definition of a clicked term, fixed in a corner; Escape closes, focus returns |
267
+ | `PartnerMap` | `latitude`, `longitude`, `zoom` (15), `mapTitleEntry` ('partner.map.map'), `mapOfEntry` ('partner.map.mapOf'), `openMapLinkEntry` ('partner.map.openInOpenStreetMap'), `label` | an OpenStreetMap embed centred on coordinates with a zoom level; builds a bounding box for OSM's embed and a full-map link; uses keyless public tiles |
268
+ | `FeaturedPartners` | `records: [{ id, name, logo, city, country, description, route }]`, `headingEntry` ('partner.featured') | a carousel of partners with bullet controls, rotated on an 8-second timer; the site provides filtered/shuffled records and routes |
269
+ | `SiblingGalleries` | `galleries: [{ id, name, image?, route }]`, `museums: [{ name, to \| href, accent?, textColor? }]`, `galleriesHeadingEntry`, `seeMoreGalleriesEntry`, `seeMoreGalleriesHref`, `museumsHeadingEntry` | two blocks of links: sibling galleries (may be unresolved, rendering as non-clickable tiles) and MWNF virtual museums (text initials over a coloured background) |
270
+ | `PopupLogo` | `content` (Markdown or raw HTML), `rawHtml` (false), `enabled` (true), `closeLabel` ('core.action.close') | a dismissible fixed modal for sponsor notices; renders content through `renderInline` by default (escaping, Markdown); when `rawHtml: true`, passes content as-is (for trusted HTML from the importer) |
271
+ | `BackLink` | `label` ('core.action.back'), `to \| href` (fallback when history is unavailable) | a "back" link that calls `router.back()` when browser history is available (length > 1); falls back to the provided route |
272
+ | `GlossaryTool` | `language`, `entity` ('glossary'), `labels` (entry-name overrides), `dir` | the search box four item sheets and a theme page each wrote for themselves: an input, the hits viewer-core's `searchGlossary` finds, the chosen definition as Markdown; a native `<details>` toggle |
273
+ | `DynastyPopout` | `dynasty` (record: `from_ah`/`to_ah`/`from_ad`/`to_ad`), `text` (its translation: `name`, `also_known_as`, `area`, `history`), `dir` | one dynasty, collapsed behind a native `<details>` toggle: name, also known as, area, AH/AD dates, history as Markdown |
274
+ | `DynastyList` | `heading`, `dynasties`, `tr` (dynasty → its translation), `dir` | one `DynastyPopout` per dynasty of a record, `RelatedRecords`'s shape |
275
+ | `TimelineEventList` | `events: [{ id, date, caption?, description? (block HTML), media?: [{ image, alt?, to? \| href?, caption? }], actions?: [{ label, to? \| href? }] }]` | one row per event; `caption`/`description` are rendered output, the same convention `RecordList`'s `name` follows; `#date`, `#caption`, `#media`, `#actions` slots (each given `{ event }`), `#empty` slot |
276
+
277
+ The record contract `RecordList`, `RecordGrid` and `RelatedRecords` share:
278
+ `{ id, image?, imageAlt?, name (inline HTML), meta: [string], badge?, href? | to? }`.
279
+
280
+ The texts they read — `core.action.apply`, `.reset`, `.close`,
281
+ `core.pagination.*`, `catalogue.pagination.*`, `record.action.*ShortDescription`,
282
+ `record.citation.heading`, `record.glossary.heading`, `.close`,
283
+ `record.media.photograph`, `record.sheet.credits`, `.languages` — are in
284
+ every bundle of `@museumwnf/viewer-i18n` from 1.7.0. `GlossaryTool` also reads
285
+ `record.glossary.instructions`, `.definition` (1.7.0) and `record.glossary.tool`
286
+ (2.3.0); `DynastyPopout` reads `record.dynasty.heading` (2.3.0) and
287
+ `sheet.field.alsoKnownAs`, `.area`, `.history`. `SourceCredit` reads
288
+ `record.source.label` (2.5.0); the footer attribution (`SiteShell`) reads
289
+ `record.source.rightsHolder` and `.termsOfUse` (2.5.0). Every other text is a
290
+ prop.
291
+
292
+ ## Content classes
293
+
294
+ Building blocks copied from the legacy sites' own `site.css` files, available as utility classes for websites to assemble search entrances, list pages and custom layouts:
295
+
296
+ | Class | Purpose |
297
+ |---|---|
298
+ | `.mwnf-panel` | White content box; a container for grouped content with border and padding |
299
+ | `.mwnf-heading` | Section heading with underline; variants `.mwnf-heading--page` (large page title, no underline) and `.mwnf-heading--label` (large label) |
300
+ | `.mwnf-form-table` | Table layout for label/value pairs in search and filter forms; header cells left-aligned, data cells right-padded |
301
+ | `.mwnf-button` | Styled button for primary actions; variant `.mwnf-button--secondary` for secondary/destructive actions |
302
+ | `.mwnf-select` | Legacy-styled select element with fixed height and padding |
303
+ | `.mwnf-back-bar` | Container for back-link buttons with standard padding and hover styling |
304
+ | `.mwnf-chip` | Inline project/source chip; variants `.mwnf-chip--<family>` where family is one of `ISLandEPM`, `DBA`, `AWE`, `DCA`, `DGA`, `EXH`, or `Galleries`, each with its own project-colour token |
305
+ | `.mwnf-popout` | Detail box for sheet data; used in record detail popups and panels |
306
+ | `.mwnf-loader` | Loading/spinner message container with centered text and standard padding |
307
+ | `.mwnf-link` | Link text colour for DXA sites; not a interactive element wrapper, a colour utility |
308
+
309
+ ## Composed views
310
+
311
+ Nine whole pages, made of the content components on viewer-core's
312
+ composables and driven by a declaration the website writes instead of a
313
+ page. They are exported from `@museumwnf/viewer-layout/views` — and only from
314
+ there: they read the records and the engine from `@museumwnf/viewer-core`
315
+ itself, whose entry point carries `.vue` files, and a website's test runner
316
+ that loads this package natively would fail on the first one if the package
317
+ root imported them. A website that names the composed views imports the
318
+ `/views` entry point and lists `@museumwnf/viewer-layout` next to
319
+ `@museumwnf/viewer-core` in its Vitest `server.deps.inline` (the template
320
+ does both). A website names them in viewer-core's `config.views` — the
321
+ `home`, `list` and `detail` slots of the router — or on its own routes, with
322
+ the spec as route props. A website whose page is not this shape writes its
323
+ own component on the same content components: the escape hatch stays open.
324
+ They live here rather than in viewer-core because they are made of this
325
+ package's components, and viewer-core does not depend on the layout.
326
+
327
+ Every text in a declaration is an **entry name, written out**, resolved by
328
+ the view through `t`; a number is placed beside its text by the view, never
329
+ inside it.
330
+
331
+ ```js
332
+ import {
333
+ CatalogueResultsView, EssayView, HomeView, PartnerListView, RecordView, LinkListView, SearchFormView, TextPageView, TimelineResultsView,
334
+ } from '@museumwnf/viewer-layout/views'
335
+
336
+ export default {
337
+ views: { home: HomeView },
338
+ home: {
339
+ title: 'mysite.home.title',
340
+ intro: 'mysite.home.intro',
341
+ cards: [{ title: 'mysite.nav.catalogue', description: 'mysite.home.catalogueText', action: 'core.action.browse', to: { name: 'catalogue' } }],
342
+ featured: { entity: 'items', heading: 'mysite.home.itemOnDisplay', action: 'core.action.viewDetails', route: 'item', eyebrow: 'type', meta: ['location', 'dates'] },
343
+ },
344
+ extraViews: [
345
+ { path: '/catalogue', name: 'catalogue', component: CatalogueResultsView, props: { spec: catalogue }, meta: { section: 'catalogue', entities: ['items', 'countries'] } },
346
+ { path: '/item/:id', name: 'item', component: RecordView, props: (route) => ({ spec: sheet, id: route.params.id }), meta: { section: 'catalogue', entities: ['items', 'countries'] } },
347
+ ],
348
+ }
349
+ ```
350
+
351
+ | View | Declaration | Slots |
352
+ |---|---|---|
353
+ | `HomeView` | `config.home` or the same as props: `title`, `intro` (Markdown), `cards: [{ title, description, action, to \| href }]`, `featured: { entity, heading, action, route, eyebrow, meta, seed }` — the pick is `useFeaturedRecord` | `before`, default, `after` |
354
+ | `CatalogueResultsView` | `spec`: `entity`, `keys`, `facets` (viewer-core's facet spec), `facetScope: 'all' \| 'matching'`, `controls: [{ key, type: 'select' \| 'year' \| 'query' \| 'checkbox', label, placeholder, anyLabel, hideEmpty }]`, `filterMode: 'apply' \| 'immediate'`, `scope(record, filters)`, `match(record, filters)`, `narrow(list, filters, helpers)` (a rule over the whole list — a keyword index, say), `dates: { mode, begin, end }`, `sort`, `pageSize`, `variant: 'list' \| 'grid'`, `record(record, helpers)`, `recordRoute`, `summary(context)`, `title`, `filterTitle`, `empty`, `actionLabel`, `pagination` | `before`, `filters`, `actions`, `aside`, `empty`, `after` — each given `{ filters, active, apply, reset, goToPage, matching, pageInfo, options }`, enough to compose the panel in the aside or a second pagination |
355
+ | `RecordView` | `spec` and `id`: `entity`, `translations`, `attribution`, `fields` (viewer-core's `sheetRows` spec, or a function of the context), `sections`, `layout`, `shortDescription`, `media(record, ctx)`, `mediaVariant`, `credits`, `workingNumber`, `citation: { project, permalink: true \| false \| string, heading } \| false` (the permalink is `sourceUrl(currentRoute)` — null, so no address, until the website declares `site.origin` — unless the spec disables it with `false` or overrides it with a string), `related: { variant, heading, record, route } \| false`, `back: { label, to \| href }`, `title(ctx)` | `header`, `before-sheet`, `after-sheet`, `aside`, `source` (under the citation; default `SourceCredit`), `related`, `after`, and one named after every `custom` or `link` row — each given `{ record, text, language, languages, select, dir, glossary, ready, attribution, t, tr }`; `related` also `records` (the rows) and `outside` (the related records the package does not carry) |
356
+ | `EssayView` | `spec` and `id`: `tree` (a `useCollectionTree` result, or `{ purpose \| rootId, childType?, entity?, order? }` / `{ themes: true \| 'themes', childType? }` for the view to build one), `entity` (the tree's items — also the tree's own translations entity when `tree` carries none of its own, a pre-built tree or a declarative form with no `entity`/`themes`; falls back to `'collections'`), `route` (a node's own page: a route name or `(node, ctx) => to \| href`), `heading(ctx)`, `placeholder` (a regex a synthesized title matches, falling back to the English title, then the internal name), `quote` / `body` (a field of the node's translation, a dotted path into it such as `'extra.intro_text'`, or a function `(ctx) => Markdown`; default `'quote'`/`'description'`, `false` to drop), `glossary`, `items: { of(node) => ids, caption(item, node, ctx) => override, meta(item, ctx) => [string] (the grid's caption lines), badge(item, ctx) => string, route }`, `panel: { variants(item, ctx) => [{ id, image, alt, caption: { title, justification, fields: [{ label (entry name), value }] } }], fields(item, node, ctx) => [{ label, value }] (the fallback when a variant carries no fields of its own) } \| false`, `navigation: 'tree' \| 'siblings' \| false` (`tree` crosses a branch boundary, `siblings` stays inside the parent), `breadcrumb`, `tabs: true \| 'siblings' \| 'children'` (the strip: `true`/`'siblings'` — the node's own siblings, the default; `'children'` — the node's own children, e.g. a theme's chapters), `about(node) => boolean \| { panel?, navigation? }` (essay only; a plain boolean drops both the picture panel and the navigation, an object keeps the named piece instead), `aboutKeeps: ['panel' \| 'navigation']` (the spec-wide equivalent, when every about page in this spec keeps the same piece), `numbering: 'roman' \| 'decimal' \| false` (counts a node among its true siblings — for a themes-package tree, whose `root` is `null`, that's every node whose own `tree.parents(id)` is also empty, so a themes tree numbers its top-level themes I, II, III); `previous`, `next`, `backTo`, `inThisTheme`, `seeAll` (entry names, defaulting to `exhibition.theme.previous`/`.next`/`.inThisTheme`/`.seeAllInTheme` and `record.action.backToResults`) | `header`, `before-body`, `after-body`, `panel`, `thumbnails`, `aside`, `justifications`, `navigation`, `after` (default `SourceCredit` — nothing until the website declares `site.origin`) — each given `{ node, text, language, tree, items, selected, select, selectedVariant, selectVariant, breadcrumb, previous, next, t, tr }` |
357
+ | `LinkListView` | `spec`: `title` (entry), `groups: [{ heading (entry), links: [{ label, href \| to, note? }] }] \| (ctx) => groups` (`label`/`note` are Markdown, rendered inline through `mdInline`), `back: { label, to \| href } \| false`, `empty` (entry) | `before`, `group` (given `{ group }`; the default renders the heading and its links — replace it for a citation list that is not link-shaped), `after` |
358
+ | `PartnerListView` | `spec`: `entity` (default `'partners'`), `scope(partner, ctx)` (a site's own axis — museum/institution, a curated project — filtered before grouping), `group: { tier: 'level' \| false, order: 'country' \| 'name' }` (over viewer-core's `groupByCountry`; `'name'`: one flat, alphabetical group, no country carries this today), `orderToggle` (an A-Z/Z-A control, mirrored in the query as `order`; every tier is always sorted by name, the toggle reverses the country order or the one flat list), `nested` (an associated partner rendered under its own parent through `partnerHierarchy`, instead of the flat tier column; one whose parent is missing or in another group stays flat), `label(countryId, ctx)` (a group's own country label, required when `group.order` is `'country'`), `record(partner, ctx) => { name, city, logo, count, route }` (the row), `route` (a route name or `(partner, ctx) => to \| href`, read by the default row only), `variant: 'accordion' \| 'open'`, `count` (the "Partners found: N" line), `associatedLabel`/`foundLabel`/`objectsLabel`/`sortAscendingLabel`/`sortDescendingLabel`/`empty`/`title` (entry names, overriding the shared `partner.*` defaults) | `before`, `group-heading` (given `{ group }`; the default renders `group.label`), `row` (given `{ group, partner, row }`, once per partner — main, associated and, when nested, a child row too), `after` — every slot also given `{ t, locale, groups, orderDir, toggleOrder }` |
359
+ | `TextPageView` | `spec`: `heading?` (entry), `body` (entry \| (ctx) => Markdown, `ctx` the same `{ t, tr, language }` shape `EssayView`'s spec functions read), `back: { label, to \| href } \| true \| false` | none |
360
+ | `SearchFormView` | `spec`: `mode: 'rows' \| 'facets' \| 'radio'`, `entity` (records an entity-backed facet or `dates: 'buckets'` read), `rows` (`'rows'`: keyword-row count, default 3), `fields: [{ key, label }]` (`'rows'`: the field select), `operators: [{ key, label }]` (`'rows'`: the AND/OR select, default AND/OR), `dates: { presets } \| 'buckets' \| false` (a from/to year select — `centuryPresets`'s asymmetric boundaries, or one `yearBuckets` list used for both ends), `language: entity \| false` (the search-language select, over `useSearchLanguage`), `extras: [{ key, type: 'checkbox', label }]`, `facets: [{ key, label, type: 'select' \| 'year', options: [{ value, label }] }]` (`'facets'`/`'radio'`: options given directly, or derived from `entity`'s own records when left out; `type: 'year'` is a plain number input), `target` (the results route this form writes into), `submitLabel`, `showAllLabel`, `howTo: routeName \| false` (a link to the search-syntax essay) | `intro`, `before`, `extras`, `actions` — each given `{ mode, submit, showAll }` |
361
+ | `TimelineResultsView` | `spec`: `scope: 'country' \| 'local' \| 'collection'` (viewer-core's `useTimelineEvents` axis), `countryLabel(id)`, `countryIdForCode(code)` (DXA's legacy 2-letter code), `collections(ctx) => [{ value, label }]` (Sharing History's exhibition/PC picker — `'pc'` is the Permanent Collection sentinel), `tr(id)` (an event's translation, the site's own), `timelinesEntity`/`eventsEntity` (data package file names), `keys` (default: every control's key), `controls: [{ key: 'country' \| 'collection' \| 'begin' \| 'end', label, placeholder, anyLabel, hideEmpty, options? }]` (`options` on `'begin'`/`'end'`: an array `[{ value, label }]` or function `(ctx) => [...]` for bucketed year selects; without `options`, a free-year number input), `pageSize`, `entrance: false \| true` (renders the form alone, with validation, navigating to `route` on submit), `route` (the entrance's own target route), `event(event, ctx) => { date, caption, description, media, actions }` (the row, see `TimelineEventList`), `gallery: { route, items(ctx) => count \| array \| boolean, label } \| false` (the "See gallery" cross-link — an item's own shape stays the site's), `summary(ctx)`, `errorSelect`, `errorPeriod` (the entrance's own validation entries), `title`, `filterTitle`, `applyLabel`, `resetLabel`, `submitLabel`, `empty`, `pagination` | `summary`, `cross-link`, `event` (replaces the whole events list), `before`, `after` — each given `{ filters, active, apply, reset, goToPage, events, pageInfo, countries, collections, gallery, t, tr }` |
362
+
363
+ The tokens they read — `--mwnf-view-*` — arrange the parts; a website themes
364
+ a composed page by theming the parts.
365
+
366
+ ### `PartnerListView` and the two site families
367
+
368
+ `PartnerListView` is the shape behind islamicart's and sharinghistory's
369
+ `PartnersResults.vue` and the DXA family's `Partners.vue`:
370
+
371
+ | Page | What it declares |
372
+ | --- | --- |
373
+ | islamicart / sharinghistory `PartnersResults` | `group: { tier: 'level', order: 'country' }` for the main/associated accordion, `variant: 'accordion'` (the default), `count: true` for the "Partners found: N" line; islamicart's own museum/institution and ISL/EPM axes are `scope`, read from its own route |
374
+ | DXA `Partners` | `group: { tier: false, order: 'country' }` (no tiers at all), `variant: 'open'`, `orderToggle: true` for the A-Z/Z-A button; a partner with `item_count: 0` still gets a row (only the "View objects" link is a site's own `#row` concern) |
375
+
376
+ ### `EssayView` and the seven site pages
377
+
378
+ `EssayView` is the shape behind islamicart's `ExhibitionTheme.vue` and
379
+ `ArtIntroTheme.vue`, baroqueart's `ExhibitionTheme.vue`, sharinghistory's
380
+ `ExhibitionTheme.vue`, `ExhibitionChapter.vue` and
381
+ `HistoricalBackgroundCountry.vue`, and the DXA family's `Theme.vue` (over
382
+ `themes.json`). No page is special-cased; each reaches the shape through the
383
+ spec and the slots, not a branch in the view:
384
+
385
+ | Page | What it declares |
386
+ | --- | --- |
387
+ | islamicart / baroqueart `ExhibitionTheme` | `navigation: 'siblings'` (legacy's Previous/Next-page row, not tabs), `panel.variants` for the "detail" close-up selector — each variant's image, title, justification and fields swap together, not just the picture — `items.caption` for the caption override merged with the item's own translation |
388
+ | islamicart `ArtIntroTheme` | the same, plus `tabs: true` (a fixed Monuments/Objects-style tab strip, specific to this theme) |
389
+ | sharinghistory `ExhibitionTheme` | `tabs: 'children'` over its chapters (a vertical list, themed through the tab tokens rather than a horizontal strip), no `panel.variants`, `breadcrumb: true` |
390
+ | sharinghistory `ExhibitionChapter` | `navigation: 'siblings'` over the theme's chapters, `breadcrumb: true` (exhibition › theme), the `justifications` slot for the curator/partner pair the default panel does not carry, `after-body` for the see-also and further-reading blocks |
391
+ | sharinghistory `HistoricalBackgroundCountry` | `items` with no `panel` (a plain `RecordGrid` of illustrating items, not a selector), `navigation: 'siblings'` over its pages; the bibliography and the maps are `after-body`/`after`, outside this view's declaration |
392
+ | DXA `Theme` (`themes.json`) | `tree: { themes: true }`, `numbering: 'roman'`, `about(node)` for theme zero, `navigation: 'tree'` for the tour's Previous/Next (which crosses from a theme's last sub-theme into the next theme); the related-works toggle and the picture→parent indirection are specific to this family and stay in the `thumbnails` slot and `items.of`/`items.caption`, not in the view itself |
393
+
394
+ ### `SearchFormView` and legacy's three entrance shapes
395
+
396
+ | Page | Mode | What it declares |
397
+ | --- | --- | --- |
398
+ | islamicart / sharinghistory / baroqueart `Database` | `rows` | three keyword rows over `fields`/`operators`, `dates: { presets: centuryPresets }` for the from/to century selects, `language: 'items'` for the search-language select, an `extras` checkbox for islamicart's "include EPM" |
399
+ | carpets / water-in-islam / the-use-of-colours-in-art / amulets `CollectionSearch` | `facets` | one `facets` entry a category, `dates: 'buckets'` for the shared start/end year list (`yearBuckets` over `entity`'s own records, as legacy's client-side bucket algorithm built it) |
400
+ | islamicart / sharinghistory `PcEntrance` | `radio` | `facets` again, but chosen one at a time — country, dynasty (or sharinghistory's theme, its own `options`), holding institution, and `begin`/`end` as `type: 'year'` rows; islamicart's "include EPM" is again an `extras` checkbox |
401
+
402
+ ### `TimelineResultsView` and the seven site pages
403
+
404
+ Two instances of the one view per site — `entrance: true` for the form-only
405
+ page, `entrance: false` (default) for the results — over `scope: 'country'`
406
+ except where noted:
407
+
408
+ | Page | What it declares |
409
+ | --- | --- |
410
+ | DXA `Timeline`/`TimelineResults` | `controls: [{ key: 'country' }, { key: 'begin' }, { key: 'end' }]`; the results instance adds `gallery: { route: 'timeline-gallery', items: (ctx) => site's own country/period join }` for the "See Gallery" box; water-in-islam's suppressed country column and `hasTimeline`/`usesLocalTimeline` gate are a `controls` without `'country'` and the site's own guard before the route renders this view at all |
411
+ | islamicart/baroqueart `TimelineEntrance`/`TimelineResults` | the same controls, `event: (event, ctx) => ({ ..., actions: [{ label: 'timeline.action.viewItemsFromPeriod', to: itemsLink(event) }] })` for the per-event "View items from this period" link |
412
+ | sharinghistory `TimelineResults` (no separate entrance page in legacy; `entrance: true` on the same spec shape covers it) | `scope: 'collection'`, `controls` adding `{ key: 'collection', label: 'sharinghistory.nav.timeline' }` fed by `collections: (ctx) => [{ value: 'pc', label: t('sharinghistory.nav.permanentCollection') }, ...exhibitions]`, `event(...)` composing the "Country \| Theme" `caption` and the image/item strip as `media` |
413
+
414
+ ## Theming
415
+
416
+ Every color, font, spacing, radius comes from a `--mwnf-*` CSS custom property with a neutral fallback. Full list: [`tokens.reference.css`](src/tokens.reference.css) (also exported as `@museumwnf/viewer-layout/tokens.reference.css`) — copy it into your website as `theme/tokens.css` and set values.
417
+
418
+ Below `48rem` the navigation folds its links behind a menu button, the
419
+ `split` banner stacks its two columns, and the banner captions are hidden.
420
+
421
+ ## Licence
422
+
423
+ This package is Content of the MWNF Website under the [MWNF legal
424
+ notice](https://www.museumwnf.org/about/legal-notice), which governs its use
425
+ (non-commercial, personal, educational and scientific use is permitted, with
426
+ attribution and mandatory reporting — see the notice for the full terms). The
427
+ notice text also ships in this package as `LICENSE.md`.
428
+
429
+ ## Release
430
+
431
+ 1. PR to `main` (direct pushes are blocked); CI must be green.
432
+ 2. Bump `version` in `package.json` + `CHANGELOG.md` entry (strict semver).
433
+ 3. Create a GitHub release with tag `vX.Y.Z` — CI publishes to npmjs.