feat(renderer): add fluid chapter reorder motion

This commit is contained in:
avi 2026-08-31 00:57:08 -05:00
commit 40a2fb305e
11 changed files with 695 additions and 38 deletions

View file

@ -134,6 +134,14 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
} }
function renderChapterList() { function renderChapterList() {
const previousPositions = animateNextRender
? new Map(
Array.from(chapterList.querySelectorAll<HTMLElement>(".chapter-row"), (row) => [
row.dataset.id ?? "",
row.getBoundingClientRect().top,
])
)
: null;
chapterList.innerHTML = ""; chapterList.innerHTML = "";
for (const ch of state.ordered) { for (const ch of state.ordered) {
const li = document.createElement("li"); const li = document.createElement("li");
@ -190,6 +198,26 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
}); });
chapterList.appendChild(li); chapterList.appendChild(li);
} }
if (!previousPositions) return;
animateNextRender = false;
const rows = Array.from(chapterList.querySelectorAll<HTMLElement>(".chapter-row"));
for (const row of rows) {
const oldTop = previousPositions.get(row.dataset.id ?? "");
if (oldTop === undefined) continue;
const delta = oldTop - row.getBoundingClientRect().top;
if (!delta) continue;
row.classList.add("reordering");
row.style.transform = `translateY(${delta}px)`;
}
requestAnimationFrame(() => {
for (const row of rows) row.style.transform = "";
});
setTimeout(() => {
for (const row of rows) {
row.classList.remove("reordering");
row.style.transform = "";
}
}, 420);
} }
async function create() { async function create() {
@ -305,6 +333,10 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
let dragStartY = 0; let dragStartY = 0;
let dragMoved = false; let dragMoved = false;
let dragJustHappened = false; let dragJustHappened = false;
let animateNextRender = false;
let dragGhost: HTMLElement | null = null;
let dragPlaceholder: HTMLElement | null = null;
let dragOffsetY = 0;
chapterList.addEventListener("pointerdown", (e) => { chapterList.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return; // left button only if (e.button !== 0) return; // left button only
@ -327,15 +359,38 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
const src = chapterList.querySelector( const src = chapterList.querySelector(
`li.chapter-row[data-id="${draggingId}"]` `li.chapter-row[data-id="${draggingId}"]`
) as HTMLElement | null; ) as HTMLElement | null;
src?.classList.add("dragging"); if (src) {
const rect = src.getBoundingClientRect();
dragOffsetY = e.clientY - rect.top;
dragGhost = src.cloneNode(true) as HTMLElement;
dragGhost.classList.add("drag-ghost");
dragGhost.style.width = `${rect.width}px`;
dragGhost.style.left = `${rect.left}px`;
dragGhost.style.top = `${rect.top}px`;
document.body.appendChild(dragGhost);
dragPlaceholder = document.createElement("li");
dragPlaceholder.className = "chapter-drop-placeholder";
dragPlaceholder.style.height = `${rect.height}px`;
src.replaceWith(dragPlaceholder);
}
document.body.classList.add("dragging-active"); document.body.classList.add("dragging-active");
} }
if (dragGhost) dragGhost.style.top = `${e.clientY - dragOffsetY}px`;
const overEl = document.elementFromPoint(e.clientX, e.clientY); const overEl = document.elementFromPoint(e.clientX, e.clientY);
const overLi = overEl?.closest("li.chapter-row") as HTMLElement | null; const overLi = overEl?.closest("li.chapter-row") as HTMLElement | null;
chapterList chapterList.querySelectorAll(".chapter-row").forEach((n) => {
.querySelectorAll(".chapter-row") n.classList.remove("drop-target", "drop-before", "drop-after");
.forEach((n) => n.classList.remove("drop-target")); });
if (overLi && overLi.dataset.id !== draggingId) overLi.classList.add("drop-target"); if (overLi && overLi.dataset.id !== draggingId) {
overLi.classList.add("drop-target");
const before = e.clientY < overLi.getBoundingClientRect().top + overLi.offsetHeight / 2;
overLi.classList.add(before ? "drop-before" : "drop-after");
if (dragPlaceholder) {
if (before) chapterList.insertBefore(dragPlaceholder, overLi);
else chapterList.insertBefore(dragPlaceholder, overLi.nextSibling);
}
}
}); });
document.addEventListener("pointerup", (e) => { document.addEventListener("pointerup", (e) => {
@ -347,23 +402,26 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
return; return;
} }
dragJustHappened = true; dragJustHappened = true;
const overEl = document.elementFromPoint(e.clientX, e.clientY); const targetIndex = dragPlaceholder
const overLi = overEl?.closest("li.chapter-row") as HTMLElement | null; ? Array.from(chapterList.children).indexOf(dragPlaceholder)
const targetId = overLi?.dataset.id ?? null; : -1;
const src = chapterList.querySelector( dragGhost?.remove();
`li.chapter-row[data-id="${draggingId}"]` dragGhost = null;
) as HTMLElement | null; dragPlaceholder?.remove();
src?.classList.remove("dragging"); dragPlaceholder = null;
chapterList chapterList.querySelectorAll(".chapter-row").forEach((n) => {
.querySelectorAll(".chapter-row") n.classList.remove("drop-target", "drop-before", "drop-after");
.forEach((n) => n.classList.remove("drop-target")); });
if (targetId && targetId !== draggingId) { let reordered = false;
if (targetIndex >= 0) {
const from = state.ordered.findIndex((c) => c.id === draggingId); const from = state.ordered.findIndex((c) => c.id === draggingId);
const to = state.ordered.findIndex((c) => c.id === targetId); if (from >= 0 && targetIndex !== from) {
if (from >= 0 && to >= 0) { const to = targetIndex;
state.ordered = moveInList(state.ordered, from, to); state.ordered = moveInList(state.ordered, from, to);
state.meta!.chapterOrder = state.ordered.map((c) => c.id); state.meta!.chapterOrder = state.ordered.map((c) => c.id);
animateNextRender = true;
renderChapterList(); renderChapterList();
reordered = true;
// Selecting the dragged row must go through selectChapter: assigning // Selecting the dragged row must go through selectChapter: assigning
// selectedId directly would pair the id with whatever document is // selectedId directly would pair the id with whatever document is
// still in the editor, and the next autosave would write that // still in the editor, and the next autosave would write that
@ -372,6 +430,10 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
void persistOrder(); void persistOrder();
} }
} }
// The source row was replaced by the gap while dragging. Restore the list
// even when the pointer is released over its original slot or outside a
// valid destination.
if (!reordered) renderChapterList();
draggingId = null; draggingId = null;
}); });

