Implemented the Projects listing redesign.

This commit is contained in:
Nick Beckley 2026-06-22 20:58:20 +01:00
parent c9a9605cd2
commit 54fdc034ea
6 changed files with 618 additions and 77 deletions

View File

@ -51,6 +51,7 @@ public interface IProjectActivityRepository
public interface IBookRepository public interface IBookRepository
{ {
Task<IReadOnlyList<Book>> ListByProjectAsync(int projectId); Task<IReadOnlyList<Book>> ListByProjectAsync(int projectId);
Task<IReadOnlyList<Book>> ListByProjectsAsync(IEnumerable<int> projectIds);
Task<Book?> GetAsync(int bookId); Task<Book?> GetAsync(int bookId);
Task<Book?> GetCoverPathsAsync(int bookId); Task<Book?> GetCoverPathsAsync(int bookId);
Task<int> SaveAsync(Book book); Task<int> SaveAsync(Book book);
@ -2374,6 +2375,67 @@ public sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IB
return rows.ToList(); return rows.ToList();
} }
public async Task<IReadOnlyList<Book>> ListByProjectsAsync(IEnumerable<int> projectIds)
{
var ids = projectIds.Distinct().ToArray();
if (ids.Length == 0)
{
return [];
}
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<Book>(
"""
SELECT
b.BookID,
b.ProjectID,
b.BookTitle,
b.Subtitle,
b.Tagline,
b.ShortDescription,
b.Status,
b.BookNumber,
b.Description,
b.TargetWordCount,
b.WritingStartDate,
b.TargetCompletionDate,
b.PublicationDate,
b.CoverThumbnailPath,
b.CoverStandardPath,
ISNULL(wordCounts.CurrentWordCount, 0) AS CurrentWordCount,
ISNULL(counts.ChapterCount, 0) AS ChapterCount,
ISNULL(counts.SceneCount, 0) AS SceneCount,
b.SortOrder,
b.CreatedDate,
b.UpdatedDate,
b.IsArchived,
b.ArchivedDate,
b.ArchivedReason
FROM dbo.Books b
OUTER APPLY
(
SELECT
COUNT(DISTINCT c.ChapterID) AS ChapterCount,
COUNT(s.SceneID) AS SceneCount
FROM dbo.Chapters c
LEFT JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0
WHERE c.BookID = b.BookID AND c.IsArchived = 0
) counts
OUTER APPLY
(
SELECT SUM(ISNULL(sw.ActualWordCount, 0)) AS CurrentWordCount
FROM dbo.Chapters c
INNER JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0
LEFT JOIN dbo.SceneWorkflow sw ON sw.SceneID = s.SceneID
WHERE c.BookID = b.BookID AND c.IsArchived = 0
) wordCounts
WHERE b.ProjectID IN @ProjectIDs AND b.IsArchived = 0
ORDER BY b.ProjectID, b.SortOrder, b.BookNumber, b.BookTitle;
""",
new { ProjectIDs = ids });
return rows.ToList();
}
public async Task<Book?> GetAsync(int bookId) public async Task<Book?> GetAsync(int bookId)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();

View File

