Artist Sources own public metadata, photographs, and biography. The directory and
map query those Sources; the directory uses the card template below. Browser actions
are enhanced by the shared guide component through data-save and data-map-link.
Shared accessible button. The guide reads and updates saved state on this device.
<button
class="save-button"
data-save="{{ slug }}"
aria-pressed="false"
aria-label="Save {{ title }}"
>
<icp-heart />
<span class="save-label">Save</span>
</button>
Receives each query result's Source route, title, and scalar metadata. A transparent image allows missing artwork to keep the same geometry with the artist's name.
<article
class="artist-card"
id="artist-{{ slug }}"
data-artist="{{ slug }}"
data-search="{{ title }} {{ category }} {{ room }} {{ booth }}"
data-category="{{ category }}"
>
<div class="card-image" data-has-image="{{ hasimage }}">
<a href="{{ href }}">
<span class="image-placeholder">{{ title }}</span>
<img
src="{{ image }}"
alt="Work by {{ title }}"
loading="lazy"
width="600"
height="600"
/>
</a>
<artist-save slug="{{ slug }}" title="{{ title }}" />
</div>
<h2><a href="{{ href }}">{{ title }}</a></h2>
<div class="card-meta">
<p class="category-label">{{ category }}</p>
<a
class="booth-chip"
data-map-link
href="/map?booth={{ booth }}"
aria-label="Find {{ title }} at {{ room }} booth {{ booth }}"
>
{{ booth }}
<icp-arrow-up-right />
</a>
</div>
</article>
.artist-grid > [data-pmx-query-component="artist-card"] {
display: contents;
}
.card-image[data-has-image="yes"] .image-placeholder,
.card-image[data-has-image="no"] img {
display: none;
}
Search and category controls wrap the query results in the default slot.
<section class="artist-directory" aria-labelledby="artists-title">
<header class="directory-sticky">
<h1 id="artists-title" class="sr-only">Artists</h1>
<div class="directory-toolbar">
<label class="search-field">
<span aria-hidden="true"><icp-search /></span>
<input
type="search"
name="search"
placeholder="Artists, booths, info…"
aria-label="Search artists, booths, places, and event information"
/>
</label>
</div>
<x-icp-artist-filters />
</header>
<slot />
<div class="empty-search" hidden>
<p>No results found.</p>
<button class="button" data-reset>Clear filters</button>
</div>
</section>
.directory-sticky {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
position: sticky;
top: 0;
z-index: 20;
background: var(--paper);
padding: 10px 0;
margin-bottom: 16px;
border-bottom: 1px solid var(--line);
}
.directory-sticky .directory-toolbar {
grid-column: 1 / -1;
display: flex;
gap: 12px;
margin: 0;
}
.directory-sticky .search-field {
max-width: none;
margin: 0;
}
.directory-sticky input {
font-size: 16px;
}
.filter-disclosure summary {
cursor: pointer;
font-size: 13px;
padding: 12px 0 4px;
min-height: 44px;
}
.filter-disclosure { min-width: 0; }
.filter-disclosure[open] { grid-column: 1 / -1; }
.directory-sticky .result-count { display: block; text-align: right; }
.filter-disclosure[open] + .result-count { grid-column: 1 / -1; padding-top: 8px; }
.artist-card { scroll-margin-top: 160px; }
.filter-disclosure .filters {
margin: 10px 0 0;
padding-bottom: 4px;
flex-wrap: wrap;
overflow: visible;
}
[data-active-filter] {
color: var(--ink);
margin-left: 8px;
}
@media (max-width: 640px) {
.directory-sticky .search-field {
width: 100%;
}
.filter-disclosure[open] .filters {
max-height: 35dvh;
overflow-y: auto;
}
}
Reads the represented artist Source directly, including when used by its layout. The gallery owns photo iteration; this component owns the artist's presentation.
<header class="artist-summary">
<div class="artist-title-row">
<h1>{{ source.title }}</h1>
<artist-save slug="{{ source.slug }}" title="{{ source.title }}" />
</div>
<p class="artist-meta"><span>{{ source.category }}</span><span class="artist-location">{{ source.hometown }}</span></p>
</header>
<div class="artist-actions">
<a class="artist-booth-link" data-map-link href="/map?booth={{ source.booth }}" aria-label="Show {{ source.room }} booth {{ source.booth }} on map">
<span>{{ source.room }} <strong>{{ source.booth }}</strong></span>
<icp-arrow-up-right />
</a>
<div class="external-links">
<a class="text-link" href="{{ source.websiteUrl }}" target="_blank" rel="noopener noreferrer">Website</a>
<a class="text-link" href="{{ source.instagramUrl }}" target="_blank" rel="noopener noreferrer">Instagram</a>
</div>
</div>
.artist-actions .external-links a[href=""] {
display: none;
}
The map itself is embedded in compact mode, framed around the booth. Expanding opens the same map with its full controls.
<section class="artist-map-widget">
<a class="artist-map-expand" data-map-link href="/map?booth={{ source.booth }}" aria-label="Expand map for {{ source.title }}, {{ source.room }} booth {{ source.booth }}"><icp-maximize /></a>
<a class="artist-map-touch" data-map-link href="/map?booth={{ source.booth }}" tabindex="-1" aria-hidden="true"></a>
<iframe class="artist-map-frame" src="/map?embedded=1&widget=1&booth={{ source.booth }}" title="Map of {{ source.room }} booth {{ source.booth }}" loading="lazy" tabindex="-1" aria-hidden="true" inert></iframe>
</section>
The shared Places query projects rooms and amenities from map Sources. Links also work without the map overlay. The guide reveals matching places during search.
<a class="place-result" data-place data-search="{{ search }}" data-map-link href="{{ href }}" aria-label="Show {{ title }} on map">
<span class="place-result-copy"><strong>{{ title }}</strong><span>{{ location }}</span></span>
<span class="place-result-action">Map <icp-arrow-up-right /></span>
</a>
.place-results {
margin: 0 0 24px;
}
.place-results h2 {
font-size: 14px;
margin: 0 0 8px;
}
.place-result {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 72px;
padding: 14px 16px;
margin-bottom: 8px;
border: 1px solid var(--line);
border-radius: 12px;
color: var(--ink);
text-decoration: none;
background: white;
}
.place-result-copy { display: grid; gap: 4px; }
.place-result-copy > span { font-size: 13px; }
.place-result-action { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.place-result-action svg { width: 18px; height: 18px; }
Indexes the question and full answer from the Event Info Source.
<a class="place-result" data-info-result data-search="{{ search }}" href="{{ href }}">
<span class="place-result-copy"><strong>{{ title }}</strong><span>{{ excerpt }}</span></span>
<span class="place-result-action"><icp-arrow-up-right /></span>
</a>