View file

@ -17,6 +17,7 @@ import {
markdownToPlain, markdownToPlain,
openEl, openEl,
setActive, setActive,
showView,
showToast, showToast,
} from "./ui.js"; } from "./ui.js";
import { import {
@ -127,7 +128,7 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
clearTimers(); clearTimers();
$("fullBookBtn").classList.remove("active"); $("fullBookBtn").classList.remove("active");
$("fullBookView").classList.add("hidden"); $("fullBookView").classList.add("hidden");
$("chapterView").classList.remove("hidden"); showView($("chapterView"));
$("editorPlaceholder").classList.add("hidden"); $("editorPlaceholder").classList.add("hidden");
} }

View file

@ -131,8 +131,10 @@ export function createExportsFeature(ctx: BookCtx): ExportsFeature {
e.stopPropagation(); e.stopPropagation();
const wasOpen = !exportMenu.classList.contains("hidden"); const wasOpen = !exportMenu.classList.contains("hidden");
closeExportMenu(); closeExportMenu();
if (!wasOpen) openEl(exportMenu); if (!wasOpen) {
exportMenuToggle.classList.toggle("open", wasOpen); openEl(exportMenu);
exportMenuToggle.classList.add("open");
}
}); });
exportMenu.addEventListener("click", (e) => { exportMenu.addEventListener("click", (e) => {

View file

@ -17,7 +17,7 @@ import type { BookMeta } from "../../main/project.js";
import type { WikilinkTarget } from "../wikilinks.js"; import type { WikilinkTarget } from "../wikilinks.js";
import type { BookCtx } from "./types.js"; import type { BookCtx } from "./types.js";
import { alertMessage } from "../dialog.js"; import { alertMessage } from "../dialog.js";
import { $, countWords } from "./ui.js"; import { $, countWords, showView } from "./ui.js";
import { import {
createTiptapEditor, createTiptapEditor,
setEditorDoc, setEditorDoc,
@ -195,7 +195,7 @@ export function createFullBookFeature(ctx: BookCtx): FullBookFeature {
$("fullBookBtn").classList.add("active"); $("fullBookBtn").classList.add("active");
$("chapterView").classList.add("hidden"); $("chapterView").classList.add("hidden");
$("editorPlaceholder").classList.add("hidden"); $("editorPlaceholder").classList.add("hidden");
fullBookView.classList.remove("hidden"); showView(fullBookView);
await renderFullBook(); await renderFullBook();
fullBookEditor.commands.focus(); fullBookEditor.commands.focus();
} }

View file

@ -23,6 +23,16 @@ export function openEl(el: HTMLElement) {
el.classList.remove("hidden", "closing"); el.classList.remove("hidden", "closing");
} }
// Reveal a primary surface with a short continuity cue. The surface remains
// usable before the animation starts, and the class is removed so later
// updates do not replay it accidentally.
export function showView(el: HTMLElement) {
el.classList.remove("hidden", "view-enter");
void el.offsetWidth;
el.classList.add("view-enter");
el.addEventListener("animationend", () => el.classList.remove("view-enter"), { once: true });
}
export function closeEl(el: HTMLElement | null) { export function closeEl(el: HTMLElement | null) {
if (!el || el.classList.contains("hidden")) return; if (!el || el.classList.contains("hidden")) return;
el.classList.add("closing"); el.classList.add("closing");
@ -35,7 +45,11 @@ export function closeEl(el: HTMLElement | null) {
el.removeEventListener("animationend", onEnd); el.removeEventListener("animationend", onEnd);
delete (el as unknown as { __close?: () => void }).__close; delete (el as unknown as { __close?: () => void }).__close;
}; };
const onEnd = () => finish(); const onEnd = (event: AnimationEvent) => {
// The modal animation bubbles through its backdrop; only the backdrop's
// own exit should decide when the overlay is safe to hide.
if (event.target === el) finish();
};
el.addEventListener("animationend", onEnd); el.addEventListener("animationend", onEnd);
const timer = setTimeout(finish, 420); const timer = setTimeout(finish, 420);
(el as unknown as { __close?: () => void }).__close = () => { (el as unknown as { __close?: () => void }).__close = () => {

View file

@ -10,7 +10,7 @@ import type { BookCtx } from "./types.js";
import type { ChapterEntry, Recent } from "./types.js"; import type { ChapterEntry, Recent } from "./types.js";
import type { BookMeta } from "../../main/project.js"; import type { BookMeta } from "../../main/project.js";
import { alertMessage, promptInput } from "../dialog.js"; import { alertMessage, promptInput } from "../dialog.js";
import { $, showUndoToast } from "./ui.js"; import { $, showUndoToast, showView } from "./ui.js";
export interface WelcomeFeature { export interface WelcomeFeature {
showWelcome(): Promise<void>; showWelcome(): Promise<void>;
@ -37,7 +37,7 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
const homeBtn = $("homeBtn") as HTMLButtonElement | null; const homeBtn = $("homeBtn") as HTMLButtonElement | null;
async function showWelcome() { async function showWelcome() {
welcomeEl.classList.remove("hidden"); showView(welcomeEl);
chapterView.classList.add("hidden"); chapterView.classList.add("hidden");
fullBookView.classList.add("hidden"); fullBookView.classList.add("hidden");
editorPlaceholder.classList.add("hidden"); editorPlaceholder.classList.add("hidden");
@ -69,7 +69,7 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
chapterView.classList.add("hidden"); chapterView.classList.add("hidden");
fullBookView.classList.add("hidden"); fullBookView.classList.add("hidden");
fullBookBtn.classList.remove("active"); fullBookBtn.classList.remove("active");
editorPlaceholder.classList.remove("hidden"); showView(editorPlaceholder);
} }
function setCurrentBook(data: { path: string; title: string }) { function setCurrentBook(data: { path: string; title: string }) {

View file

@ -1,5 +1,7 @@
// In-app modal dialog used in place of window.prompt/confirm/alert, // In-app modal dialog used in place of window.prompt/confirm/alert,
// which Electron does not support (prompt() throws). // which Electron does not support (prompt() throws).
import { closeEl, openEl } from "./book/ui.js";
type Mode = "prompt" | "confirm" | "alert"; type Mode = "prompt" | "confirm" | "alert";
function el(id: string): HTMLElement { function el(id: string): HTMLElement {
@ -29,7 +31,7 @@ function open(mode: Mode, title: string, body: string, value: string): Promise<s
msgEl.classList.toggle("hidden", mode === "prompt"); msgEl.classList.toggle("hidden", mode === "prompt");
cancelBtn.classList.toggle("hidden", mode === "alert"); cancelBtn.classList.toggle("hidden", mode === "alert");
okBtn.textContent = mode === "confirm" ? "Delete" : "OK"; okBtn.textContent = mode === "confirm" ? "Delete" : "OK";
backdrop.classList.remove("hidden"); openEl(backdrop);
if (mode === "prompt") { if (mode === "prompt") {
inputEl.focus(); inputEl.focus();
inputEl.select(); inputEl.select();
@ -42,7 +44,7 @@ function open(mode: Mode, title: string, body: string, value: string): Promise<s
} }
function close(value: string | null): void { function close(value: string | null): void {
backdrop.classList.add("hidden"); closeEl(backdrop);
if (resolver) { if (resolver) {
const r = resolver; const r = resolver;
resolver = null; resolver = null;

View file

@ -395,7 +395,9 @@ html, body {
:root[data-theme="dark-mode"] #addChapterBtn, :root[data-theme="dark-mode"] #addChapterBtn,
:root[data-theme="neon"] #addChapterBtn, :root[data-theme="neon"] #addChapterBtn,
:root[data-theme="meridian"] #addChapterBtn, :root[data-theme="meridian"] #addChapterBtn,
:root[data-theme="impeccable-dark"] #addChapterBtn { :root[data-theme="impeccable-dark"] #addChapterBtn,
:root[data-theme="cosmic"] #addChapterBtn,
:root[data-theme="cosmic-stardust"] #addChapterBtn {
color: #ffffff; color: #ffffff;
} }
@ -403,7 +405,9 @@ html, body {
:root[data-theme="dark-mode"] #homeBtn .home-label, :root[data-theme="dark-mode"] #homeBtn .home-label,
:root[data-theme="neon"] #homeBtn .home-label, :root[data-theme="neon"] #homeBtn .home-label,
:root[data-theme="meridian"] #homeBtn .home-label, :root[data-theme="meridian"] #homeBtn .home-label,
:root[data-theme="impeccable-dark"] #homeBtn .home-label { :root[data-theme="impeccable-dark"] #homeBtn .home-label,
:root[data-theme="cosmic"] #homeBtn .home-label,
:root[data-theme="cosmic-stardust"] #homeBtn .home-label {
color: #ffffff; color: #ffffff;
} }
@ -422,7 +426,11 @@ html, body {
:root[data-theme="meridian"] .brand-icon-light, :root[data-theme="meridian"] .brand-icon-light,
:root[data-theme="meridian"] .welcome-brand-light, :root[data-theme="meridian"] .welcome-brand-light,
:root[data-theme="impeccable-dark"] .brand-icon-light, :root[data-theme="impeccable-dark"] .brand-icon-light,
:root[data-theme="impeccable-dark"] .welcome-brand-light { :root[data-theme="impeccable-dark"] .welcome-brand-light,
:root[data-theme="cosmic"] .brand-icon-light,
:root[data-theme="cosmic"] .welcome-brand-light,
:root[data-theme="cosmic-stardust"] .brand-icon-light,
:root[data-theme="cosmic-stardust"] .welcome-brand-light {
display: none; display: none;
} }
:root[data-theme="dark-mode"] .brand-icon-dark, :root[data-theme="dark-mode"] .brand-icon-dark,
@ -432,7 +440,11 @@ html, body {
:root[data-theme="meridian"] .brand-icon-dark, :root[data-theme="meridian"] .brand-icon-dark,
:root[data-theme="meridian"] .welcome-brand-dark, :root[data-theme="meridian"] .welcome-brand-dark,
:root[data-theme="impeccable-dark"] .brand-icon-dark, :root[data-theme="impeccable-dark"] .brand-icon-dark,
:root[data-theme="impeccable-dark"] .welcome-brand-dark { :root[data-theme="impeccable-dark"] .welcome-brand-dark,
:root[data-theme="cosmic"] .brand-icon-dark,
:root[data-theme="cosmic"] .welcome-brand-dark,
:root[data-theme="cosmic-stardust"] .brand-icon-dark,
:root[data-theme="cosmic-stardust"] .welcome-brand-dark {
display: inline-block; display: inline-block;
} }
@ -582,10 +594,50 @@ body.dragging-active * {
cursor: grabbing; cursor: grabbing;
} }
.drag-ghost {
position: fixed;
z-index: 1001;
pointer-events: none;
opacity: 1;
background: var(--color-panel);
border: 1px solid var(--color-accent);
box-shadow: 0 10px 24px var(--color-shadow-strong);
transform: rotate(1deg) scale(1.02);
transition: transform 140ms var(--ease-out), box-shadow 140ms var(--ease-out);
}
.chapter-drop-placeholder {
list-style: none;
margin: 0;
border: 1px dashed var(--color-accent);
border-radius: var(--radius);
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
}
.chapter-row.drop-target { .chapter-row.drop-target {
border-color: var(--color-accent); border-color: var(--color-accent);
background: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 18%, transparent);
color: var(--color-on-accent); }
/* Keep the destination edge visible while dragging. The FLIP pass carries the
rows into their new positions after release. */
.chapter-row.drop-before {
background-image: linear-gradient(var(--color-accent), var(--color-accent));
background-size: 100% 3px;
background-position: top;
background-repeat: no-repeat;
}
.chapter-row.drop-after {
background-image: linear-gradient(var(--color-accent), var(--color-accent));
background-size: 100% 3px;
background-position: bottom;
background-repeat: no-repeat;
}
.chapter-row.reordering {
transition: transform 360ms var(--ease-out), background var(--motion-fast) var(--ease-out),
color var(--motion-fast) var(--ease-out), border-color var(--motion-fast) var(--ease-out);
} }
.chapter-row:focus-visible { .chapter-row:focus-visible {
@ -1771,6 +1823,10 @@ body.focus-mode .editor {
from { opacity: 0; transform: scale(0.985); } from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); } to { opacity: 1; transform: scale(1); }
} }
@keyframes viewIn {
from { opacity: 0.72; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
/* Dialogs / modals */ /* Dialogs / modals */
.modal-backdrop:not(.hidden) .modal { .modal-backdrop:not(.hidden) .modal {
@ -1803,6 +1859,12 @@ body.focus-mode .editor {
animation: rootIn var(--motion-window) var(--ease-out) both; animation: rootIn var(--motion-window) var(--ease-out) both;
} }
/* Primary navigation changes keep the manuscript's spatial continuity without
delaying input or hiding the destination before it is ready. */
.view-enter {
animation: viewIn var(--motion-base) var(--ease-out) both;
}
/* List item entrances */ /* List item entrances */
.recent-list .recent-item { .recent-list .recent-item {
animation: listIn var(--motion-base) var(--ease-out) both; animation: listIn var(--motion-base) var(--ease-out) both;
@ -1865,7 +1927,7 @@ body:not(.dragging-active) .chapter-row {
scroll-behavior: auto !important; scroll-behavior: auto !important;
} }
.modal, .modal-backdrop, .dropdown-menu, .color-popover, .modal, .modal-backdrop, .dropdown-menu, .color-popover,
.recent-item, .chapter-row, #root { .recent-item, .chapter-row, .view-enter, #root {
transform: none !important; transform: none !important;
animation: none !important; animation: none !important;
} }

View file

@ -39,6 +39,10 @@
<option value="meridian">Meridian</option> <option value="meridian">Meridian</option>
<option value="impeccable">Impeccable Light</option> <option value="impeccable">Impeccable Light</option>
<option value="impeccable-dark">Impeccable Dark</option> <option value="impeccable-dark">Impeccable Dark</option>
<option value="cosmic">Cosmic</option>
<option value="cosmic-light">Cosmic Light</option>
<option value="cosmic-stardust">Cosmic Stardust</option>
<option value="cosmic-stardust-light">Cosmic Stardust Light</option>
</select> </select>
</div> </div>
</div> </div>

View file

@ -17,7 +17,7 @@ const LIGHT = "light";
// Themes with a real entry in themes.css + the selector. Anything else that // Themes with a real entry in themes.css + the selector. Anything else that
// might linger in saved settings (a removed theme, a typo) falls back to // might linger in saved settings (a removed theme, a typo) falls back to
// Light so the <select> never desyncs from its <option> list. // Light so the <select> never desyncs from its <option> list.
const KNOWN = new Set(["light", "dark-mode", "neon", "meridian", "impeccable", "impeccable-dark"]); const KNOWN = new Set(["light", "dark-mode", "neon", "meridian", "impeccable", "impeccable-dark", "cosmic", "cosmic-light", "cosmic-stardust", "cosmic-stardust-light"]);
function normalizeTheme(id: unknown): string { function normalizeTheme(id: unknown): string {
if (typeof id !== "string" || !id || id === "default") return LIGHT; if (typeof id !== "string" || !id || id === "default") return LIGHT;

View file

@ -380,3 +380,513 @@ html[data-theme="impeccable-dark"] .chapter-list li.is-active {
color: var(--color-accent); color: var(--color-accent);
border-left-color: var(--color-accent); border-left-color: var(--color-accent);
} }
/* ---- Cosmic (Original/Dark) ----
Warm, atmospheric dark theme inspired by painted night skies.
Deep purple-brown surfaces with rose/gold accents and colorful headings. */
:root[data-theme="cosmic"] {
--color-bg: #0a0d17;
--color-panel: #060910;
--color-border: #2a2040;
--color-text: #f4ebec;
--color-muted: #b8a5cc;
--color-accent: #b4797f;
--color-accent-hover: #c57e89;
--color-on-accent: #020412;
--color-on-accent-soft: rgba(2, 4, 18, 0.18);
--color-on-accent-border: rgba(2, 4, 18, 0.38);
--color-selection: rgba(180, 121, 127, 0.28);
--color-selection-strong: rgba(180, 121, 127, 0.55);
--color-shadow: rgba(0, 0, 0, 0.55);
--color-shadow-strong: rgba(0, 0, 0, 0.7);
--color-backdrop: rgba(2, 4, 18, 0.6);
--color-scrollbar: #2a1838;
--color-scrollbar-hover: #45285e;
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-serif: "Tirra", "Source Serif 4", Georgia, serif;
--font-display: "Yeseva One", "Cormorant Garamond", Georgia, serif;
--color-h1: #dda2a1;
--color-h2: #97c7dc;
--color-h3: #d8c2e3;
--color-h4: #d8c2e3;
--color-h5: #97c7dc;
--color-h6: #f2def2;
}
html[data-theme="cosmic"] h1,
html[data-theme="cosmic"] h2,
html[data-theme="cosmic"] h3 {
font-family: var(--font-display);
font-style: italic;
font-weight: 400;
}
html[data-theme="cosmic"] h1 { color: var(--color-h1); }
html[data-theme="cosmic"] h2 { color: var(--color-h2); }
html[data-theme="cosmic"] h3 { color: var(--color-h3); }
html[data-theme="cosmic"] h4 { color: var(--color-h4); }
html[data-theme="cosmic"] h5 { color: var(--color-h5); text-transform: uppercase; letter-spacing: 0.08em; font-size: 0.92em; }
html[data-theme="cosmic"] h6 { color: var(--color-h6); text-transform: uppercase; letter-spacing: 0.12em; }
html[data-theme="cosmic"] .sidebar {
border-right-color: var(--color-border);
}
html[data-theme="cosmic"] .chapter-list li.is-active {
background: linear-gradient(90deg, color-mix(in srgb, var(--color-accent) 16%, transparent), transparent);
color: var(--color-text);
box-shadow: inset 3px 0 0 var(--color-accent);
}
html[data-theme="cosmic"] .button.button-primary {
background: linear-gradient(135deg, var(--color-accent), color-mix(in srgb, var(--color-accent) 74%, var(--color-h2)));
color: var(--color-on-accent);
border-color: transparent;
box-shadow: 0 8px 22px color-mix(in srgb, var(--color-accent) 22%, transparent);
}
html[data-theme="cosmic"] .button.button-primary:hover:not(:disabled) {
filter: brightness(1.08) saturate(1.08);
}
html[data-theme="cosmic"] .button.button-ghost {
background: var(--color-panel);
border-color: var(--color-border);
}
html[data-theme="cosmic"] input[type="text"],
html[data-theme="cosmic"] input[type="search"],
html[data-theme="cosmic"] select,
html[data-theme="cosmic"] textarea,
html[data-theme="cosmic"] .modal-input {
background: var(--color-panel);
border-color: var(--color-border);
}
html[data-theme="cosmic"] code,
html[data-theme="cosmic"] .folio-prose code {
background: color-mix(in srgb, var(--color-accent) 32%, transparent);
border-color: color-mix(in srgb, var(--color-accent) 52%, var(--color-h4));
color: var(--color-h1) !important;
font-weight: 700;
}
/* ---- Cosmic Light ----
Light variant of the Cosmic theme. Warm cream surfaces with deep rose accents. */
:root[data-theme="cosmic-light"] {
--color-bg: #f0e8f4;
--color-panel: #f4ebec;
--color-border: #d8cce8;
--color-text: #201b43;
--color-muted: #3a2e5f;
--color-accent: #8b526c;
--color-accent-hover: #7e4265;
--color-on-accent: #f4ebec;
--color-on-accent-soft: rgba(244, 235, 236, 0.18);
--color-on-accent-border: rgba(244, 235, 236, 0.38);
--color-selection: rgba(139, 82, 108, 0.25);
--color-selection-strong: rgba(139, 82, 108, 0.45);
--color-shadow: rgba(32, 27, 67, 0.15);
--color-shadow-strong: rgba(32, 27, 67, 0.25);
--color-backdrop: rgba(32, 27, 67, 0.4);
--color-scrollbar: #d8cce8;
--color-scrollbar-hover: #c8b8d8;
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-serif: "Tirra", "Source Serif 4", Georgia, serif;
--font-display: "Yeseva One", "Cormorant Garamond", Georgia, serif;
--color-h1: #8b526c;
--color-h2: #30506f;
--color-h3: #593670;
--color-h4: #593670;
--color-h5: #30506f;
--color-h6: #412e5f;
}
html[data-theme="cosmic-light"] h1,
html[data-theme="cosmic-light"] h2,
html[data-theme="cosmic-light"] h3 {
font-family: var(--font-display);
font-style: italic;
font-weight: 400;
}
html[data-theme="cosmic-light"] h1 { color: var(--color-h1); }
html[data-theme="cosmic-light"] h2 { color: var(--color-h2); }
html[data-theme="cosmic-light"] h3 { color: var(--color-h3); }
html[data-theme="cosmic-light"] h4 { color: var(--color-h4); }
html[data-theme="cosmic-light"] h5 { color: var(--color-h5); text-transform: uppercase; letter-spacing: 0.08em; font-size: 0.92em; }
html[data-theme="cosmic-light"] h6 { color: var(--color-h6); text-transform: uppercase; letter-spacing: 0.12em; }
html[data-theme="cosmic-light"] .sidebar {
border-right-color: var(--color-border);
}
html[data-theme="cosmic-light"] .chapter-list li.is-active {
background: linear-gradient(90deg, color-mix(in srgb, var(--color-accent) 16%, transparent), transparent);
color: var(--color-text);
box-shadow: inset 3px 0 0 var(--color-accent);
}
html[data-theme="cosmic-light"] .button.button-primary {
background: linear-gradient(135deg, var(--color-accent), color-mix(in srgb, var(--color-accent) 74%, var(--color-h2)));
color: var(--color-on-accent);
border-color: transparent;
box-shadow: 0 8px 22px color-mix(in srgb, var(--color-accent) 22%, transparent);
}
html[data-theme="cosmic-light"] .button.button-primary:hover:not(:disabled) {
filter: brightness(1.08) saturate(1.08);
}
html[data-theme="cosmic-light"] .button.button-ghost {
background: var(--color-panel);
border-color: var(--color-border);
}
html[data-theme="cosmic-light"] input[type="text"],
html[data-theme="cosmic-light"] input[type="search"],
html[data-theme="cosmic-light"] select,
html[data-theme="cosmic-light"] textarea,
html[data-theme="cosmic-light"] .modal-input {
background: var(--color-panel);
border-color: var(--color-border);
}
html[data-theme="cosmic-light"] code,
html[data-theme="cosmic-light"] .folio-prose code {
background: color-mix(in srgb, var(--color-accent) 18%, transparent);
border-color: var(--color-border);
color: var(--color-h1) !important;
font-weight: 700;
}
/* ---- Cosmic Stardust (Dark) ----
Warm amber/gold dark theme. Deep brown-black surfaces with gold accents,
teal headings, and coral highlights. Inspired by stardust and cosmic dust clouds. */
:root[data-theme="cosmic-stardust"] {
--color-bg: #171109;
--color-panel: #0d0a06;
--color-border: #2a2210;
--color-text: #fff5dc;
--color-muted: #d5bd8d;
--color-accent: #efbd62;
--color-accent-hover: #ffdb91;
--color-on-accent: #0d0a06;
--color-on-accent-soft: rgba(13, 10, 6, 0.18);
--color-on-accent-border: rgba(13, 10, 6, 0.38);
--color-selection: rgba(239, 189, 98, 0.3);
--color-selection-strong: rgba(239, 189, 98, 0.55);
--color-shadow: rgba(0, 0, 0, 0.6);
--color-shadow-strong: rgba(0, 0, 0, 0.75);
--color-backdrop: rgba(13, 10, 6, 0.65);
--color-scrollbar: #2a2210;
--color-scrollbar-hover: #403518;
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-serif: "Tirra", "Source Serif 4", Georgia, serif;
--font-display: "Yeseva One", "Cormorant Garamond", Georgia, serif;
--color-h1: #efbd62;
--color-h2: #74d7cb;
--color-h3: #f08e7d;
--color-h4: #d89ce7;
--color-h5: #79c7ef;
--color-h6: #f5ddae;
}
html[data-theme="cosmic-stardust"] h1,
html[data-theme="cosmic-stardust"] h2,
html[data-theme="cosmic-stardust"] h3 {
font-family: var(--font-display);
font-style: italic;
font-weight: 400;
}
html[data-theme="cosmic-stardust"] h1 { color: var(--color-h1); }
html[data-theme="cosmic-stardust"] h2 { color: var(--color-h2); }
html[data-theme="cosmic-stardust"] h3 { color: var(--color-h3); }
html[data-theme="cosmic-stardust"] h4 { color: var(--color-h4); }
html[data-theme="cosmic-stardust"] h5 { color: var(--color-h5); text-transform: uppercase; letter-spacing: 0.08em; font-size: 0.92em; }
html[data-theme="cosmic-stardust"] h6 { color: var(--color-h6); text-transform: uppercase; letter-spacing: 0.12em; }
html[data-theme="cosmic-stardust"] .sidebar {
border-right-color: var(--color-border);
}
html[data-theme="cosmic-stardust"] .chapter-list li.is-active {
background: linear-gradient(90deg, color-mix(in srgb, var(--color-accent) 16%, transparent), transparent);
color: var(--color-text);
box-shadow: inset 3px 0 0 var(--color-accent);
}
html[data-theme="cosmic-stardust"] .button.button-primary {
background: linear-gradient(135deg, var(--color-accent), color-mix(in srgb, var(--color-accent) 74%, var(--color-h2)));
color: var(--color-on-accent);
border-color: transparent;
box-shadow: 0 8px 22px color-mix(in srgb, var(--color-accent) 22%, transparent);
}
html[data-theme="cosmic-stardust"] .button.button-primary:hover:not(:disabled) {
filter: brightness(1.08) saturate(1.08);
}
html[data-theme="cosmic-stardust"] .button.button-ghost {
background: var(--color-panel);
border-color: var(--color-border);
}
html[data-theme="cosmic-stardust"] input[type="text"],
html[data-theme="cosmic-stardust"] input[type="search"],
html[data-theme="cosmic-stardust"] select,
html[data-theme="cosmic-stardust"] textarea,
html[data-theme="cosmic-stardust"] .modal-input {
background: var(--color-panel);
border-color: var(--color-border);
}
html[data-theme="cosmic-stardust"] code,
html[data-theme="cosmic-stardust"] .folio-prose code {
background: color-mix(in srgb, var(--color-accent) 32%, transparent);
border-color: color-mix(in srgb, var(--color-accent) 52%, var(--color-h4));
color: var(--color-h1) !important;
font-weight: 700;
}
/* ---- Cosmic Stardust Light ----
Light variant of Stardust. Warm cream/parchment surfaces with deep gold accents,
teal headings, and terracotta highlights. */
:root[data-theme="cosmic-stardust-light"] {
--color-bg: #fff8e8;
--color-panel: #f5ead2;
--color-border: #e8d8b8;
--color-text: #44341e;
--color-muted: #786344;
--color-accent: #8a5b15;
--color-accent-hover: #633d0b;
--color-on-accent: #fff8e8;
--color-on-accent-soft: rgba(255, 248, 232, 0.18);
--color-on-accent-border: rgba(255, 248, 232, 0.38);
--color-selection: rgba(138, 91, 21, 0.25);
--color-selection-strong: rgba(138, 91, 21, 0.45);
--color-shadow: rgba(68, 52, 30, 0.15);
--color-shadow-strong: rgba(68, 52, 30, 0.25);
--color-backdrop: rgba(68, 52, 30, 0.4);
--color-scrollbar: #d8c8a8;
--color-scrollbar-hover: #c8b898;
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-serif: "Tirra", "Source Serif 4", Georgia, serif;
--font-display: "Yeseva One", "Cormorant Garamond", Georgia, serif;
--color-h1: #8a5b15;
--color-h2: #24756f;
--color-h3: #a54c3f;
--color-h4: #754c7e;
--color-h5: #2e668b;
--color-h6: #765525;
}
html[data-theme="cosmic-stardust-light"] h1,
html[data-theme="cosmic-stardust-light"] h2,
html[data-theme="cosmic-stardust-light"] h3 {
font-family: var(--font-display);
font-style: italic;
font-weight: 400;
}
html[data-theme="cosmic-stardust-light"] h1 { color: var(--color-h1); }
html[data-theme="cosmic-stardust-light"] h2 { color: var(--color-h2); }
html[data-theme="cosmic-stardust-light"] h3 { color: var(--color-h3); }
html[data-theme="cosmic-stardust-light"] h4 { color: var(--color-h4); }
html[data-theme="cosmic-stardust-light"] h5 { color: var(--color-h5); text-transform: uppercase; letter-spacing: 0.08em; font-size: 0.92em; }
html[data-theme="cosmic-stardust-light"] h6 { color: var(--color-h6); text-transform: uppercase; letter-spacing: 0.12em; }
html[data-theme="cosmic-stardust-light"] .sidebar {
border-right-color: var(--color-border);
}
html[data-theme="cosmic-stardust-light"] .chapter-list li.is-active {
background: linear-gradient(90deg, color-mix(in srgb, var(--color-accent) 16%, transparent), transparent);
color: var(--color-text);
box-shadow: inset 3px 0 0 var(--color-accent);
}
html[data-theme="cosmic-stardust-light"] .button.button-primary {
background: linear-gradient(135deg, var(--color-accent), color-mix(in srgb, var(--color-accent) 74%, var(--color-h2)));
color: var(--color-on-accent);
border-color: transparent;
box-shadow: 0 8px 22px color-mix(in srgb, var(--color-accent) 22%, transparent);
}
html[data-theme="cosmic-stardust-light"] .button.button-primary:hover:not(:disabled) {
filter: brightness(1.08) saturate(1.08);
}
html[data-theme="cosmic-stardust-light"] .button.button-ghost {
background: var(--color-panel);
border-color: var(--color-border);
}
html[data-theme="cosmic-stardust-light"] input[type="text"],
html[data-theme="cosmic-stardust-light"] input[type="search"],
html[data-theme="cosmic-stardust-light"] select,
html[data-theme="cosmic-stardust-light"] textarea,
html[data-theme="cosmic-stardust-light"] .modal-input {
background: var(--color-panel);
border-color: var(--color-border);
}
html[data-theme="cosmic-stardust-light"] code,
html[data-theme="cosmic-stardust-light"] .folio-prose code {
background: color-mix(in srgb, var(--color-accent) 18%, transparent);
border-color: var(--color-border);
color: var(--color-h1) !important;
font-weight: 700;
}
/* ---- Cosmic Glass Effects ----
Meridian-style frosted glass for all Cosmic themes. Each theme gets
its own glass textures (grain + sheen) tuned to its palette. */
:root[data-theme="cosmic"],
:root[data-theme="cosmic-light"],
:root[data-theme="cosmic-stardust"],
:root[data-theme="cosmic-stardust-light"] {
--glass-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.07 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
:root[data-theme="cosmic"] {
--glass-sheen:
radial-gradient(900px 620px at 82% -18%, rgba(212, 158, 154, 0.18), transparent 62%),
radial-gradient(820px 640px at -28% 118%, rgba(151, 199, 220, 0.16), transparent 60%),
radial-gradient(720px 520px at 50% 45%, rgba(216, 194, 227, 0.12), transparent 65%);
}
:root[data-theme="cosmic-light"] {
--glass-sheen:
radial-gradient(900px 620px at 82% -18%, rgba(139, 82, 108, 0.14), transparent 62%),
radial-gradient(820px 640px at -28% 118%, rgba(48, 80, 111, 0.12), transparent 60%),
radial-gradient(720px 520px at 50% 45%, rgba(89, 54, 112, 0.1), transparent 65%);
}
:root[data-theme="cosmic-stardust"] {
--glass-sheen:
radial-gradient(900px 620px at 82% -18%, rgba(239, 189, 98, 0.18), transparent 62%),
radial-gradient(820px 640px at -28% 118%, rgba(116, 215, 203, 0.16), transparent 60%),
radial-gradient(720px 520px at 50% 45%, rgba(240, 142, 125, 0.12), transparent 65%);
}
:root[data-theme="cosmic-stardust-light"] {
--glass-sheen:
radial-gradient(900px 620px at 82% -18%, rgba(138, 91, 21, 0.14), transparent 62%),
radial-gradient(820px 640px at -28% 118%, rgba(36, 117, 111, 0.12), transparent 60%),
radial-gradient(720px 520px at 50% 45%, rgba(165, 76, 63, 0.1), transparent 65%);
}
/* Ambient background layer for dark cosmic themes */
:root[data-theme="cosmic"] body {
background:
var(--glass-grain),
radial-gradient(1200px 800px at 85% -10%, rgba(212, 158, 154, 0.22), transparent 60%),
radial-gradient(1000px 750px at -10% 110%, rgba(151, 199, 220, 0.18), transparent 58%),
radial-gradient(720px 520px at 50% 45%, rgba(216, 194, 227, 0.1), transparent 65%),
linear-gradient(170deg, #060910, #0a0d17 55%, #080b12) fixed;
}
:root[data-theme="cosmic-stardust"] body {
background:
var(--glass-grain),
radial-gradient(1200px 800px at 85% -10%, rgba(239, 189, 98, 0.22), transparent 60%),
radial-gradient(1000px 750px at -10% 110%, rgba(116, 215, 203, 0.18), transparent 58%),
radial-gradient(720px 520px at 50% 45%, rgba(240, 142, 125, 0.1), transparent 65%),
linear-gradient(170deg, #0d0a06, #171109 55%, #120d08) fixed;
}
/* Chrome frosting — sidebar, modals, popovers */
:root[data-theme="cosmic"] .sidebar,
:root[data-theme="cosmic-stardust"] .sidebar {
background-image: var(--glass-grain), var(--glass-sheen);
-webkit-backdrop-filter: blur(12px) saturate(115%);
backdrop-filter: blur(12px) saturate(115%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.07);
}
:root[data-theme="cosmic"] .modal,
:root[data-theme="cosmic"] .color-popover,
:root[data-theme="cosmic-stardust"] .modal,
:root[data-theme="cosmic-stardust"] .color-popover {
background-image: var(--glass-grain), var(--glass-sheen);
-webkit-backdrop-filter: blur(16px) saturate(115%);
backdrop-filter: blur(16px) saturate(115%);
}
:root[data-theme="cosmic"] .modal,
:root[data-theme="cosmic"] .color-popover,
:root[data-theme="cosmic-stardust"] .modal,
:root[data-theme="cosmic-stardust"] .color-popover {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.09),
0 8px 16px var(--color-shadow-strong);
}
/* Light cosmic themes — lighter frosting */
:root[data-theme="cosmic-light"] .sidebar,
:root[data-theme="cosmic-stardust-light"] .sidebar {
background-image: var(--glass-grain), var(--glass-sheen);
-webkit-backdrop-filter: blur(10px) saturate(110%);
backdrop-filter: blur(10px) saturate(110%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15);
}
:root[data-theme="cosmic-light"] .modal,
:root[data-theme="cosmic-light"] .color-popover,
:root[data-theme="cosmic-stardust-light"] .modal,
:root[data-theme="cosmic-stardust-light"] .color-popover {
background-image: var(--glass-grain), var(--glass-sheen);
-webkit-backdrop-filter: blur(14px) saturate(110%);
backdrop-filter: blur(14px) saturate(110%);
}
:root[data-theme="cosmic-light"] .modal,
:root[data-theme="cosmic-light"] .color-popover,
:root[data-theme="cosmic-stardust-light"] .modal,
:root[data-theme="cosmic-stardust-light"] .color-popover {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
0 8px 16px var(--color-shadow-strong);
}
/* Editor/manuscript pane joins the glass */
:root[data-theme="cosmic"] .editor,
:root[data-theme="cosmic-stardust"] .editor {
background-image: var(--glass-grain), var(--glass-sheen);
}
:root[data-theme="cosmic"] .folio-prose,
:root[data-theme="cosmic-stardust"] .folio-prose {
background-color: transparent;
}
:root[data-theme="cosmic"] .editor-panes .folio-prose.ProseMirror,
:root[data-theme="cosmic-stardust"] .editor-panes .folio-prose.ProseMirror {
background: transparent !important;
background-color: transparent !important;
}
/* Light themes — editor with subtle glass */
:root[data-theme="cosmic-light"] .editor,
:root[data-theme="cosmic-stardust-light"] .editor {
background-image: var(--glass-grain), var(--glass-sheen);
background-color: var(--color-bg);
}
:root[data-theme="cosmic-light"] .folio-prose,
:root[data-theme="cosmic-stardust-light"] .folio-prose {
background-color: transparent;
}
:root[data-theme="cosmic-light"] .editor-panes .folio-prose.ProseMirror,
:root[data-theme="cosmic-stardust-light"] .editor-panes .folio-prose.ProseMirror {
background: transparent !important;
background-color: transparent !important;
}
/* Fallback when backdrop-filter unsupported */
@supports not ((backdrop-filter: blur(4px)) or (-webkit-backdrop-filter: blur(4px))) {
:root[data-theme="cosmic"] {
--color-bg: rgba(6, 9, 16, 0.97);
--color-panel: rgba(10, 13, 23, 0.94);
}
:root[data-theme="cosmic-stardust"] {
--color-bg: rgba(13, 10, 6, 0.97);
--color-panel: rgba(20, 16, 10, 0.94);
}
:root[data-theme="cosmic-light"] {
--color-bg: rgba(240, 232, 244, 0.97);
--color-panel: rgba(244, 235, 236, 0.94);
}
:root[data-theme="cosmic-stardust-light"] {
--color-bg: rgba(255, 248, 232, 0.97);
--color-panel: rgba(245, 234, 210, 0.94);
}
}