From 72d698e427e15431e9f46735391dfb297c9fce43 Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 22 Jun 2026 12:40:29 -0400 Subject: [PATCH] changes --- src-tauri/Cargo.lock | 2 +- src/components/DeviceFrame.tsx | 67 ++++ src/components/Dock.tsx | 156 ++++++-- src/devices.ts | 115 ++++++ src/store/usePreviewMode.ts | 15 + src/styles/app.css | 191 +++++++++ website/package.json | 3 +- website/src/components/AppWindow.astro | 387 ++++++++++++++----- website/src/components/ClosingCta.astro | 6 - website/src/components/DownloadButtons.astro | 29 +- website/src/components/DownloadScript.astro | 12 + website/src/components/Hero.astro | 115 ++++-- website/src/components/Nav.astro | 14 + website/src/components/OfferBadge.astro | 1 - website/src/components/Pricing.astro | 6 - website/src/data/site.ts | 2 +- 16 files changed, 922 insertions(+), 199 deletions(-) create mode 100644 src/components/DeviceFrame.tsx create mode 100644 src/devices.ts create mode 100644 src/store/usePreviewMode.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 20f9a7a..35a5759 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4704,7 +4704,7 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[package]] name = "margin-app" -version = "0.1.1" +version = "0.1.2" dependencies = [ "base64 0.22.1", "harper-core", diff --git a/src/components/DeviceFrame.tsx b/src/components/DeviceFrame.tsx new file mode 100644 index 0000000..c0db39d --- /dev/null +++ b/src/components/DeviceFrame.tsx @@ -0,0 +1,67 @@ +import type { CSSProperties, ReactNode } from "react"; +import type { Device } from "../devices"; +import type { Book } from "../model/book"; +import { CoverArt } from "./CoverArt"; + +interface DeviceFrameProps { + device: Device; + eyebrow?: ReactNode; + title?: ReactNode; + html?: string; + cover?: Book; +} + +function frameVars(device: Device): CSSProperties { + return { + "--dv-bg": device.bg, + "--dv-ink": device.ink, + "--dv-fs": device.fontSize, + "--dv-lh": device.lineHeight, + "--dv-mx": device.marginX, + "--dv-my": device.marginY, + "--dv-bezel": device.bezel, + "--dv-bezel-w": device.bezelWidth, + "--dv-radius": device.radius, + "--dv-aspect": device.aspect, + } as CSSProperties; +} + +function StatusBar() { + return ( +
+ 9:41 + +
+ ); +} + +export function DeviceFrame({ device, eyebrow, title, html, cover }: DeviceFrameProps) { + return ( +
+
+
+ {device.kind === "tablet" && } +
+ {cover ? ( + + ) : ( + <> +
+ {eyebrow &&
{eyebrow}
} + {title &&
{title}
} +
+
+ + )} +
+ {device.kind === "eink" && ( +
+ {cover ? device.label : title} + +
+ )} +
+
+
+ ); +} diff --git a/src/components/Dock.tsx b/src/components/Dock.tsx index 377111c..700306f 100644 --- a/src/components/Dock.tsx +++ b/src/components/Dock.tsx @@ -1,12 +1,15 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { generateHTML } from "@tiptap/core"; import { COVER_ID, useBook } from "../store/useBook"; -import { type TrimSize, bodyNumber, chapterKind } from "../model/book"; +import { type Chapter, type TrimSize, bodyNumber, chapterKind } from "../model/book"; import { editorExtensions } from "../editor/extensions"; import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst"; import { compilePdf, isDesktop } from "../ipc"; +import { DEVICES, type Device, findDevice } from "../devices"; +import { usePreviewMode } from "../store/usePreviewMode"; import { PdfPreview } from "./PdfPreview"; import { CoverArt } from "./CoverArt"; +import { DeviceFrame } from "./DeviceFrame"; const TRIMS: { value: TrimSize; label: string }[] = [ { value: "6x9", label: "6 × 9 in" }, @@ -15,26 +18,77 @@ const TRIMS: { value: TrimSize; label: string }[] = [ { value: "a5", label: "A5" }, ]; -function TrimSelect() { +function PreviewSelect() { const trim = useBook((s) => s.book?.settings.trim ?? "6x9"); const setSettings = useBook((s) => s.setSettings); + const mode = usePreviewMode((s) => s.mode); + const setMode = usePreviewMode((s) => s.setMode); + const value = mode === "print" ? trim : mode; return ( ); } +function DockHead({ meta }: { meta?: ReactNode }) { + return ( +
+ Preview +
+ {meta} + +
+
+ ); +} + +function chapterEyebrow(chapter: Chapter, chapters: Chapter[], idx: number): string { + const kind = chapterKind(chapter); + return kind === "body" ? `Chapter ${bodyNumber(chapters, idx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter"; +} + +function useActiveChapter() { + const book = useBook((s) => s.book); + const activeChapterId = useBook((s) => s.activeChapterId); + const chapters = book?.chapters ?? []; + const idx = chapters.findIndex((c) => c.id === activeChapterId); + const chapter = chapters[idx] ?? chapters[0]; + const html = useMemo(() => (chapter ? generateHTML(chapter.content, editorExtensions) : ""), [chapter]); + const eyebrow = chapter ? chapterEyebrow(chapter, chapters, idx) : ""; + return { book, coverActive: activeChapterId === COVER_ID, chapters, idx, chapter, html, eyebrow }; +} + export function Dock() { + const mode = usePreviewMode((s) => s.mode); + const device = findDevice(mode); + if (device) return ; return isDesktop ? : ; } @@ -67,13 +121,7 @@ function PdfDock() { return (
-
- Preview -
- {busy && updating…} - -
-
+ updating… : null} /> {error ? (
{error}
) : pdf ? ( @@ -86,26 +134,14 @@ function PdfDock() { } function HtmlDock() { - const book = useBook((s) => s.book); - const activeChapterId = useBook((s) => s.activeChapterId); - const coverActive = activeChapterId === COVER_ID; - const chapters = book?.chapters ?? []; - const idx = chapters.findIndex((c) => c.id === activeChapterId); - const chapter = chapters[idx] ?? chapters[0]; - const html = useMemo(() => (chapter ? generateHTML(chapter.content, editorExtensions) : ""), [chapter]); + const { book, coverActive, chapters, idx, chapter, html, eyebrow } = useActiveChapter(); if (!book) return null; if (coverActive) { return (
-
- Preview -
- Cover - -
-
+ Cover} />
@@ -115,21 +151,15 @@ function HtmlDock() { if (!chapter) return null; - const kind = chapterKind(chapter); - const eyebrow = - kind === "body" ? `Chapter ${bodyNumber(chapters, idx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter"; - return (
-
- Preview -
+ {idx + 1} of {chapters.length} - -
-
+ } + />
{eyebrow}
@@ -141,3 +171,45 @@ function HtmlDock() {
); } + +function DeviceDock({ device }: { device: Device }) { + const { book, coverActive, chapters, idx, chapter, html, eyebrow } = useActiveChapter(); + + if (!book) return null; + + if (coverActive) { + return ( +
+ Cover} /> + +
+ ); + } + + if (!chapter) { + return ( +
+ +
No chapter selected.
+
+ ); + } + + return ( +
+ + {idx + 1} of {chapters.length} + + } + /> + +
+ ); +} diff --git a/src/devices.ts b/src/devices.ts new file mode 100644 index 0000000..9fc799d --- /dev/null +++ b/src/devices.ts @@ -0,0 +1,115 @@ +export type DeviceId = "kindle" | "kobo" | "ipad" | "iphone" | "android"; +export type PreviewMode = "print" | DeviceId; +export type DeviceKind = "eink" | "phone" | "tablet"; + +export interface Device { + id: DeviceId; + label: string; + kind: DeviceKind; + bg: string; + ink: string; + fontSize: number; + lineHeight: number; + marginX: number; + marginY: number; + aspect: number; + bezel: string; + bezelWidth: number; + radius: number; +} + +export const DEVICES: Device[] = [ + { + id: "kindle", + label: "Kindle Paperwhite", + kind: "eink", + bg: "#f4f3ee", + ink: "#2b2b2b", + fontSize: 16, + lineHeight: 1.55, + marginX: 34, + marginY: 40, + aspect: 1240 / 1680, + bezel: "#1c1c1c", + bezelWidth: 18, + radius: 14, + }, + { + id: "kobo", + label: "Kobo", + kind: "eink", + bg: "#f6f5f0", + ink: "#262626", + fontSize: 16, + lineHeight: 1.6, + marginX: 34, + marginY: 40, + aspect: 1264 / 1680, + bezel: "#2a2a2a", + bezelWidth: 16, + radius: 16, + }, + { + id: "ipad", + label: "iPad", + kind: "tablet", + bg: "#fbfaf7", + ink: "#1d1d1f", + fontSize: 18, + lineHeight: 1.65, + marginX: 56, + marginY: 56, + aspect: 1640 / 2360, + bezel: "#3a3a3c", + bezelWidth: 20, + radius: 28, + }, + { + id: "iphone", + label: "iPhone", + kind: "phone", + bg: "#ffffff", + ink: "#1d1d1f", + fontSize: 17, + lineHeight: 1.6, + marginX: 26, + marginY: 30, + aspect: 1179 / 2556, + bezel: "#1a1a1c", + bezelWidth: 12, + radius: 44, + }, + { + id: "android", + label: "Android phone", + kind: "phone", + bg: "#ffffff", + ink: "#202124", + fontSize: 16, + lineHeight: 1.6, + marginX: 24, + marginY: 28, + aspect: 1080 / 2400, + bezel: "#0f0f0f", + bezelWidth: 11, + radius: 36, + }, +]; + +const DEVICE_MAP = new Map(DEVICES.map((d) => [d.id, d])); + +const KEY = "margin-preview-mode"; + +export function findDevice(id: string): Device | undefined { + return DEVICE_MAP.get(id); +} + +export function initialPreviewMode(): PreviewMode { + const saved = localStorage.getItem(KEY); + if (saved === "print" || (saved && DEVICE_MAP.has(saved))) return saved as PreviewMode; + return "print"; +} + +export function persistPreviewMode(mode: PreviewMode) { + localStorage.setItem(KEY, mode); +} diff --git a/src/store/usePreviewMode.ts b/src/store/usePreviewMode.ts new file mode 100644 index 0000000..39298c3 --- /dev/null +++ b/src/store/usePreviewMode.ts @@ -0,0 +1,15 @@ +import { create } from "zustand"; +import { type PreviewMode, initialPreviewMode, persistPreviewMode } from "../devices"; + +interface PreviewState { + mode: PreviewMode; + setMode: (mode: PreviewMode) => void; +} + +export const usePreviewMode = create((set) => ({ + mode: initialPreviewMode(), + setMode: (mode) => { + persistPreviewMode(mode); + set({ mode }); + }, +})); diff --git a/src/styles/app.css b/src/styles/app.css index 11a5c8c..e3cd46e 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -1992,3 +1992,194 @@ body.resizing { background: var(--accent-wash); color: var(--ink); } + +.device-stage { + max-width: 420px; + margin: 0 auto; + padding-block: 6px; + container-type: inline-size; +} + +.device-frame { + --dv-scale: calc(100cqi / 380); + width: 100%; + padding: calc(var(--dv-bezel-w) * var(--dv-scale)); + background: var(--dv-bezel); + border-radius: calc(var(--dv-radius) * var(--dv-scale)); + box-shadow: var(--shadow-page); +} + +.device-screen { + aspect-ratio: var(--dv-aspect); + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--dv-bg); + color: var(--dv-ink); + border-radius: calc(max(0px, (var(--dv-radius) - var(--dv-bezel-w)) * var(--dv-scale))); +} + +.device-statusbar { + flex: none; + height: calc(30 * var(--dv-scale)); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 calc(18 * var(--dv-scale)); + font-family: var(--font-ui); + font-size: calc(11 * var(--dv-scale)); + font-weight: 600; + color: var(--dv-ink); +} + +.device-clock { + letter-spacing: 0.01em; +} + +.device-batt { + position: relative; + flex: none; + width: calc(22 * var(--dv-scale)); + height: calc(11 * var(--dv-scale)); + border: calc(1.4 * var(--dv-scale)) solid var(--dv-ink); + border-radius: calc(3 * var(--dv-scale)); + opacity: 0.7; +} + +.device-batt::before { + content: ""; + position: absolute; + top: calc(1.5 * var(--dv-scale)); + bottom: calc(1.5 * var(--dv-scale)); + left: calc(1.5 * var(--dv-scale)); + width: 55%; + background: var(--dv-ink); + border-radius: 1px; +} + +.device-batt::after { + content: ""; + position: absolute; + right: calc(-3 * var(--dv-scale)); + top: 30%; + height: 40%; + width: calc(2 * var(--dv-scale)); + border-radius: 1px; + background: var(--dv-ink); +} + +.device-content { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: calc(var(--dv-my) * var(--dv-scale)) calc(var(--dv-mx) * var(--dv-scale)); + font-family: var(--font-book); + font-size: calc(var(--dv-fs) * var(--dv-scale)); + line-height: var(--dv-lh); +} + +.device-frame[data-kind="eink"] .device-content::-webkit-scrollbar { + display: none; +} + +.device-opener { + text-align: center; + margin-bottom: calc(20 * var(--dv-scale)); +} + +.device-eyebrow { + font-family: var(--font-ui); + font-size: calc(10 * var(--dv-scale)); + font-weight: 600; + letter-spacing: 0.22em; + text-transform: uppercase; + opacity: 0.5; + margin-bottom: calc(8 * var(--dv-scale)); +} + +.device-title { + font-family: var(--font-book); + font-weight: 500; + font-size: calc(24 * var(--dv-scale)); + line-height: 1.2; + letter-spacing: -0.01em; +} + +.device-body p { + font-family: var(--font-book); + hyphens: auto; + margin: 0 0 0.8em; + color: var(--dv-ink); +} + +.device-body p[data-indent="true"] { + text-indent: 1.2em; +} + +.device-frame[data-kind="eink"] .device-body p { + text-align: justify; +} + +.device-frame[data-kind="phone"] .device-body p, +.device-frame[data-kind="tablet"] .device-body p { + text-align: left; +} + +.device-body h1, +.device-body h2, +.device-body h3 { + font-family: var(--font-book); + font-weight: 600; + line-height: 1.25; + margin: 1.2em 0 0.5em; +} + +.device-body blockquote { + margin: 0 0 0.8em; + padding-left: 1em; + border-left: 2px solid currentColor; + font-style: italic; + opacity: 0.85; +} + +.device-body hr { + border: none; + border-top: 1px solid currentColor; + width: calc(40 * var(--dv-scale)); + margin: calc(16 * var(--dv-scale)) auto; + opacity: 0.3; +} + +.device-body img { + display: block; + max-width: 100%; + height: auto; + margin: 0.8em auto; +} + +.device-footer { + flex: none; + display: flex; + align-items: center; + justify-content: space-between; + gap: calc(10 * var(--dv-scale)); + padding: calc(8 * var(--dv-scale)) calc(var(--dv-mx) * var(--dv-scale)); + font-family: var(--font-ui); + font-size: calc(11 * var(--dv-scale)); + color: var(--dv-ink); + opacity: 0.55; +} + +.device-foot-title { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.device-content .cover-art { + width: 100%; + margin: auto; + box-shadow: none; + border: none; + border-radius: 0; +} diff --git a/website/package.json b/website/package.json index fa956a9..3c84e46 100644 --- a/website/package.json +++ b/website/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "astro dev", "build": "astro build", - "preview": "astro preview" + "preview": "astro preview", + "deploy": "astro build && npx wrangler pages deploy dist --project-name=margin --commit-dirty=true" }, "dependencies": { "astro": "^5.6.1" diff --git a/website/src/components/AppWindow.astro b/website/src/components/AppWindow.astro index b6306ec..ae42bda 100644 --- a/website/src/components/AppWindow.astro +++ b/website/src/components/AppWindow.astro @@ -1,37 +1,70 @@ --- -// A faithful CSS mock-up of the Margin desktop app: chapter sidebar, the -// writing surface set in Literata, and the live PDF preview dock. The editor -// shows live proofing underlines; the PDF dock shows the clean typeset page. +// A faithful CSS mock-up of the Margin desktop app: the All-books sidebar with +// front-matter / chapter sections, the writing surface set in Literata with live +// proofing, and the live PDF preview dock with its trim selector. ---
diff --git a/website/src/components/Nav.astro b/website/src/components/Nav.astro index 49532df..8a0fbe8 100644 --- a/website/src/components/Nav.astro +++ b/website/src/components/Nav.astro @@ -19,6 +19,12 @@ import Logo from "./Logo.astro";