Artist presentation

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.

Save action

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>

Directory card

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;
}

Artist directory

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;
  }
}

Artist summary

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;
}

Artist booth map

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&amp;widget=1&amp;booth={{ source.booth }}" title="Map of {{ source.room }} booth {{ source.booth }}" loading="lazy" tabindex="-1" aria-hidden="true" inert></iframe>
</section>

Searchable place

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; }

Event information search result

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>