<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { CSSProperties } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToast } from 'gnexus-ui-kit/vue'
import { api, type Project, type Task } from '../api'
import PriorityBadge from '../components/PriorityBadge.vue'
import ProjectMark from '../components/ProjectMark.vue'
import { notifyCreated } from '../gamification'
import { useRealtimeRefresh } from '../realtime'
import { deadlineCountdown, deadlineOverdue, daysUntil, formatDate, relevanceLabel, timeAgo } from '../taskui'
import QuickPrompt from '../components/QuickPrompt.vue'
import Pagination from '../components/Pagination.vue'
// Проекты (ТЗ 3.8): актуальность, приоритет, заметка с ресурсами.
// Карточка списка — только метрики (0.62): заметка целиком живёт на странице
// проекта, в списке она делала карточки разной высоты и мешала сравнивать.
// Карточка кликабельна целиком (0.63): кнопки «Открыть» нет, ссылка — обёртка
// вокруг GnCard, поэтому заголовок внутри — <span>. Плюс цвет-метка проекта.
// Прогресс на карточке — счётчики и клетки по задачам (0.64): полоса разбита
// насечками «одна клетка — одна задача» и залита цветом проекта.
// Метка проекта (0.65) — кружок и фавикон сайта одним блоком, тайтл карточки
// крупнее: проект узнаётся по картинке раньше, чем прочитано имя.
// Булавка и свежесть (0.66): закреплённые держатся секцией «Закреплённые» сверху,
// внутри групп порядок — по свежей активности задач; подвал карточки — отсчёт до
// ближайшего дедлайна словами, низ карточек в ряду выровнен.
// Подвал виден всегда (0.69): слева свежесть задач словами («3 дня назад»),
// справа ближайший срок, — у карточки появляется второй ярус метаданных.
const { t } = useI18n()
const toast = useToast()
const projects = ref<Project[]>([])
const tasks = ref<Task[]>([])
const error = ref('')
const loading = ref(false)
// Метрики карточки проекта (0.62): прогресс, «в работе», просрочка, ближайший
// дедлайн. Определения согласованы с полосой прогресса — она считает все задачи
// проекта, поэтому фильтр по detail_state здесь не применяем.
// activity (0.66) — время свежей активности, epoch ms для сортировки; null —
// у проекта без задач (такие всегда идут в конце своей группы)
type ProjectStats = {
total: number
done: number
inProgress: number
overdue: number
nextDeadline: string | null
activity: number | null
}
const NO_STATS: ProjectStats = {
total: 0,
done: 0,
inProgress: 0,
overdue: 0,
nextDeadline: null,
activity: null,
}
const projectStats = computed(() => {
const map = new Map<number, ProjectStats>()
tasks.value.forEach((task) => {
if (!task.project) return
const stats = map.get(task.project.id) ?? { ...NO_STATS }
stats.total += 1
if (task.status === 'done') stats.done += 1
if (task.status === 'in_progress') stats.inProgress += 1
// закрытые задачи ни просроченными, ни «ближайшим дедлайном» не считаем
if (task.status !== 'done' && task.status !== 'cancelled') {
if (deadlineOverdue(task)) stats.overdue += 1
if (task.deadline_date && (!stats.nextDeadline || task.deadline_date < stats.nextDeadline)) {
stats.nextDeadline = task.deadline_date
}
}
// Свежесть (0.66): самая поздняя из созданных/закрытых задач проекта.
// Правки самого проекта (заметка, цвет, статус) в активность не идут —
// сортировка про работу над проектом, а не про правку его карточки
const created = Date.parse(task.created_at) || 0
const done = task.done_at ? Date.parse(task.done_at) || 0 : 0
stats.activity = Math.max(stats.activity ?? 0, created, done)
map.set(task.project.id, stats)
})
return map
})
// Метрики проекта: у проекта без задач — нули
function statsOf(id: number): ProjectStats {
return projectStats.value.get(id) ?? NO_STATS
}
// Клеток больше этого числа не рисуем: клетка становится уже 4px и полоса
// рябит — у больших проектов она остаётся сплошной.
const CELL_MAX = 24
// Оформление полосы задаётся переменными на корне карточки, а не на самой
// полосе: кит в GnProgress ставит свой style после ...attrs, и внешний теряется.
function progressVars(row: { project: Project; stats: ProjectStats }): CSSProperties {
const vars: CSSProperties = {}
if (row.project.color) vars['--card-color'] = row.project.color
if (row.stats.total >= 2) {
vars['--task-cells'] = row.stats.total
vars['--done-cells'] = row.stats.done
}
return vars
}
async function load() {
loading.value = true
error.value = ''
try {
;[projects.value, tasks.value] = await Promise.all([api.listProjects(), api.listTasks()])
} catch (e) {
error.value = String(e)
} finally {
loading.value = false
}
}
onMounted(load)
// Реактивность (ТЗ 3.14): счётчики зависят от задач, карточки — от проектов
useRealtimeRefresh(load, ['project.changed', 'project.deleted', 'task.changed', 'task.deleted'])
// Пагинация: по 30 проектов на страницу
const page = ref(1)
// Порядок (0.66): закреплённые → с задачами → без задач, внутри группы — по
// свежей активности задач, затем по id. Сортировка идёт ДО пагинации, иначе
// секции разъедутся по страницам и «Без задач» окажется в середине списка.
// Группы взаимоисключающие: закреплённый проект с задачами живёт только
// в «Закреплённых» (у булавки приоритет над наличием задач)
function byActivity(a: Project, b: Project): number {
const left = statsOf(a.id).activity
const right = statsOf(b.id).activity
// пустая активность — в конец группы; сравнение числовое, done_at бывает null
if (left !== right) return (right ?? -Infinity) - (left ?? -Infinity)
return a.id - b.id
}
const orderedProjects = computed(() => {
const groups: Project[][] = [[], [], []]
projects.value.forEach((project) => {
const group = project.pinned ? 0 : statsOf(project.id).total > 0 ? 1 : 2
groups[group].push(project)
})
return groups.flatMap((group) => group.sort(byActivity))
})
const pagedProjects = computed(() =>
orderedProjects.value.slice((page.value - 1) * 30, page.value * 30),
)
// Проект без задач терялся среди заполненных: одинаковый бейдж «закрыто 0/0»
// ничем не отличался от соседей. Такие проекты уходят в отдельную секцию внизу,
// закреплённые — в секцию сверху (0.66); заголовки показываем только когда
// закреплённые или пустые есть, иначе список выглядит как раньше.
// Счётчики и показ заголовков — по всему списку, а не по странице: иначе на
// второй странице заголовки то появляются, то исчезают.
// Метрики и отсчёт считаем здесь же, чтобы карточка не звала statsOf по пять раз.
const projectSections = computed(() => {
const rows = pagedProjects.value.map((project) => {
const stats = statsOf(project.id)
return {
project,
stats,
daysLeft: stats.nextDeadline ? daysUntil(stats.nextDeadline) : null,
// отсчёт словами вместо даты (0.66); полная дата — в подсказке
countdown: stats.nextDeadline ? deadlineCountdown(stats.nextDeadline) : '',
deadlineTitle: stats.nextDeadline
? t('projects.nextDeadline', { date: formatDate(stats.nextDeadline) })
: '',
// Свежесть (0.69): подвал карточки есть всегда, поэтому в проекте без задач
// строка пустая (активности по задачам там нет); полная дата — в подсказке
activityAgo: timeAgo(stats.activity),
activityTitle: stats.activity
? t('projects.lastActivity', {
date: formatDate(new Date(stats.activity).toISOString()),
})
: '',
}
})
const pinnedRows = rows.filter((row) => row.project.pinned)
const withTasks = rows.filter((row) => !row.project.pinned && row.stats.total > 0)
const withoutTasks = rows.filter((row) => !row.project.pinned && row.stats.total === 0)
const pinnedTotal = projects.value.filter((project) => project.pinned).length
const withoutTotal = projects.value.filter(
(project) => !project.pinned && statsOf(project.id).total === 0,
).length
const titled = pinnedTotal > 0 || withoutTotal > 0
return [
{
key: 'pinned',
titled,
icon: 'ph-push-pin',
badge: 'warning',
label: t('projects.pinned'),
count: pinnedTotal,
items: pinnedRows,
},
{
key: 'with',
titled,
icon: 'ph-folders',
badge: 'info',
label: t('projects.withTasks'),
count: projects.value.length - pinnedTotal - withoutTotal,
items: withTasks,
},
{
key: 'without',
titled,
icon: 'ph-folder-dotted',
badge: 'neutral',
label: t('projects.withoutTasks'),
count: withoutTotal,
items: withoutTasks,
},
].filter((section) => section.items.length > 0)
})
// Создание — модальное окно вместо window.prompt
const createOpen = ref(false)
async function createProject(name: string) {
try {
await api.createProject(name)
toast.success({ title: t('common.created') })
// наградный тост после основного (провайдер кита держит один тост)
notifyCreated()
await load()
} catch (e) {
toast.error({ title: t('common.error'), text: String(e) })
}
}
// Булавка (0.66): переворачиваем сразу — computed пересчитаются, и карточка
// тут же уезжает в «Закреплённые». Свою перезагрузку не зовём: SSE-эхо
// project.changed и так вызовет load(), а вместе получилось бы два запроса.
// Ответ сервера заменяет элемент массива (в нём нормализованные поля).
async function togglePin(project: Project) {
const before = project.pinned
project.pinned = !before
// при закреплении карточка уходит в начало списка — открываем первую страницу,
// чтобы переезд был виден (при откреплении страницу не трогаем)
if (!before) page.value = 1
try {
const updated = await api.updateProject(project.id, { pinned: !before })
const index = projects.value.findIndex((item) => item.id === project.id)
if (index !== -1) projects.value[index] = updated
} catch (e) {
project.pinned = before
toast.error({ title: t('common.error'), text: String(e) })
}
}
</script>
<template>
<section>
<GnPageHeader kicker="GNexus Tasks" :title="t('projects.title')">
<template #meta>{{ t('projects.count', { n: projects.length }) }}</template>
<template #actions>
<GnLoader
v-if="loading && projects.length"
circle
label=""
:aria-label="t('common.loading')"
class="refresh-loader"
/>
<GnButton variant="primary" icon="ph-plus" @click="createOpen = true">
{{ t('projects.new') }}
</GnButton>
</template>
</GnPageHeader>
<GnAlert v-if="error" variant="error">{{ error }}</GnAlert>
<!-- Скелетон карточек проектов: имя + ряд бейджей + блок прогресса,
как в реальных -->
<div v-if="loading && projects.length === 0" class="list-skeleton" aria-hidden="true">
<div v-for="i in 3" :key="i" class="card">
<div class="card-content">
<!-- Шапка скелетона (0.67): имя и квадрат под булавку в правом верхнем
углу — там же, где она у настоящей карточки -->
<div class="sk-head">
<span class="skeleton skeleton-title" />
<span class="skeleton skeleton-square" />
</div>
<div class="sk-badges">
<span v-for="j in 3" :key="j" class="skeleton skeleton-square" />
</div>
<div class="sk-progress">
<span class="skeleton skeleton-line" style="width: 40%" />
<span class="skeleton skeleton-line" />
</div>
</div>
<!-- Подвал скелетона (0.66): строка свежести слева, место под отсчёт
справа — как в настоящей карточке, где подвал есть всегда (0.69);
иначе при загрузке карточки ниже, чем после отрисовки данных.
С 0.81 это отдельная полоса карточки, как у настоящего подвала -->
<footer class="card-footer sk-foot">
<span class="skeleton skeleton-line" style="width: 35%" />
<span class="skeleton skeleton-line" style="width: 20%" />
</footer>
</div>
</div>
<GnEmptyState
v-if="!loading && projects.length === 0"
icon="ph-folders"
:title="t('projects.emptyTitle')"
:text="t('projects.emptyText')"
>
<template #actions>
<GnButton variant="primary" icon="ph-plus" @click="createOpen = true">
{{ t('projects.new') }}
</GnButton>
</template>
</GnEmptyState>
<div class="project-list">
<template v-for="section in projectSections" :key="section.key">
<div v-if="section.titled" class="list-section-title">
<i :class="`ph ${section.icon}`" aria-hidden="true" />
<span>{{ section.label }}</span>
<GnBadge :variant="section.badge">
{{ section.count }}
</GnBadge>
</div>
<!-- Вся карточка — одна ссылка: заголовок внутри неё уже не ссылка
(ссылка в ссылке невалидна), зато «открыть в новой вкладке»
работает по правому клику в любом месте карточки.
Булавка — сосед ссылки, а не её потомок: интерактив внутри <a>
так же невалиден, поэтому обёрткой служит .card-wrap (0.66) -->
<div v-for="row in section.items" :key="row.project.id" class="card-wrap">
<RouterLink class="project-card" :to="`/projects/${row.project.id}`">
<GnCard :style="progressVars(row)">
<template #title>
<!-- Актуальность — тихий статус в шапке справа, чтобы ряд ниже
занимали только изменчивые метрики -->
<span class="card-name">
<!-- Метка (0.65): кружок проекта + фавикон сайта — ссылку внутрь
карточки не вставляем, карточка целиком сама ссылка -->
<ProjectMark
:color="row.project.color"
:project-id="row.project.id"
:site-url="row.project.site_url"
/>{{ row.project.name }}
</span>
<GnBadge
class="relevance-badge"
:variant="row.project.relevance_status === 'active' ? 'info' : 'neutral'"
>
<i class="ph ph-activity" aria-hidden="true" />{{ relevanceLabel(row.project.relevance_status) }}
</GnBadge>
</template>
<!-- Метрики: приоритет, «в работе», просрочка. Нулевые не показываем —
иначе ряд бейджей шумит одинаковыми «0» на каждой карточке -->
<div class="project-meta">
<PriorityBadge v-if="row.project.priority !== null" :priority="row.project.priority" />
<GnBadge v-if="row.stats.inProgress" variant="accent">
<i class="ph ph-circle-half" aria-hidden="true" />{{ t('projects.inProgress', { n: row.stats.inProgress }) }}
</GnBadge>
<GnBadge v-if="row.stats.overdue" variant="error">
<i class="ph ph-alarm" aria-hidden="true" />{{ t('projects.overdue', { n: row.stats.overdue }) }}
</GnBadge>
</div>
<!-- Прогресс по задачам проекта: счётчик «✓ закрыто / всего»,
процент кита и полоса клетками — одна клетка это одна задача
(0.64). У проекта без задач — подсказка, иначе на карточке
остаётся дыра на месте полосы -->
<GnProgress
v-if="row.stats.total > 0"
class="project-progress"
:class="{
celled: row.stats.total >= 2 && row.stats.total <= CELL_MAX,
'project-progress-done': row.stats.done === row.stats.total,
}"
:value="row.stats.done"
:max="row.stats.total"
:title="t('projects.progressLabel', { done: row.stats.done, total: row.stats.total })"
>
<template #label>
<span class="tasks-done">
<i class="ph ph-check-circle" aria-hidden="true" />{{ row.stats.done }}
</span>
<span class="tasks-sep">/</span>
<span class="tasks-total">{{ row.stats.total }}</span>
</template>
</GnProgress>
<p v-else class="card-hint">{{ t('projects.noTasksYet') }}</p>
<!-- Подвал карточки есть всегда (0.69): слева свежесть — когда последний
раз была активность по задачам («3 дня назад»), справа отсчёт до
ближайшего срока словами (0.66); полные даты — в подсказках.
С 0.81 это слот #footer кита, а не строка внутри содержимого:
так подвал отделён чертой во всю ширину карточки, как в карточке
задачи (общая полоса — в kit-overrides.css) -->
<template #footer>
<span v-if="row.activityAgo" class="card-activity" :title="row.activityTitle">
<i class="ph ph-clock-counter-clockwise normalize" aria-hidden="true" />
{{ row.activityAgo }}
</span>
<span
v-if="row.countdown"
class="card-deadline"
:class="{ late: row.daysLeft !== null && row.daysLeft < 0 }"
:title="row.deadlineTitle"
>
<i class="ph ph-calendar-check normalize" aria-hidden="true" />
{{ row.countdown }}
</span>
</template>
</GnCard>
</RouterLink>
<!-- Булавка: закреплённые держатся секцией сверху. Кит кладёт label
в aria-label, aria-pressed проходит через ...attrs -->
<GnIconButton
class="card-pin"
size="sm"
:icon="row.project.pinned ? 'ph-push-pin-fill' : 'ph-push-pin'"
:label="t(row.project.pinned ? 'projects.unpin' : 'projects.pin')"
:title="t(row.project.pinned ? 'projects.unpin' : 'projects.pin')"
:aria-pressed="row.project.pinned"
@click="togglePin(row.project)"
/>
</div>
</template>
</div>
<Pagination v-model:page="page" :total="projects.length" />
<QuickPrompt
v-model:open="createOpen"
:title="t('projects.prompt')"
:label="t('projects.promptLabel')"
:placeholder="t('projects.prompt')"
:confirm-text="t('common.create')"
@confirm="createProject"
/>
</section>
</template>
<style scoped>
/* Карточки проектов — по три в ряд (0.68; две были в 0.61), кликабельны
целиком (0.63): грид-элемент — обёртка .card-wrap (0.66: булавка рядом со
ссылкой), поэтому min-width снимаем с обёртки, а не с .card;
align-items: stretch (0.66) — карточки ряда одной высоты, низ совпадает;
скелетон повторяет ту же сетку, чтобы загрузка не дёргала раскладку. */
.project-list,
.list-skeleton {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
align-items: stretch;
}
.project-list > .card-wrap,
.list-skeleton > .card {
min-width: 0;
}
/* Уже 1200 — трём карточкам тесно: в шапке имя, бейдж актуальности и булавка
(40px запаса), на 380px колонки имя рвётся по слогам */
@media (max-width: 1199px) {
.project-list,
.list-skeleton {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 860px) {
.project-list,
.list-skeleton {
grid-template-columns: minmax(0, 1fr);
}
}
/* Подпись секции списка (паттерн стека): проекты с задачами и без — 0.60;
в сетке заголовок занимает всю ширину строки */
.list-section-title {
grid-column: 1 / -1;
display: flex;
gap: 0.6rem;
align-items: center;
margin: 0.75rem 0 0;
font-weight: 600;
font-size: 1.05rem;
}
.list-section-title:first-child {
margin-top: 0;
}
.list-section-title > i {
color: var(--accent, #7aa2f7);
}
/* Ссылка-обёртка всей карточки: тянется на ширину трека и на всю его высоту
(0.66: карточки ряда растягиваются, подвал прижимается к низу), наследует
цвет текста */
.project-card {
display: block;
height: 100%;
min-width: 0;
color: inherit;
text-decoration: none;
border-radius: 6px;
}
/* .card-wrap — грид-элемент: держит ссылку и булавку, задаёт им точку отсчёта */
.card-wrap {
position: relative;
min-width: 0;
}
/* Выравнивание подвала (0.66): карточка — колонка на всю высоту трека,
содержимое забирает свободное место, подвал прижимается к низу.
height: auto у .card-content обязателен: у кита там height: 100%, и на
растянутой карточке содержимое переполнялось бы на высоту шапки */
.card-wrap :deep(.card) {
display: flex;
flex-direction: column;
height: 100%;
}
.card-wrap :deep(.card-content) {
display: flex;
flex-direction: column;
flex: 1;
height: auto;
min-height: 0;
}
.project-card:hover :deep(.card) {
border-color: var(--accent, #7aa2f7);
}
.project-card:focus-visible {
outline: 2px solid var(--accent, #7aa2f7);
outline-offset: 2px;
}
/* Имя проекта с меткой (0.65: кружок + фавикон): переносится, а не выдавливает
бейдж из шапки (у шапки кита flex, и без min-width заголовок не сжимается) */
.card-name {
display: inline-flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
overflow-wrap: anywhere;
}
/* Бейдж актуальности — справа в шапке: базовый `.card-title` кита это block,
флекс в нём только у варианта status-card, поэтому раскладываем сами.
Заодно размер тайтла (0.65): в ките у .card-title размера нет — берётся
наследованием от body (14px), поэтому здесь он поднимается явно. */
.project-card :deep(.card-title) {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
font-size: 16px;
/* Запас справа под булавку в углу (0.67): 0.5rem отступ + 28px кнопка */
padding-right: 2.5rem;
}
/* Бейдж актуальности держим у верха шапки, а не по её центру (0.67): булавка
стоит в углу карточки и не может лечь в шапку (вложенный интерактив внутри
ссылки невалиден), поэтому общей точкой выравнивания служит центр первой
строки имени — он не зависит от того, перенеслось ли имя на вторую строку.
По центру шапки бейдж на перенесённом имени уезжал бы вниз от булавки.
1px — чтобы центр бейджа (24px) встал ровно на центр булавки (top 1rem,
28px → 30px от верха .card-wrap) */
.relevance-badge {
flex: none;
align-self: flex-start;
margin-top: 1px;
}
.list-skeleton {
margin-bottom: 1rem;
}
/* карточка-скелет: как реальная карточка проекта — тянется на ширину трека
и на высоту ряда, содержимое колонкой (0.66 — подвал скелета внизу) */
.list-skeleton .card {
display: flex;
flex-direction: column;
width: 100%;
max-width: none;
}
.list-skeleton .card-content {
display: flex;
flex-direction: column;
flex: 1;
height: auto;
}
/* строка-заготовка имени — под кегль тайтла карточки (16px), а не под китовые
22px: загрузка не должна дёргать раскладку. Квадрат справа — под булавку
в углу (кнопка btn-icon-sm кита), поэтому он в шапке, а не в подвале (0.67) */
.sk-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 10px;
}
.sk-head .skeleton-title {
display: block;
height: 24px;
flex: 1;
}
.sk-head .skeleton-square,
.sk-foot .skeleton-square {
width: 28px;
height: 28px;
}
.sk-badges {
display: flex;
gap: 6px;
}
/* Блок прогресса в скелетоне — строка счётчиков и строка полосы */
.sk-progress {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 14px;
}
/* Подвал скелетона: строка отсчёта. Полоса целиком — та же, что у настоящего
подвала (padding, черта, кегль и высота строки из kit-overrides.css), здесь
только раскладка строки; иначе при загрузке карточки ниже, чем после
отрисовки данных */
.sk-foot {
justify-content: space-between;
gap: 6px;
}
.project-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
/* Полоса прогресса на всю ширину карточки (в ките .progress упёрт в max-width: 640px) */
.project-progress {
max-width: 100%;
margin-bottom: 0.75rem;
}
/* Трек тоньше китовских 18px (0.68): на карточке полоса — подложка под
счётчиком «закрыто / всего», а не самостоятельный элемент. 8px вместе с
рамкой кита (2px сверху и снизу) — 4px заливки */
.project-progress :deep(.progress-track) {
height: 8px;
}
/* Счётчики задач: «✓ закрыто / всего» слева, процент кита справа. Закрытых
показываем крупнее (галочка наследует цвет), всего — тише. Цвет проекта
в подписях не берём: тёмные оттенки палитры (светлота от 36%, 0.72) нечитаемы
как текст — им закрашена только полоса. */
.project-progress :deep(.tasks-done) {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--gn-color-text-light, #c0caf5);
font-size: 1.25em;
font-weight: 700;
}
.project-progress :deep(.tasks-sep) {
opacity: 0.5;
}
.project-progress :deep(.tasks-total) {
font-size: 1.1em;
}
/* Заполнение — цветом-меткой проекта (0.63), у проекта без цвета — акцент кита */
.project-progress :deep(.progress-bar) {
background: var(--card-color, var(--gn-color-secondary, #7aa2f7));
}
/* Проект закрыт целиком — полоса и счётчик уходят в зелёный кита */
.project-progress.project-progress-done :deep(.progress-bar) {
background: var(--gn-color-success, #9ece6a);
}
.project-progress.project-progress-done :deep(.tasks-done) {
color: var(--gn-color-success, #9ece6a);
}
/* Клетки по задачам: насечка на каждой границе клетки (шаг 100%/число задач).
Слой — ::after трека, а не его фон: фон рисуется под заливкой, и насечки
не резали бы закрытые клетки. */
.project-progress.celled :deep(.progress-track)::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background-image: repeating-linear-gradient(
90deg,
transparent 0 calc(100% - 2px),
var(--gn-color-black, #16161e) calc(100% - 2px) 100%
);
background-size: calc(100% / var(--task-cells)) 100%;
}
/* Заливка ровно по клеткам: кит считает ширину от процента с округлением
до целого, и на границе клетки осталась бы щель в 1–2px */
.project-progress.celled :deep(.progress-bar) {
width: calc(100% / var(--task-cells) * var(--done-cells));
}
/* Подсказка вместо полосы у проекта без задач */
.card-hint {
margin: 0 0 0.75rem;
color: var(--gn-color-text-medium, #a9b1d6);
font-size: 0.85em;
}
/* Подвал карточки (0.66, всегда виден с 0.69): слева свежесть — активность по
задачам, справа ближайший срок. Сама полоса подвала (черта, раскладка, размер
текста, высота пустой строки) — общая с карточкой задачи, в kit-overrides.css
(0.81) */
.card-activity,
.card-deadline {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
/* срок стоит справа: свежесть слева, важное — под ней же, в одном и том же
углу у всех карточек, независимо от того, есть ли активность */
.card-deadline {
margin-left: auto;
}
.card-deadline.late {
color: var(--gn-color-error, #f7768e);
}
/* Булавка (0.66, переставлена в 0.67): правый верхний угол — важное действие,
всегда на виду, рядом с бейджем актуальности. Шапке добавлен запас справа
(см. .card-title), поэтому бейдж сдвигается левее и не заезжает под кнопку.
В покое НЕ приглушаем: жалоба была именно на незаметность. Кит при наведении
сам красит .btn-icon в янтарный, так что отдельного hover-цвета не нужно */
.card-pin {
position: absolute;
/* По вертикали — по бейджу актуальности (0.67): общая точка — центр первой
строки имени, 29.8px от верха .card-wrap; кнопка 28px, значит top =
29.8 - 14 */
top: 1rem;
right: 0.5rem;
z-index: 1;
}
.card-pin[aria-pressed='true'] {
color: var(--gn-color-warning, #e0af68);
}
</style>