Files
remanso/src/views/RepoList.vue
2026-05-14 16:18:11 +02:00

1112 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import { vInfiniteScroll } from "@vueuse/components"
import fontColorContrast from "font-color-contrast"
import { getHex } from "pastel-color"
import { computed, ref } from "vue"
import SignInAtproto from "@/components/SignInAtproto.vue"
import SignInGithub from "@/components/SignInGithub.vue"
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
import { useRepos } from "@/hooks/useRepos.hook"
import { useRepoList } from "@/modules/repo/hooks/useRepoList.hook"
import type { RepoBase } from "@/modules/repo/interfaces/RepoBase"
const { username, accessToken } = useGitHubLogin()
const { isLoggedIn: isATProtoLoggedIn, handle, avatarUrl } = useATProtoLogin()
const { isReady, hasCredentialError } = useRepos()
const {
favoriteRepos,
otherRepos,
favoriteCheckboxes,
toggleCheckbox,
canLoadMore,
loadMore
} = useRepoList()
const isGitHubLoggedIn = computed(() => !!accessToken.value)
const isAnyUserLoggedIn = computed(
() => isGitHubLoggedIn.value || isATProtoLoggedIn.value
)
const displayUsername = computed(() => username.value || handle.value || "")
const displayInitial = computed(() =>
(displayUsername.value[0] || "?").toUpperCase()
)
const filterQuery = ref("")
const normalizedQuery = computed(() => filterQuery.value.trim().toLowerCase())
const matchesQuery = (name: string) =>
normalizedQuery.value === "" ||
name.toLowerCase().includes(normalizedQuery.value)
const filteredFavoriteRepos = computed<RepoBase[]>(() =>
favoriteRepos.value.filter((r) => matchesQuery(r.name))
)
const filteredOtherRepos = computed<RepoBase[]>(() =>
otherRepos.value.filter((r) => matchesQuery(r.name))
)
const groupedOtherRepos = computed<
Array<{ letter: string; repos: RepoBase[] }>
>(() => {
const groups = new Map<string, RepoBase[]>()
const sorted = [...filteredOtherRepos.value].sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
)
for (const repo of sorted) {
const ch = repo.name[0]?.toUpperCase() ?? "#"
const letter = /[A-Z]/.test(ch) ? ch : "#"
if (!groups.has(letter)) groups.set(letter, [])
groups.get(letter)!.push(repo)
}
return [...groups.entries()].map(([letter, repos]) => ({ letter, repos }))
})
const tileStyle = (seed: string) => {
const bg = getHex(seed)
return { backgroundColor: bg, color: fontColorContrast(bg) }
}
const isStarred = (repo: RepoBase) => favoriteCheckboxes.value.includes(repo.id)
</script>
<template>
<div
class="repo-list-page"
v-infinite-scroll="[loadMore, { canLoadMore: () => canLoadMore }]"
>
<!-- Top Nav -->
<nav class="topnav">
<div class="topnav-left">
<router-link :to="{ name: 'Home' }" class="brand">
<img
src="/favicon.png"
alt="Remanso"
class="brand-mark"
width="28"
height="28"
/>
<span class="brand-word">Remanso</span>
</router-link>
</div>
<div class="topnav-right">
<router-link :to="{ name: 'Home' }" class="navlink">Home</router-link>
<router-link :to="{ name: 'PublicNoteListView' }" class="navlink"
>Public&nbsp;notes</router-link
>
<router-link
:to="{
name: 'FluxNoteView',
params: { user: 'remanso-space', repo: 'getting-started' }
}"
class="navlink"
>Getting&nbsp;started</router-link
>
<button
v-if="isAnyUserLoggedIn"
class="profile-chip"
onclick="profile_modal.showModal()"
>
<img
v-if="isATProtoLoggedIn && avatarUrl"
:src="avatarUrl"
class="profile-avatar-small"
alt="Profile"
/>
<span v-else class="profile-avatar-small profile-avatar-initial">
{{ displayInitial }}
</span>
<span class="profile-name">{{ displayUsername }}</span>
</button>
<button
v-else
class="hw-btn hw-btn-ghost"
onclick="profile_modal.showModal()"
>
Sign in
</button>
</div>
</nav>
<!-- Main -->
<main>
<!-- Hero -->
<section class="rl-hero">
<div class="rl-hero-inner">
<div class="eyebrow mono">manage your repos</div>
<h1 class="li-h1">Your repositories</h1>
<p class="li-sub">
<span class="mono"
>{{ favoriteRepos.length }} starred
<span class="li-dot">·</span>
{{ otherRepos.length }} in your account</span
>
<span class="li-dot">·</span>
<router-link :to="{ name: 'Home' }" class="li-sub-link"
> back to home</router-link
>
</p>
</div>
</section>
<!-- Filter -->
<section class="rl-filter-wrap" v-if="isReady && !hasCredentialError">
<label class="rl-filter">
<span class="rl-filter-prefix mono">filter</span>
<input
v-model="filterQuery"
type="text"
class="rl-filter-input"
placeholder="repo name…"
spellcheck="false"
autocapitalize="off"
/>
<button
v-if="filterQuery"
type="button"
class="rl-clear-btn"
aria-label="Clear filter"
@click="filterQuery = ''"
>
×
</button>
</label>
</section>
<!-- Loading state -->
<section class="rl-section" v-if="!isReady">
<div class="section-label mono">§ loading repos</div>
<div class="favs-grid">
<div
v-for="i in 6"
:key="i"
class="repo-tile repo-tile--skel"
aria-hidden="true"
>
<span class="skel skel-handle" />
<span class="skel skel-title" />
</div>
</div>
</section>
<!-- Credential-error state -->
<section class="rl-section" v-else-if="hasCredentialError">
<div class="rl-card">
<div class="eyebrow mono">sign in needed</div>
<h2 class="rl-card-title">GitHub credentials expired</h2>
<p class="rl-card-body">
Your GitHub session is no longer valid. Sign back in to load and
manage your repositories.
</p>
<sign-in-github />
</div>
</section>
<!-- Repos -->
<template v-else>
<!-- Favorites -->
<section class="rl-section">
<div class="section-label mono">§ starred</div>
<p
v-if="filteredFavoriteRepos.length === 0 && filterQuery"
class="rl-empty"
>
nothing matches &ldquo;{{ filterQuery }}&rdquo; in your starred
repos.
</p>
<p v-else-if="favoriteRepos.length === 0" class="rl-empty">
no starred repos yet tap a below to pin one here.
</p>
<div v-else class="favs-grid">
<router-link
v-for="repo in filteredFavoriteRepos"
:key="repo.id"
class="repo-tile"
:style="tileStyle(`${repo.name}-${username}`)"
:to="{
name: 'FluxNoteView',
params: { user: username, repo: repo.name }
}"
>
<div class="tile-top">
<span class="tile-owner mono">{{ username }}/</span>
<span class="tile-name">{{ repo.name }}</span>
</div>
<button
type="button"
class="tile-star tile-star--on"
aria-pressed="true"
aria-label="Unstar"
@click.stop.prevent="toggleCheckbox(repo)"
>
</button>
<span
v-if="repo.isPrivate"
class="tile-lock"
aria-label="Private"
title="Private repo"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path
d="M5 13a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"
/>
<path d="M11 16a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M8 11v-4a4 4 0 1 1 8 0v4" />
</svg>
</span>
</router-link>
</div>
</section>
<!-- Others AZ -->
<section class="rl-section rl-others">
<div class="section-label mono">§ all repos</div>
<p
v-if="groupedOtherRepos.length === 0 && filterQuery"
class="rl-empty"
>
nothing matches &ldquo;{{ filterQuery }}&rdquo;.
</p>
<p v-else-if="otherRepos.length === 0" class="rl-empty">
no other repos in your account.
</p>
<div v-else class="rl-letter-stack">
<div
v-for="group in groupedOtherRepos"
:key="group.letter"
class="rl-letter-group"
>
<div class="rl-letter mono">{{ group.letter }}</div>
<ul class="rl-rows">
<li v-for="repo in group.repos" :key="repo.id" class="rl-row">
<button
type="button"
class="row-star"
:class="{ 'row-star--on': isStarred(repo) }"
:aria-pressed="isStarred(repo)"
:aria-label="isStarred(repo) ? 'Unstar' : 'Star'"
@click="toggleCheckbox(repo)"
>
{{ isStarred(repo) ? "★" : "☆" }}
</button>
<router-link
class="rl-row-name"
:to="{
name: 'FluxNoteView',
params: { user: username, repo: repo.name }
}"
>
{{ repo.name }}
</router-link>
<span
v-if="repo.isPrivate"
class="row-lock"
aria-label="Private"
title="Private repo"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path
d="M5 13a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"
/>
<path d="M11 16a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M8 11v-4a4 4 0 1 1 8 0v4" />
</svg>
</span>
</li>
</ul>
</div>
</div>
<p v-if="canLoadMore" class="rl-loading-more mono">loading</p>
</section>
</template>
</main>
<!-- Profile modal -->
<dialog id="profile_modal" class="modal hw-modal">
<div class="modal-box hw-modal-box">
<div class="hw-modal-head">
<h3>Profile</h3>
<form method="dialog">
<button class="hw-modal-x" aria-label="close">×</button>
</form>
</div>
<div class="hw-modal-section">
<div class="hw-ms-label mono">Bluesky / ATProto</div>
<sign-in-atproto :with-sign-out="true" />
</div>
<hr class="hw-rule" />
<div class="hw-modal-section">
<div class="hw-ms-label mono">GitHub</div>
<sign-in-github />
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button></button>
</form>
</dialog>
</div>
</template>
<style lang="scss" scoped>
/* ── Tokens ────────────────────────────────────────────────── */
.repo-list-page {
--hw-surface: var(--color-base-100);
--hw-paper: var(--color-base-200);
--hw-paper-warm: var(--color-base-300);
--hw-ink: var(--color-base-content);
--hw-ink-soft: color-mix(
in oklch,
var(--color-base-content) 65%,
var(--color-base-200)
);
--hw-ink-faint: color-mix(
in oklch,
var(--color-base-content) 38%,
var(--color-base-200)
);
--hw-rule: color-mix(
in oklch,
var(--color-base-content) 12%,
var(--color-base-200)
);
--hw-pink: #e36598;
--hw-pink-deep: color-mix(in oklch, #e36598 75%, var(--color-base-content));
--hw-pink-wash: color-mix(in oklch, #e36598 12%, var(--color-base-200));
--hw-pink-wash-2: color-mix(in oklch, #e36598 22%, var(--color-base-200));
--hw-serif: "Libertinus Serif", "Iowan Old Style", "Palatino", serif;
--hw-mono: "Courier Prime", "IBM Plex Mono", ui-monospace, monospace;
font-family: var(--hw-serif);
background: var(--hw-paper);
color: var(--hw-ink);
font-size: 18px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
min-height: 100dvh;
flex: 1;
display: flex;
flex-direction: column;
position: relative;
overflow-y: auto;
}
.repo-list-page::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
radial-gradient(
circle at 20% 10%,
rgba(227, 101, 152, 0.04),
transparent 40%
),
radial-gradient(
circle at 90% 80%,
rgba(107, 142, 78, 0.035),
transparent 45%
);
}
main {
position: relative;
flex: 1;
}
/* ── Shared buttons ────────────────────────────────────────── */
.hw-btn {
font-family: var(--hw-serif);
font-size: 16px;
padding: 0.55rem 1rem;
border-radius: 2px;
border: 1px solid var(--hw-ink);
background: transparent;
color: var(--hw-ink);
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
transform 0.1s;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
&:hover {
background: var(--hw-ink);
color: var(--hw-paper);
}
&:active {
transform: translateY(1px);
}
}
.hw-btn-ghost {
border-color: var(--hw-rule);
color: var(--hw-ink-soft);
&:hover {
background: var(--hw-pink-wash);
border-color: var(--hw-pink);
color: var(--hw-pink-deep);
}
}
.mono {
font-family: var(--hw-mono);
}
.hw-rule {
border: 0;
border-top: 1px solid var(--hw-rule);
margin: 1.25rem 0;
}
/* ── Top nav ───────────────────────────────────────────────── */
.topnav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 2rem;
border-bottom: 1px solid var(--hw-rule);
background: var(--hw-paper);
position: sticky;
top: 0;
z-index: 20;
backdrop-filter: saturate(1.1) blur(6px);
}
.brand {
display: flex;
align-items: center;
gap: 0.55rem;
text-decoration: none;
color: var(--hw-ink);
}
.brand-mark {
display: block;
object-fit: contain;
view-transition-name: remanso-logo;
}
.brand-word {
font-size: 1.25rem;
letter-spacing: 0.02em;
font-weight: 600;
}
.topnav-right {
display: flex;
align-items: center;
gap: 1.25rem;
}
.navlink {
text-decoration: none;
color: var(--hw-ink-soft);
font-size: 0.95rem;
transition: color 0.15s;
&:hover {
color: var(--hw-pink-deep);
}
}
.profile-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem 0.25rem 0.25rem;
border-radius: 999px;
border: 1px solid var(--hw-rule);
background: transparent;
cursor: pointer;
font-family: var(--hw-serif);
color: var(--hw-ink);
transition:
border-color 0.15s,
background 0.15s;
&:hover {
border-color: var(--hw-pink);
background: var(--hw-pink-wash);
}
}
.profile-avatar-small {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
display: block;
}
.profile-avatar-initial {
background: var(--hw-pink);
color: white;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--hw-serif);
font-weight: 600;
font-size: 0.9rem;
}
.profile-name {
font-size: 0.95rem;
}
/* ── Hero ──────────────────────────────────────────────────── */
.rl-hero {
padding: 3rem 2rem 1rem;
}
.rl-hero-inner {
max-width: 1180px;
margin: 0 auto;
}
.eyebrow {
font-size: 0.78rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--hw-pink-deep);
margin-bottom: 1rem;
}
.li-h1 {
font-family: var(--hw-serif);
font-size: clamp(2rem, 4vw, 3.2rem);
font-weight: 600;
margin: 0.25rem 0 0.75rem;
letter-spacing: -0.005em;
}
.li-sub {
margin: 0;
color: var(--hw-ink-soft);
font-size: 0.95rem;
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
align-items: center;
}
.li-dot {
color: var(--hw-ink-faint);
}
.li-sub-link {
color: var(--hw-ink-soft);
text-decoration: underline;
text-decoration-color: var(--hw-rule);
&:hover {
color: var(--hw-pink-deep);
}
}
/* ── Filter ────────────────────────────────────────────────── */
.rl-filter-wrap {
max-width: 1180px;
margin: 0 auto;
padding: 0.5rem 2rem 0;
}
.rl-filter {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.45rem 0.35rem 0.95rem;
background: var(--hw-surface);
border: 1px solid var(--hw-rule);
border-radius: 999px;
box-shadow:
0 1px 0 rgba(0, 0, 0, 0.02),
0 6px 18px -10px rgba(201, 74, 125, 0.15);
cursor: text;
transition:
border-color 0.15s,
box-shadow 0.15s;
min-width: min(360px, 100%);
&:focus-within {
border-color: var(--hw-pink);
box-shadow:
0 1px 0 rgba(0, 0, 0, 0.02),
0 10px 28px -10px rgba(201, 74, 125, 0.25);
}
}
.rl-filter-prefix {
color: var(--hw-ink-faint);
font-size: 0.78rem;
letter-spacing: 0.12em;
text-transform: uppercase;
user-select: none;
}
.rl-filter-input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font-family: var(--hw-mono);
font-size: 0.95rem;
color: var(--hw-ink);
padding: 0.15rem 0;
&::placeholder {
color: var(--hw-ink-faint);
font-style: italic;
}
}
.rl-clear-btn {
flex-shrink: 0;
width: 1.6rem;
height: 1.6rem;
border: 0;
border-radius: 999px;
background: var(--hw-pink-wash);
color: var(--hw-pink-deep);
font-size: 1.05rem;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
background 0.15s,
color 0.15s;
&:hover {
background: var(--hw-pink);
color: white;
}
}
/* ── Sections ──────────────────────────────────────────────── */
.rl-section {
max-width: 1180px;
margin: 0 auto;
padding: 0.5rem 2rem 2rem;
}
.section-label {
font-size: 0.72rem;
letter-spacing: 0.18em;
color: var(--hw-ink-faint);
text-transform: lowercase;
margin: 1.75rem 0 1rem;
}
.rl-empty {
margin: 0.5rem 0 1rem;
color: var(--hw-ink-faint);
font-style: italic;
font-size: 0.95rem;
}
/* ── Favorites grid ────────────────────────────────────────── */
.favs-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.repo-tile {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 1rem 1.1rem;
border-radius: 6px;
border: 1px solid rgba(0, 0, 0, 0.04);
transition:
transform 0.15s ease,
box-shadow 0.2s ease;
min-height: 110px;
position: relative;
overflow: hidden;
text-decoration: none;
color: inherit;
&:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px -10px rgba(0, 0, 0, 0.15);
}
}
.tile-top {
display: flex;
flex-direction: column;
gap: 0.1rem;
padding-right: 2rem;
}
.tile-owner {
font-size: 0.75rem;
opacity: 0.65;
}
.tile-name {
font-size: 1.2rem;
font-weight: 600;
line-height: 1.15;
word-break: break-word;
}
.tile-star {
position: absolute;
top: 0.55rem;
right: 0.6rem;
width: 1.9rem;
height: 1.9rem;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.55);
color: var(--hw-pink-deep);
font-size: 1.05rem;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
transition:
background 0.15s,
transform 0.1s;
&:hover {
background: white;
transform: scale(1.08);
}
&:active {
transform: scale(0.95);
}
}
.tile-lock {
position: absolute;
bottom: 0.55rem;
right: 0.7rem;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0.75;
user-select: none;
}
/* ── Skeleton ──────────────────────────────────────────────── */
.repo-tile--skel {
background: var(--hw-surface);
border-color: var(--hw-rule);
pointer-events: none;
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 1rem 1.1rem;
justify-content: flex-start;
}
.skel {
display: block;
border-radius: 3px;
background: linear-gradient(
90deg,
var(--hw-pink-wash) 0%,
var(--hw-pink-wash-2) 50%,
var(--hw-pink-wash) 100%
);
background-size: 200% 100%;
animation: skel-shimmer 1.4s ease-in-out infinite;
}
.skel-handle {
height: 0.6rem;
width: 45%;
}
.skel-title {
height: 1.1rem;
width: 75%;
}
@keyframes skel-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* ── Credential-error card ─────────────────────────────────── */
.rl-card {
max-width: 560px;
padding: 2rem 2rem 1.75rem;
background: var(--hw-surface);
border: 1px solid var(--hw-rule);
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.rl-card-title {
font-family: var(--hw-serif);
font-size: 1.6rem;
font-weight: 600;
margin: 0;
}
.rl-card-body {
margin: 0 0 0.5rem;
color: var(--hw-ink-soft);
font-size: 1rem;
line-height: 1.55;
}
/* ── Others (AZ) ──────────────────────────────────────────── */
.rl-letter-stack {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.rl-letter-group {
display: grid;
grid-template-columns: 2.5rem 1fr;
gap: 0.5rem 1rem;
align-items: start;
}
.rl-letter {
font-size: 1.4rem;
color: var(--hw-pink-deep);
letter-spacing: 0.05em;
font-weight: 600;
padding-top: 0.45rem;
text-align: right;
border-right: 1px dashed var(--hw-rule);
padding-right: 0.75rem;
position: sticky;
top: calc(72px + 0.5rem);
}
.rl-rows {
list-style: none;
padding: 0;
margin: 0;
}
.rl-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.55rem 0.75rem;
border-bottom: 1px dashed var(--hw-rule);
border-radius: 4px;
transition: background 0.15s ease;
&:last-child {
border-bottom: 0;
}
&:hover {
background: var(--hw-pink-wash);
}
}
.row-star {
flex-shrink: 0;
width: 1.8rem;
height: 1.8rem;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--hw-ink-faint);
font-size: 1.05rem;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
color 0.15s,
background 0.15s,
transform 0.1s;
&:hover {
color: var(--hw-pink-deep);
background: var(--hw-pink-wash-2);
}
&:active {
transform: scale(0.92);
}
&.row-star--on {
color: var(--hw-pink-deep);
}
}
.rl-row-name {
flex: 1;
min-width: 0;
color: var(--hw-ink);
text-decoration: none;
font-size: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
color: var(--hw-pink-deep);
}
}
.row-lock {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--hw-ink-faint);
user-select: none;
}
.rl-loading-more {
margin: 1.5rem 0 0;
text-align: center;
font-size: 0.78rem;
letter-spacing: 0.18em;
color: var(--hw-ink-faint);
text-transform: lowercase;
}
/* ── Profile modal ─────────────────────────────────────────── */
.hw-modal .hw-modal-box {
background: var(--hw-paper);
border: 1px solid var(--hw-rule);
border-radius: 6px;
padding: 1.5rem;
box-shadow: 0 30px 60px -20px rgba(0, 0, 0, 0.3);
color: var(--hw-ink);
font-family: var(--hw-serif);
}
.hw-modal-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
h3 {
font-family: var(--hw-serif);
font-size: 1.3rem;
margin: 0;
font-weight: 600;
}
}
.hw-modal-x {
border: 0;
background: transparent;
font-size: 1.5rem;
cursor: pointer;
color: var(--hw-ink-soft);
line-height: 1;
padding: 0.25rem 0.5rem;
&:hover {
color: var(--hw-pink-deep);
}
}
.hw-modal-section {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
margin-bottom: 0.5rem;
}
.hw-ms-label {
display: block;
width: 100%;
font-size: 0.72rem;
letter-spacing: 0.14em;
color: var(--hw-ink-faint);
margin-bottom: 0.25rem;
}
/* ── Responsive ────────────────────────────────────────────── */
@media (max-width: 900px) {
.favs-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.topnav {
padding: 1rem 1.25rem;
}
.navlink {
display: none;
}
.rl-hero,
.rl-filter-wrap,
.rl-section {
padding-left: 1.25rem;
padding-right: 1.25rem;
}
.rl-letter-group {
grid-template-columns: 2rem 1fr;
gap: 0.4rem 0.75rem;
}
.rl-letter {
font-size: 1.15rem;
padding-right: 0.4rem;
}
}
@media (max-width: 520px) {
.favs-grid {
grid-template-columns: 1fr;
}
}
</style>