feat(frontend): replace emoji with inline Material Symbols icon system

Add a self-contained icon system: vendored Material Symbols registry, an
<Icon> component that references a single <symbol> sprite via <use>, plus
IconButton/IconSprite. Replace every emoji/Unicode glyph (logo, theme
switcher, cover placeholder, like/comment counts, lightbox controls, back
links) and add leading icons to key controls. Decorative icons are
aria-hidden; icon-only buttons get labels; theme toggles expose
aria-pressed; counters get accessible names.

Recorded in openspec change material-icons-ui (all tasks complete).
This commit is contained in:
iu
2026-10-10 13:19:29 +03:00
parent 84ce4d1930
commit 997ec5e6c1
21 changed files with 582 additions and 69 deletions
+14 -4
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { api } from '$lib/api';
import Icon from './Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
import type { Comment } from '$lib/types';
@@ -61,9 +62,14 @@
{#if auth.user?.id === comment.user_id}
<button
type="button"
class="text-text-subtle hover:text-danger text-xs"
onclick={() => remove(comment.id)}>удалить</button
class="text-text-subtle hover:text-danger inline-flex shrink-0 items-center gap-0.5 text-xs"
aria-label="Удалить комментарий"
title="Удалить комментарий"
onclick={() => remove(comment.id)}
>
<Icon name="delete" class="size-3.5" />
удалить
</button>
{/if}
</div>
{/each}
@@ -75,9 +81,13 @@
placeholder="Комментарий…"
class="border-border bg-bg text-text flex-1 rounded-lg border px-3 py-1.5 text-sm"
/>
<button class="bg-primary text-primary-fg rounded-lg px-3 py-1.5 text-sm" type="submit"
>Отправить</button
<button
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm"
type="submit"
>
<Icon name="send" class="size-4" />
Отправить
</button>
</form>
{/if}
{#if error}
+27
View File
@@ -0,0 +1,27 @@
<script lang="ts">
import { icons, type IconName } from '$lib/icons/registry';
interface Props {
name: IconName;
label?: string;
class?: string;
}
let { name, label, class: className = 'size-5' }: Props = $props();
const known = $derived(name in icons);
</script>
{#if known}
<svg
viewBox="0 0 24 24"
fill="currentColor"
class={className}
role={label ? 'img' : undefined}
aria-label={label}
aria-hidden={label ? undefined : 'true'}
focusable="false"
>
<use href="#i-{name}" />
</svg>
{/if}
@@ -0,0 +1,40 @@
<script lang="ts">
import Icon from './Icon.svelte';
import type { IconName } from '$lib/icons/registry';
interface Props {
name: IconName;
label: string;
active?: boolean;
pressed?: boolean;
disabled?: boolean;
class?: string;
onclick?: (event: MouseEvent) => void;
}
let {
name,
label,
active = false,
pressed,
disabled = false,
class: className = '',
onclick
}: Props = $props();
const state = $derived(
active ? 'bg-primary text-primary-fg' : 'text-text-muted hover:bg-bg-muted hover:text-text'
);
</script>
<button
type="button"
{disabled}
aria-label={label}
title={label}
aria-pressed={pressed}
{onclick}
class="inline-flex items-center justify-center rounded-md transition-colors disabled:opacity-50 {state} {className}"
>
<Icon {name} />
</button>
@@ -0,0 +1,13 @@
<script lang="ts">
import { icons } from '$lib/icons/registry';
</script>
<!--
Единственный источник path-данных. Не использовать display:none — некоторые браузеры
тогда не рисуют <use>; прячем через нулевой размер и overflow.
-->
<svg width="0" height="0" class="absolute h-0 w-0 overflow-hidden" aria-hidden="true" focusable="false">
{#each Object.entries(icons) as [key, def] (key)}
<symbol id="i-{key}" viewBox="0 0 24 24">{@html def.path}</symbol>
{/each}
</svg>
+28 -7
View File
@@ -2,6 +2,7 @@
import { api } from '$lib/api';
import type { Photo } from '$lib/types';
import Comments from './Comments.svelte';
import Icon from './Icon.svelte';
interface Props {
photos: Photo[];
@@ -55,9 +56,11 @@
class="bg-bg/95 fixed inset-0 z-50 flex flex-col"
role="dialog"
aria-modal="true"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
onkeydown={onKey}
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
>
@@ -67,23 +70,37 @@
{#if fresh?.download_url}
<a
href={fresh.download_url}
class="border-border text-text rounded-lg border px-3 py-1.5 text-sm"
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm"
target="_blank"
rel="noreferrer">Скачать</a
rel="noreferrer"
>
<Icon name="download" class="size-4" />
Скачать
</a>
{/if}
<button
type="button"
onclick={onClose}
class="border-border text-text rounded-lg border px-3 py-1.5 text-sm">✕</button
aria-label="Закрыть"
title="Закрыть"
class="border-border text-text inline-flex items-center justify-center rounded-lg border px-2 py-1.5"
>
<Icon name="close" />
</button>
</div>
</div>
<div class="flex flex-1 items-center justify-center overflow-hidden px-2 pb-2">
<button type="button" class="text-text-muted px-2 text-3xl" onclick={prev} disabled={index === 0}
>‹</button
<button
type="button"
class="text-text-muted hover:text-text inline-flex items-center px-2"
aria-label="Предыдущее фото"
title="Предыдущее фото"
onclick={prev}
disabled={index === 0}
>
<Icon name="chevron_left" class="size-8" />
</button>
{#if current}
<img
src={fresh?.preview_url ?? fresh?.original_url ?? current.preview_url ?? current.original_url}
@@ -93,10 +110,14 @@
{/if}
<button
type="button"
class="text-text-muted px-2 text-3xl"
class="text-text-muted hover:text-text inline-flex items-center px-2"
aria-label="Следующее фото"
title="Следующее фото"
onclick={next}
disabled={index === photos.length - 1}>›</button
disabled={index === photos.length - 1}
>
<Icon name="chevron_right" class="size-8" />
</button>
</div>
{#if current}
+16 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import Icon from './Icon.svelte';
import type { Photo } from '$lib/types';
interface Props {
@@ -34,9 +35,22 @@
</span>
{/if}
<span
class="bg-bg/70 text-text absolute right-1 bottom-1 rounded px-1.5 py-0.5 text-xs"
class="bg-bg/70 text-text absolute right-1 bottom-1 flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs"
>
♥ {photo.likes_count} · 💬 {photo.comments_count}
<span class="inline-flex items-center gap-0.5" aria-label="{photo.likes_count} лайков">
<Icon
name={photo.liked_by_me ? 'favorite' : 'favorite_outline'}
class="size-3.5"
/>
{photo.likes_count}
</span>
<span
class="inline-flex items-center gap-0.5"
aria-label="{photo.comments_count} комментариев"
>
<Icon name="mode_comment" class="size-3.5" />
{photo.comments_count}
</span>
</span>
</button>
</li>
@@ -1,27 +1,29 @@
<script lang="ts">
import IconButton from './IconButton.svelte';
import type { IconName } from '$lib/icons/registry';
import { theme, type Theme } from '$lib/stores/theme.svelte';
const options: { value: Theme; label: string; icon: string }[] = [
{ value: 'light', label: 'Light', icon: '☀️' },
{ value: 'dark', label: 'Dark', icon: '🌙' },
{ value: 'neutral', label: 'Neutral', icon: '🌾' },
{ value: 'system', label: 'System', icon: '💻' }
const options: { value: Theme; label: string; icon: IconName }[] = [
{ value: 'light', label: 'Light', icon: 'light_mode' },
{ value: 'dark', label: 'Dark', icon: 'dark_mode' },
{ value: 'neutral', label: 'Neutral', icon: 'palette' },
{ value: 'system', label: 'System', icon: 'brightness_auto' }
];
</script>
<div class="border-border bg-bg-elevated inline-flex gap-1 rounded-lg border p-1" role="group" aria-label="Theme">
<div
class="border-border bg-bg-elevated inline-flex gap-1 rounded-lg border p-1"
role="group"
aria-label="Theme"
>
{#each options as option (option.value)}
<button
type="button"
title={option.label}
aria-label={option.label}
aria-pressed={theme.current === option.value}
<IconButton
name={option.icon}
label={option.label}
active={theme.current === option.value}
pressed={theme.current === option.value}
class="px-2 py-1 text-sm"
onclick={() => theme.set(option.value)}
class="rounded-md px-2 py-1 text-sm transition-colors {theme.current === option.value
? 'bg-primary text-primary-fg'
: 'text-text-muted hover:bg-bg-muted'}"
>
{option.icon}
</button>
/>
{/each}
</div>
+4 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { auth } from '$lib/stores/auth.svelte';
import Icon from './Icon.svelte';
import type { Photo } from '$lib/types';
interface Props {
@@ -96,13 +97,15 @@
class="hidden"
onchange={onChange}
/>
<Icon name="cloud_upload" class="text-text-subtle mx-auto mb-3 size-10" />
<p class="text-text mb-3 text-sm">Перетащите фото сюда или</p>
<button
type="button"
onclick={pick}
disabled={busy}
class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
>
<Icon name="add_photo_alternate" class="size-4" />
{busy ? 'Загрузка…' : 'Выбрать файлы'}
</button>