Newer
Older
gnexus-tasks / frontend / src / views / ProjectView.vue
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useToast } from 'gnexus-ui-kit/vue'
import { celebrateEarned } from '../gamification'
import { api, ApiError, type Attachment, type Project, type Tag, type Task } from '../api'
import { useRealtimeRefresh } from '../realtime'
import { scopedKey } from '../userScope'
import { deadlineOverdue, gradeToPriority, priorityOptions, priorityToGrade, priorityLabel, priorityVariant, relevanceLabel, renderMarkdown, setPageTitle, statusIcon, statusOptions } from '../taskui'
import { isMobile } from '../viewport'
import PriorityBadge from '../components/PriorityBadge.vue'
import MdEditor from '../components/MdEditor.vue'
import MdLightbox from '../components/MdLightbox.vue'
import InlineField from '../components/InlineField.vue'
import ProjectColorDot from '../components/ProjectColorDot.vue'
import ProjectMark from '../components/ProjectMark.vue'
import ProjectColorPicker from '../components/ProjectColorPicker.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import TaskForm from '../components/TaskForm.vue'
import TaskCard from '../components/TaskCard.vue'
import ProjectHeatmap from '../components/ProjectHeatmap.vue'
import Pagination from '../components/Pagination.vue'

defineOptions({ name: 'ProjectView' })

// Страница проекта: двухколоночная раскладка. Заметка, список задач с фильтрами
// и дерево — в основной колонке; статистика и параметры — в сайдбаре.
// Создание задачи здесь = createTask + PATCH project_id (TaskCreate не принимает
// project_id) — задача попадёт в стек на детализацию.

const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const toast = useToast()

const projectId = computed(() => Number(route.params.id))
const project = ref<Project | null>(null)
const tasks = ref<Task[]>([])
const tags = ref<Tag[]>([])
const error = ref('')
const loading = ref(false)
const notFound = ref(false)

// Тайтл вкладки — название проекта (после загрузки уточняет meta маршрута)
watchEffect(() => {
  setPageTitle(project.value?.name ?? t('nav.projects'))
})

// Фильтры списка: статус (по умолчанию «К выполнению»), поиск по тегу. Выбор
// пользователя запоминается в localStorage с привязкой к проекту и пользователю —
// вернувшись, видит те же фильтры; в приватном окне/при очищенных данных просто
// работают дефолты. Тег здесь — текст поиска (0.70), а не id: фильтр подстрочный
const filters = ref({ status: 'to_do', tag: '' })

const filtersKey = computed(() => scopedKey(`project-filters:${projectId.value}`))

function loadStoredFilters() {
  try {
    const raw = localStorage.getItem(filtersKey.value)
    if (raw) {
      // сохранённый tag_id из старых версий не читаем: имя тега по id можно
      // восстановить только после загрузки справочника — фильтр просто не вернётся
      const saved = JSON.parse(raw) as { status?: string; tag?: string }
      filters.value = {
        status: saved.status ?? 'to_do',
        tag: saved.tag ?? '',
      }
    }
  } catch {
    // битый JSON или недоступный localStorage — не мешаем работе
  }
}
watch(filtersKey, loadStoredFilters, { immediate: true })
// deep: ref хранит объект — по умолчанию watch видит лишь замену .value,
// а селекты мутируют поля (v-model="filters.status")
watch(filters, () => {
  try {
    localStorage.setItem(filtersKey.value, JSON.stringify(filters.value))
  } catch {
    // переполнение/запрет localStorage — фильтры просто не запомнятся
  }
}, { deep: true })

const filteredTasks = computed(() =>
  tasks.value.filter(
    (task) =>
      (!filters.value.status || task.status === filters.value.status) &&
      // тег ищется по подстроке имени (0.70): набранное «bug» находит задачи с
      // любым тегом, в имени которого есть «bug»; задачи без тегов отсеиваются
      (!filters.value.tag ||
        task.tags.some((tag) =>
          tag.name.toLowerCase().includes(filters.value.tag.trim().toLowerCase()),
        )),
  ),
)

// Текст поиска тега без пробелов: по нему решаем, что показать в подсказках
// (совпавшие теги или подсказку «начните вводить») и нужен ли крестик сброса
const tagQuery = computed(() => filters.value.tag.trim())

function clearTag() {
  filters.value.tag = ''
}

// Пагинация: по 30 карточек, смена фильтра — с первой
const page = ref(1)
const pagedTasks = computed(() =>
  filteredTasks.value.slice((page.value - 1) * 30, page.value * 30),
)
watch(filters, () => {
  page.value = 1
}, { deep: true })

// Статистика по всем задачам проекта (фильтры не влияют)
const stats = computed(() => {
  const list = tasks.value
  const open = list.filter((task) => task.status !== 'done' && task.status !== 'cancelled')
  return {
    total: list.length,
    done: list.filter((task) => task.status === 'done').length,
    inProgress: list.filter((task) => task.status === 'in_progress').length,
    toDo: list.filter((task) => task.status === 'to_do').length,
    overdue: open.filter((task) => deadlineOverdue(task)).length,
  }
})

const doneCount = computed(() => stats.value.done)

// Сайдбар «Статистика» — строка = иконка + термин + значение
const statRows = computed(() => {
  const s = stats.value
  const rows: { key: string; icon: string; term: string; value: string; warn?: boolean }[] = [
    { key: 'total', icon: 'ph-stack', term: t('projectPage.total'), value: String(s.total) },
    { key: 'done', icon: 'ph-check-circle', term: t('projectPage.doneN'), value: String(s.done) },
    { key: 'inProgress', icon: 'ph-circle-half', term: t('projectPage.inProgress'), value: String(s.inProgress) },
    { key: 'toDo', icon: 'ph-circle', term: t('projectPage.toDo'), value: String(s.toDo) },
  ]
  if (s.overdue)
    rows.push({ key: 'overdue', icon: 'ph-alarm', term: t('projectPage.overdue'), value: String(s.overdue), warn: true })
  return rows
})

// Хост ссылки проекта для строки параметров и шапки (0.63): «https://x.dev/a» → «x.dev».
// Общий для сайта и репозитория (0.67) — схемы в подписи не показываем
function urlHost(url: string): string {
  try {
    return new URL(url).host
  } catch {
    return url
  }
}

