diff --git a/components/board/BoardCanvas.module.css b/components/board/BoardCanvas.module.css
index 157042b..e943ad0 100644
--- a/components/board/BoardCanvas.module.css
+++ b/components/board/BoardCanvas.module.css
@@ -285,18 +285,24 @@
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
}
-/* Zoom controls - matching navbar button style */
+/* Zoom controls - styled like the panel switcher buttons (secondary pill, no
+ border, secondary-hover on hover) and sitting in the same top toolbar row,
+ just to their right. The anchor is at top:8/left:8; the primary side has two
+ 20px buttons + 4px gap ending ~52px, so left:56px keeps a matching 4px gap.
+ z-index sits above the board's top shadow gradient (z-index:10). */
.zoom_controls {
position: absolute;
- bottom: 24px;
- left: 24px;
+ top: 8px;
+ left: 56px;
+ z-index: 11;
display: flex;
align-items: center;
- gap: 4px;
- padding: 6px 12px;
- border-radius: 64px;
- border: 2px solid var(--tertiary);
+ gap: 2px;
+ height: 36px;
+ padding: 0 4px;
+ border-radius: 16px;
background-color: var(--secondary);
+ color: var(--secondary-text);
user-select: none;
}
@@ -304,36 +310,36 @@
display: flex;
align-items: center;
justify-content: center;
- width: 28px;
+ width: 26px;
height: 28px;
border: none;
- border-radius: 50%;
+ border-radius: 14px;
background: transparent;
- color: var(--primary-text);
+ color: var(--secondary-text);
cursor: pointer;
transition: background-color 0.15s ease;
}
.zoom_btn:hover {
- background-color: var(--tertiary);
+ background-color: var(--secondary-hover);
}
.zoom_level {
- min-width: 50px;
+ min-width: 38px;
text-align: center;
- font-size: 13px;
- color: var(--primary-text);
+ font-size: 12px;
font-weight: 500;
+ color: var(--secondary-text);
}
/* Hints in corner with low opacity */
.hints {
position: absolute;
bottom: 24px;
- right: 24px;
+ left: 24px;
display: flex;
flex-direction: column;
- align-items: flex-end;
+ align-items: flex-start;
gap: 4px;
font-size: 12px;
color: var(--secondary-text);
diff --git a/components/board/BoardCanvas.tsx b/components/board/BoardCanvas.tsx
index 9927b30..ad1ed28 100644
--- a/components/board/BoardCanvas.tsx
+++ b/components/board/BoardCanvas.tsx
@@ -989,11 +989,11 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }
{Math.round(scale * 100)}%
diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx
index 60a053a..0ebdd65 100644
--- a/components/editor/DocumentEditorPanel.tsx
+++ b/components/editor/DocumentEditorPanel.tsx
@@ -139,6 +139,16 @@ const DocumentEditorPanel = ({
editor.setEditable(!isReadOnly);
}, [editor, isReadOnly]);
+ // Marker class on the editor DOM so global CSS (scriptio.css) can drop the
+ // first-of-page top-margin reset in endless-scroll mode. There the page-break
+ // widgets are hidden, so the reset would otherwise make each page's first
+ // node stick to the previous page's content.
+ useEffect(() => {
+ const el = editor?.view?.dom;
+ if (!el) return;
+ el.classList.toggle("endless-scroll", isEndlessScroll);
+ }, [editor, isEndlessScroll]);
+
// Ready state
useEffect(() => {
if (editor && isYjsReady) {
diff --git a/components/editor/sidebar/DocumentTreeItem.tsx b/components/editor/sidebar/DocumentTreeItem.tsx
index 4960f13..0c5ceae 100644
--- a/components/editor/sidebar/DocumentTreeItem.tsx
+++ b/components/editor/sidebar/DocumentTreeItem.tsx
@@ -161,7 +161,11 @@ const DocumentTreeItem = ({
) : (
)}
-
+
{isRenaming ? (
{
(parentId: string | null, type: "folder" | "editor" | "board") => {
if (!repository) return;
if (parentId) setExpanded((prev) => new Set(prev).add(parentId));
- if (type === "folder") repository.createFolder(t("untitledFolder"), parentId);
- else if (type === "board") repository.createBoardDocument(t("boardTitle"), parentId);
- else repository.createEditorDocument(t("untitledDocument"), parentId);
+ let id: string;
+ if (type === "folder") id = repository.createFolder(t("untitledFolder"), parentId);
+ else if (type === "board") id = repository.createBoardDocument(t("boardTitle"), parentId);
+ else id = repository.createEditorDocument(t("untitledDocument"), parentId);
+ // Drop the new node straight into inline rename so the writer can name it instantly.
+ if (id) setRenamingId(id);
},
[repository, t],
);
diff --git a/components/editor/sidebar/EditorSidebarNavigation.module.css b/components/editor/sidebar/EditorSidebarNavigation.module.css
index d10e97f..30118f7 100644
--- a/components/editor/sidebar/EditorSidebarNavigation.module.css
+++ b/components/editor/sidebar/EditorSidebarNavigation.module.css
@@ -10,7 +10,7 @@
display: flex;
flex-direction: column;
gap: 20px;
- padding: 0 15px 30px 30px;
+ padding: 0 15px 20px 30px;
width: var(--navigation-sidebar-width);
min-height: 0;
overflow: hidden;
diff --git a/components/navbar/ProductionPanel.module.css b/components/navbar/ProductionPanel.module.css
index 0de9f09..db74197 100644
--- a/components/navbar/ProductionPanel.module.css
+++ b/components/navbar/ProductionPanel.module.css
@@ -9,7 +9,9 @@
display: flex;
flex-direction: column;
border-radius: 16px;
- overflow: hidden;
+ /* Not `hidden`: the revision dropdown menu in the last section needs to
+ extend past the panel's bottom edge without being clipped. */
+ overflow: visible;
}
.header {
@@ -162,19 +164,27 @@
cursor: not-allowed;
}
-/* Revision swatches */
-.swatches {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
+/* Revision color dropdown */
+.revision_select {
margin-top: 10px;
+ padding: 8px 12px;
+ font-size: 0.85rem;
+ background: var(--secondary);
+ border: 1px solid var(--separator);
+ border-radius: 8px;
+ color: var(--primary-text);
}
-.swatch {
- width: 18px;
- height: 18px;
+.revision_option {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.revision_dot {
+ width: 14px;
+ height: 14px;
border-radius: 50%;
border: 1px solid var(--separator);
- opacity: 0.5;
- cursor: not-allowed;
+ flex-shrink: 0;
}
diff --git a/components/navbar/ProductionPanel.tsx b/components/navbar/ProductionPanel.tsx
index b2297d4..d00b815 100644
--- a/components/navbar/ProductionPanel.tsx
+++ b/components/navbar/ProductionPanel.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useContext, useEffect, useMemo, useRef } from "react";
+import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { X, BookOpen, Clapperboard, PencilLine, Settings } from "lucide-react";
@@ -12,6 +12,7 @@ import { computeSceneItems } from "@src/lib/screenplay/scenes";
import { unlockDraftPopup, unlockPagesPopup, unlockScenesPopup } from "@src/lib/screenplay/popup";
import { getPageAnchors, getPageAnchorInfo } from "@src/lib/screenplay/extensions/pagination-extension";
import Switch from "@components/utils/Switch";
+import Dropdown, { DropdownOption } from "@components/utils/Dropdown";
import styles from "./ProductionPanel.module.css";
@@ -20,16 +21,18 @@ interface ProductionPanelProps {
onClose: () => void;
}
-const REVISION_COLORS = [
- "#ffffff", // white
- "#bbdfff", // blue
- "#ffb6c1", // pink
- "#ffea7a", // yellow
- "#a5d6a7", // green
- "#d4a017", // goldenrod
- "#e0c58b", // buff
- "#fa8072", // salmon
- "#9b1c2a", // cherry
+// Standard production revision color order. Names stay in English on purpose —
+// they're surfaced verbatim in the printed page headers.
+const REVISION_COLORS: { name: string; value: string }[] = [
+ { name: "White", value: "#ffffff" },
+ { name: "Blue", value: "#bbdfff" },
+ { name: "Pink", value: "#ffb6c1" },
+ { name: "Yellow", value: "#ffea7a" },
+ { name: "Green", value: "#a5d6a7" },
+ { name: "Goldenrod", value: "#d4a017" },
+ { name: "Buff", value: "#e0c58b" },
+ { name: "Salmon", value: "#fa8072" },
+ { name: "Cherry", value: "#9b1c2a" },
];
const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => {
@@ -52,6 +55,20 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => {
const panelRef = useRef(null);
+ // Revisions are inert in v1: this only tracks the previewed color locally
+ // until revision tracking is wired to the repository.
+ const [revisionColor, setRevisionColor] = useState(REVISION_COLORS[0].name);
+
+ const revisionOptions: DropdownOption[] = REVISION_COLORS.map((c) => ({
+ value: c.name,
+ label: (
+
+
+ {c.name}
+
+ ),
+ }));
+
const handleOpenSettings = () => {
onClose();
openDashboard("Production");
@@ -424,16 +441,12 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => {
{}} ariaLabel={t("revisions")} />
-
- {REVISION_COLORS.map((color, idx) => (
-
- ))}
-
+
);
diff --git a/components/navbar/ViewOptionsDropdown.tsx b/components/navbar/ViewOptionsDropdown.tsx
index 4684915..78e77d8 100644
--- a/components/navbar/ViewOptionsDropdown.tsx
+++ b/components/navbar/ViewOptionsDropdown.tsx
@@ -1,16 +1,10 @@
"use client";
-import { useContext, useState, useRef, useEffect, useCallback } from "react";
+import { useState, useRef, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
-import { UserContext } from "@src/context/UserContext";
import { PanelType, useViewContext } from "@src/context/ViewContext";
import {
ChevronDown,
- Scroll,
- MessageSquare,
- MessageSquareOff,
- Maximize,
- Minimize,
PanelRight,
PanelRightClose,
ArrowLeftRight,
@@ -22,14 +16,7 @@ import styles from "./ViewOptionsDropdown.module.css";
const ViewOptionsDropdown = () => {
const t = useTranslations("navbar");
- const { isZenMode, updateIsZenMode } = useContext(UserContext);
const {
- isEndlessScroll,
- setIsEndlessScroll,
- showComments,
- setShowComments,
- setLeftSidebarOpen,
- setRightSidebarOpen,
isSplit,
primaryPanel,
setSecondaryPanel,
@@ -50,7 +37,6 @@ const ViewOptionsDropdown = () => {
}, [isSplit, primaryPanel, setSecondaryPanel]);
const [isOpen, setIsOpen] = useState(false);
- const sidebarsBeforeFocus = useRef<{ left: boolean; right: boolean } | null>(null);
const dropdownRef = useRef(null);
// Close dropdown when clicking outside
@@ -67,41 +53,6 @@ const ViewOptionsDropdown = () => {
return () => window.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
- const enterFocusMode = useCallback(() => {
- setLeftSidebarOpen((prev) => {
- setRightSidebarOpen((prevRight) => {
- sidebarsBeforeFocus.current = { left: prev, right: prevRight };
- return false;
- });
- return false;
- });
- updateIsZenMode(true);
- document.documentElement.requestFullscreen?.();
- }, [updateIsZenMode, setLeftSidebarOpen, setRightSidebarOpen]);
-
- const exitFocusMode = useCallback(() => {
- updateIsZenMode(false);
- if (document.fullscreenElement) {
- document.exitFullscreen();
- }
- if (sidebarsBeforeFocus.current) {
- setLeftSidebarOpen(sidebarsBeforeFocus.current.left);
- setRightSidebarOpen(sidebarsBeforeFocus.current.right);
- sidebarsBeforeFocus.current = null;
- }
- }, [updateIsZenMode, setLeftSidebarOpen, setRightSidebarOpen]);
-
- // Sync zen mode state when user exits fullscreen via Escape
- useEffect(() => {
- const onFullscreenChange = () => {
- if (!document.fullscreenElement && isZenMode) {
- exitFocusMode();
- }
- };
- document.addEventListener("fullscreenchange", onFullscreenChange);
- return () => document.removeEventListener("fullscreenchange", onFullscreenChange);
- }, [isZenMode, exitFocusMode]);
-
return (
-
-
-
{/* Right sidebar */}
diff --git a/components/project/SplitPanelContainer.module.css b/components/project/SplitPanelContainer.module.css
index 7911d9c..ef54dcf 100644
--- a/components/project/SplitPanelContainer.module.css
+++ b/components/project/SplitPanelContainer.module.css
@@ -33,6 +33,19 @@
border-radius: 4px;
background-color: var(--editor-style-bg-hover);
opacity: 0.5;
+ transition:
+ left 0.12s ease,
+ right 0.12s ease;
+}
+
+/* Edge variants preview a split: the overlay shrinks to the half the dropped
+ document would occupy, hinting that the panel will divide rather than swap. */
+.panel_drop_overlay_left {
+ right: 50%;
+}
+
+.panel_drop_overlay_right {
+ left: 50%;
}
/* Mounted-but-hidden panels: kept alive in the DOM so editors
diff --git a/components/project/SplitPanelContainer.tsx b/components/project/SplitPanelContainer.tsx
index 6ae9698..550b88d 100644
--- a/components/project/SplitPanelContainer.tsx
+++ b/components/project/SplitPanelContainer.tsx
@@ -1,9 +1,9 @@
"use client";
-import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
-import { UserContext } from "@src/context/UserContext";
-import { PanelType, SplitSide, useViewContext } from "@src/context/ViewContext";
+import { DocumentPanelKind, PanelType, SplitSide, useViewContext } from "@src/context/ViewContext";
+import { join } from "@src/lib/utils/misc";
import { DOC_DND_MIME } from "@components/editor/sidebar/DocumentTreeItem";
import EditorPanel from "@components/editor/EditorPanel";
import TitlePagePanel from "@components/editor/TitlePagePanel";
@@ -21,14 +21,9 @@ import {
Clapperboard,
FileText,
ListTree,
- Maximize,
Menu,
- MessageSquare,
- MessageSquareOff,
- Minimize,
PanelRight,
PanelRightClose,
- Scroll,
} from "lucide-react";
import styles from "./SplitPanelContainer.module.css";
import dropdown from "@components/navbar/ViewOptionsDropdown.module.css";
@@ -88,20 +83,14 @@ const SWITCHABLE_PANELS: { type: PanelType; icon: typeof Clapperboard; labelKey:
const PanelSwitcherMenu = ({ currentPanel, side }: { currentPanel: PanelType; side: "primary" | "secondary" }) => {
const t = useTranslations("navbar");
- const { isZenMode, updateIsZenMode } = useContext(UserContext);
const {
setSidePanel,
isSplit,
primaryPanel,
setSecondaryPanel,
swapPanels,
- isEndlessScroll,
- setIsEndlessScroll,
- showComments,
- setShowComments,
leftSidebarOpen,
setLeftSidebarOpen,
- setRightSidebarOpen,
} = useViewContext();
const handleSplitToggle = useCallback(() => {
@@ -116,7 +105,6 @@ const PanelSwitcherMenu = ({ currentPanel, side }: { currentPanel: PanelType; si
}, [isSplit, primaryPanel, setSecondaryPanel]);
const [isOpen, setIsOpen] = useState(false);
const ref = useRef(null);
- const sidebarsBeforeFocus = useRef<{ left: boolean; right: boolean } | null>(null);
useEffect(() => {
if (!isOpen) return;
@@ -135,40 +123,6 @@ const PanelSwitcherMenu = ({ currentPanel, side }: { currentPanel: PanelType; si
[currentPanel, side, setSidePanel],
);
- const enterFocusMode = useCallback(() => {
- setLeftSidebarOpen((prev) => {
- setRightSidebarOpen((prevRight) => {
- sidebarsBeforeFocus.current = { left: prev, right: prevRight };
- return false;
- });
- return false;
- });
- updateIsZenMode(true);
- document.documentElement.requestFullscreen?.();
- }, [updateIsZenMode, setLeftSidebarOpen, setRightSidebarOpen]);
-
- const exitFocusMode = useCallback(() => {
- updateIsZenMode(false);
- if (document.fullscreenElement) {
- document.exitFullscreen();
- }
- if (sidebarsBeforeFocus.current) {
- setLeftSidebarOpen(sidebarsBeforeFocus.current.left);
- setRightSidebarOpen(sidebarsBeforeFocus.current.right);
- sidebarsBeforeFocus.current = null;
- }
- }, [updateIsZenMode, setLeftSidebarOpen, setRightSidebarOpen]);
-
- useEffect(() => {
- const onFullscreenChange = () => {
- if (!document.fullscreenElement && isZenMode) {
- exitFocusMode();
- }
- };
- document.addEventListener("fullscreenchange", onFullscreenChange);
- return () => document.removeEventListener("fullscreenchange", onFullscreenChange);
- }, [isZenMode, exitFocusMode]);
-
return (
{side === "primary" && (
@@ -213,34 +167,28 @@ const PanelSwitcherMenu = ({ currentPanel, side }: { currentPanel: PanelType; si
{t(labelKey as Parameters[0])}
))}
-
-
setIsEndlessScroll(!isEndlessScroll)}
- >
-
- {t("endlessScroll")}
-
-
setShowComments(!showComments)}
- >
- {showComments ? : }
- {t("toggleComments")}
-
-
- {isZenMode ? : }
- {t("focusMode")}
-
)}
);
};
+// Drop zone within a panel: "center" replaces the panel's content, while the
+// "left"/"right" edges split it and open the document on that side.
+type DropZone = "left" | "center" | "right";
+
+// Fraction of the panel width on each side that counts as a split edge.
+const SPLIT_EDGE_RATIO = 0.3;
+
+const computeDropZone = (e: React.DragEvent, allowSplit: boolean): DropZone => {
+ if (!allowSplit) return "center";
+ const rect = e.currentTarget.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ if (x < rect.width * SPLIT_EDGE_RATIO) return "left";
+ if (x > rect.width * (1 - SPLIT_EDGE_RATIO)) return "right";
+ return "center";
+};
+
const SplitPanelContainer = ({
suggestions,
updateSuggestions,
@@ -258,10 +206,13 @@ const SplitPanelContainer = ({
focusedSide,
setFocusedSide,
setSideDocument,
+ splitWithDocument,
} = useViewContext();
- // Side currently highlighted while a document is dragged from the sidebar.
- const [docDragOverSide, setDocDragOverSide] = useState(null);
+ // Where a document dragged from the sidebar would land: which side it is over
+ // and which zone of that side ("center" replaces the panel; "left"/"right"
+ // splits, opening the document on that edge).
+ const [docDragOver, setDocDragOver] = useState<{ side: SplitSide; zone: DropZone } | null>(null);
// Capture-phase so the panel claims a document drop before the editor's own
// drop handling sees it; non-document drags fall through untouched.
@@ -270,9 +221,11 @@ const SplitPanelContainer = ({
if (!e.dataTransfer.types.includes(DOC_DND_MIME)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
- setDocDragOverSide((prev) => (prev === side ? prev : side));
+ // Edge zones only split when there is room for a second panel.
+ const zone = computeDropZone(e, !isSplit);
+ setDocDragOver((prev) => (prev?.side === side && prev.zone === zone ? prev : { side, zone }));
},
- [],
+ [isSplit],
);
const handleDocDrop = useCallback(
@@ -281,16 +234,22 @@ const SplitPanelContainer = ({
if (!raw) return;
e.preventDefault();
e.stopPropagation();
- setDocDragOverSide(null);
+ const zone = computeDropZone(e, !isSplit);
+ setDocDragOver(null);
let data: { id: string; type: "editor" | "board" };
try {
data = JSON.parse(raw);
} catch {
return;
}
- setSideDocument(side, data.id, data.type === "board" ? "board" : "document");
+ const kind: DocumentPanelKind = data.type === "board" ? "board" : "document";
+ if (zone === "center") {
+ setSideDocument(side, data.id, kind);
+ } else {
+ splitWithDocument(data.id, kind, zone === "left" ? "primary" : "secondary");
+ }
},
- [setSideDocument],
+ [isSplit, setSideDocument, splitWithDocument],
);
const gridStyle = useMemo(() => {
@@ -318,7 +277,7 @@ const SplitPanelContainer = ({
}) => {
const { keyId, panelKind, side, isPrimary, isVisible, content } = opts;
const isFocused = isSplit && isVisible && focusedSide === side;
- const isDocDropTarget = isVisible && docDragOverSide === side;
+ const dropZone = isVisible && docDragOver?.side === side ? docDragOver.zone : null;
const panelClass = !isVisible
? styles.panel_hidden
: `${styles.panel}${isFocused ? ` ${styles.panel_focused}` : ""}`;
@@ -334,12 +293,20 @@ const SplitPanelContainer = ({
onDragLeave={
isVisible
? (e) => {
- if (!e.currentTarget.contains(e.relatedTarget as Node)) setDocDragOverSide(null);
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) setDocDragOver(null);
}
: undefined
}
>
- {isDocDropTarget && }
+ {dropZone && (
+
+ )}
{isVisible && }
{content}
diff --git a/components/project/WritingTimer.module.css b/components/project/WritingTimer.module.css
new file mode 100644
index 0000000..46b4f7b
--- /dev/null
+++ b/components/project/WritingTimer.module.css
@@ -0,0 +1,223 @@
+.anchor {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.time {
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+ color: var(--secondary-text);
+ min-width: 38px;
+ text-align: center;
+}
+
+.time_done {
+ color: var(--primary-text);
+ font-weight: 600;
+}
+
+/* Popover opens above the footer pill. */
+.panel {
+ position: absolute;
+ bottom: calc(100% + 10px);
+ right: 0;
+ z-index: 20;
+ width: 220px;
+ padding: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ background-color: var(--secondary);
+ border: 1px solid var(--separator);
+ border-radius: 14px;
+ box-shadow: var(--panel-shadow);
+ color: var(--secondary-text);
+ cursor: default;
+}
+
+.title {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--primary-text);
+}
+
+.tabs {
+ display: flex;
+ gap: 4px;
+ padding: 3px;
+ background-color: var(--main-bg);
+ border-radius: 8px;
+}
+
+.tab {
+ flex: 1;
+ padding: 5px 8px;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--secondary-text);
+ font-size: 11px;
+ cursor: pointer;
+ transition:
+ background-color 0.15s ease,
+ color 0.15s ease;
+}
+
+.tab:hover:not(:disabled) {
+ color: var(--primary-text);
+}
+
+.tab_active {
+ background-color: var(--secondary-hover);
+ color: var(--primary-text);
+}
+
+.tab:disabled {
+ cursor: default;
+ opacity: 0.5;
+}
+
+/* Big time flanked by up/down minute steppers (countdown setup only). */
+.stepper_group {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+}
+
+.stepper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 20px;
+ border: none;
+ border-radius: 8px;
+ background-color: var(--main-bg);
+ color: var(--secondary-text);
+ cursor: pointer;
+ transition:
+ background-color 0.15s ease,
+ color 0.15s ease;
+}
+
+.stepper:hover {
+ background-color: var(--secondary-hover);
+ color: var(--primary-text);
+}
+
+/* The display and its edit input occupy an identical fixed box so swapping
+ between them (on click) causes no layout shift: same width, height, border
+ (the display's is transparent), font, and box-sizing. A fixed height avoids
+ the input's intrinsic font-based height differing from the div's. */
+.display,
+.display_input {
+ box-sizing: border-box;
+ width: 130px;
+ height: 44px;
+ padding: 0;
+ font-family: inherit;
+ font-size: 30px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: 1px;
+ text-align: center;
+ color: var(--primary-text);
+ border: 1px solid transparent;
+ border-radius: 8px;
+}
+
+/* Center the div text to match the input's intrinsic vertical centering. */
+.display {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.display_done {
+ color: var(--tertiary-hover);
+}
+
+/* Click-to-edit affordance on the countdown display. */
+.display_editable {
+ cursor: text;
+ transition: background-color 0.15s ease;
+}
+
+.display_editable:hover {
+ background-color: var(--main-bg);
+}
+
+.display_input {
+ background-color: var(--main-bg);
+ border-color: var(--tertiary-hover);
+ outline: none;
+}
+
+.presets {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.chip {
+ flex: 1 1 calc(25% - 6px);
+ padding: 6px 0;
+ border: 1px solid var(--separator);
+ border-radius: 8px;
+ background: transparent;
+ color: var(--secondary-text);
+ font-size: 11px;
+ cursor: pointer;
+ transition:
+ background-color 0.15s ease,
+ color 0.15s ease,
+ border-color 0.15s ease;
+}
+
+.chip:hover {
+ color: var(--primary-text);
+ border-color: var(--tertiary-hover);
+}
+
+.chip_active {
+ background-color: var(--secondary-hover);
+ border-color: var(--tertiary-hover);
+ color: var(--primary-text);
+}
+
+.controls {
+ display: flex;
+ gap: 8px;
+}
+
+.btn {
+ flex: 1;
+ padding: 8px 0;
+ border: none;
+ border-radius: 9px;
+ background-color: var(--main-bg);
+ color: var(--primary-text);
+ font-size: 11px;
+ font-weight: 500;
+ cursor: pointer;
+ transition:
+ opacity 0.15s ease,
+ background-color 0.15s ease;
+}
+
+.btn:hover {
+ background-color: var(--secondary-hover);
+}
+
+.btn_primary {
+ background-color: var(--tertiary-hover);
+ color: var(--primary-text);
+}
+
+.btn_primary:hover {
+ opacity: 0.9;
+ background-color: var(--tertiary-hover);
+}
diff --git a/components/project/WritingTimer.tsx b/components/project/WritingTimer.tsx
new file mode 100644
index 0000000..2fa0dfd
--- /dev/null
+++ b/components/project/WritingTimer.tsx
@@ -0,0 +1,304 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { useTranslations } from "next-intl";
+import { ChevronDown, ChevronUp, Timer } from "lucide-react";
+import { join } from "@src/lib/utils/misc";
+
+import styles from "./WritingTimer.module.css";
+
+type TimerMode = "countdown" | "stopwatch";
+
+const PRESETS = [10, 15, 25, 45];
+// Minutes added/removed per stepper click, and the allowed duration range (sec).
+const STEP = 5;
+const MIN_SECONDS = 30;
+const MAX_SECONDS = 180 * 60;
+
+const clampSeconds = (sec: number) => Math.min(MAX_SECONDS, Math.max(MIN_SECONDS, Math.round(sec)));
+
+/** Parse a manually typed duration: "M", "M:SS", or "H:MM:SS". Returns total
+ * seconds, or null if unparseable. A bare number is read as minutes. */
+const parseClock = (raw: string): number | null => {
+ const s = raw.trim();
+ if (!s) return null;
+ const parts = s.split(":");
+ if (parts.length > 3) return null;
+ const nums = parts.map((p) => Number(p));
+ if (nums.some((n) => !Number.isFinite(n) || n < 0)) return null;
+ if (nums.length === 1) return nums[0] * 60;
+ if (nums.length === 2) return nums[0] * 60 + nums[1];
+ return nums[0] * 3600 + nums[1] * 60 + nums[2];
+};
+
+/** Gentle two-note chime when a countdown completes. Synthesised so no audio
+ * asset is needed; failures (autoplay policy, no WebAudio) are ignored. */
+const playChime = () => {
+ try {
+ const Ctx =
+ window.AudioContext ||
+ (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
+ if (!Ctx) return;
+ const ctx = new Ctx();
+ const now = ctx.currentTime;
+ [880, 1175].forEach((freq, i) => {
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.type = "sine";
+ osc.frequency.value = freq;
+ const t0 = now + i * 0.18;
+ gain.gain.setValueAtTime(0.0001, t0);
+ gain.gain.exponentialRampToValueAtTime(0.2, t0 + 0.02);
+ gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.35);
+ osc.connect(gain);
+ gain.connect(ctx.destination);
+ osc.start(t0);
+ osc.stop(t0 + 0.4);
+ });
+ setTimeout(() => ctx.close().catch(() => {}), 1000);
+ } catch {
+ // Audio is a nice-to-have; ignore failures.
+ }
+};
+
+const formatClock = (ms: number, roundUp: boolean): string => {
+ const totalSec = Math.max(0, roundUp ? Math.ceil(ms / 1000) : Math.floor(ms / 1000));
+ const h = Math.floor(totalSec / 3600);
+ const m = Math.floor((totalSec % 3600) / 60);
+ const s = totalSec % 60;
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
+};
+
+interface WritingTimerProps {
+ /** Shared footer action-button classes, so the trigger matches the others. */
+ triggerClassName: string;
+ triggerActiveClassName: string;
+}
+
+/**
+ * Writing-session timer: a footer icon that opens a popover to run either a
+ * countdown (set a duration, get a chime at zero) or a count-up stopwatch.
+ * Timer state is local and ephemeral — it lives as long as the footer is
+ * mounted but is not persisted across reloads.
+ */
+const WritingTimer = ({ triggerClassName, triggerActiveClassName }: WritingTimerProps) => {
+ const t = useTranslations("timer");
+
+ const [open, setOpen] = useState(false);
+ const [mode, setMode] = useState("countdown");
+ const [durationSec, setDurationSec] = useState(25 * 60);
+ const [running, setRunning] = useState(false);
+ const [elapsedMs, setElapsedMs] = useState(0);
+ const [completed, setCompleted] = useState(false);
+ // Manual editing of the countdown duration via the display field.
+ const [editing, setEditing] = useState(false);
+ const [editValue, setEditValue] = useState("");
+
+ const anchorRef = useRef(null);
+ // Timestamp such that elapsed = Date.now() - startTsRef while running.
+ const startTsRef = useRef(0);
+
+ const targetMs = durationSec * 1000;
+ const isIdle = !running && elapsedMs === 0 && !completed;
+ const isActive = running || elapsedMs > 0;
+ // Duration is only adjustable before a countdown starts.
+ const canSetDuration = mode === "countdown" && isIdle;
+ const displayMs = mode === "countdown" ? Math.max(0, targetMs - elapsedMs) : elapsedMs;
+ const clock = formatClock(displayMs, mode === "countdown");
+
+ // Tick while running.
+ useEffect(() => {
+ if (!running) return;
+ const id = setInterval(() => setElapsedMs(Date.now() - startTsRef.current), 250);
+ return () => clearInterval(id);
+ }, [running]);
+
+ // Countdown completion.
+ useEffect(() => {
+ if (mode === "countdown" && running && elapsedMs >= targetMs) {
+ setRunning(false);
+ setElapsedMs(targetMs);
+ setCompleted(true);
+ playChime();
+ }
+ }, [mode, running, elapsedMs, targetMs]);
+
+ // Close the popover on outside click.
+ useEffect(() => {
+ if (!open) return;
+ const onDown = (e: MouseEvent) => {
+ if (anchorRef.current && !anchorRef.current.contains(e.target as Node)) {
+ setOpen(false);
+ setEditing(false);
+ }
+ };
+ window.addEventListener("mousedown", onDown);
+ return () => window.removeEventListener("mousedown", onDown);
+ }, [open]);
+
+ const start = () => {
+ setEditing(false);
+ setCompleted(false);
+ startTsRef.current = Date.now() - elapsedMs;
+ setRunning(true);
+ };
+ const pause = () => setRunning(false);
+ const reset = () => {
+ setEditing(false);
+ setRunning(false);
+ setElapsedMs(0);
+ setCompleted(false);
+ };
+ const adjustDuration = (deltaMin: number) =>
+ setDurationSec((s) => clampSeconds(s + deltaMin * 60));
+
+ const selectMode = (next: TimerMode) => {
+ if (next === mode) return;
+ reset();
+ setMode(next);
+ };
+
+ const toggleOpen = () => {
+ setOpen((o) => !o);
+ setEditing(false);
+ };
+
+ const startEdit = () => {
+ setEditValue(formatClock(durationSec * 1000, false));
+ setEditing(true);
+ };
+ const commitEdit = () => {
+ const parsed = parseClock(editValue);
+ if (parsed !== null) setDurationSec(clampSeconds(parsed));
+ setEditing(false);
+ };
+
+ return (
+
+
+
+
+
+ {isActive &&
{clock}}
+
+ {open && (
+
+
{t("title")}
+
+
+ selectMode("countdown")}
+ disabled={isActive}
+ >
+ {t("countdown")}
+
+ selectMode("stopwatch")}
+ disabled={isActive}
+ >
+ {t("stopwatch")}
+
+
+
+
+ {canSetDuration && (
+
adjustDuration(STEP)}
+ aria-label={`+${STEP} ${t("minutesShort")}`}
+ >
+
+
+ )}
+ {editing ? (
+
setEditValue(e.target.value)}
+ onFocus={(e) => e.currentTarget.select()}
+ onBlur={commitEdit}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") commitEdit();
+ else if (e.key === "Escape") setEditing(false);
+ }}
+ />
+ ) : (
+
+ {clock}
+
+ )}
+ {canSetDuration && (
+
adjustDuration(-STEP)}
+ aria-label={`-${STEP} ${t("minutesShort")}`}
+ >
+
+
+ )}
+
+
+ {canSetDuration && (
+
+ {PRESETS.map((m) => (
+ setDurationSec(m * 60)}
+ >
+ {m} {t("minutesShort")}
+
+ ))}
+
+ )}
+
+
+ {running ? (
+
+ {t("pause")}
+
+ ) : isActive && !completed ? (
+
+ {t("resume")}
+
+ ) : !completed ? (
+
+ {t("start")}
+
+ ) : null}
+ {isActive && (
+
+ {t("reset")}
+
+ )}
+
+
+ )}
+
+ );
+};
+
+export default WritingTimer;
diff --git a/messages/de.json b/messages/de.json
index 20a69cd..5cd3936 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -26,7 +26,18 @@
"swapPanels": "Panels tauschen",
"draftEditor": "Entwurfseditor",
"viewOnly": "Nur ansehen",
- "viewOnlyHint": "Sie haben Lesezugriff. Die Bearbeitung ist deaktiviert."
+ "viewOnlyHint": "Sie haben Lesezugriff. Die Bearbeitung ist deaktiviert.",
+ "pageCount": "{count, plural, one {# Seite} other {# Seiten}}"
+ },
+ "timer": {
+ "title": "Schreibsitzung",
+ "countdown": "Countdown",
+ "stopwatch": "Stoppuhr",
+ "start": "Starten",
+ "pause": "Pause",
+ "resume": "Fortsetzen",
+ "reset": "Zurücksetzen",
+ "minutesShort": "Min"
},
"common": {
"save": "Änderungen speichern",
diff --git a/messages/en.json b/messages/en.json
index 6dcdfed..c72afd3 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -25,7 +25,18 @@
"swapPanels": "Swap Panels",
"draftEditor": "Draft Editor",
"viewOnly": "View only",
- "viewOnlyHint": "You have viewer access. Editing is disabled."
+ "viewOnlyHint": "You have viewer access. Editing is disabled.",
+ "pageCount": "{count, plural, one {# page} other {# pages}}"
+ },
+ "timer": {
+ "title": "Writing session",
+ "countdown": "Countdown",
+ "stopwatch": "Stopwatch",
+ "start": "Start",
+ "pause": "Pause",
+ "resume": "Resume",
+ "reset": "Reset",
+ "minutesShort": "min"
},
"common": {
"save": "Save",
diff --git a/messages/es.json b/messages/es.json
index 449d2c7..d212320 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -25,7 +25,18 @@
"swapPanels": "Intercambiar paneles",
"draftEditor": "Editor de borrador",
"viewOnly": "Solo lectura",
- "viewOnlyHint": "Tienes acceso de solo visualización. La edición está desactivada."
+ "viewOnlyHint": "Tienes acceso de solo visualización. La edición está desactivada.",
+ "pageCount": "{count, plural, one {# página} other {# páginas}}"
+ },
+ "timer": {
+ "title": "Sesión de escritura",
+ "countdown": "Cuenta atrás",
+ "stopwatch": "Cronómetro",
+ "start": "Iniciar",
+ "pause": "Pausar",
+ "resume": "Reanudar",
+ "reset": "Reiniciar",
+ "minutesShort": "min"
},
"common": {
"save": "Guardar cambios",
diff --git a/messages/fr.json b/messages/fr.json
index 7e11c07..df70cb1 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -26,7 +26,18 @@
"swapPanels": "Inverser les panneaux",
"draftEditor": "Éditeur de brouillon",
"viewOnly": "Vue seule",
- "viewOnlyHint": "Vous avez un accès visiteur. La modification est désactivée."
+ "viewOnlyHint": "Vous avez un accès visiteur. La modification est désactivée.",
+ "pageCount": "{count, plural, one {# page} other {# pages}}"
+ },
+ "timer": {
+ "title": "Session d'écriture",
+ "countdown": "Compte à rebours",
+ "stopwatch": "Chronomètre",
+ "start": "Démarrer",
+ "pause": "Pause",
+ "resume": "Reprendre",
+ "reset": "Réinitialiser",
+ "minutesShort": "min"
},
"common": {
"save": "Enregistrer",
diff --git a/messages/ja.json b/messages/ja.json
index 039fc42..02b93ce 100644
--- a/messages/ja.json
+++ b/messages/ja.json
@@ -25,7 +25,18 @@
"swapPanels": "パネルを入れ替え",
"draftEditor": "下書きエディター",
"viewOnly": "閲覧のみ",
- "viewOnlyHint": "閲覧者アクセスです。編集は無効になっています。"
+ "viewOnlyHint": "閲覧者アクセスです。編集は無効になっています。",
+ "pageCount": "{count}ページ"
+ },
+ "timer": {
+ "title": "執筆セッション",
+ "countdown": "カウントダウン",
+ "stopwatch": "ストップウォッチ",
+ "start": "開始",
+ "pause": "一時停止",
+ "resume": "再開",
+ "reset": "リセット",
+ "minutesShort": "分"
},
"common": {
"save": "変更を保存",
diff --git a/messages/ko.json b/messages/ko.json
index 9b4336b..8d464cf 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -25,7 +25,18 @@
"swapPanels": "패널 교체",
"draftEditor": "초안 편집기",
"viewOnly": "보기 전용",
- "viewOnlyHint": "뷰어 권한이 있습니다. 편집이 비활성화되었습니다."
+ "viewOnlyHint": "뷰어 권한이 있습니다. 편집이 비활성화되었습니다.",
+ "pageCount": "{count}페이지"
+ },
+ "timer": {
+ "title": "글쓰기 세션",
+ "countdown": "카운트다운",
+ "stopwatch": "스톱워치",
+ "start": "시작",
+ "pause": "일시정지",
+ "resume": "계속",
+ "reset": "초기화",
+ "minutesShort": "분"
},
"common": {
"save": "변경사항 저장",
diff --git a/messages/pl.json b/messages/pl.json
index f5bf623..d91a6f9 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -25,7 +25,18 @@
"swapPanels": "Zamień panele",
"draftEditor": "Edytor szkiców",
"viewOnly": "Tylko podgląd",
- "viewOnlyHint": "Masz dostęp gościa. Edytowanie jest wyłączone."
+ "viewOnlyHint": "Masz dostęp gościa. Edytowanie jest wyłączone.",
+ "pageCount": "{count, plural, one {# strona} few {# strony} other {# stron}}"
+ },
+ "timer": {
+ "title": "Sesja pisania",
+ "countdown": "Odliczanie",
+ "stopwatch": "Stoper",
+ "start": "Start",
+ "pause": "Pauza",
+ "resume": "Wznów",
+ "reset": "Resetuj",
+ "minutesShort": "min"
},
"common": {
"save": "Zapisz zmiany",
diff --git a/messages/zh.json b/messages/zh.json
index d4153cc..3bd3101 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -25,7 +25,18 @@
"swapPanels": "交换面板",
"draftEditor": "草稿编辑器",
"viewOnly": "仅查看",
- "viewOnlyHint": "您拥有访客权限,编辑功能已禁用。"
+ "viewOnlyHint": "您拥有访客权限,编辑功能已禁用。",
+ "pageCount": "{count} 页"
+ },
+ "timer": {
+ "title": "写作计时",
+ "countdown": "倒计时",
+ "stopwatch": "秒表",
+ "start": "开始",
+ "pause": "暂停",
+ "resume": "继续",
+ "reset": "重置",
+ "minutesShort": "分钟"
},
"common": {
"save": "保存",
diff --git a/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx
index 6dd2490..5229e0c 100644
--- a/src/context/ProjectContext.tsx
+++ b/src/context/ProjectContext.tsx
@@ -17,6 +17,7 @@ import { PersistentPageMap } from "@src/lib/screenplay/page-locking";
import { ProjectMembershipPayload } from "@src/server/repository/project-repository";
import { ProjectRole } from "@src/generated/client/browser";
import { useUser } from "@src/lib/utils/hooks";
+import { getCloudToken } from "@src/lib/utils/requests";
import {
CollaboratorInfo,
ConnectionStatus,
@@ -446,14 +447,11 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) =
setProject((prev) => (prev ? { ...prev, role: newRole as ProjectRole } : prev));
if (project?.project.id) {
try {
- const res = await fetch(`/api/projects/${project.project.id}/cloud-token`);
- if (res.ok) {
- const { token } = (await res.json()) as { token: string };
- if (token) {
- // Update token silently so future reconnects use the new role
- // We don't force reconnect because the DO already updated our active session
- await provider.updateToken(token, false);
- }
+ const { token } = await getCloudToken(project.project.id);
+ if (token) {
+ // Update token silently so future reconnects use the new role.
+ // We don't force reconnect because the DO already updated our active session.
+ await provider.updateToken(token, false);
}
} catch (e) {
console.warn("Failed to fetch new token on role change", e);
diff --git a/src/context/ViewContext.tsx b/src/context/ViewContext.tsx
index 520f3e8..ef08bbe 100644
--- a/src/context/ViewContext.tsx
+++ b/src/context/ViewContext.tsx
@@ -32,6 +32,12 @@ interface ViewContextType {
setSidePanel: (side: SplitSide, panel: PanelType) => void;
/** Open a specific document (board/editor) on a given side and focus it. */
setSideDocument: (side: SplitSide, docId: string, kind: DocumentPanelKind) => void;
+ /**
+ * Split the single panel and open a document on one side, keeping the
+ * currently-shown panel on the other. `side` is where the new document
+ * goes. Assumes the view is not already split.
+ */
+ splitWithDocument: (docId: string, kind: DocumentPanelKind, side: SplitSide) => void;
/** Clear a document from any side currently showing it (e.g. after delete). */
closeDocument: (docId: string) => void;
swapPanels: () => void;
@@ -194,6 +200,25 @@ export const ViewProvider = ({ children }: { children: ReactNode }) => {
setFocusedSideState(side);
}, []);
+ const splitWithDocument = useCallback(
+ (docId: string, kind: DocumentPanelKind, side: SplitSide) => {
+ if (side === "primary") {
+ // New document takes the left; the existing panel slides right.
+ setSecondaryPanelState(primaryPanel);
+ setSecondaryDocId(primaryDocId);
+ setPrimaryPanelState(kind);
+ setPrimaryDocId(docId);
+ setFocusedSideState("primary");
+ } else {
+ // Existing panel stays on the left; new document opens on the right.
+ setSecondaryPanelState(kind);
+ setSecondaryDocId(docId);
+ setFocusedSideState("secondary");
+ }
+ },
+ [primaryPanel, primaryDocId],
+ );
+
const closeDocument = useCallback((docId: string) => {
setPrimaryDocId((prev) => (prev === docId ? null : prev));
setSecondaryDocId((prev) => (prev === docId ? null : prev));
@@ -231,6 +256,7 @@ export const ViewProvider = ({ children }: { children: ReactNode }) => {
setFocusedPanel,
setSidePanel,
setSideDocument,
+ splitWithDocument,
closeDocument,
swapPanels,
setIsEndlessScroll,
@@ -238,7 +264,7 @@ export const ViewProvider = ({ children }: { children: ReactNode }) => {
setLeftSidebarOpen,
setRightSidebarOpen,
}),
- [primaryPanel, secondaryPanel, primaryDocId, secondaryDocId, splitRatio, isSplit, visiblePanels, mountedPanels, focusedSide, focusedPanel, isEndlessScroll, showComments, leftSidebarOpen, rightSidebarOpen, setPrimaryPanel, setSecondaryPanel, setFocusedSide, setFocusedPanel, setSidePanel, setSideDocument, closeDocument, swapPanels, setIsEndlessScroll, setShowComments],
+ [primaryPanel, secondaryPanel, primaryDocId, secondaryDocId, splitRatio, isSplit, visiblePanels, mountedPanels, focusedSide, focusedPanel, isEndlessScroll, showComments, leftSidebarOpen, rightSidebarOpen, setPrimaryPanel, setSecondaryPanel, setFocusedSide, setFocusedPanel, setSidePanel, setSideDocument, splitWithDocument, closeDocument, swapPanels, setIsEndlessScroll, setShowComments],
);
return {children};
diff --git a/styles/scriptio.css b/styles/scriptio.css
index 2c7ae64..9e2d0fe 100644
--- a/styles/scriptio.css
+++ b/styles/scriptio.css
@@ -73,8 +73,15 @@
(0) collapses with the next paragraph's margin-top (16) and leaves a
wasted 16px gap at the top of every page. Pagination's break decisions
use measured outer heights in isolation, so removing the margin only
- affects rendering — same paragraphs per page, no determinism hit. */
- > .pagination-page-break + p,
+ affects rendering — same paragraphs per page, no determinism hit.
+
+ In endless-scroll mode the page-break widgets are hidden (display:none),
+ so there is no gap to collapse and zeroing the margin would make each
+ page's first node stick to the previous page's content. The
+ `:not(.endless-scroll)` guard skips it there so the node keeps its
+ natural top margin. The first-page widget is still shown in endless
+ scroll, so its following paragraph keeps the reset. */
+ &:not(.endless-scroll) > .pagination-page-break + p,
> .pagination-first-page + p {
margin-top: 0 !important;
}