Skip to content

Commit d999f9b

Browse files
committed
feat(site): full 287-map catalogue with per-map detail pages
## Catalogue (/maps) - MapCatalogue.vue island with search + filter + sort - Filter rail: authority, source script, destination script, with-tests-only - Sort by authority (default), system code, or name - Live result count, clear button when filters active - Card grid: authority stamp, name, system code, script flow, test vector - Empty state when no matches - Each card links to /maps/[systemCode] ## Per-map detail (/maps/[systemCode]) - Generates 287 pages from maps-catalogue.json (one per system) - Hero: name, system code, eyebrow with authority + year - Metadata grid: authority, year, source script, destination script, language, confirmation date - Description block (if present) - Authority-document link (if present) - Live preview: MapPreview.vue island — transliterates live if system IR is bundled (10 systems); otherwise shows install instructions - Reference vector: source → expected output side-by-side - Source link to interscript/maps .imp file on GitHub ## MapPreview component - Single-system variant of MapExplorer - Engine status pill: ready / loading / missing / unavailable - Graceful fallback for systems without bundled IR (287 - 10 = 277) - Shows Ruby + TS install commands in fallback notice ## Build - 305 pages total (was 18): home + 7 sections + 287 map detail + blog + docs - All static; only MapCatalogue + MapPreview ship client JS ## Tests (68 vitest, was 43) - test/catalogue.test.ts: 13 component tests for MapCatalogue (render, filter, search, sort, clear, link, sample rendering) - test/maps-pages.test.ts: 8 build smoke tests for catalogue + detail (page count > 280, metadata, test vectors, source links, back links, MapPreview island, reference vector section) - Existing 47 tests continue to pass
1 parent d9989c7 commit d999f9b

7 files changed

Lines changed: 7600 additions & 41 deletions

File tree

src/components/MapCatalogue.vue