@ -411,14 +411,50 @@ public sealed class ProjectService(
return new ProjectIndexViewModel(); return new ProjectIndexViewModel();
} }
var activeProjects = await projects.ListActiveForUserAsync(currentUser.UserId.Value);
var archivedProjects = await projects.ListArchivedForUserAsync(currentUser.UserId.Value);
var projectBooks = await books.ListByProjectsAsync(activeProjects.Concat(archivedProjects).Select(project => project.ProjectID));
var booksByProject = projectBooks
.GroupBy(book => book.ProjectID)
.ToDictionary(group => group.Key, group => group.ToList());
return new ProjectIndexViewModel return new ProjectIndexViewModel
{ {
ActiveProjects = await projects.ListActiveForUserAsync(currentUser.UserId.Value), ActiveProjects = activeProjects.Select(project => ToIndexCard(project, booksByProject)).ToList(),
ArchivedProjects = await projects.ListArchivedForUserAsync(currentUser.UserId.Value), ArchivedProjects = archivedProjects.Select(project => ToIndexCard(project, booksByProject)).ToList(),
Trial = TrialVisibilityViewModel.FromSubscription(await subscriptions.GetCurrentSubscriptionAsync(currentUser.UserId.Value), DateTime.UtcNow) Trial = TrialVisibilityViewModel.FromSubscription(await subscriptions.GetCurrentSubscriptionAsync(currentUser.UserId.Value), DateTime.UtcNow)
}; };
} }
private static ProjectIndexCardViewModel ToIndexCard(Project project, IReadOnlyDictionary<int, List<Book>> booksByProject)
{
booksByProject.TryGetValue(project.ProjectID, out var projectBooks);
projectBooks ??= [];
return new ProjectIndexCardViewModel
{
ProjectID = project.ProjectID,
ProjectName = project.ProjectName,
Description = project.Description,
CreatedDate = project.CreatedDate,
UpdatedDate = project.UpdatedDate,
IsArchived = project.IsArchived,
ArchivedDate = project.ArchivedDate,
AccessRole = project.AccessRole,
BookCount = projectBooks.Count,
TotalWords = projectBooks.Sum(book => book.CurrentWordCount),
LastBookUpdatedDate = projectBooks.Count == 0 ? null : projectBooks.Max(book => book.UpdatedDate),
CoverThumbnailPaths = projectBooks
.Where(book => !string.IsNullOrWhiteSpace(book.CoverThumbnailPath))
.OrderBy(book => book.SortOrder)
.ThenBy(book => book.BookNumber)
.ThenBy(book => book.BookTitle)
.Select(book => book.CoverThumbnailPath!)
.Take(3)
.ToList()
};
}
public async Task<ProjectDetailViewModel?> GetDetailAsync(int projectId) public async Task<ProjectDetailViewModel?> GetDetailAsync(int projectId)
{ {
if (!currentUser.UserId.HasValue) if (!currentUser.UserId.HasValue)

View File

@ -23,11 +23,28 @@ public sealed class ProjectEditViewModel
public sealed class ProjectIndexViewModel public sealed class ProjectIndexViewModel
{ {
public IReadOnlyList<Project> ActiveProjects { get; init; } = []; public IReadOnlyList<ProjectIndexCardViewModel> ActiveProjects { get; init; } = [];
public IReadOnlyList<Project> ArchivedProjects { get; init; } = []; public IReadOnlyList<ProjectIndexCardViewModel> ArchivedProjects { get; init; } = [];
public TrialVisibilityViewModel? Trial { get; init; } public TrialVisibilityViewModel? Trial { get; init; }
} }
public sealed class ProjectIndexCardViewModel
{
public int ProjectID { get; set; }
public string ProjectName { get; set; } = string.Empty;
public string? Description { get; set; }
public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; }
public bool IsArchived { get; set; }
public DateTime? ArchivedDate { get; set; }
public string AccessRole { get; set; } = "Owner";
public bool IsOwner => string.Equals(AccessRole, "Owner", StringComparison.OrdinalIgnoreCase);
public int BookCount { get; set; }
public int TotalWords { get; set; }
public DateTime? LastBookUpdatedDate { get; set; }
public IReadOnlyList<string> CoverThumbnailPaths { get; set; } = [];
}
public sealed class ProjectDetailViewModel public sealed class ProjectDetailViewModel
{ {
public Project Project { get; set; } = new(); public Project Project { get; set; } = new();

View File

@ -56,62 +56,173 @@
} }
else else
{ {
<section class="list-section"> <section class="project-gallery" aria-label="Project lists">
<h2>Active Projects</h2> <ul class="nav nav-pills project-tabs" id="projectTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active"
id="active-projects-tab"
data-bs-toggle="tab"
data-bs-target="#active-projects-panel"
type="button"
role="tab"
aria-controls="active-projects-panel"
aria-selected="true">
Active Projects (@Model.ActiveProjects.Count)
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link"
id="archived-projects-tab"
data-bs-toggle="tab"
data-bs-target="#archived-projects-panel"
type="button"
role="tab"
aria-controls="archived-projects-panel"
aria-selected="false">
Archived Projects (@Model.ArchivedProjects.Count)
</button>
</li>
</ul>
<div class="tab-content project-tab-content" id="projectTabsContent">
<div class="tab-pane fade show active"
id="active-projects-panel"
role="tabpanel"
aria-labelledby="active-projects-tab"
tabindex="0">
@if (!Model.ActiveProjects.Any()) @if (!Model.ActiveProjects.Any())
{ {
<p class="muted">No active projects.</p> <div class="empty-panel project-empty-state">
<h2>No active projects</h2>
<p class="muted mb-0">Restored projects will appear here, ready to open again.</p>
</div>
} }
else else
{ {
<div class="content-grid"> <div class="project-card-grid">
@foreach (var project in Model.ActiveProjects) @foreach (var project in Model.ActiveProjects)
{ {
<article class="plain-card"> <article class="project-card">
<h3>@project.ProjectName</h3> <div class="project-card__visual">
<p class="eyebrow">@project.AccessRole</p> @if (project.CoverThumbnailPaths.Any())
<p>@(string.IsNullOrWhiteSpace(project.Description) ? "No description yet." : project.Description)</p> {
<div class="button-row"> <div class="project-cover-stack" aria-label="Book covers for @project.ProjectName">
<a class="btn btn-outline-primary btn-sm" asp-action="Details" asp-route-id="@project.ProjectID">Open</a> @foreach (var cover in project.CoverThumbnailPaths.Select((path, index) => new { path, index }))
{
<img src="@cover.path"
alt=""
class="project-cover-stack__cover project-cover-stack__cover--@cover.index"
loading="lazy" />
}
</div>
}
else
{
<div class="project-cover-fallback" aria-hidden="true">
<span>@Initials(project.ProjectName)</span>
</div>
}
</div>
<div class="project-card__body">
<div class="project-card__heading">
<div>
<p class="eyebrow mb-1">@project.AccessRole</p>
<h2><a asp-action="Details" asp-route-id="@project.ProjectID">@project.ProjectName</a></h2>
</div>
</div>
<p class="project-card__description">@(string.IsNullOrWhiteSpace(project.Description) ? "No description yet." : project.Description)</p>
<div class="project-card__stats" aria-label="Project summary">
<span><strong>@project.BookCount.ToString("N0")</strong> books</span>
<span><strong>@project.TotalWords.ToString("N0")</strong> words</span>
<span>Modified @FormatDate(ProjectLastUpdated(project))</span>
</div>
</div>
<div class="project-card__actions">
<a class="btn btn-primary btn-sm" asp-action="Details" asp-route-id="@project.ProjectID">Open</a>
<a class="btn btn-outline-secondary btn-sm" asp-action="Edit" asp-route-id="@project.ProjectID">Edit</a> <a class="btn btn-outline-secondary btn-sm" asp-action="Edit" asp-route-id="@project.ProjectID">Edit</a>
@if (project.IsOwner) @if (project.IsOwner)
{ {
<a class="btn btn-outline-secondary btn-sm" asp-controller="Exports" asp-action="ProjectBackup" asp-route-projectId="@project.ProjectID">Download backup</a> <div class="dropdown pw-actions">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
More
</button>
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item" asp-controller="Exports" asp-action="ProjectBackup" asp-route-projectId="@project.ProjectID">Download backup</a>
<div class="dropdown-divider"></div>
<form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this project?&#10;&#10;Archived projects are hidden from your active workspace but remain stored within PlotDirector. Export your project first if you wish to maintain an external backup."> <form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this project?&#10;&#10;Archived projects are hidden from your active workspace but remain stored within PlotDirector. Export your project first if you wish to maintain an external backup.">
<input type="hidden" name="id" value="@project.ProjectID" /> <input type="hidden" name="id" value="@project.ProjectID" />
<button class="btn btn-outline-danger btn-sm" type="submit">Archive</button> <button class="dropdown-item text-danger" type="submit">Archive</button>
</form> </form>
</div>
</div>
} }
</div> </div>
</article> </article>
} }
</div> </div>
} }
</section> </div>
<section class="list-section"> <div class="tab-pane fade"
<h2>Archived Projects</h2> id="archived-projects-panel"
role="tabpanel"
aria-labelledby="archived-projects-tab"
tabindex="0">
@if (!Model.ArchivedProjects.Any()) @if (!Model.ArchivedProjects.Any())
{ {
<p class="muted">No archived projects.</p> <div class="empty-panel project-empty-state">
<h2>No archived projects</h2>
<p class="muted mb-0">Archived projects will live here, recoverable but out of your active workspace.</p>
</div>
} }
else else
{ {
<div class="content-grid"> <div class="project-card-grid">
@foreach (var project in Model.ArchivedProjects) @foreach (var project in Model.ArchivedProjects)
{ {
var archivedDateLabel = project.ArchivedDate?.ToLocalTime().ToString("yyyy-MM-dd"); var archivedDateLabel = project.ArchivedDate?.ToLocalTime().ToString("yyyy-MM-dd");
<article class="plain-card"> <article class="project-card project-card--archived">
<h3>@project.ProjectName</h3> <div class="project-card__visual">
<p class="eyebrow"> @if (project.CoverThumbnailPaths.Any())
{
<div class="project-cover-stack" aria-label="Book covers for @project.ProjectName">
@foreach (var cover in project.CoverThumbnailPaths.Select((path, index) => new { path, index }))
{
<img src="@cover.path"
alt=""
class="project-cover-stack__cover project-cover-stack__cover--@cover.index"
loading="lazy" />
}
</div>
}
else
{
<div class="project-cover-fallback" aria-hidden="true">
<span>@Initials(project.ProjectName)</span>
</div>
}
</div>
<div class="project-card__body">
<div class="project-card__heading">
<div>
<p class="eyebrow mb-1">
Archived Archived
@if (!string.IsNullOrWhiteSpace(archivedDateLabel)) @if (!string.IsNullOrWhiteSpace(archivedDateLabel))
{ {
<span> / @archivedDateLabel</span> <span> / @archivedDateLabel</span>
} }
</p> </p>
<p>@(string.IsNullOrWhiteSpace(project.Description) ? "No description yet." : project.Description)</p> <h2>@project.ProjectName</h2>
<div class="button-row"> </div>
</div>
<p class="project-card__description">@(string.IsNullOrWhiteSpace(project.Description) ? "No description yet." : project.Description)</p>
<div class="project-card__stats" aria-label="Project summary">
<span><strong>@project.BookCount.ToString("N0")</strong> books</span>
<span><strong>@project.TotalWords.ToString("N0")</strong> words</span>
<span>Modified @FormatDate(ProjectLastUpdated(project))</span>
</div>
</div>
<div class="project-card__actions">
<a class="btn btn-outline-secondary btn-sm" asp-controller="Exports" asp-action="ProjectBackup" asp-route-projectId="@project.ProjectID">Download backup</a> <a class="btn btn-outline-secondary btn-sm" asp-controller="Exports" asp-action="ProjectBackup" asp-route-projectId="@project.ProjectID">Download backup</a>
<form asp-action="Restore" method="post" class="archive-button-form" data-confirm-message="Restore this project?&#10;&#10;It will return to your active project list."> <form asp-action="Restore" method="post" class="archive-button-form" data-confirm-message="Restore this project?&#10;&#10;It will return to your active project list.">
<input type="hidden" name="id" value="@project.ProjectID" /> <input type="hidden" name="id" value="@project.ProjectID" />
@ -132,6 +243,8 @@ else
} }
</div> </div>
} }
</div>
</div>
</section> </section>
<div class="modal fade" id="hardDeleteProjectModal" tabindex="-1" aria-labelledby="hardDeleteProjectModalTitle" aria-hidden="true"> <div class="modal fade" id="hardDeleteProjectModal" tabindex="-1" aria-labelledby="hardDeleteProjectModalTitle" aria-hidden="true">
@ -165,6 +278,20 @@ else
@functions { @functions {
private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd"); private static string FormatDate(DateTime value) => value.ToLocalTime().ToString("yyyy-MM-dd");
private static DateTime ProjectLastUpdated(ProjectIndexCardViewModel project) =>
new[] { project.UpdatedDate, project.LastBookUpdatedDate ?? project.UpdatedDate }.Max();
private static string Initials(string value)
{
var words = value
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Take(2)
.Select(word => char.ToUpperInvariant(word[0]));
var initials = string.Concat(words);
return string.IsNullOrWhiteSpace(initials) ? "PD" : initials;
}
} }
@section Scripts { @section Scripts {

View File

@ -420,6 +420,217 @@ a:hover {
background: var(--plotline-danger); background: var(--plotline-danger);
} }
.project-gallery {
display: grid;
gap: 1rem;
}
.project-tabs {
gap: 0.45rem;
padding: 0.35rem;
border: 1px solid var(--plotline-border-soft);
border-radius: 999px;
background: rgba(255, 250, 241, 0.72);
box-shadow: var(--plotline-shadow-subtle);
width: fit-content;
max-width: 100%;
}
.project-tabs .nav-link {
border-radius: 999px;
color: var(--plotline-text-muted);
font-weight: 800;
padding: 0.48rem 0.9rem;
}
.project-tabs .nav-link:hover,
.project-tabs .nav-link:focus {
color: var(--plotline-accent-dark);
background: rgba(122, 77, 44, 0.1);
}
.project-tabs .nav-link.active {
color: #fff8ec;
background: linear-gradient(180deg, var(--plotline-accent), var(--plotline-accent-dark));
box-shadow: 0 8px 16px rgba(62, 45, 28, 0.14);
}
.project-tab-content {
min-width: 0;
}
.project-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 22rem), 1fr));
gap: 1rem;
}
.project-card {
display: grid;
grid-template-rows: auto 1fr auto;
overflow: hidden;
min-height: 100%;
border: 1px solid var(--plotline-border-soft);
border-radius: var(--plotline-radius);
background: var(--plotline-surface);
box-shadow: var(--plotline-shadow-subtle);
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
}
.project-card:hover {
border-color: rgba(122, 77, 44, 0.24);
box-shadow: var(--plotline-shadow-card);
transform: translateY(-1px);
}
.project-card--archived {
opacity: 0.88;
}
.project-card__visual {
position: relative;
display: grid;
place-items: center;
min-height: 11rem;
overflow: hidden;
background:
radial-gradient(circle at 26% 22%, rgba(255, 255, 255, 0.64), transparent 11rem),
linear-gradient(135deg, rgba(242, 226, 207, 0.94), rgba(223, 236, 239, 0.78));
}
.project-card--archived .project-card__visual {
filter: saturate(0.55);
}
.project-card__body {
display: grid;
gap: 0.75rem;
padding: 1rem 1rem 0.85rem;
}
.project-card__heading {
display: flex;
justify-content: space-between;
gap: 0.8rem;
}
.project-card__heading h2 {
margin: 0;
font-size: 1.35rem;
line-height: 1.1;
}
.project-card__heading h2 a {
color: var(--plotline-text);
text-decoration-color: rgba(122, 77, 44, 0.28);
}
.project-card__description {
display: -webkit-box;
min-height: 3.2rem;
margin: 0;
overflow: hidden;
color: var(--plotline-text-muted);
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.project-card__stats {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.project-card__stats span {
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
border: 1px solid rgba(84, 68, 48, 0.12);
border-radius: 999px;
padding: 0.28rem 0.6rem;
color: var(--plotline-text-muted);
background: rgba(255, 250, 241, 0.74);
font-size: 0.82rem;
font-weight: 700;
}
.project-card__stats strong {
color: var(--plotline-accent-dark);
}
.project-card__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
border-top: 1px solid rgba(84, 68, 48, 0.1);
padding: 0.85rem 1rem 1rem;
}
.project-card__actions form {
display: inline-flex;
margin: 0;
}
.project-cover-stack {
position: relative;
width: min(11.5rem, 70%);
height: 9.4rem;
}
.project-cover-stack__cover {
position: absolute;
top: 50%;
left: 50%;
width: 4.65rem;
aspect-ratio: 2 / 3;
border: 3px solid rgba(255, 253, 248, 0.86);
border-radius: 0.45rem;
background: var(--plotline-soft);
box-shadow: 0 12px 28px rgba(62, 45, 28, 0.22);
object-fit: cover;
}
.project-cover-stack__cover--0 {
z-index: 3;
transform: translate(-50%, -50%) rotate(-1deg) scale(1.08);
}
.project-cover-stack__cover--1 {
z-index: 2;
transform: translate(-88%, -47%) rotate(-8deg) scale(0.94);
}
.project-cover-stack__cover--2 {
z-index: 1;
transform: translate(-12%, -47%) rotate(8deg) scale(0.94);
}
.project-cover-fallback {
display: grid;
place-items: center;
width: min(9.5rem, 62%);
aspect-ratio: 2 / 3;
border: 1px solid rgba(84, 68, 48, 0.18);
border-radius: 0.8rem;
background:
linear-gradient(180deg, rgba(255, 253, 248, 0.34), transparent 32%),
linear-gradient(135deg, var(--plotline-accent), var(--plotline-accent-dark));
box-shadow: 0 14px 30px rgba(62, 45, 28, 0.2);
color: #fff8ec;
}
.project-cover-fallback span {
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(2.2rem, 7vw, 3.2rem);
font-weight: 700;
letter-spacing: 0.02em;
}
.project-empty-state {
margin: 0;
}
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.pw-page-heading { .pw-page-heading {
align-items: stretch; align-items: stretch;
@ -433,6 +644,21 @@ a:hover {
.pw-project-nav { .pw-project-nav {
padding: 0.65rem; padding: 0.65rem;
} }
.project-tabs {
width: 100%;
border-radius: var(--plotline-radius);
}
.project-tabs .nav-item,
.project-tabs .nav-link {
flex: 1 1 auto;
}
.project-card__actions .btn,
.project-card__actions .dropdown {
flex: 1 1 auto;
}
} }
.timeline-workspace { .timeline-workspace {
@ -1830,6 +2056,79 @@ a:focus-visible {
color: #ffb2aa !important; color: #ffb2aa !important;
} }
[data-theme="dark"] .project-tabs {
border-color: rgba(241, 195, 143, 0.18);
background: rgba(35, 29, 25, 0.88);
}
[data-theme="dark"] .project-tabs .nav-link {
color: #ead8c4;
}
[data-theme="dark"] .project-tabs .nav-link:hover,
[data-theme="dark"] .project-tabs .nav-link:focus {
color: #ffd5a6;
background: rgba(241, 195, 143, 0.12);
}
[data-theme="dark"] .project-tabs .nav-link.active {
color: #21160f;
background: linear-gradient(180deg, #f1c38f, var(--plotline-accent));
}
[data-theme="dark"] .project-card {
color: var(--app-text);
border-color: var(--app-border);
background: var(--app-surface);
}
[data-theme="dark"] .project-card:hover {
border-color: rgba(241, 195, 143, 0.34);
}
[data-theme="dark"] .project-card__visual {
background:
radial-gradient(circle at 28% 24%, rgba(241, 195, 143, 0.22), transparent 11rem),
linear-gradient(135deg, rgba(58, 42, 32, 0.96), rgba(33, 56, 61, 0.72));
}
[data-theme="dark"] .project-card__heading h2,
[data-theme="dark"] .project-card__heading h2 a {
color: var(--app-text);
}
[data-theme="dark"] .project-card__description {
color: var(--app-text-muted);
}
[data-theme="dark"] .project-card__stats span {
color: #ead8c4;
border-color: rgba(231, 214, 190, 0.2);
background: rgba(231, 214, 190, 0.08);
}
[data-theme="dark"] .project-card__stats strong {
color: #ffd5a6;
}
[data-theme="dark"] .project-card__actions {
border-top-color: rgba(231, 214, 190, 0.14);
}
[data-theme="dark"] .project-cover-stack__cover {
border-color: rgba(42, 35, 30, 0.92);
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.38);
}
[data-theme="dark"] .project-cover-fallback {
color: #21160f;
border-color: rgba(241, 195, 143, 0.34);
background:
linear-gradient(180deg, rgba(255, 244, 232, 0.3), transparent 32%),
linear-gradient(135deg, #f1c38f, var(--plotline-accent));
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.34);
}
[data-theme="dark"] .timeline-filter, [data-theme="dark"] .timeline-filter,
[data-theme="dark"] .workspace-heading .button-row, [data-theme="dark"] .workspace-heading .button-row,
[data-theme="dark"] .timeline-control-panel { [data-theme="dark"] .timeline-control-panel {

File diff suppressed because one or more lines are too long