// Репозиторий в строке параметров (0.67): «host/owner/repo» без схемы,
// «.git» и хвостового слэша — по нему и узнаётся репозиторий в списке.
// После слэшей — невидимый разделитель (U+200B): сайдбар узкий, и без него
// строка рвётся посреди сегмента пути («git.example/r|oot/repo»)
function repoLabel(url: string): string {
  try {
    const parsed = new URL(url)
    const path = parsed.pathname.replace(/\.git$/, '').replace(/\/$/, '')
    return (parsed.host + path).replace(/\//g, '/​')
  } catch {
    return url
  }
}

// Сайдбар «Параметры» проекта — строки всегда (пустой приоритет — «не указано»,
// кликабельно); строки инлайн-редактируемые, цвет и сайт — новые (0.63)
const paramRows = computed(() => {
  const project0 = project.value
  if (!project0) return []
  return [
    {
      key: 'relevance',
      icon: 'ph-activity',
      term: t('projects.form.relevance'),
      value: relevanceLabel(project0.relevance_status),
      badge: undefined,
      empty: false,
      inline: true,
    },
    {
      key: 'priority',
      icon: 'ph-flag',
      term: t('projects.form.priority'),
      value: project0.priority === null ? '' : priorityLabel(project0.priority),
      badge: project0.priority === null ? undefined : priorityVariant(project0.priority),
      empty: project0.priority === null,
      inline: true,
    },
    {
      key: 'color',
      icon: 'ph-palette',
      term: t('projects.form.color'),
      value: project0.color ?? '',
      badge: undefined,
      empty: project0.color === null,
      inline: false,
    },
    {
      key: 'site',
      icon: 'ph-globe',
      term: t('projects.form.siteUrl'),
      value: project0.site_url ? urlHost(project0.site_url) : '',
      badge: undefined,
      empty: project0.site_url === null,
      inline: true,
    },
    // Репозиторий (0.67): отдельное поле, из него тянется README вторым описанием
    {
      key: 'repository',
      icon: 'ph-git-fork',
      term: t('projects.form.repositoryUrl'),
      value: project0.repository_url ? repoLabel(project0.repository_url) : '',
      badge: undefined,
      empty: project0.repository_url === null,
      inline: true,
    },
    // Булавка (0.66): тот же переключатель, что на карточке списка —
    // проект остаётся наверху списка, пока его не открепят
    {
      key: 'pinned',
      icon: 'ph-push-pin',
      term: t('projects.pinnedLabel'),
      value: project0.pinned ? t('common.yes') : t('common.no'),
      badge: undefined,
      empty: false,
      inline: false,
    },
  ]
})

// Теги, используемые в задачах проекта, с количеством
const tagRows = computed(() => {
  const counts = new Map<string, number>()
  tasks.value.forEach((task) => task.tags.forEach((tag) => counts.set(tag.name, (counts.get(tag.name) ?? 0) + 1)))
  return [...counts.entries()].sort((a, b) => b[1] - a[1])
})

// Облако тегов (0.83) вместо списка: клик по тегу ставит фильтр. Размер имени и есть
// счётчик — чем чаще тег в задачах проекта, тем крупнее (0.85…1.2em); точное число
// осталось в подсказке, чтобы облако не превращалось в таблицу
function tagFontSize(count: number): string {
  const max = tagRows.value[0]?.[1] ?? 1
  const min = tagRows.value[tagRows.value.length - 1]?.[1] ?? 1
  if (max === min) return '1em'
  return `${(0.85 + 0.35 * ((count - min) / (max - min))).toFixed(3)}em`
}

// Активным считаем тег, чьё имя стоит в поиске: фильтр подстрочный, поэтому
// «bug» в поле подсвечивает и тег «bugfix», если он совпал целиком
function isActiveTag(name: string): boolean {
  return tagQuery.value.toLowerCase() === name.toLowerCase()
}

// Список задач на узком экране лежит выше сайдбара — после клика по тегу уводим
// к нему, иначе результат фильтрации остался бы за кадром
const listAnchor = ref<HTMLElement | null>(null)

function filterByTag(name: string) {
  // повторный клик по активному тегу снимает фильтр
  filters.value.tag = isActiveTag(name) ? '' : name
  if (window.matchMedia('(max-width: 960px)').matches) {
    void nextTick(() => listAnchor.value?.scrollIntoView({ behavior: 'smooth', block: 'start' }))
  }
}

const noteHtml = computed(() => (project.value ? renderMarkdown(project.value.note) : ''))

// README репозитория проекта (0.67) — второе описание. Раньше заметка из одной
// ссылки подменялась README; теперь ссылка живёт в отдельном поле repository_url,
// и заметка всегда остаётся заметкой. Тянет бэкенд-прокси (кэш 10 мин), ссылку
// фронт не передаёт — роут читает поле проекта сам.
type ReadmeState = 'idle' | 'loading' | 'ok' | 'missing' | 'error'
const readmeState = ref<ReadmeState>('idle')
const readme = ref('')
const readmeHtml = computed(() => (readme.value ? renderMarkdown(readme.value) : ''))

watch(
  () => project.value?.repository_url,
  async (url) => {
    readme.value = ''
    if (!url || !project.value) {
      readmeState.value = 'idle'
      return
    }
    readmeState.value = 'loading'
    try {
      readme.value = (await api.projectReadme(project.value.id)).content
      readmeState.value = 'ok'
    } catch (e) {
      // 404 — README в репозитории нет (тихая подпись), прочее — ошибка связи
      readmeState.value = e instanceof ApiError && e.status === 404 ? 'missing' : 'error'
    }
  },
  { immediate: true },
)

// Большая заметка (README бывает огромный) сворачивается: показывается
// начало + кнопка «Развернуть» и подсказка. Высота мерится по факту
// рендера (наблюдателем — картинки догружаются и растут после).
const COLLAPSE_PX = 420
const noteBodyEl = ref<HTMLElement | null>(null)
const noteOverflow = ref(false)
const noteExpanded = ref(false)

function measureNote() {
  const el = noteBodyEl.value
  noteOverflow.value = !!el && el.scrollHeight > COLLAPSE_PX
}

watch(noteHtml, async () => {
  noteExpanded.value = false
  await nextTick()
  measureNote()
})

// картинки/раскрытие меняют высоту после факта — доглядываем наблюдателем
let noteResize: ResizeObserver | undefined
watch(noteBodyEl, (el) => {
  noteResize?.disconnect()
  noteResize = undefined
  if (el) {
    noteResize = new ResizeObserver(measureNote)
    noteResize.observe(el)
  }
})
onBeforeUnmount(() => noteResize?.disconnect())

// README сворачивается тем же порогом и теми же кнопками, но своим состоянием:
// короткий README и заметка не должны делить одну кнопку «Развернуть»
const readmeBodyEl = ref<HTMLElement | null>(null)
const readmeOverflow = ref(false)
const readmeExpanded = ref(false)

function measureReadme() {
  const el = readmeBodyEl.value
  readmeOverflow.value = !!el && el.scrollHeight > COLLAPSE_PX
}

watch(readmeHtml, async () => {
  readmeExpanded.value = false
  await nextTick()
  measureReadme()
})

let readmeResize: ResizeObserver | undefined
watch(readmeBodyEl, (el) => {
  readmeResize?.disconnect()
  readmeResize = undefined
  if (el) {
    readmeResize = new ResizeObserver(measureReadme)
    readmeResize.observe(el)
  }
})
onBeforeUnmount(() => readmeResize?.disconnect())

// Клик по картинке в заметке → просмотр на весь экран
const noteImage = ref<{ url: string; name: string } | null>(null)

function onNoteClick(e: MouseEvent) {
  const target = e.target as HTMLElement
  if (target.tagName === 'IMG') {
    const img = target as HTMLImageElement
    noteImage.value = { url: img.getAttribute('src') ?? '', name: img.alt }
  }
}

// Картинки в заметке: загрузка из буфера (MdEditor вставит markdown в курсор).
// Если заметка ещё ни разу не сохранялась, документа нет (создаётся лениво) —
// материализуем его текущей заметкой, не трогая черновик.
async function uploadImages(files: File[]): Promise<Attachment[]> {
  try {
    if (project.value && project.value.document_id === null) {
      project.value = await api.updateProject(project.value.id, { note: project.value.note })
    }
    const docId = project.value?.document_id
    if (!docId) return []
    return await api.uploadAttachments(docId, files)
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
    return []
  }
}

// Токен запроса: поздний ответ для старого id (быстрая навигация по проектам)
// не должен перетирать данные нового
let loadSeq = 0

async function load() {
  const seq = ++loadSeq
  loading.value = true
  notFound.value = false
  error.value = ''
  try {
    const [fetched, projectTasks, tagList] = await Promise.all([
      api.getProject(projectId.value),
      api.listTasks({ project_id: projectId.value }),
      api.listTags(),
    ])
    if (seq !== loadSeq) return
    project.value = fetched
    tasks.value = projectTasks
    tags.value = tagList
  } catch (e) {
    if (seq !== loadSeq) return
    if (e instanceof ApiError && e.status === 404) {
      notFound.value = true
    } else {
      error.value = String(e)
    }
  } finally {
    if (seq === loadSeq) loading.value = false
  }
}

onMounted(load)

// Реактивность (ТЗ 3.14): задачи проекта меняют MCP и соседние вкладки,
// сам проект могут переименовать/заархивировать — перезагружаемся без навигации
useRealtimeRefresh(
  () => {
    if (projectId.value) void load()
  },
  ['task.changed', 'task.deleted', 'project.changed', 'project.deleted'],
)

// /projects/:id → /projects/:id переиспользует инстанс компонента —
// перезагружаемся по смене id (та же логика, что в TaskView)
watch(projectId, () => {
  void load()
})

// ---------- Инлайн-редактирование полей проекта ----------
// Draft сеется на @open, ✓ шлёт PATCH; ответ заменяет project.value —
// шапка, бейджи и заметка пересчитаются сами.

const draftName = ref('')
const draftNote = ref('')
const noteEditing = ref(false)
const noteBusy = ref(false)
const draftRelevance = ref('')
const draftPriority = ref('')
// Сайт проекта (0.63) — инлайн-правка; пустое значение сохраняем как null (очистка)
const draftSiteUrl = ref('')
// Репозиторий проекта (0.67) — инлайн-правка отдельного поля, из него README
const draftRepositoryUrl = ref('')
// Иконка сайта не загрузилась (сайт без favicon, SVG или SSRF-барьер) — рисуем глобус
const faviconBroken = ref(false)

// Смена проекта/сайта — снова пробуем иконку (иначе флаг «сломан» залипнет)
watch(
  () => project.value?.site_url,
  () => {
    faviconBroken.value = false
  },
)

const priorityOptionsWithNone = computed(() => [
  { value: '', label: t('common.none') },
  ...priorityOptions(),
])

// Инлайн-PATCH: ошибка → тост + throw (InlineField оставит редактор открытым)
async function patchProject(patch: Partial<Project>) {
  const project0 = project.value
  if (!project0) return
  try {
    project.value = await api.updateProject(project0.id, patch)
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
    throw e
  }
}

async function saveName() {
  const name = draftName.value.trim()
  if (!name) throw new Error('empty') // пустое имя не сохраняем
  await patchProject({ name })
}

async function saveNote() {
  noteBusy.value = true
  try {
    await patchProject({ note: draftNote.value })
    noteEditing.value = false
  } catch {
    // тост уже показан patchProject
  } finally {
    noteBusy.value = false
  }
}

async function saveRelevance() {
  await patchProject({ relevance_status: draftRelevance.value })
}

async function savePriority() {
  await patchProject({
    priority: draftPriority.value === '' ? null : gradeToPriority(draftPriority.value),
  })
}

// Цвет-метка (0.63): выбор из палитры сохраняем сразу, без промежуточного ✓
async function saveColor(color: string | null) {
  try {
    await patchProject({ color })
  } catch {
    // тост уже показан patchProject
  }
}

// Булавка (0.66): клик по значению «Да/Нет» в параметрах закрепляет проект —
// то же поле, что и булавка на карточке списка
async function togglePin() {
  const project0 = project.value
  if (!project0) return
  try {
    await patchProject({ pinned: !project0.pinned })
  } catch {
    // тост уже показан patchProject
  }
}

async function saveSiteUrl() {
  const url = draftSiteUrl.value.trim()
  await patchProject({ site_url: url === '' ? null : url })
}

async function saveRepositoryUrl() {
  const url = draftRepositoryUrl.value.trim()
  await patchProject({ repository_url: url === '' ? null : url })
}

// Архив (ТЗ 3.11): у архивного проекта чтение и история доступны,
// создание задач и правки не имеют смысла — они спрятаны в шаблоне
const isArchived = computed(() => project.value?.is_archived ?? false)

async function restore() {
  const project0 = project.value
  if (!project0) return
  try {
    project.value = await api.restoreProject(project0.id)
    toast.success({ title: t('archive.restored', { name: project0.name }) })
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

// Действия над самим проектом — дропдаун «…» в шапке: правка (drawer),
// архив/восстановление, удаление; разрушительное — через подтверждение
const archiveConfirmOpen = ref(false)
const projectDeleteOpen = ref(false)

const projectActions = computed(() =>
  isArchived.value
    ? [
        { label: t('archive.restore'), icon: 'ph-arrow-counter-clockwise', onSelect: restore },
        { label: t('common.delete'), icon: 'ph-trash', danger: true, onSelect: () => (projectDeleteOpen.value = true) },
      ]
    : [
        { label: t('common.edit'), icon: 'ph-pencil-simple', onSelect: startEdit },
        { label: t('common.archive'), icon: 'ph-archive', onSelect: () => (archiveConfirmOpen.value = true) },
        { label: t('common.delete'), icon: 'ph-trash', danger: true, onSelect: () => (projectDeleteOpen.value = true) },
      ],
)

async function archiveSelf() {
  const project0 = project.value
  if (!project0) return
  archiveConfirmOpen.value = false
  try {
    await api.archiveProject(project0.id)
    toast.success({ title: t('projects.archived') })
    void router.push('/projects')
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

async function deleteSelf() {
  const project0 = project.value
  if (!project0) return
  projectDeleteOpen.value = false
  try {
    await api.deleteProject(project0.id)
    toast.success({ title: t('common.projectDeleted') })
    void router.push('/projects')
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

// Правка проекта — drawer, как в списке проектов
const drawerOpen = ref(false)
const editForm = ref({
  name: '',
  relevance_status: 'active',
  priorityGrade: '' as string,
  note: '',
  color: null as string | null,
  site_url: '',
  repository_url: '',
})

const relevanceOptions = computed(() =>
  // «Закрыт» больше нет — вместо него архив (ТЗ 3.11)
  ['active', 'paused'].map((value) => ({ value, label: relevanceLabel(value) })),
)


function startEdit() {
  if (!project.value) return
  editForm.value = {
    name: project.value.name,
    relevance_status: project.value.relevance_status,
    priorityGrade: project.value.priority === null ? '' : priorityToGrade(project.value.priority),
    note: project.value.note,
    color: project.value.color,
    site_url: project.value.site_url ?? '',
    repository_url: project.value.repository_url ?? '',
  }
  drawerOpen.value = true
}

function closeDrawer() {
  drawerOpen.value = false
}

async function saveEdit() {
  if (!project.value) return
  try {
    await api.updateProject(project.value.id, {
      name: editForm.value.name,
      relevance_status: editForm.value.relevance_status,
      priority:
        editForm.value.priorityGrade === '' ? null : gradeToPriority(editForm.value.priorityGrade),
      note: editForm.value.note,
      color: editForm.value.color,
      site_url: editForm.value.site_url.trim() === '' ? null : editForm.value.site_url.trim(),
      repository_url:
        editForm.value.repository_url.trim() === '' ? null : editForm.value.repository_url.trim(),
    })
    toast.success({ title: t('common.saved') })
    closeDrawer()
    await load()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

async function setStatus(_task: Task, status: string) {
  error.value = ''
  try {
    await api.updateTask(_task.id, { status })
    celebrateEarned()
    await load()
  } catch (e) {
    error.value = String(e)
  }
}

// Удаление задачи — через подтверждение
const deleteTarget = ref<Task | null>(null)
const confirmOpen = ref(false)

function askDelete(task: Task) {
  deleteTarget.value = task
  confirmOpen.value = true
}

async function remove() {
  const task = deleteTarget.value
  if (!task) return
  confirmOpen.value = false
  try {
    await api.deleteTask(task.id)
    toast.success({ title: t('common.taskDeleted') })
    await load()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

// Новая задача проекта: полная форма (TaskForm create + project_id по умолчанию)
const newTaskOpen = ref(false)

// Массовое добавление: форму не закрываем (проект остаётся дефолтом);
// переход к задаче не делаем — drawer умрёт вместе со страницей
function onTaskCreated() {
  // На мобильных форма создания закрывается (ТЗ 0.86), на десктопе остаётся
  if (isMobile()) newTaskOpen.value = false
  void load()
}

// Фильтр статуса — табы (0.70), как в списке задач: пять статусов из выпадашки
// не видны, а так состояние списка читается с одного взгляда. id «» = «Все»
const statusTabs = computed(() => [
  { id: '', label: t('common.all'), icon: 'ph-list-bullets' },
  ...statusOptions().map((o) => ({ id: o.value, label: o.label, icon: statusIcon(o.value) })),
])
// Опции поиска тега: полный справочник, подсказки GnCombobox фильтрует сам
const tagOptions = computed(() => tags.value.map((tg) => ({ value: String(tg.id), label: tg.name })))
</script>

<template>
  <section>
    <!-- Скелетон страницы проекта: шапка + статистика + список задач -->
    <div v-if="loading && !project" class="page-skeleton" aria-hidden="true">
      <span class="skeleton skeleton-title" />
      <div class="page-skeleton-cards">
        <div v-for="i in 2" :key="i" class="card">
          <div class="card-content">
            <span class="skeleton skeleton-line" style="width: 35%" />
            <span class="skeleton skeleton-line" style="width: 78%" />
            <span class="skeleton skeleton-line" style="width: 64%" />
          </div>
        </div>
      </div>
    </div>

    <GnEmptyState
      v-if="!loading && notFound"
      icon="ph-folder-simple"
      :title="t('projectPage.notFoundTitle')"
      :text="t('projectPage.notFoundText')"
    >
      <template #actions>
        <GnButton variant="primary" icon="ph-folders" @click="router.push('/projects')">
          {{ t('nav.projects') }}
        </GnButton>
      </template>
    </GnEmptyState>

    <template v-if="project">
      <GnPageHeader kicker="GNexus Tasks" :title="project.name">
        <template #title>
          <!-- Метка проекта (0.65): кружок и фавикон сайта рядом с названием,
               фавикон — ссылка на сайт (сокращение к чипу ниже) -->
          <span class="title-row">
            <ProjectMark
              :color="project.color"
              :project-id="project.id"
              :site-url="project.site_url"
              :href="project.site_url"
              :label="project.site_url ? `${t('projectPage.openSite')}: ${urlHost(project.site_url)}` : undefined"
            />
            <!-- Инлайн-правка названия: клик по нему же → поле на его месте -->
            <InlineField
              v-if="!isArchived"
              :label="t('task.editTitle')"
              :save="saveName"
              @open="draftName = project.name"
            >
              <template #display="{ edit }">
                <button class="title-editable" type="button" @click="edit">{{ project.name }}</button>
              </template>
              <template #edit="{ save }">
                <GnInput
                  v-model="draftName"
                  autofocus
                  class="title-input"
                  @keydown.enter="save"
                />
              </template>
            </InlineField>
            <span v-else>{{ project.name }}</span>
            <!-- Булавка (0.66, переставлена к имени в 0.67): важное действие —
                 раньше пряталось строкой «Закреплён» в параметрах, где её не
                 находили. В покое не приглушена, у закреплённой — янтарная.
                 В архиве скрыта (страница только для чтения), состояние видно
                 строкой «Закреплён» -->
            <GnIconButton
              v-if="!isArchived"
              class="title-pin"
              size="sm"
              :icon="project.pinned ? 'ph-push-pin-fill' : 'ph-push-pin'"
              :label="t(project.pinned ? 'projects.unpin' : 'projects.pin')"
              :title="t(project.pinned ? 'projects.unpin' : 'projects.pin')"
              :aria-pressed="project.pinned"
              @click="togglePin"
            />
          </span>
        </template>
        <template #meta>
          <GnBadge variant="info"><i class="ph ph-activity" aria-hidden="true" />
            {{ relevanceLabel(project.relevance_status) }}
          </GnBadge>
          <PriorityBadge v-if="project.priority !== null" :priority="project.priority" />
          <GnBadge variant="neutral"><i class="ph ph-list-checks" aria-hidden="true" />
            {{ t('projectPage.taskCount', { n: tasks.length }) }}
          </GnBadge>
          <!-- Сайт проекта (0.63): иконку тянет бэкенд-прокси, при осечке — глобус -->
          <a
            v-if="project.site_url"
            class="site-link"
            :href="project.site_url"
            target="_blank"
            rel="noopener noreferrer"
            :title="t('projectPage.openSite')"
          >
            <img
              v-if="!faviconBroken"
              class="site-icon"
              :src="api.projectFaviconUrl(project.id)"
              alt=""
              @error="faviconBroken = true"
            />
            <i v-else class="ph ph-globe" aria-hidden="true" />
            {{ urlHost(project.site_url) }}
          </a>
        </template>
        <template #actions>
          <!-- Спиннер обновления: данные уже есть, страница дотягивается -->
          <GnLoader
            v-if="loading"
            circle
            label=""
            :aria-label="t('common.loading')"
            class="refresh-loader"
          />
          <GnButton v-if="!isArchived" variant="primary" size="sm" icon="ph-plus" class="new-task-full" @click="newTaskOpen = true">
            {{ t('projectPage.newTask') }}
          </GnButton>
          <GnIconButton
            v-if="!isArchived"
            class="new-task-compact"
            icon="ph-plus"
            :label="t('projectPage.newTask')"
            @click="newTaskOpen = true"
          />
          <GnDropdown class="project-menu" :items="projectActions">
            <template #trigger="{ toggle }">
              <GnIconButton icon="ph-dots-three-outline" :label="t('common.actions')" @click="toggle" />
            </template>
          </GnDropdown>
        </template>
      </GnPageHeader>

      <GnAlert v-if="error" variant="error">{{ error }}</GnAlert>

      <!-- Проект в архиве: только история, восстановление из плашки -->
      <GnAlert v-if="isArchived" variant="warning" class="archived-banner">
        <i class="ph ph-archive" aria-hidden="true" />
        {{ t('archive.banner') }}
        <GnButton variant="success" size="sm" icon="ph-arrow-counter-clockwise" class="banner-restore" @click="restore">
          {{ t('archive.restore') }}
        </GnButton>
      </GnAlert>

      <div class="project-grid">
        <div class="project-main">
          <!-- Заметка (Markdown: ссылки, контекст): карандаш — правка прямо здесь -->
          <GnCard class="note-card">
            <template #title>
              <span class="card-title-icon"><i class="ph ph-notebook" aria-hidden="true" /></span>
              {{ t('projectPage.note') }}
              <GnIconButton
                v-if="!noteEditing && !isArchived"
                icon="ph-pencil-simple"
                size="sm"
                :label="t('projectPage.editNote')"
                @click="noteEditing = true; draftNote = project.note"
              />
            </template>
            <div v-if="noteEditing" class="inline-note">
              <MdEditor v-model="draftNote" caption="" :rows="8" :upload-images="uploadImages" />
              <span class="inline-actions">
                <GnIconButton
                  icon="ph-check"
                  size="sm"
                  :label="t('common.save')"
                  :disabled="noteBusy"
                  @click="saveNote"
                />
                <GnIconButton
                  icon="ph-x"
                  size="sm"
                  :label="t('common.cancel')"
                  :disabled="noteBusy"
                  @click="noteEditing = false"
                />
              </span>
            </div>
            <template v-else>
              <!-- Заметка: большая — сворачивается. README репозитория сюда
                   больше не подмешивается (0.67) — он в своей карточке ниже -->
              <div
                v-if="project.note.trim()"
                ref="noteBodyEl"
                :class="['md-view', { collapsed: noteOverflow && !noteExpanded }]"
                v-html="noteHtml"
                @click="onNoteClick"
              />
              <!-- Подсказка: длинная заметка показана не вся -->
              <div v-if="noteOverflow" class="note-fold">
                <span class="muted">{{ t('projectPage.noteCollapsedHint') }}</span>
                <GnButton
                  size="sm"
                  :icon="noteExpanded ? 'ph-caret-up' : 'ph-caret-down'"
                  @click="noteExpanded = !noteExpanded"
                >
                  {{ noteExpanded ? t('projectPage.noteCollapse') : t('projectPage.noteExpand') }}
                </GnButton>
              </div>
              <p v-if="!project.note.trim()" class="muted">
                <i class="ph ph-note-blank" aria-hidden="true" />
                {{ t('projectPage.noteEmpty') }}
              </p>
            </template>
          </GnCard>
          <MdLightbox
            v-if="noteImage"
            :src="noteImage.url"
            :alt="noteImage.name"
            @close="noteImage = null"
          />

          <!-- README репозитория (0.67) — второе описание проекта: заметка
               остаётся заметкой, а README живёт отдельной карточкой. Нет поля
               repository_url — карточки нет вовсе -->
          <GnCard v-if="readmeState !== 'idle'" class="readme-card">
            <template #title>
              <span class="card-title-icon"><i class="ph ph-book-open" aria-hidden="true" /></span>
              {{ t('projectPage.readmeTitle') }}
              <a
                v-if="project.repository_url"
                class="repo-src"
                :href="project.repository_url"
                target="_blank"
                rel="noopener noreferrer"
                :title="project.repository_url"
              >
                {{ repoLabel(project.repository_url) }}
              </a>
            </template>
            <!-- Загрузка: строки-заготовки, чтобы карточка не прыгала высотой -->
            <div v-if="readmeState === 'loading'" class="readme-skeleton" aria-hidden="true">
              <span v-for="i in 3" :key="i" class="skeleton skeleton-line" />
            </div>
            <template v-else-if="readmeState === 'ok'">
              <div
                ref="readmeBodyEl"
                :class="['md-view', { collapsed: readmeOverflow && !readmeExpanded }]"
                v-html="readmeHtml"
                @click="onNoteClick"
              />
              <!-- Подсказка: длинный README показан не весь -->
              <div v-if="readmeOverflow" class="note-fold">
                <span class="muted">{{ t('projectPage.noteCollapsedHint') }}</span>
                <GnButton
                  size="sm"
                  :icon="readmeExpanded ? 'ph-caret-up' : 'ph-caret-down'"
                  @click="readmeExpanded = !readmeExpanded"
                >
                  {{ readmeExpanded ? t('projectPage.noteCollapse') : t('projectPage.noteExpand') }}
                </GnButton>
              </div>
            </template>
            <!-- README не нашёлся (404) — тихая подпись: поле необязательное -->
            <p v-else-if="readmeState === 'missing'" class="muted">
              <i class="ph ph-book-open" aria-hidden="true" />
              {{ t('projectPage.readmeMissing') }}
            </p>
            <p v-else class="muted">
              <i class="ph ph-warning-circle" aria-hidden="true" />
              {{ t('projectPage.readmeError') }}
            </p>
          </GnCard>

          <!-- Прогресс: выполнено задач проекта -->
          <GnProgress
            class="progress"
            :value="doneCount"
            :max="Math.max(tasks.length, 1)"
            :label="t('projectPage.progress', { done: doneCount, total: tasks.length })"
          />

          <!-- Фильтры списка: статус — табами (0.70), тег — поиском по имени.
               Табы на узком экране прокручиваются по горизонтали внутри себя -->
          <GnTabs
            :model-value="filters.status"
            :items="statusTabs"
            compact
            class="status-tabs"
            :aria-label="t('list.filterStatus')"
            @update:model-value="filters.status = $event"
          />
          <div ref="listAnchor" class="filters">
            <!-- Поиск тега (0.70, доработано 0.71): подсказки — только совпавшие
                 теги; на пустом вводе вместо простыни из десятков тегов подсказка.
                 Подписи «Тег» тут нет: фильтр на странице один, и иконка тега с
                 плейсхолдером уже всё говорят. Обёртка задаёт ширину: class кита
                 уходит на <input>, не на form-group -->
            <div class="filter-tag">
              <GnCombobox
                v-model="filters.tag"
                icon="ph-tag"
                :options="tagQuery ? tagOptions : []"
                :placeholder="t('list.filterTagSearch')"
                :aria-label="t('list.filterTag')"
                :not-found-text="tagQuery ? t('list.noTagsFound') : t('list.filterTagHint')"
              />
              <!-- Сброс фильтра — крестик внутри поля (0.71): отдельной кнопкой
                   в ряду он выглядел чужеродно, а у поиска нет пункта
                   «— любой —», и без крестика фильтр не снять -->
              <GnIconButton
                v-if="tagQuery"
                class="filter-tag-clear"
                icon="ph-x"
                size="sm"
                :label="t('list.filterTagClear')"
                @click="clearTag"
              />
            </div>
          </div>

          <GnEmptyState
            v-if="filteredTasks.length === 0"
            icon="ph-magnifying-glass"
            :title="t('projectPage.emptyTitle')"
            :text="t('projectPage.emptyText')"
          />

          <div class="task-list">
            <TaskCard
              v-for="task in pagedTasks"
              :key="task.id"
              :task="task"
              :show-project="false"
              @status-change="setStatus"
              @delete="askDelete"
              @moved="load"
            />
          </div>

          <Pagination v-model:page="page" :total="filteredTasks.length" />
        </div>

        <!-- Сайдбар: статистика, параметры, теги -->
        <aside class="project-side">
          <GnCard class="side-card">
            <template #title>
              <span class="card-title-icon"><i class="ph ph-chart-bar" aria-hidden="true" /></span>
              {{ t('projectPage.stats') }}
            </template>
            <div class="p-rows">
              <div v-for="row in statRows" :key="row.key" class="p-row" :class="{ warn: row.warn }">
                <i :class="`ph ${row.icon}`" aria-hidden="true" />
                <span class="p-term">{{ row.term }}</span>
                <span class="p-value">{{ row.value }}</span>
              </div>
            </div>
          </GnCard>

          <!-- Активность проекта (0.83): сетка «как в GitHub» по задачам проекта,
               рядом со статистикой — обе карточки про проект в целом -->
          <ProjectHeatmap :tasks="tasks" />

          <GnCard class="side-card">
            <template #title>
              <span class="card-title-icon"><i class="ph ph-list-dashes" aria-hidden="true" /></span>
              {{ t('task.fields') }}
            </template>
            <div class="p-rows">
              <div
                v-for="row in paramRows"
                :key="row.key"
                class="p-row editing"
              >
                <i :class="`ph ${row.icon}`" aria-hidden="true" />
                <span class="p-term">{{ row.term }}</span>

                <!-- Архив: только чтение -->
                <template v-if="isArchived">
                  <GnBadge v-if="row.badge" :variant="row.badge" class="p-value-badge">
                    {{ row.value }}
                  </GnBadge>
                  <span v-else-if="row.key === 'color' && project.color" class="p-value">
                    <ProjectColorDot :color="project.color" /> {{ row.value }}
                  </span>
                  <span v-else class="p-value" :class="{ empty: row.empty }">
                    {{ row.empty ? t('common.notSet') : row.value }}
                  </span>
                </template>

                <!-- Актуальность: select -->
                <InlineField
                  v-else-if="row.key === 'relevance'"
                  below
                  :label="t('projects.form.relevance')"
                  :save="saveRelevance"
                  @open="draftRelevance = project.relevance_status"
                >
                  <template #display="{ edit }">
                    <span class="p-value clickable" @click="edit">{{ row.value }}</span>
                  </template>
                  <template #edit>
                    <GnSelect
                      v-model="draftRelevance"
                      :options="relevanceOptions"
                      class="inline-select"
                    />
                  </template>
                </InlineField>

                <!-- Цвет-метка: палитра, сохраняем сразу выбором (0.63) -->
                <ProjectColorPicker
                  v-else-if="row.key === 'color'"
                  align="right"
                  :model-value="project.color"
                  @update:model-value="saveColor"
                />

                <!-- Сайт: инлайн-правка ссылки (0.63) -->
                <InlineField
                  v-else-if="row.key === 'site'"
                  below
                  :label="t('projects.form.siteUrl')"
                  :save="saveSiteUrl"
                  @open="draftSiteUrl = project.site_url ?? ''"
                >
                  <template #display="{ edit }">
                    <span v-if="project.site_url" class="p-value clickable" @click="edit">
                      {{ row.value }}
                    </span>
                    <span v-else class="p-value empty clickable" @click="edit">
                      {{ t('common.notSet') }}
                    </span>
                  </template>
                  <template #edit>
                    <GnInput
                      v-model="draftSiteUrl"
                      type="url"
                      class="inline-url"
                      :placeholder="t('projects.sitePlaceholder')"
                    />
                  </template>
                </InlineField>

                <!-- Репозиторий: инлайн-правка ссылки (0.67), как у сайта -->
                <InlineField
                  v-else-if="row.key === 'repository'"
                  below
                  :label="t('projects.form.repositoryUrl')"
                  :save="saveRepositoryUrl"
                  @open="draftRepositoryUrl = project.repository_url ?? ''"
                >
                  <template #display="{ edit }">
                    <span
                      v-if="project.repository_url"
                      class="p-value clickable repo-value"
                      :title="project.repository_url"
                      @click="edit"
                    >
                      {{ row.value }}
                    </span>
                    <span v-else class="p-value empty clickable" @click="edit">
                      {{ t('common.notSet') }}
                    </span>
                  </template>
                  <template #edit>
                    <GnInput
                      v-model="draftRepositoryUrl"
                      type="url"
                      class="inline-url"
                      :placeholder="t('projects.repositoryPlaceholder')"
                    />
                  </template>
                </InlineField>

                <!-- Булавка (0.66): значение кликабельно, как у остальных
                     параметров — строку «Закреплён» переключаем кликом. При
                     этом булавка есть и у имени проекта (0.67) -->
                <template v-else-if="row.key === 'pinned'">
                  <span class="p-value clickable" @click="togglePin">{{ row.value }}</span>
                </template>

                <!-- Приоритет: select -->
                <InlineField
                  v-else
                  below
                  :label="t('projects.form.priority')"
                  :save="savePriority"
                  @open="draftPriority = project.priority === null ? '' : priorityToGrade(project.priority)"
                >
                  <template #display="{ edit }">
                    <GnBadge
                      v-if="row.badge"
                      :variant="row.badge"
                      class="p-value-badge"
                      @click="edit"
                    >
                      <i class="ph ph-flag" aria-hidden="true" />{{ row.value }}
                    </GnBadge>
                    <span v-else class="p-value empty clickable" @click="edit">
                      {{ t('common.notSet') }}
                    </span>
                  </template>
                  <template #edit>
                    <GnSelect
                      v-model="draftPriority"
                      :options="priorityOptionsWithNone"
                      class="inline-select"
                    />
                  </template>
                </InlineField>
              </div>
            </div>
          </GnCard>

          <GnCard v-if="tagRows.length" class="side-card tags-card">
            <template #title>
              <span class="card-title-icon"><i class="ph ph-tag" aria-hidden="true" /></span>
              {{ t('stack.form.tags') }}
            </template>
            <!-- Облако (0.83) вместо списка: теги проекта, размер имени — по числу
                 задач, клик ставит фильтр по имени. Кнопки, а не ссылки: это действие
                 на этой же странице, а не переход -->
            <div class="tag-cloud">
              <button
                v-for="[name, count] in tagRows"
                :key="name"
                type="button"
                class="tag-chip"
                :class="{ active: isActiveTag(name) }"
                :style="{ fontSize: tagFontSize(count) }"
                :title="t('projectPage.tagCount', { n: count })"
                :aria-label="
                  isActiveTag(name)
                    ? t('projectPage.tagHide', { name })
                    : t('projectPage.tagShow', { name })
                "
                :aria-pressed="isActiveTag(name)"
                @click="filterByTag(name)"
              >
                {{ name }}
              </button>
            </div>
          </GnCard>
        </aside>
      </div>
    </template>

    <GnDrawer v-model:open="newTaskOpen" :title="t('projectPage.newTask')" position="right">
      <TaskForm
        v-if="newTaskOpen"
        :key="projectId"
        :project-id="projectId"
        @saved="onTaskCreated"
        @cancel="newTaskOpen = false"
      />
    </GnDrawer>

    <GnDrawer v-model:open="drawerOpen" :title="project?.name || t('projects.edit')" position="right" @close="closeDrawer">
      <form class="edit-form" @submit.prevent="saveEdit">
        <GnInput v-model="editForm.name" :label="t('projects.form.name')" required />
        <ProjectColorPicker v-model="editForm.color" :label="t('projects.form.color')" />
        <GnInput
          v-model="editForm.site_url"
          type="url"
          icon="ph-globe"
          :label="t('projects.form.siteUrl')"
          :placeholder="t('projects.sitePlaceholder')"
        />
        <GnInput
          v-model="editForm.repository_url"
          type="url"
          icon="ph-git-fork"
          :label="t('projects.form.repositoryUrl')"
          :placeholder="t('projects.repositoryPlaceholder')"
        />
        <GnSelect
          v-model="editForm.relevance_status"
          :label="t('projects.form.relevance')"
          icon="ph-activity"
          :options="relevanceOptions"
        />
        <GnSelect
          v-model="editForm.priorityGrade"
          :label="t('projects.form.priority')"
          icon="ph-flag"
          :options="priorityOptionsWithNone"
        />
        <MdEditor v-model="editForm.note" :caption="t('projects.form.note')" :rows="6" :upload-images="uploadImages" />
        <div class="form-actions">
          <GnButton type="submit" variant="success" icon="ph-check-circle">
            {{ t('common.save') }}
          </GnButton>
          <GnButton type="button" variant="primary" @click="closeDrawer">
            {{ t('common.cancel') }}
          </GnButton>
        </div>
      </form>
    </GnDrawer>

    <ConfirmDialog
      v-model:open="confirmOpen"
      :title="t('common.confirmTitle')"
      :message="t('common.deleteConfirmTask', { title: deleteTarget?.title ?? '' })"
      :confirm-text="t('common.delete')"
      :cancel-text="t('common.cancel')"
      confirm-variant="danger"
      confirm-icon="ph-trash"
      @confirm="remove"
    />

    <ConfirmDialog
      v-model:open="archiveConfirmOpen"
      :title="t('common.archiveTitle')"
      :message="t('projects.archiveConfirm', { name: project?.name ?? '', n: tasks.length })"
      :confirm-text="t('common.archive')"
      :cancel-text="t('common.cancel')"
      confirm-variant="warning"
      @confirm="archiveSelf"
    />

    <ConfirmDialog
      v-model:open="projectDeleteOpen"
      :title="t('common.confirmTitle')"
      :message="t('common.deleteConfirmProject', { name: project?.name ?? '' })"
      :confirm-text="t('common.delete')"
      :cancel-text="t('common.cancel')"
      confirm-variant="danger"
      confirm-icon="ph-trash"
      @confirm="deleteSelf"
    />
  </section>
</template>

<style scoped>
/* Плашка архива: текст и кнопка в строку */
.archived-banner {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-bottom: 1.5rem;
}
.banner-restore {
  margin-left: auto;
}
.page-skeleton {
  margin-bottom: 1rem;
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.page-skeleton > .skeleton-title {
  display: block;
  margin-bottom: 6px;
}
.page-skeleton-cards {
  display: flex;
  flex-direction: column;
  gap: 12px;
}
/* карточка-скелет: как реальная карточка — тянется на ширину */
.page-skeleton .card {
  width: 100%;
  max-width: none;
}
/* «Новая задача» в шапке страницы: на мобильном — GnIconButton кита (btn-icon)
   вместо кнопки с лейблом; переключение display, без ручных размеров */
.new-task-compact {
  display: none;
}
@media (max-width: 640px) {
  .new-task-full {
    display: none;
  }
  .new-task-compact {
    display: flex;
  }
}
/* Две колонки на широком экране: содержимое + сайдбар */
.project-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 340px;
  gap: 1.5rem;
  align-items: start;
}
@media (max-width: 960px) {
  .project-grid {
    /* minmax(0,1fr): минимум auto растягивал трек содержимым карточек */
    grid-template-columns: minmax(0, 1fr);
  }
}
.project-main > * + *,
.project-side > * + * {
  margin-top: 1.5rem;
}
.md-view {
  overflow-x: auto;
  font-size: 1.02rem;
  line-height: 1.65;
}
.md-view :deep(img) {
  max-width: 100%;
  height: auto;
}
.p-rows {
  display: flex;
  flex-direction: column;
  gap: 0.55rem;
}
.p-row {
  display: flex;
  gap: 0.6rem;
  align-items: baseline;
  font-size: 0.95rem;
}
.p-row > i {
  flex: 0 0 1.25rem;
  text-align: center;
  color: var(--accent, #7aa2f7);
  font-size: 1.05em;
  align-self: center;
}
.p-term {
  color: var(--text-muted, #888);
  flex: 1 1 auto;
}
.p-value {
  flex: 0 0 auto;
  font-weight: 600;
  overflow-wrap: anywhere;
}
/* Путь к репозиторию (0.67): сайдбар узкий, адрес длинный — переносим по
   слэшам (разделители в repoLabel), а не посреди сегмента: общий .p-value
   рвёт где угодно. Слишком длинный сегмент всё равно переносится, а не
   вылезает за карточку */
.p-value.repo-value {
  overflow-wrap: break-word;
}
.p-row.warn > i,
.p-row.warn .p-value {
  color: var(--danger, #f7768e);
}
/* строки с инлайн-правкой: пока редактор не открыт — обычная строка в один ряд;
   при открытом редакторе (below в InlineField) он переносится на отдельную
   строку на всю ширину. :has — по факту открытого редактора, а не по классу
   строки: класс стоит всегда, показ ломаться не должен */
.p-row.editing {
  align-items: center;
}
.p-row:has(.inline-editing) {
  flex-wrap: wrap;
}
.p-row:has(.inline-editing) .inline-field {
  flex: 1 1 100%;
}
/* кликабельное значение параметра: курсор + подсветка по hover */
.p-value.clickable {
  cursor: pointer;
}
.p-value.clickable:hover {
  color: var(--accent, #7aa2f7);
}
.p-value.empty {
  color: var(--text-muted, #888);
  font-weight: normal;
}
.p-value-badge {
  cursor: pointer;
}
/* инлайн-редакторы: обёртка .form-group кита в строке — без отступов формы, ужато */
.p-row .form-group {
  margin: 0;
  min-width: 0;
}
.p-row :deep(.form-group .label) {
  margin: 0;
}
.p-row :deep(.form-group .label .input) {
  margin-top: 0;
  min-height: 0;
  padding: 4px 10px;
  border-bottom-width: 2px;
  font-size: 14px;
}
.p-row :deep(.inline-select) {
  min-width: 7rem;
}
/* URL в инлайне: ссылки длинные — поле шире селекта */
.p-row :deep(.inline-url) {
  min-width: 13rem;
}
/* цвет-метка в строке параметров (0.63): кружок+hex в триггере */
.p-row .color-field {
  flex: 0 0 auto;
}
/* стрелка/иконочный отступ кита в инлайне не нужен */
.p-row :deep(.select-wrap .select) {
  padding-left: 10px;
}
/* название проекта как кнопка правки */
.title-editable {
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  color: inherit;
  text-align: inherit;
  cursor: pointer;
  overflow-wrap: anywhere;
}
.title-editable:hover {
  color: var(--accent, #7aa2f7);
}
/* Шапка: метка проекта (кружок + фавикон, 0.65) перед названием, ссылка на
   сайт — чипом среди бейджей */
.title-row {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  min-width: 0;
  max-width: 100%;
}
/* Метка в шапке (0.65): база метки — 18px, кружок 18px и фавикон 21px при H1 23px.
   Фавикон рядом с кружком — ссылка на сайт, поэтому в заголовке их пара. */
.title-row :deep(.proj-mark) {
  font-size: 18px;
}
/* Ссылка на сайт — в ряду бейджей (0.65): геометрия бейджа кита
   (24px/13px/600), но без рамки и без капса — домен в верхнем регистре читается
   хуже. Раньше это была тихая подпись (13px, --gn-color-text-dark) и выпадала
   из ряда бейджей. */
.site-link {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  min-height: 24px;
  padding: 0 9px 0 5px;
  color: var(--gn-color-text-light, #c0caf5);
  font-size: 13px;
  font-weight: 600;
  line-height: 1;
  text-decoration: none;
  max-width: 18rem;
  overflow-wrap: anywhere;
  transition: color var(--gn-motion-base, 0.2s) ease;
}
.site-link:hover {
  color: var(--accent, #7aa2f7);
  text-decoration: underline;
  text-underline-offset: 3px;
}
.site-icon {
  width: 20px;
  height: 20px;
  flex: none;
  border-radius: 4px;
  object-fit: contain;
}
/* фолбэк, когда иконки нет (нет favicon/сайта, SVG) — глобус в размер иконки */
.site-link .ph-globe {
  font-size: 18px;
}
/* Булавка у имени проекта (0.67): важное действие в быстром доступе — раньше
   её находили только строкой «Закреплён» в параметрах. В покое не приглушена
   (жалоба была на незаметность), у закреплённого — янтарная кита */
.title-pin {
  flex: none;
}
.title-pin[aria-pressed='true'] {
  color: var(--gn-color-warning, #e0af68);
}
/* шапка карточки заметки: текст слева, карандаш справа в цвет заголовка */
.note-card .card-title {
  display: flex;
  align-items: center;
}
/* шапка карточки README (0.67): заголовок слева, ссылка на репозиторий справа */
.readme-card .card-title {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}
.note-card .card-title .btn-icon {
  margin-left: auto;
  margin-top: -23px;
  color: inherit; /* тёмный цвет текста заголовка — на светлой полосе шапки */
  width: 24px;
  height: 24px;
  font-size: 16px;
}
/* правка названия: поле кита (input.input) наследует h1, компактнее по высоте */
/* правка названия: поле кита (GnInput, обёртка .form-group) наследует h1 */
.page-header :deep(.inline-editing) {
  width: 100%;
  max-width: 34rem;
  flex-wrap: nowrap; /* редактор и кнопки в одной строке шапки */
}
.page-header .form-group {
  margin: 0;
  flex: 1 1 auto;
  min-width: 0;
}
.page-header :deep(.form-group .label) {
  margin: 0;
}
.page-header :deep(input.title-input) {
  font: inherit;
  font-weight: inherit;
  min-height: 0;
  padding: 4px 12px;
  margin-top: 0;
}
/* правка заметки: редактор + кнопки */
.inline-note {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}
.inline-note .inline-actions {
  display: flex;
  gap: 0.25rem;
}
.muted {
  color: var(--text-muted, #888);
  font-size: 0.9em;
}
/* README репозитория (0.67): ссылка на репозиторий — в шапке карточки, справа;
   хост+путь без схемы, длинный путь срезается многоточием */
.repo-src {
  margin-left: auto;
  min-width: 0;
  max-width: 22rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--accent, #7aa2f7);
  font-size: 13px;
  font-weight: 600;
  /* Кит капсит .card-title, а домен с путём в верхнем регистре читается хуже
     (то же решение, что у ссылки на сайт, 0.65) */
  text-transform: none;
  text-decoration: none;
}
.repo-src:hover {
  text-decoration: underline;
  text-underline-offset: 3px;
}
/* Загрузка README: строки-заготовки вместо текста */
.readme-skeleton {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.readme-skeleton .skeleton-line {
  display: block;
  height: 14px;
}
.readme-skeleton .skeleton-line:last-child {
  width: 60%;
}
/* Тегов бывает много — карточка не растягивает сайдбар, скроллим внутри */
.tags-card :deep(.card-content) {
  max-height: 400px;
  overflow-y: auto;
}
/* Облако тегов (0.83): размер имени — частота тега в задачах проекта, поэтому
   строку держим по базовой линии, а не по центру коробок; gap в двух направлениях —
   иначе крупные и мелкие чипы в одной строке липнут друг к другу */
.tag-cloud {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.4rem 0.5rem;
}
/* Тег — кнопка: клик ставит фильтр по имени. Рамка и заливка — китовые токены,
   те же, что у соседних карточек сайдбара */
.tag-chip {
  padding: 0 0.4rem;
  background: none;
  border: var(--gn-border-width-base, 2px) solid var(--gn-border-color-muted, rgba(192, 202, 245, 0.24));
  border-radius: 3px;
  font-family: inherit;
  line-height: 1.6;
  color: var(--gn-color-text-medium, #a9b1d6);
  cursor: pointer;
}
.tag-chip:hover {
  color: var(--accent, #7aa2f7);
  border-color: var(--accent, #7aa2f7);
}
/* активный тег — тот, чьё имя стоит в поиске: фильтр видно и в облаке, а не
   только в поле над списком */
.tag-chip.active {
  color: var(--accent, #7aa2f7);
  border-color: var(--accent, #7aa2f7);
  background: color-mix(in srgb, var(--accent, #7aa2f7) 15%, transparent);
}
/* свёрнутая длинная заметка: видно начало, низ срезается */
.md-view.collapsed {
  max-height: 420px;
  overflow: hidden;
}
.note-fold {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  margin-top: 0.5rem;
}
/* фильтры: поиск по тегу (0.70) — в ките это form-group, ширина задаётся ниже */
.filters {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  align-items: flex-end;
  margin-bottom: 1rem;
}
/* Табы статуса (0.70): class ложится на корень GnTabs, поэтому без :deep.
   Кит тянет компактные табы на max-width:620px — шестой статус уезжал за край */
.status-tabs {
  width: auto;
  max-width: 100%;
  margin-bottom: 1rem;
}
/* Прогресс на всю ширину контейнера (в ките .progress упёрт в max-width: 640px) */
.progress {
  max-width: 100%;
}
.filters :deep(.form-group) {
  width: 14rem;
  margin-bottom: 0;
}
/* Ширина поиска тега — на обёртке, потому что class из GnCombobox уходит
   на <input>, а не на form-group. Обёртка же — точка отсчёта для крестика.
   300px (0.71): фильтр на странице один, и в 224px имя тега не читалось */
.filter-tag {
  position: relative;
}
.filter-tag :deep(.form-group) {
  width: 300px;
  min-width: 0;
}
/* Иконка тега в поле — китовый ярус (bottom:1px, height:46px) в коробке поля
   на пиксель ниже её центра, а глиф кажется ниже ещё на пару: поднимаем на 3px */
.filter-tag :deep(.label > .ph) {
  transform: translateY(-3px);
}
/* Крестик внутри поля (0.71) — своей кнопкой в ряду он не вписывался, а слот
   внутрь GnCombobox кит не даёт, поэтому кладём его поверх поля: поле — последний
   элемент коробки, отсчёт снизу складывается из margin-bottom .label кита (5px)
   и половины разницы высот поля (50px) и кнопки (28px) */
.filter-tag-clear {
  position: absolute;
  right: 4px;
  bottom: calc(5px + (50px - 28px) / 2);
}
/* текст поля не заезжает под крестик — отступ только когда крестик есть */
.filter-tag:has(.filter-tag-clear) :deep(input.input) {
  padding-right: 2.4rem;
}
/* на моб фильтры уже (просьба пользователя). Исключение — поиск по тегу: со 0.70
   он остался единственным фильтром страницы, и в 10rem плейсхолдер обрезался
   («Поиск по те…»); ему на узком экране отдаём всю ширину */
@media (max-width: 640px) {
  .filters :deep(.form-group) {
    width: 10rem;
  }
  .filter-tag,
  .filter-tag :deep(.form-group) {
    width: 100%;
  }
}
.filters :deep(.select-wrap) {
  margin-top: 0;
}
/* Список задач — две карточки в ряд (0.83): в одну колонку карточка занимает всю
   ширину основной колонки, а данных в ней на полстроки. Высоту ряда не выравниваем
   руками: карточки тянутся (stretch по умолчанию), поэтому подвалы соседей стоят
   на одной линии. На узком экране — снова одна колонка */
.task-list {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.75rem;
}
@media (max-width: 700px) {
  .task-list {
    grid-template-columns: minmax(0, 1fr);
  }
}
.edit-form {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}
.form-actions {
  display: flex;
  gap: 0.75rem;
  align-items: center;
}
.md-preview {
  border: 2px solid var(--gn-border-color-muted, rgba(192, 202, 245, 0.24));
  border-radius: 3px;
  padding: 0.75rem;
  overflow-x: auto;
}
.md-preview :deep(img) {
  max-width: 100%;
  height: auto;
}
</style>