Lines changed: 398 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,398 @@
1+
<script setup lang="ts">
2+
/**
3+
* MapCatalogue — searchable, filterable browser for all 287 maps.
4+
*
5+
* Visual metaphor: library card catalogue. Each map is a card with an
6+
* authority "stamp", script flow (source → destination), and a sample
7+
* test vector. Cards link to /maps/[systemCode] for full detail.
8+
*
9+
* Filter rail: by authority, source script, destination script.
10+
* Search: matches system code or name (fuzzy on lowercase).
11+
*/
12+
import { ref, computed, defineProps } from "vue"
13+
14+
interface CatalogueEntry {
15+
code: string
16+
authority: string
17+
name: string
18+
sourceScript: string
19+
destinationScript: string
20+
year: string
21+
language?: string
22+
description?: string
23+
hasTest: boolean
24+
testInput: string | null
25+
testExpected: string | null
26+
}
27+
28+
const props = defineProps<{
29+
entries: CatalogueEntry[]
30+
authorities: string[]
31+
sourceScripts: string[]
32+
destinationScripts: string[]
33+
}>()
34+
35+
const search = ref("")
36+
const authorityFilter = ref<string>("")
37+
const sourceFilter = ref<string>("")
38+
const destFilter = ref<string>("")
39+
const onlyWithTests = ref(false)
40+
const sortBy = ref<"authority" | "code" | "name">("authority")
41+
42+
const filtered = computed(() => {
43+
const q = search.value.trim().toLowerCase()
44+
let list = props.entries.filter((e) => {
45+
if (authorityFilter.value && e.authority !== authorityFilter.value) return false
46+
if (sourceFilter.value && e.sourceScript !== sourceFilter.value) return false
47+
if (destFilter.value && e.destinationScript !== destFilter.value) return false
48+
if (onlyWithTests.value && !e.hasTest) return false
49+
if (q) {
50+
const hay = `${e.code} ${e.name} ${e.authority}`.toLowerCase()
51+
if (!hay.includes(q)) return false
52+
}
53+
return true
54+
})
55+
56+
list = [...list].sort((a, b) => {
57+
if (sortBy.value === "code") return a.code.localeCompare(b.code)
58+
if (sortBy.value === "name") return a.name.localeCompare(b.name)
59+
// authority sort: by authority, then code
60+
if (a.authority !== b.authority) return a.authority.localeCompare(b.authority)
61+
return a.code.localeCompare(b.code)
62+
})
63+
64+
return list
65+
})
66+
67+
function clearFilters() {
68+
search.value = ""
69+
authorityFilter.value = ""
70+
sourceFilter.value = ""
71+
destFilter.value = ""
72+
onlyWithTests.value = false
73+
}
74+
75+
const hasActiveFilters = computed(
76+
() =>
77+
search.value !== "" ||
78+
authorityFilter.value !== "" ||
79+
sourceFilter.value !== "" ||
80+
destFilter.value !== "" ||
81+
onlyWithTests.value,
82+
)
83+
</script>
84+
85+
<template>
86+
<div class="catalogue">
87+
<aside class="filter-rail">
88+
<div class="rail-section">
89+
<label class="field">
90+
<span class="field-label">Search</span>
91+
<input
92+
v-model="search"
93+
type="search"
94+
class="field-input"
95+
placeholder="System code or name…"
96+
/>
97+
</label>
98+
</div>
99+
100+
<div class="rail-section">
101+
<label class="field">
102+
<span class="field-label">Authority</span>
103+
<select v-model="authorityFilter" class="field-input">
104+
<option value="">All ({{ authorities.length }})</option>
105+
<option v-for="a in authorities" :key="a" :value="a">{{ a }}</option>
106+
</select>
107+
</label>
108+
</div>
109+
110+
<div class="rail-section">
111+
<label class="field">
112+
<span class="field-label">Source script</span>
113+
<select v-model="sourceFilter" class="field-input">
114+
<option value="">All ({{ sourceScripts.length }})</option>
115+
<option v-for="s in sourceScripts" :key="s" :value="s">{{ s }}</option>
116+
</select>
117+
</label>
118+
</div>
119+
120+
<div class="rail-section">
121+
<label class="field">
122+
<span class="field-label">Destination script</span>
123+
<select v-model="destFilter" class="field-input">
124+
<option value="">All ({{ destinationScripts.length }})</option>
125+
<option v-for="s in destinationScripts" :key="s" :value="s">{{ s }}</option>
126+
</select>
127+
</label>
128+
</div>
129+
130+
<div class="rail-section rail-checkbox">
131+
<label>
132+
<input type="checkbox" v-model="onlyWithTests" />
133+
<span>With test vectors only</span>
134+
</label>
135+
</div>
136+
137+
<div class="rail-section">
138+
<label class="field">
139+
<span class="field-label">Sort by</span>
140+
<select v-model="sortBy" class="field-input">
141+
<option value="authority">Authority</option>
142+
<option value="code">System code</option>
143+
<option value="name">Name</option>
144+
</select>
145+
</label>
146+
</div>
147+
148+
<button
149+
v-if="hasActiveFilters"
150+
class="clear-btn"
151+
type="button"
152+
@click="clearFilters"
153+
>
154+
Clear filters
155+
</button>
156+
157+
<p class="rail-count">{{ filtered.length }} of {{ entries.length }} maps</p>
158+
</aside>
159+
160+
<div class="results">
161+
<ol class="cards" v-if="filtered.length > 0">
162+
<li v-for="entry in filtered" :key="entry.code" class="card">
163+
<a :href="`/maps/${entry.code}`" class="card-link">
164+
<div class="card-head">
165+
<span class="card-auth">{{ entry.authority }}</span>
166+
<span class="card-year">{{ entry.year }}</span>
167+
</div>
168+
<h3 class="card-name">{{ entry.name }}</h3>
169+
<code class="card-code">{{ entry.code }}</code>
170+
<div class="card-flow">
171+
<span class="script">{{ entry.sourceScript }}</span>
172+
<span class="arrow" aria-hidden="true">→</span>
173+
<span class="script">{{ entry.destinationScript }}</span>
174+
</div>
175+
<div v-if="entry.hasTest && entry.testInput && entry.testExpected" class="card-sample">
176+
<span class="sample-input">{{ entry.testInput }}</span>
177+
<span class="sample-arrow" aria-hidden="true">⇒</span>
178+
<span class="sample-output">{{ entry.testExpected }}</span>
179+
</div>
180+
<div v-else class="card-sample card-sample-empty">
181+
<span>No reference vector</span>
182+
</div>
183+
</a>
184+
</li>
185+
</ol>
186+
<div v-else class="empty">
187+
<p>No maps match these filters.</p>
188+
<button class="clear-btn" type="button" @click="clearFilters">Clear filters</button>
189+
</div>
190+
</div>
191+
</div>
192+
</template>
193+
194+
<style scoped>
195+
.catalogue {
196+
display: grid;
197+
grid-template-columns: 16rem 1fr;
198+
gap: 2.5rem;
199+
align-items: start;
200+
}
201+
@media (max-width: 1024px) {
202+
.catalogue { grid-template-columns: 1fr; }
203+
}
204+
205+
/* Filter rail */
206+
.filter-rail {
207+
position: sticky;
208+
top: 5rem;
209+
display: flex;
210+
flex-direction: column;
211+
gap: 1.25rem;
212+
padding: 1.5rem;
213+
background: var(--color-vellum);
214+
border: 1px solid var(--color-rule);
215+
border-radius: 4px;
216+
}
217+
@media (max-width: 1024px) {
218+
.filter-rail { position: static; }
219+
}
220+
.rail-section { display: flex; flex-direction: column; gap: 0.4rem; }
221+
.rail-checkbox label {
222+
display: flex;
223+
align-items: center;
224+
gap: 0.5rem;
225+
font-size: 0.875rem;
226+
color: var(--color-ink);
227+
cursor: pointer;
228+
}
229+
.field { display: flex; flex-direction: column; gap: 0.4rem; }
230+
.field-label {
231+
font-family: var(--font-mono);
232+
font-size: 0.6875rem;
233+
letter-spacing: 0.12em;
234+
text-transform: uppercase;
235+
color: var(--color-stone);
236+
}
237+
.field-input {
238+
font-family: var(--font-sans);
239+
font-size: 0.875rem;
240+
padding: 0.5rem 0.625rem;
241+
border: 1px solid var(--color-rule);
242+
border-radius: 3px;
243+
background: var(--color-parchment);
244+
color: var(--color-ink);
245+
width: 100%;
246+
}
247+
.field-input:focus-visible {
248+
outline: 2px solid var(--color-ochre);
249+
outline-offset: 1px;
250+
border-color: var(--color-ochre);
251+
}
252+
.clear-btn {
253+
font-family: var(--font-mono);
254+
font-size: 0.75rem;
255+
letter-spacing: 0.05em;
256+
text-transform: uppercase;
257+
padding: 0.4rem 0.75rem;
258+
border: 1px solid var(--color-ink);
259+
background: transparent;
260+
color: var(--color-ink);
261+
border-radius: 3px;
262+
cursor: pointer;
263+
transition: background 0.15s ease, color 0.15s ease;
264+
align-self: flex-start;
265+
}
266+
.clear-btn:hover {
267+
background: var(--color-ink);
268+
color: var(--color-vellum);
269+
}
270+
.rail-count {
271+
margin: 0;
272+
padding-top: 0.75rem;
273+
border-top: 1px solid var(--color-rule);
274+
font-family: var(--font-mono);
275+
font-size: 0.75rem;
276+
color: var(--color-stone);
277+
}
278+
279+
/* Results */
280+
.cards {
281+
list-style: none;
282+
margin: 0;
283+
padding: 0;
284+
display: grid;
285+
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
286+
gap: 1rem;
287+
}
288+
.card {
289+
background: var(--color-vellum);
290+
border: 1px solid var(--color-rule);
291+
border-radius: 4px;
292+
transition: border-color 0.15s ease, transform 0.15s ease;
293+
position: relative;
294+
}
295+
.card:hover {
296+
border-color: var(--color-ochre);
297+
transform: translateY(-1px);
298+
}
299+
.card-link {
300+
display: flex;
301+
flex-direction: column;
302+
gap: 0.4rem;
303+
padding: 1.25rem;
304+
text-decoration: none;
305+
color: var(--color-ink);
306+
height: 100%;
307+
}
308+
.card-head {
309+
display: flex;
310+
justify-content: space-between;
311+
align-items: baseline;
312+
font-family: var(--font-mono);
313+
font-size: 0.6875rem;
314+
letter-spacing: 0.1em;
315+
text-transform: uppercase;
316+
}
317+
.card-auth {
318+
color: var(--color-ochre);
319+
font-weight: 500;
320+
}
321+
.card-year {
322+
color: var(--color-stone);
323+
}
324+
.card-name {
325+
font-family: var(--font-display);
326+
font-size: 1.0625rem;
327+
font-weight: 500;
328+
line-height: 1.25;
329+
margin: 0;
330+
color: var(--color-ink);
331+
}
332+
.card:hover .card-name {
333+
color: var(--color-ochre);
334+
}
335+
.card-code {
336+
font-family: var(--font-mono);
337+
font-size: 0.75rem;
338+
color: var(--color-stone);
339+
background: transparent;
340+
padding: 0;
341+
word-break: break-all;
342+
}
343+
.card-flow {
344+
display: flex;
345+
align-items: center;
346+
gap: 0.5rem;
347+
font-family: var(--font-mono);
348+
font-size: 0.75rem;
349+
color: var(--color-stone);
350+
margin-top: 0.25rem;
351+
}
352+
.card-flow .script { color: var(--color-ink); }
353+
.card-flow .arrow { color: var(--color-stone-light); }
354+
355+
.card-sample {
356+
margin-top: 0.75rem;
357+
padding-top: 0.75rem;
358+
border-top: 1px dashed var(--color-rule);
359+
display: grid;
360+
grid-template-columns: 1fr auto 1fr;
361+
gap: 0.5rem;
362+
align-items: baseline;
363+
font-family: var(--font-display);
364+
font-size: 0.9375rem;
365+
}
366+
.sample-input {
367+
color: var(--color-stone);
368+
word-break: break-word;
369+
}
370+
.sample-arrow {
371+
color: var(--color-ochre);
372+
font-family: var(--font-mono);
373+
}
374+
.sample-output {
375+
color: var(--color-ochre);
376+
font-style: italic;
377+
word-break: break-word;
378+
}
379+
.card-sample-empty {
380+
font-family: var(--font-mono);
381+
font-size: 0.75rem;
382+
font-style: italic;
383+
color: var(--color-stone-light);
384+
display: block;
385+
}
386+
387+
.empty {
388+
padding: 4rem 2rem;
389+
text-align: center;
390+
background: var(--color-vellum);
391+
border: 1px dashed var(--color-rule);
392+
border-radius: 4px;
393+
}
394+
.empty p {
395+
color: var(--color-stone);
396+
margin: 0 0 1rem;
397+
}
398+
</style>

0 commit comments

Comments
 (0)