Merge pull request 'feat(frontend): replace emoji with inline Material Symbols icon system' (#1) from feature/material-icons-ui into develop

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
iu
2026-10-10 10:32:40 +00:00
22 changed files with 625 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">
{#each options as option (option.value)}
<button
type="button"
title={option.label}
aria-label={option.label}
aria-pressed={theme.current === option.value}
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'}"
<div
class="border-border bg-bg-elevated inline-flex gap-1 rounded-lg border p-1"
role="group"
aria-label="Theme"
>
{option.icon}
</button>
{#each options as option (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)}
/>
{/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>
+46
View File
@@ -0,0 +1,46 @@
// Вендоренные иконки Google Material Symbols (стиль filled), источник — Iconify
// (prefix: material-symbols). Лицензия Apache-2.0. Не редактировать вручную без
// необходимости; при добавлении иконки добавьте запись сюда и используйте <Icon name>.
//
// Generated file: см. openspec/changes/material-icons-ui (design.md).
export interface IconDef {
/** Внутренняя разметка <symbol> (обычно один <path>). */
path: string;
}
export const icons = {
photo_camera: { path: '<path fill="currentColor" d="M12 17.5q1.875 0 3.188-1.312T16.5 13t-1.312-3.187T12 8.5T8.813 9.813T7.5 13t1.313 3.188T12 17.5m0-2q-1.05 0-1.775-.725T9.5 13t.725-1.775T12 10.5t1.775.725T14.5 13t-.725 1.775T12 15.5M4 21q-.825 0-1.412-.587T2 19V7q0-.825.588-1.412T4 5h3.15L9 3h6l1.85 2H20q.825 0 1.413.588T22 7v12q0 .825-.587 1.413T20 21z"/>' },
login: { path: '<path fill="currentColor" d="M12 21v-2h7V5h-7V3h7q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm-2-4l-1.375-1.45l2.55-2.55H3v-2h8.175l-2.55-2.55L10 7l5 5z"/>' },
logout: { path: '<path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h7v2H5v14h7v2zm11-4l-1.375-1.45l2.55-2.55H9v-2h8.175l-2.55-2.55L16 7l5 5z"/>' },
light_mode: { path: '<path fill="currentColor" d="M8.463 15.538Q7 14.075 7 12t1.463-3.537T12 7t3.538 1.463T17 12t-1.463 3.538T12 17t-3.537-1.463M5 13H1v-2h4zm18 0h-4v-2h4zM11 5V1h2v4zm0 18v-4h2v4zM6.4 7.75L3.875 5.325L5.3 3.85l2.4 2.5zm12.3 12.4l-2.425-2.525L17.6 16.25l2.525 2.425zM16.25 6.4l2.425-2.525L20.15 5.3l-2.5 2.4zM3.85 18.7l2.525-2.425L7.75 17.6l-2.425 2.525z"/>' },
dark_mode: { path: '<path fill="currentColor" d="M12 21q-3.75 0-6.375-2.625T3 12t2.625-6.375T12 3q.35 0 .688.025t.662.075q-1.025.725-1.638 1.888T11.1 7.5q0 2.25 1.575 3.825T16.5 12.9q1.375 0 2.525-.613T20.9 10.65q.05.325.075.662T21 12q0 3.75-2.625 6.375T12 21"/>' },
palette: { path: '<path fill="currentColor" d="M12 22q-2.05 0-3.875-.788t-3.187-2.15t-2.15-3.187T2 12q0-2.075.813-3.9t2.2-3.175T8.25 2.788T12.2 2q2 0 3.775.688t3.113 1.9t2.125 2.875T22 11.05q0 2.875-1.75 4.413T16 17h-1.85q-.225 0-.312.125t-.088.275q0 .3.375.863t.375 1.287q0 1.25-.687 1.85T12 22m-4.425-9.425Q8 12.15 8 11.5t-.425-1.075T6.5 10t-1.075.425T5 11.5t.425 1.075T6.5 13t1.075-.425m3-4Q11 8.15 11 7.5t-.425-1.075T9.5 6t-1.075.425T8 7.5t.425 1.075T9.5 9t1.075-.425m5 0Q16 8.15 16 7.5t-.425-1.075T14.5 6t-1.075.425T13 7.5t.425 1.075T14.5 9t1.075-.425m3 4Q19 12.15 19 11.5t-.425-1.075T17.5 10t-1.075.425T16 11.5t.425 1.075T17.5 13t1.075-.425"/>' },
brightness_auto: { path: '<path fill="currentColor" d="M7.8 16h1.6l.8-2.3h3.65l.8 2.3h1.55l-3.4-9h-1.6zm2.85-3.6l1.3-3.75h.1l1.3 3.75zM12 23.3L8.65 20H4v-4.65L.7 12L4 8.65V4h4.65L12 .7L15.35 4H20v4.65L23.3 12L20 15.35V20h-4.65z"/>' },
image: { path: '<path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm1-4h12l-3.75-5l-3 4L9 13z"/>' },
add: { path: '<path fill="currentColor" d="M11 13H5v-2h6V5h2v6h6v2h-6v6h-2z"/>' },
photo_library: { path: '<path fill="currentColor" d="M9 14h10l-3.45-4.5l-2.3 3l-1.55-2zm-1 4q-.825 0-1.412-.587T6 16V4q0-.825.588-1.412T8 2h12q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18zm-4 4q-.825 0-1.412-.587T2 20V6h2v14h14v2z"/>' },
group: { path: '<path fill="currentColor" d="M1 20v-2.8q0-.85.438-1.562T2.6 14.55q1.55-.775 3.15-1.162T9 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T17 17.2V20zm18 0v-3q0-1.1-.612-2.113T16.65 13.15q1.275.15 2.4.513t2.1.887q.9.5 1.375 1.112T23 17v3zM6.175 10.825Q5 9.65 5 8t1.175-2.825T9 4t2.825 1.175T13 8t-1.175 2.825T9 12t-2.825-1.175m11.65 0Q16.65 12 15 12q-.275 0-.7-.062t-.7-.138q.675-.8 1.038-1.775T15 8t-.362-2.025T13.6 4.2q.35-.125.7-.163T15 4q1.65 0 2.825 1.175T19 8t-1.175 2.825"/>' },
settings: { path: '<path fill="currentColor" d="m9.25 22l-.4-3.2q-.325-.125-.612-.3t-.563-.375L4.7 19.375l-2.75-4.75l2.575-1.95Q4.5 12.5 4.5 12.338v-.675q0-.163.025-.338L1.95 9.375l2.75-4.75l2.975 1.25q.275-.2.575-.375t.6-.3l.4-3.2h5.5l.4 3.2q.325.125.613.3t.562.375l2.975-1.25l2.75 4.75l-2.575 1.95q.025.175.025.338v.674q0 .163-.05.338l2.575 1.95l-2.75 4.75l-2.95-1.25q-.275.2-.575.375t-.6.3l-.4 3.2zm2.8-6.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5"/>' },
cloud_upload: { path: '<path fill="currentColor" d="M11 20H6.5q-2.275 0-3.887-1.575T1 14.575q0-1.95 1.175-3.475T5.25 9.15q.625-2.3 2.5-3.725T12 4q2.925 0 4.963 2.038T19 11q1.725.2 2.863 1.488T23 15.5q0 1.875-1.312 3.188T18.5 20H13v-7.15l1.6 1.55L16 13l-4-4l-4 4l1.4 1.4l1.6-1.55z"/>' },
add_photo_alternate: { path: '<path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h9q-.5.65-.75 1.425T13 6q0 2.075 1.463 3.538T18 11q.8 0 1.575-.25T21 10v9q0 .825-.587 1.413T19 21zm1-4h12l-3.75-5l-3 4L9 13zm11-8V7h-2V5h2V3h2v2h2v2h-2v2z"/>' },
expand_more: { path: '<path fill="currentColor" d="m12 15.4l-6-6L7.4 8l4.6 4.6L16.6 8L18 9.4z"/>' },
favorite: { path: '<path fill="currentColor" d="m12 21l-1.45-1.3q-2.525-2.275-4.175-3.925T3.75 12.812T2.388 10.4T2 8.15Q2 5.8 3.575 4.225T7.5 2.65q1.3 0 2.475.55T12 4.75q.85-1 2.025-1.55t2.475-.55q2.35 0 3.925 1.575T22 8.15q0 1.15-.387 2.25t-1.363 2.412t-2.625 2.963T13.45 19.7z"/>' },
favorite_outline: { path: '<path fill="currentColor" d="m12 21l-1.45-1.3q-2.525-2.275-4.175-3.925T3.75 12.812T2.388 10.4T2 8.15Q2 5.8 3.575 4.225T7.5 2.65q1.3 0 2.475.55T12 4.75q.85-1 2.025-1.55t2.475-.55q2.35 0 3.925 1.575T22 8.15q0 1.15-.387 2.25t-1.363 2.412t-2.625 2.963T13.45 19.7zm0-2.7q2.4-2.15 3.95-3.687t2.45-2.675t1.25-2.026T20 8.15q0-1.5-1-2.5t-2.5-1q-1.175 0-2.175.662T12.95 7h-1.9q-.375-1.025-1.375-1.687T7.5 4.65q-1.5 0-2.5 1t-1 2.5q0 .875.35 1.763t1.25 2.025t2.45 2.675T12 18.3m0-6.825"/>' },
mode_comment: { path: '<path fill="currentColor" d="M4 18q-.825 0-1.412-.587T2 16V4q0-.825.588-1.412T4 2h16q.825 0 1.413.588T22 4v18l-4-4z"/>' },
send: { path: '<path fill="currentColor" d="M3 20v-6l8-2l-8-2V4l19 8z"/>' },
delete: { path: '<path fill="currentColor" d="M7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21zm2-4h2V8H9zm4 0h2V8h-2z"/>' },
close: { path: '<path fill="currentColor" d="M6.4 19L5 17.6l5.6-5.6L5 6.4L6.4 5l5.6 5.6L17.6 5L19 6.4L13.4 12l5.6 5.6l-1.4 1.4l-5.6-5.6z"/>' },
chevron_left: { path: '<path fill="currentColor" d="m14 18l-6-6l6-6l1.4 1.4l-4.6 4.6l4.6 4.6z"/>' },
chevron_right: { path: '<path fill="currentColor" d="M12.6 12L8 7.4L9.4 6l6 6l-6 6L8 16.6z"/>' },
download: { path: '<path fill="currentColor" d="m12 16l-5-5l1.4-1.45l2.6 2.6V4h2v8.15l2.6-2.6L17 11zm-6 4q-.825 0-1.412-.587T4 18v-3h2v3h12v-3h2v3q0 .825-.587 1.413T18 20z"/>' },
arrow_back: { path: '<path fill="currentColor" d="m7.825 13l5.6 5.6L12 20l-8-8l8-8l1.425 1.4l-5.6 5.6H20v2z"/>' },
save: { path: '<path fill="currentColor" d="M21 7v12q0 .825-.587 1.413T19 21H5q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h12zm-6.875 10.125Q15 16.25 15 15t-.875-2.125T12 12t-2.125.875T9 15t.875 2.125T12 18t2.125-.875M6 10h9V6H6z"/>' },
add_link: { path: '<path fill="currentColor" d="M17 20v-3h-3v-2h3v-3h2v3h3v2h-3v3zm-6-3H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm14-1h-2q0-1.25-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12"/>' },
link_off: { path: '<path fill="currentColor" d="m19.25 16.45l-1.5-1.55q1-.275 1.625-1.063T20 12q0-1.25-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12q0 1.425-.737 2.625T19.25 16.45M15.85 13l-2-2H16v2zm3.95 9.6L1.4 4.2l1.4-1.4l18.4 18.4zM11 17H7q-2.075 0-3.537-1.463T2 12q0-1.725 1.05-3.075t2.7-1.775L7.6 9H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h1.625l1.975 2z"/>' },
delete_forever: { path: '<path fill="currentColor" d="m9.4 16.5l2.6-2.6l2.6 2.6l1.4-1.4l-2.6-2.6L16 9.9l-1.4-1.4l-2.6 2.6l-2.6-2.6L8 9.9l2.6 2.6L8 15.1zM7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21z"/>' },
content_copy: { path: '<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"/>' },
check: { path: '<path fill="currentColor" d="m9.55 18l-5.7-5.7l1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z"/>' }
} satisfies Record<string, IconDef>;
export type IconName = keyof typeof icons;
+19 -4
View File
@@ -3,6 +3,8 @@
import { goto } from '$app/navigation';
import '../app.css';
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
import Icon from '$lib/components/Icon.svelte';
import IconSprite from '$lib/components/IconSprite.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { theme } from '$lib/stores/theme.svelte';
import { authApi } from '$lib/api';
@@ -28,20 +30,33 @@
}
</script>
<IconSprite />
<div class="bg-bg text-text min-h-screen">
<header class="border-border bg-bg-elevated border-b">
<div class="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
<a href="/" class="text-lg font-semibold">📷 Galo</a>
<a href="/" class="inline-flex items-center gap-2 text-lg font-semibold">
<Icon name="photo_camera" />
Galo
</a>
<div class="flex items-center gap-3">
{#if auth.isAuthenticated}
<span class="text-text-muted hidden text-sm sm:inline">{auth.user?.name ?? ''}</span>
<button
type="button"
class="text-text-muted hover:text-text text-sm"
onclick={logout}>Выйти</button
class="text-text-muted hover:text-text inline-flex items-center gap-1.5 text-sm"
onclick={logout}
>
<Icon name="logout" class="size-4" />
Выйти
</button>
{:else}
<a href="/login" class="text-text-muted hover:text-text text-sm">Войти</a>
<a
href="/login"
class="text-text-muted hover:text-text inline-flex items-center gap-1.5 text-sm"
>
<Icon name="login" class="size-4" />
Войти
</a>
{/if}
<ThemeSwitcher />
</div>
+21 -5
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { api } from '$lib/api';
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
import type { Album } from '$lib/types';
@@ -47,7 +48,13 @@
<div class="py-16 text-center">
<h1 class="mb-2 text-2xl font-bold">Общие фотоальбомы</h1>
<p class="text-text-muted mb-6">Войдите, чтобы создавать альбомы и делиться фотографиями.</p>
<a href="/login" class="bg-primary text-primary-fg rounded-lg px-5 py-2.5 font-medium">Войти</a>
<a
href="/login"
class="bg-primary text-primary-fg inline-flex items-center gap-2 rounded-lg px-5 py-2.5 font-medium"
>
<Icon name="login" class="size-5" />
Войти
</a>
</div>
{:else}
<div class="mb-6 flex items-center justify-between gap-4">
@@ -58,9 +65,13 @@
placeholder="Название альбома"
class="border-border bg-bg-elevated text-text rounded-lg border px-3 py-2 text-sm"
/>
<button class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm" type="submit"
>Создать</button
<button
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm"
type="submit"
>
<Icon name="add" class="size-4" />
Создать
</button>
</form>
</div>
@@ -69,7 +80,10 @@
{#if loading}
<p class="text-text-muted">Загрузка…</p>
{:else if albums.length === 0}
<p class="text-text-muted">Альбомов пока нет. Создайте первый.</p>
<div class="text-text-muted flex flex-col items-center gap-3 py-12">
<Icon name="photo_library" class="size-16 text-text-subtle" />
<p>Альбомов пока нет. Создайте первый.</p>
</div>
{:else}
<ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each albums as album (album.id)}
@@ -78,7 +92,9 @@
href="/albums/{album.id}"
class="border-border bg-bg-elevated hover:border-primary block overflow-hidden rounded-xl border transition-colors"
>
<div class="bg-bg-muted flex aspect-video items-center justify-center text-4xl">🖼️</div>
<div class="bg-bg-muted flex aspect-video items-center justify-center">
<Icon name="image" class="size-16 text-text-subtle" />
</div>
<div class="p-4">
<h2 class="text-text font-semibold">{album.title}</h2>
<p class="text-text-muted line-clamp-2 text-sm">{album.description}</p>
+15 -5
View File
@@ -4,11 +4,12 @@
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
import Lightbox from '$lib/components/Lightbox.svelte';
import Uploader from '$lib/components/Uploader.svelte';
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { SSEClient, type SSEEvent } from '$lib/sse';
import type { Album, Photo } from '$lib/types';
const albumId = $derived($page.params.id);
const albumId = $derived($page.params.id ?? '');
let album = $state<Album | null>(null);
let role = $state('');
@@ -129,13 +130,19 @@
<div class="flex gap-2">
<a
href="/albums/{albumId}/members"
class="border-border text-text rounded-lg border px-3 py-2 text-sm">Участники</a
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm"
>
<Icon name="group" class="size-4" />
Участники
</a>
{#if role === 'owner' || role === 'admin'}
<a
href="/albums/{albumId}/settings"
class="border-border text-text rounded-lg border px-3 py-2 text-sm">Настройки</a
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm"
>
<Icon name="settings" class="size-4" />
Настройки
</a>
{/if}
</div>
</div>
@@ -159,9 +166,12 @@
<div class="mt-6 text-center">
<button
type="button"
class="border-border text-text rounded-lg border px-4 py-2 text-sm"
onclick={loadMore}>Показать ещё</button
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm"
onclick={loadMore}
>
<Icon name="expand_more" class="size-4" />
Показать ещё
</button>
</div>
{/if}
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { api } from '$lib/api';
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
import type { Album, AlbumMember } from '$lib/types';
@@ -74,11 +75,22 @@
error = (e as Error).message;
}
}
async function copy(text: string) {
try {
await navigator.clipboard.writeText(text);
} catch {
/* clipboard недоступен */
}
}
</script>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">Участники</h1>
<a href="/albums/{albumId}" class="text-primary text-sm">← К альбому</a>
<a href="/albums/{albumId}" class="text-primary inline-flex items-center gap-1 text-sm">
<Icon name="arrow_back" class="size-4" />
К альбому
</a>
</div>
{#if error}<p class="text-danger mb-4 text-sm">{error}</p>{/if}
@@ -105,9 +117,12 @@
</select>
<button
type="button"
class="text-danger text-sm"
onclick={() => removeMember(member.user_id)}>Удалить</button
class="text-danger inline-flex items-center gap-1 text-sm"
onclick={() => removeMember(member.user_id)}
>
<Icon name="delete" class="size-4" />
Удалить
</button>
{:else}
<span class="text-text-muted text-sm">{member.role}</span>
{/if}
@@ -148,11 +163,26 @@
/>
</label>
</div>
<button class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm" type="submit"
>Создать ссылку</button
<button
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm"
type="submit"
>
<Icon name="add_link" class="size-4" />
Создать ссылку
</button>
{#if inviteUrl}
<p class="text-text-muted break-all text-sm">Ссылка: <a class="text-primary" href={inviteUrl}>{inviteUrl}</a></p>
<div class="flex items-center gap-2 text-sm">
<span class="text-text-muted break-all">Ссылка: <a class="text-primary" href={inviteUrl}>{inviteUrl}</a></span>
<button
type="button"
class="text-text-muted hover:text-text inline-flex shrink-0 items-center gap-1"
aria-label="Скопировать ссылку"
title="Скопировать ссылку"
onclick={() => copy(inviteUrl)}
>
<Icon name="content_copy" class="size-4" />
</button>
</div>
{/if}
</form>
{/if}
@@ -2,6 +2,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import Icon from '$lib/components/Icon.svelte';
import type { Album, ShareLink } from '$lib/types';
const albumId = $derived($page.params.id);
@@ -81,7 +82,10 @@
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">Настройки</h1>
<a href="/albums/{albumId}" class="text-primary text-sm">← К альбому</a>
<a href="/albums/{albumId}" class="text-primary inline-flex items-center gap-1 text-sm">
<Icon name="arrow_back" class="size-4" />
К альбому
</a>
</div>
{#if error}<p class="text-danger mb-4 text-sm">{error}</p>{/if}
@@ -103,7 +107,13 @@
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
></textarea>
</label>
<button class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm" type="submit">Сохранить</button>
<button
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm"
type="submit"
>
<Icon name="save" class="size-4" />
Сохранить
</button>
{#if saved}<span class="text-success ml-3 text-sm">Сохранено</span>{/if}
</form>
@@ -117,9 +127,13 @@
class="border-border bg-bg text-text w-32 rounded-lg border px-2 py-1.5"
/>
</label>
<button class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm" type="submit"
>Создать ссылку</button
<button
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm"
type="submit"
>
<Icon name="add_link" class="size-4" />
Создать ссылку
</button>
</form>
<ul class="divide-border divide-y">
{#each shareLinks as link (link.id)}
@@ -129,7 +143,14 @@
· <a class="text-primary" href="/share/{link.token}">/share/{link.token}</a>
</span>
{#if !link.revoked_at}
<button type="button" class="text-danger" onclick={() => revoke(link.id)}>Отозвать</button>
<button
type="button"
class="text-danger inline-flex shrink-0 items-center gap-1"
onclick={() => revoke(link.id)}
>
<Icon name="link_off" class="size-4" />
Отозвать
</button>
{/if}
</li>
{/each}
@@ -138,6 +159,10 @@
<button
type="button"
class="border-danger text-danger rounded-lg border px-4 py-2 text-sm"
onclick={removeAlbum}>Удалить альбом</button
class="border-danger text-danger inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm"
onclick={removeAlbum}
>
<Icon name="delete_forever" class="size-4" />
Удалить альбом
</button>
@@ -2,6 +2,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
const token = $derived($page.params.token);
@@ -43,13 +44,20 @@
type="button"
disabled={busy}
onclick={accept}
class="bg-primary text-primary-fg rounded-lg px-5 py-2.5 font-medium"
class="bg-primary text-primary-fg inline-flex items-center gap-2 rounded-lg px-5 py-2.5 font-medium"
>
<Icon name="check" class="size-5" />
{busy ? 'Принятие…' : 'Принять приглашение'}
</button>
{:else}
<p class="mb-4 text-sm">Войдите или зарегистрируйтесь, чтобы принять приглашение.</p>
<a href="/login" class="bg-primary text-primary-fg rounded-lg px-5 py-2.5 font-medium">Войти</a>
<a
href="/login"
class="bg-primary text-primary-fg inline-flex items-center gap-2 rounded-lg px-5 py-2.5 font-medium"
>
<Icon name="login" class="size-5" />
Войти
</a>
{/if}
{:else}
<p class="text-text-muted">Загрузка…</p>
+26 -4
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { api } from '$lib/api';
import Icon from '$lib/components/Icon.svelte';
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
import type { Album, Photo } from '$lib/types';
@@ -54,9 +55,12 @@
<div class="mt-6 text-center">
<button
type="button"
class="border-border text-text rounded-lg border px-4 py-2 text-sm"
onclick={loadMore}>Показать ещё</button
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm"
onclick={loadMore}
>
<Icon name="expand_more" class="size-4" />
Показать ещё
</button>
</div>
{/if}
@@ -64,6 +68,10 @@
<div
class="bg-bg/95 fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
tabindex="-1"
onkeydown={(e) => {
if (e.key === 'Escape') active = -1;
}}
onclick={() => (active = -1)}
>
<img
@@ -71,13 +79,27 @@
alt={photos[active].original_filename}
class="max-h-[90vh] max-w-full object-contain"
/>
<div class="absolute top-4 right-4 flex gap-2">
<a
href={photos[active].download_url}
target="_blank"
rel="noreferrer"
class="border-border bg-bg-elevated text-text absolute top-4 right-4 rounded-lg border px-3 py-1.5 text-sm"
onclick={(e) => e.stopPropagation()}>Скачать</a
class="border-border bg-bg-elevated text-text inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm"
onclick={(e) => e.stopPropagation()}
>
<Icon name="download" class="size-4" />
Скачать
</a>
<button
type="button"
aria-label="Закрыть"
title="Закрыть"
class="border-border bg-bg-elevated text-text inline-flex items-center justify-center rounded-lg border px-2 py-1.5"
onclick={() => (active = -1)}
>
<Icon name="close" />
</button>
</div>
</div>
{/if}
{:else}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-10
@@ -0,0 +1,85 @@
# Design
## Context
Фронтенд — SvelteKit 2 + Svelte 5, `adapter-static` в режиме SPA (`ssr=false`),
собирается в статику и вшивается в Go-бинарник через `go:embed`. Стили — Tailwind v4,
семантические токены через CSS-переменные и `data-theme` на `<html>` (темы light/dark/neutral
+ system). Внешние CDN-запросы нежелательны: приложение self-hosted и должно работать
полностью автономно. Сейчас все «иконки» — эмодзи и юникод-глифы, рендерящиеся системным
шрифтом (мотивация — в proposal.md).
## Goals / Non-Goals
**Goals:**
- Единый набор векторных иконок, самодостаточный (без внешних запросов и без иконочных шрифтов).
- Иконки наследуют цвет текущей темы и не влияют на размеры/раскладку при переключении тем.
- Переиспользуемый компонентный API, спрайт для дедупликации повторяющихся иконок.
- Доступность: корректное поведение для декоративных и смысловых иконок и для тумблеров.
**Non-Goals:**
- Не менять доменную логику, API, БД и backend.
- Не вводить анимации/переходы иконок.
- Не добавлять внешние зависимости (иконочные пакеты, Vite-плагины).
- Не делать полный редизайн макета — только замена иконок и ведущие иконки контролов.
## Decisions
### 1. Inline SVG через спрайт `<symbol>` + `<use>`
В `+layout.svelte` монтируется один скрытый `<svg>` со всеми `<symbol id="i-...">`;
места использования рисуют `<svg><use href="#i-..."/></svg>`.
- **Почему:** нет внешних запросов, работает внутри статичного бандла, `currentColor`
наследуется через `<use>`, а данные пути хранятся один раз в DOM (важно для сеток, где
`favorite`/`mode_comment` повторяются на каждой плитке).
- **Альтернативы (отклонены):** иконочный шрифт через CDN — внешний запрос, FOUT, ломает
автономность; внешний файл-спрайт `<use href="/icons.svg#id">` — межфайловые `<use>` имеют
ограничения в ряде браузеров; «иконка = компонент с полным inline-путём» — дублирование
path-данных в DOM при массовом повторении.
### 2. Вендорим данные иконок в `registry.ts`, без зависимостей
Реестр — объект `имя -> { path }`, где `path` — сырая inner-разметка (`<path d="..." .../>`,
иногда с `fill-rule`/`clip-rule`). Источник — Google Material Symbols (через Iconify,
prefix `material-symbols`); базовые имена дают стиль **filled**, поэтому набор вендорится в
filled-стиле, а для состояний-пар берутся отдельные ключи (`favorite` = filled, лайкнут;
`favorite-outline` = контурный, не лайкнут). Примечание: контурного варианта для всех
иконок в источнике нет, поэтому единый filled-набор согласованнее, чем смесь стилей.
- **Почему:** ноль зависимостей и supply-chain, малый бандл, полный контроль, согласуется с
философией «один статичный бинарник».
- **Альтернативы (отклонены):** `@mdi/js` — готовые path-строки, но новая зависимость и другой
визуальный набор; Iconify + `unplugin-icons` — мощный DX, но тяжёлый тулчейн ради ~25 иконок.
- **Задел:** при росте набора переход на `@mdi/js` не меняет компонентный API — только источник
данных реестра.
### 3. Компонентный API
- `Icon.svelte`: пропсы `name`, необязательный `label`; размер и цвет задаются классами
(`size-4/5/6/16`, `text-*`). По умолчанию `aria-hidden="true" focusable="false"`; при
заданном `label` — `role="img"` + `aria-label` и `aria-hidden` снимается.
- `IconSprite.svelte`: рендерит все `<symbol>` из реестра один раз, монтируется в `+layout.svelte`.
- `IconButton.svelte`: иконочная кнопка с обязательным `label` (попадает в `aria-label`).
- **Неизвестное имя:** в dev — `console.warn` и пустой бокс (не падать); в prod — пусто.
### 4. Стиль и метрики
- `fill="currentColor"`, шкала размеров: `size-4` (в тексте/счётчиках), `size-5`
(кнопки/навигация), `size-6` (панель лайтбокса), `size-16` (плейсхолдеры).
- Пара состояний лайка: `favorite-outline` (не лайкнут) / `favorite` (залитая, лайкнут).
### 5. Маппинг иконок тем
`light_mode`, `dark_mode`, `palette` (нейтральная), `brightness_auto` (системная).
### 6. Доступность
- Декоративные иконки рядом с текстом/внутри кнопки с текстом — `aria-hidden`.
- Одиночные иконочные кнопки (тумблеры тем) — `aria-label` на `<button>` + `aria-pressed`.
- Счётчики лайков/комментариев: иконка декоративная, число несёт доступное имя
(`aria-label="12 лайков"`).
## Risks / Trade-offs
- **[Спрайт должен оставаться в DOM]** → `<use>` ссылается на `#id` в том же документе;
`IconSprite` живёт в `+layout.svelte` и не размонтируется.
- **[`{@html}` в `<symbol>`]** → данные статичны и вендорятся на этапе сборки, не приходят от
пользователя; риск XSS отсутствует.
- **[Рост DOM при больших сетках]** → митигируется спрайтом (одна копия пути).
- **[Визуальная регрессия/раскладка]** → размеры задаём классами, иконки в квадратном
`viewBox 0 0 24 24`; проверяем на трёх темах.
- **[Лицензия/атрибуция]** → Material Symbols под Apache 2.0; добавляем уведомление об
источнике рядом с реестром.
@@ -0,0 +1,46 @@
# Proposal
## Why
Сейчас все «иконки» в интерфейсе — это эмодзи и юникод-глифы, которые рисует системный
шрифт. Они по-разному выглядят в разных ОС и браузерах (проект self-hosted — парк
устройств непредсказуем), плохо наследуют цвет темы и семантически неточны: 🌾 для
«нейтральной темы», 💻 для «системной», ♥/💬 для метрик. Нужен единый, самодостаточный
набор векторных иконок без внешних запросов, согласованный с тремя темами и доступностью.
## What Changes
- Ввести inline-SVG иконочную систему: спрайт `<symbol>` + `<use>`, реестр иконок
(`registry.ts`), компоненты `Icon`, `IconSprite`, `IconButton`.
- Заменить все эмодзи/юникод-глифы на SVG-иконки: логотип (`📷`), переключатель тем
(`☀️ 🌙 🌾 💻`), плейсхолдер обложки (`🖼️`), лайк/комментарий (`♥ 💬`), закрытие
и стрелки лайтбокса (`✕ ‹ ›`), «назад» (`←`).
- Добавить ведущие иконки ключевым контролам: создать, загрузить, скачать, сохранить,
отправить, удалить, выйти, участники, настройки, share-ссылки, принять инвайт.
- Источник данных — Google Material Symbols (стиль outlined) плюс `*_fill`-варианты для
состояний (например, лайк нажат/не нажат). Данные иконок вендорятся в репозиторий,
новые зависимости не добавляются.
- Проход по доступности: декоративные иконки скрыты от скринридеров (`aria-hidden`),
смысловые/одиночные получают `aria-label`/`role="img"`, тумблеры тем — `aria-pressed`.
## Capabilities
### New Capabilities
- `ui-iconography`: единая inline-SVG система иконок фронтенда, её доступность и
наследование цветовых токенов тем.
### Modified Capabilities
_Нет — существующие спеки описывают доменную логику (albums, auth, photos, …); иконочная
система их требований не меняет._
## Impact
- **Код:** `frontend/src/lib/icons/*`, `frontend/src/lib/components/Icon.svelte`,
`IconSprite.svelte`, `IconButton.svelte`, правки в `+layout.svelte` и во всех
экранах/компонентах (`ThemeSwitcher`, `PhotoGrid`, `Lightbox`, `Uploader`, `Comments`,
список альбомов, страница альбома, участники, настройки, инвайт, публичный share).
- **Зависимости:** без новых — данные иконок вендорятся.
- **Совместимость:** backend, API и БД не затрагиваются; меняется только фронтенд-разметка
и доступность.
@@ -0,0 +1,43 @@
# Spec Delta
## Purpose
Единая система иконок веб-интерфейса: самодостаточные векторные иконки, которые наследуют
цвет темы и корректно ведут себя для вспомогательных технологий.
## ADDED Requirements
### Requirement: Самодостаточные векторные иконки
Интерфейс SHALL отображать иконки как векторную графику, встроенную в документ, без запросов
к внешним источникам за иконками.
#### Scenario: Отображение иконки
- **WHEN** интерфейс показывает иконку (например, лайк, тему, закрытие лайтбокса)
- **THEN** она отрисована встроенной векторной графикой, а не эмодзи или символом системного шрифта
#### Scenario: Автономная загрузка
- **WHEN** страница загружается без доступа к внешней сети, кроме собственного сервера
- **THEN** все иконки отображаются, так как не требуют внешних ресурсов
### Requirement: Наследование цвета темы
Иконка SHALL наследовать цвет текущей темы и изменять его при переключении темы.
#### Scenario: Переключение темы
- **WHEN** пользователь переключает тему (light/dark/neutral)
- **THEN** цвет иконок меняется вместе с темой без дополнительных действий
### Requirement: Доступность иконок
Система SHALL скрывать декоративные иконки от вспомогательных технологий и давать доступное
имя смысловым иконочным элементам управления.
#### Scenario: Декоративная иконка
- **WHEN** иконка сопровождает видимый текст или находится внутри кнопки с текстом
- **THEN** она скрыта от скринридеров
#### Scenario: Иконочная кнопка без текста
- **WHEN** кнопка содержит только иконку (например, выбор темы)
- **THEN** кнопка имеет доступное имя
#### Scenario: Тумблер темы
- **WHEN** кнопка выбора темы активна или неактивна
- **THEN** её состояние передаётся вспомогательным технологиям как «нажата/не нажата»
@@ -0,0 +1,35 @@
# Tasks
## 1. Ядро иконочной системы
- [x] 1.1 Создать `frontend/src/lib/icons/registry.ts`: записи `имя -> { path, filled?, label? }` с вендоренной inner-разметкой Material Symbols; проверить, что реестр импортируется без ошибок и содержит все иконки из инвентаря
- [x] 1.2 Реализовать `Icon.svelte` (пропсы `name`, `label`; `aria-hidden`/`focusable`, `role=img` при `label`, классы размера, `fill=currentColor`); проверить рендер известного и неизвестного имени (dev-warn)
- [x] 1.3 Реализовать `IconSprite.svelte` (один скрытый `<svg>` со всеми `<symbol id="i-...">` из реестра) и смонтировать его в `+layout.svelte`; проверить, что `<use>` в дочерних компонентах разрешается
- [x] 1.4 Реализовать `IconButton.svelte` с обязательным `label` (-> `aria-label` на `<button>`); проверить доступное имя в дереве доступности
## 2. Замена эмодзи и юникод-глифов
- [x] 2.1 `ThemeSwitcher`: заменить `☀️ 🌙 🌾 💻` на `light_mode`/`dark_mode`/`palette`/`brightness_auto`; проверить переключение тем и `aria-pressed`
- [x] 2.2 Логотип в `+layout.svelte`: заменить `📷` на `photo_camera` рядом с текстом «Galo»; проверить отображение в трёх темах
- [x] 2.3 Список альбомов: заменить `🖼️` на `image` (крупный плейсхолдер); проверить вёрстку карточки
- [x] 2.4 `PhotoGrid`: заменить `♥`/`💬` на `favorite`/`favorite_fill` и `mode_comment`; проверить счётчики и состояние «лайкнут»
- [x] 2.5 `Lightbox`: заменить `✕ ‹ ›` на `close`/`chevron_left`/`chevron_right`; проверить навигацию и закрытие
- [x] 2.6 Ссылки «назад к альбому» (участники, настройки): заменить `←` на `arrow_back`; проверить навигацию
## 3. Ведущие иконки контролов
- [x] 3.1 Список альбомов и страница альбома: добавить `add`/`photo_library` (создать, пустой список); проверить создание альбома
- [x] 3.2 `Uploader`: `cloud_upload`/`add_photo_alternate`; проверить загрузку фото
- [x] 3.3 `Lightbox`/share: `download`; проверить скачивание
- [x] 3.4 Участники/настройки: `group`, `settings`, `add_link`, `link_off`, `save`, `delete_forever`; проверить роли, share-ссылки, сохранение и удаление
- [x] 3.5 Навигация и вход: `login`, `logout`, `check`, `send`, `delete`, `content_copy`, `expand_more`; проверить login/logout, инвайт и комментарии
## 4. Доступность
- [x] 4.1 Проставить `aria-hidden="true" focusable="false"` декоративным иконкам и `aria-label` иконочным кнопкам; проверить через дерево доступности браузера
- [x] 4.2 Дать счётчикам лайков/комментариев доступные имена (`aria-label="N лайков"`); проверить, что число читается скринридером
## 5. Проверка
- [x] 5.1 Собрать фронтенд (`make frontend`) и прогнать `svelte-check`; проверить отсутствие ошибок типов и сборки
- [x] 5.2 Playwright-проверка: иконки отрисованы, эмодзи/юникод-глифы в разметке отсутствуют; проверить на трёх темах
+43
View File
@@ -0,0 +1,43 @@
# ui-iconography Specification
## Purpose
Единая система иконок веб-интерфейса: самодостаточные векторные иконки, которые наследуют
цвет темы и корректно ведут себя для вспомогательных технологий.
## Requirements
### Requirement: Самодостаточные векторные иконки
Интерфейс SHALL отображать иконки как векторную графику, встроенную в документ, без запросов
к внешним источникам за иконками.
#### Scenario: Отображение иконки
- **WHEN** интерфейс показывает иконку (например, лайк, тему, закрытие лайтбокса)
- **THEN** она отрисована встроенной векторной графикой, а не эмодзи или символом системного шрифта
#### Scenario: Автономная загрузка
- **WHEN** страница загружается без доступа к внешней сети, кроме собственного сервера
- **THEN** все иконки отображаются, так как не требуют внешних ресурсов
### Requirement: Наследование цвета темы
Иконка SHALL наследовать цвет текущей темы и изменять его при переключении темы.
#### Scenario: Переключение темы
- **WHEN** пользователь переключает тему (light/dark/neutral)
- **THEN** цвет иконок меняется вместе с темой без дополнительных действий
### Requirement: Доступность иконок
Система SHALL скрывать декоративные иконки от вспомогательных технологий и давать доступное
имя смысловым иконочным элементам управления.
#### Scenario: Декоративная иконка
- **WHEN** иконка сопровождает видимый текст или находится внутри кнопки с текстом
- **THEN** она скрыта от скринридеров
#### Scenario: Иконочная кнопка без текста
- **WHEN** кнопка содержит только иконку (например, выбор темы)
- **THEN** кнопка имеет доступное имя
#### Scenario: Тумблер темы
- **WHEN** кнопка выбора темы активна или неактивна
- **THEN** её состояние передаётся вспомогательным технологиям как «нажата/не нажата»