This commit is contained in:
pj committed 2026-06-22 12:40:29 -04:00
1 parent 8b83f80d52
commit 72d698e427
16 files changed
+922 -199

No files matched your search

+1 -1
View File
@@ -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",
+67
View File
@@ -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 (
<div className="device-statusbar">
<span className="device-clock">9:41</span>
<span className="device-batt" />
</div>
);
}
export function DeviceFrame({ device, eyebrow, title, html, cover }: DeviceFrameProps) {
return (
<div className="device-stage">
<div className="device-frame" data-kind={device.kind} style={frameVars(device)}>
<div className="device-screen">
{device.kind === "tablet" && <StatusBar />}
<div className="device-content">
{cover ? (
<CoverArt book={cover} />
) : (
<>
<div className="device-opener">
{eyebrow && <div className="device-eyebrow">{eyebrow}</div>}
{title && <div className="device-title">{title}</div>}
</div>
<div className="device-body" dangerouslySetInnerHTML={{ __html: html ?? "" }} />
</>
)}
</div>
{device.kind === "eink" && (
<div className="device-footer">
<span className="device-foot-title">{cover ? device.label : title}</span>
<span className="device-batt" />
</div>
)}
</div>
</div>
</div>
);
}
+114 -42
View File
@@ -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 (
<select
className="trim-select"
value={trim}
onChange={(e) => setSettings({ trim: e.target.value as TrimSize })}
title="Trim size"
value={value}
onChange={(e) => {
const v = e.target.value;
if (findDevice(v)) {
setMode(v as Device["id"]);
} else {
setSettings({ trim: v as TrimSize });
setMode("print");
}
}}
title="Preview format"
>
{TRIMS.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
<optgroup label="Print">
{TRIMS.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</optgroup>
<optgroup label="Devices">
{DEVICES.map((d) => (
<option key={d.id} value={d.id}>
{d.label}
</option>
))}
</optgroup>
</select>
);
}
function DockHead({ meta }: { meta?: ReactNode }) {
return (
<div className="dock-head">
<span className="label">Preview</span>
<div className="dock-tools">
{meta}
<PreviewSelect />
</div>
</div>
);
}
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 <DeviceDock device={device} />;
return isDesktop ? <PdfDock /> : <HtmlDock />;
}
@@ -67,13 +121,7 @@ function PdfDock() {
return (
<section className="dock">
<div className="dock-head">
<span className="label">Preview</span>
<div className="dock-tools">
{busy && <span className="meta">updating…</span>}
<TrimSelect />
</div>
</div>
<DockHead meta={busy ? <span className="meta">updating…</span> : null} />
{error ? (
<pre className="dock-error">{error}</pre>
) : 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 (
<section className="dock">
<div className="dock-head">
<span className="label">Preview</span>
<div className="dock-tools">
<span className="meta">Cover</span>
<TrimSelect />
</div>
</div>
<DockHead meta={<span className="meta">Cover</span>} />
<div className="cover-stage">
<CoverArt book={book} />
</div>
@@ -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 (
<section className="dock">
<div className="dock-head">
<span className="label">Preview</span>
<div className="dock-tools">
<DockHead
meta={
<span className="meta">
{idx + 1} of {chapters.length}
</span>
<TrimSelect />
</div>
</div>
}
/>
<div className="page">
<div className="p-opener">
<div className="p-num">{eyebrow}</div>
@@ -141,3 +171,45 @@ function HtmlDock() {
</section>
);
}
function DeviceDock({ device }: { device: Device }) {
const { book, coverActive, chapters, idx, chapter, html, eyebrow } = useActiveChapter();
if (!book) return null;
if (coverActive) {
return (
<section className="dock">
<DockHead meta={<span className="meta">Cover</span>} />
<DeviceFrame device={device} cover={book} />
</section>
);
}
if (!chapter) {
return (
<section className="dock">
<DockHead />
<div className="dock-empty">No chapter selected.</div>
</section>
);
}
return (
<section className="dock">
<DockHead
meta={
<span className="meta">
{idx + 1} of {chapters.length}
</span>
}
/>
<DeviceFrame
device={device}
eyebrow={eyebrow}
title={chapter.noTitle ? undefined : chapter.title || "Untitled"}
html={html}
/>
</section>
);
}
+115
View File
@@ -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<string, Device>(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);
}
+15
View File
@@ -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<PreviewState>((set) => ({
mode: initialPreviewMode(),
setMode: (mode) => {
persistPreviewMode(mode);
set({ mode });
},
}));
+191
View File
@@ -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;
}
+2 -1
View File
@@ -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"
+288 -99
View File
@@ -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.
---
<div class="win" role="img" aria-label="The Margin app: a chapter sidebar, a writing surface with live proofing, and a live PDF page preview.">
<div class="win__bar">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="win__chrome"><span class="dot"></span><span class="dot"></span><span class="dot"></span></span>
<span class="win__title">The Lighthouse Keeper</span>
<span class="win__menu">Export ▾</span>
<span class="win__actions">
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" /></svg></span>
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14" /></svg></span>
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M4 7h16M4 12h12M4 17h7M13.5 18.5c1-1.2 2-1.2 3 0s2 1.2 3 0" /></svg></span>
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" /></svg></span>
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" /></svg></span>
<span class="ibtn"><svg class="i" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></svg></span>
<span class="ibtn is-on"><svg class="i" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM14 4.5v15" /></svg></span>
</span>
</div>
<div class="win__body">
<aside class="side">
<p class="side__label">Manuscript</p>
<ul>
<li>Title Page</li>
<li>Dedication</li>
<li class="is-active">1 · The Cliff</li>
<li>2 · A Light at Sea</li>
<li>3 · Salt and Silence</li>
<li>4 · The Long Night</li>
</ul>
<p class="side__label">Book setup</p>
<ul class="side__meta">
<li>Trim · 5 × 8 in</li>
<li>Language · English</li>
</ul>
<span class="side__brand">
<svg class="i" viewBox="0 0 24 24"><path d="M14 7l-5 5 5 5" /></svg>All books
</span>
<span class="side__cover">
<svg class="i" viewBox="0 0 24 24"><path d="M5 4h11l3 3v13H5zM16 4v4h3" /></svg>Cover
</span>
<span class="side__add">
<svg class="i" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>Add page
</span>
<div class="side__scroll">
<div class="side__section">
<p class="side__label">Front matter</p>
<ul class="side__list">
<li class="row"><span class="grip"></span><span class="num"></span><span class="text"><span class="title">Title Page</span></span></li>
<li class="row"><span class="grip"></span><span class="num"></span><span class="text"><span class="title">Dedication</span></span></li>
</ul>
</div>
<div class="side__section">
<p class="side__label">Chapters</p>
<ul class="side__list">
<li class="row is-active">
<span class="grip"><svg class="i" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
<span class="num">1</span>
<span class="text">
<span class="title">The Cliff</span>
<span class="meta">Edited yesterday</span>
</span>
</li>
<li class="row"><span class="grip"></span><span class="num">2</span><span class="text"><span class="title">A Light at Sea</span></span></li>
<li class="row"><span class="grip"></span><span class="num">3</span><span class="text"><span class="title">Salt and Silence</span></span></li>
<li class="row"><span class="grip"></span><span class="num">4</span><span class="text"><span class="title">The Long Night</span></span></li>
</ul>
<span class="side__newchapter">
<svg class="i" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>New chapter
</span>
</div>
</div>
</aside>
<main class="page">
<div class="page__sheet">
<p class="chapno">Chapter One</p>
<p class="chapno">Chapter 1</p>
<h4 class="chaptitle">The Cliff</h4>
<p>
The lighthouse had stood on the headland for ninety years, and in all that time it had
@@ -47,21 +80,36 @@
</main>
<aside class="dock">
<p class="dock__label">Live PDF · pg 7</p>
<div class="dock__pdf">
<span class="rh">THE LIGHTHOUSE KEEPER</span>
<p class="pch">Chapter One</p>
<p class="pdt">The Cliff</p>
<p class="pp pp--first">
The lighthouse had stood on the headland for ninety years, and in all that time it had
never once gone dark. Mara intended to keep it that way.
</p>
<p class="pp">
She climbed the iron stair each evening at dusk, counting the hundred and one steps out
of habit, and watched the lamp turn its slow circle across the water.
</p>
<p class="pp">The sea, tonight, was the colour of pewter.</p>
<span class="pg">7</span>
<div class="dock__head">
<span class="dock__label">Preview</span>
<span class="dock__trim">
5 × 8 in
<svg class="i" viewBox="0 0 24 24"><path d="M8 10l4-4 4 4M8 14l4 4 4-4" /></svg>
</span>
</div>
<div class="dock__pages">
<div class="dock__page">
<p class="pch">Chapter 1</p>
<p class="pdt">The Cliff</p>
<p class="pp pp--first">
The lighthouse had stood on the headland for ninety years, and in all that time it had
never once gone dark. Mara intended to keep it that way.
</p>
<p class="pp">
She climbed the iron stair each evening at dusk, counting the hundred and one steps out
of habit, and watched the lamp turn its slow circle across the water.
</p>
<p class="pp">The sea, tonight, was the colour of pewter.</p>
<span class="pg">1</span>
</div>
<div class="dock__page dock__page--peek">
<p class="pp pp--first">
Morning came grey and slow. She brewed coffee in the keeper's kitchen and listened to the
gulls argue over the rocks below.
</p>
<p class="pp">The radio crackled once, then fell silent.</p>
</div>
</div>
</aside>
</div>
@@ -76,115 +124,231 @@
box-shadow: var(--shadow-deep);
overflow: hidden;
}
.i {
width: 15px;
height: 15px;
flex: none;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.win__bar {
display: flex;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 8px;
height: 40px;
padding-inline: 16px;
height: 44px;
padding-inline: 14px;
border-bottom: 1px solid var(--line);
background: var(--sidebar);
font-size: 0.78rem;
color: var(--ink-faint);
background: var(--shell);
}
.win__chrome {
display: flex;
gap: 8px;
}
.dot {
width: 11px;
height: 11px;
border-radius: 50%;
}
.win__bar .dot:nth-child(1) {
.win__chrome .dot:nth-child(1) {
background: #ff5f57;
}
.win__bar .dot:nth-child(2) {
.win__chrome .dot:nth-child(2) {
background: #febc2e;
}
.win__bar .dot:nth-child(3) {
.win__chrome .dot:nth-child(3) {
background: #28c840;
}
.win__title {
margin-inline: auto;
font-weight: 500;
}
.win__menu {
grid-column: 2;
font-size: 0.82rem;
letter-spacing: 0.01em;
color: var(--ink-soft);
font-weight: 500;
}
.win__actions {
grid-column: 3;
justify-self: end;
display: flex;
gap: 2px;
}
.ibtn {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: var(--r-sm);
color: var(--ink-soft);
}
.ibtn.is-on {
color: var(--accent);
background: var(--accent-wash);
box-shadow: inset 0 0 0 1px var(--line-strong);
}
.win__body {
display: grid;
grid-template-columns: 172px 1fr 196px;
min-height: 392px;
grid-template-columns: 196px 1fr 200px;
min-height: 432px;
}
.side {
display: flex;
flex-direction: column;
background: var(--sidebar);
border-right: 1px solid var(--line);
padding: 16px 12px;
padding: 12px 10px;
font-size: 0.8rem;
}
.side__brand,
.side__cover,
.side__add,
.side__newchapter {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: var(--r-sm);
color: var(--ink-soft);
}
.side__brand {
color: var(--ink-faint);
font-size: 0.74rem;
}
.side__cover {
color: var(--ink);
margin-top: 2px;
}
.side__add {
color: var(--ink-faint);
margin-bottom: 8px;
}
.side__scroll {
flex: 1;
}
.side__section + .side__section {
margin-top: 16px;
}
.side__label {
font-size: 0.66rem;
letter-spacing: 0.1em;
font-size: 0.62rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-faint);
font-weight: 650;
margin: 0 0 8px;
margin: 0 0 6px;
padding-inline: 8px;
}
.side ul {
.side__list {
list-style: none;
margin: 0 0 18px;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
gap: 1px;
}
.side li {
.row {
display: grid;
grid-template-columns: 14px 16px 1fr;
align-items: center;
gap: 4px;
padding: 5px 8px;
border-radius: var(--r-sm);
color: var(--ink-soft);
}
.side li.is-active {
background: var(--accent-wash);
color: var(--ink);
font-weight: 600;
}
.side__meta li {
.row .grip {
color: var(--ink-faint);
font-size: 0.74rem;
opacity: 0;
display: grid;
place-items: center;
}
.row .grip .i {
width: 13px;
height: 13px;
}
.row .num {
font-size: 0.72rem;
color: var(--ink-faint);
text-align: center;
}
.row .text {
display: flex;
flex-direction: column;
min-width: 0;
}
.row .title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row .meta {
font-size: 0.66rem;
color: var(--ink-faint);
margin-top: 1px;
}
.row.is-active {
background: var(--raised);
color: var(--ink);
box-shadow: 0 1px 2px rgba(35, 32, 27, 0.08), inset 0 0 0 1px var(--line);
}
.row.is-active .grip {
opacity: 1;
}
.row.is-active .num {
color: var(--ink-soft);
}
.side__newchapter {
color: var(--ink-faint);
font-size: 0.76rem;
margin-top: 4px;
}
.page {
display: flex;
justify-content: center;
padding: 26px 18px;
padding: 30px 18px;
overflow: hidden;
}
.page__sheet {
width: 100%;
max-width: 360px;
max-width: 372px;
font-family: var(--font-book);
font-size: 0.92rem;
line-height: 1.62;
line-height: 1.6;
color: var(--ink);
}
.page__sheet p {
margin: 0 0 0.7em;
text-indent: 1.1em;
margin: 0;
text-indent: 1.3em;
text-align: justify;
hyphens: auto;
-webkit-hyphens: auto;
}
.page__sheet p + p {
margin-top: 0.2em;
}
.chapno {
text-indent: 0 !important;
text-align: center;
text-align: center !important;
font-family: var(--font-ui);
font-size: 0.62rem;
letter-spacing: 0.18em;
font-size: 0.6rem;
font-weight: 600;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--ink-faint);
margin-bottom: 0.4em !important;
margin: 0 0 0.5em !important;
}
.chaptitle {
text-indent: 0;
text-align: center;
font-size: 1.5rem;
font-weight: 560;
margin: 0 0 1.1em;
font-weight: 540;
letter-spacing: -0.01em;
margin: 0 0 1.4em;
}
.chaptitle + p {
text-indent: 0;
}
.u-spell {
text-decoration: wavy underline var(--danger);
@@ -200,17 +364,45 @@
.dock {
background: var(--shell);
border-left: 1px solid var(--line);
padding: 16px 14px;
padding: 14px 14px 0;
overflow: hidden;
}
.dock__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.dock__label {
font-size: 0.66rem;
letter-spacing: 0.1em;
font-size: 0.62rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-faint);
font-weight: 650;
margin: 0 0 12px;
}
.dock__pdf {
.dock__trim {
display: inline-flex;
align-items: center;
gap: 4px;
font-family: var(--font-ui);
font-size: 0.66rem;
color: var(--ink-soft);
padding: 3px 5px 3px 8px;
border: 1px solid var(--line-strong);
border-radius: var(--r-sm);
background: var(--raised);
}
.dock__trim .i {
width: 12px;
height: 12px;
color: var(--ink-faint);
}
.dock__pages {
display: flex;
flex-direction: column;
gap: 14px;
}
.dock__page {
position: relative;
aspect-ratio: 5 / 8;
background: #fcfbf7;
@@ -219,26 +411,18 @@
padding: 18px 16px 22px;
overflow: hidden;
color: #2a2622;
flex: none;
}
.dock__pdf .rh {
display: block;
font-family: var(--font-ui);
font-size: 4.4px;
letter-spacing: 0.16em;
color: #b3ab9c;
text-align: center;
margin-bottom: 14px;
}
.dock__pdf .pch {
.dock__page .pch {
font-family: var(--font-ui);
font-size: 4px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #9b9484;
text-align: center;
margin: 0 0 4px;
margin: 6px 0 4px;
}
.dock__pdf .pdt {
.dock__page .pdt {
font-family: var(--font-book);
font-size: 9px;
font-weight: 560;
@@ -246,19 +430,21 @@
text-align: center;
margin: 0 0 11px;
}
.dock__pdf .pp {
.dock__page .pp {
font-family: var(--font-book);
font-size: 5.6px;
line-height: 1.62;
text-align: justify;
hyphens: auto;
-webkit-hyphens: auto;
text-indent: 1.3em;
margin: 0 0 0.5em;
color: #2a2622;
}
.dock__pdf .pp--first {
.dock__page .pp--first {
text-indent: 0;
}
.dock__pdf .pg {
.dock__page .pg {
position: absolute;
bottom: 9px;
left: 0;
@@ -268,10 +454,13 @@
font-size: 5px;
color: #b3ab9c;
}
.dock__page--peek {
padding-top: 18px;
}
@media (max-width: 720px) {
.win__body {
grid-template-columns: 1fr 168px;
grid-template-columns: 1fr 180px;
}
.side {
display: none;
-6
View File
@@ -38,12 +38,6 @@ import OfferBadge from "./OfferBadge.astro";
justify-content: center;
margin-top: 32px;
}
.cta__btns :global(.dl) {
align-items: center;
}
.cta__btns :global(.dl__os) {
justify-content: center;
}
.cta__offer {
display: flex;
justify-content: center;
+13 -16
View File
@@ -22,7 +22,6 @@ const { large = false } = Astro.props;
</a>
<div class="dl__os">
<span class="dl__also">Also for</span>
{
platforms.map((p) => (
<a
@@ -32,7 +31,7 @@ const { large = false } = Astro.props;
rel="noopener"
download
aria-label={`Download for ${p.label}`}
title={p.label}
title={`Download for ${p.label}`}
>
<img class="dl__logo" src={`/logos/${p.logo}`} alt={p.label} />
</a>
@@ -45,38 +44,36 @@ const { large = false } = Astro.props;
.dl {
display: flex;
flex-direction: column;
gap: 14px;
align-items: flex-start;
align-items: center;
gap: 16px;
}
.dl__os {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.dl__also {
font-size: 0.82rem;
color: var(--ink-faint);
font-weight: 500;
justify-content: center;
gap: 12px;
}
.dl__chip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
width: 44px;
height: 44px;
background: #ffffff;
border: 1px solid var(--line-strong);
border-radius: var(--r-md);
transition: box-shadow 0.18s var(--ease), transform 0.18s var(--ease);
box-shadow: var(--shadow-page);
transition:
box-shadow 0.18s var(--ease),
transform 0.18s var(--ease);
}
.dl__chip:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-pop);
}
.dl__logo {
width: 20px;
height: 20px;
width: 22px;
height: 22px;
object-fit: contain;
}
</style>
@@ -36,6 +36,18 @@ const { repo, releasesUrl } = site;
if (label) label.textContent = "Download for " + LABELS[os];
});
document.querySelectorAll(".dl__alt [data-download-os]").forEach((el) => {
if (el.dataset.downloadOs === os) el.remove();
});
document.querySelectorAll("[data-osrow]").forEach((row) => {
const primary = row.querySelector(`[data-download-os="${os}"]`);
if (primary) {
primary.classList.add("is-detected");
row.prepend(primary);
}
});
(async () => {
try {
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
+89 -26
View File
@@ -1,7 +1,7 @@
---
import AppWindow from "./AppWindow.astro";
import DownloadButtons from "./DownloadButtons.astro";
import OfferBadge from "./OfferBadge.astro";
import { site, platforms } from "../data/site";
---
<section class="hero">
@@ -11,63 +11,126 @@ import OfferBadge from "./OfferBadge.astro";
<span class="book-accent">Own</span> every word.
</h1>
<p class="lede hero__lede">
Margin is a calm, offline writing studio for authors. Write your book, format it
beautifully, and send it to every store and to print, all from your own machine. No
subscription, no account. Buy once, own it forever.
A calm, offline studio to write, format, and publish your book to every store and to
print. No subscription, no account. Buy once, own it forever.
</p>
<div class="hero__cta">
<DownloadButtons large />
</div>
<div class="hero__offer">
<OfferBadge />
</div>
</div>
<div class="container hero__art">
<AppWindow />
</div>
<div class="container hero__get" id="download">
<div class="hero__buttons" data-osrow>
{
platforms.map((p) => (
<a
class="osbtn"
href={site.releasesUrl}
data-download-os={p.id}
rel="noopener"
download
>
<img class="osbtn__icon" src={`/logos/${p.logo}`} alt="" />
<span>Download for {p.label}</span>
</a>
))
}
</div>
<p class="hero__meta">
<span data-version>v0.1.2</span> · Works offline · No account
</p>
<div class="hero__offer">
<OfferBadge />
</div>
</div>
</section>
<style>
.hero {
padding-top: clamp(48px, 7vw, 92px);
padding-bottom: clamp(40px, 6vw, 72px);
padding-top: clamp(36px, 4.5vw, 60px);
padding-bottom: clamp(48px, 6vw, 80px);
background:
radial-gradient(120% 90% at 50% -10%, var(--paper) 0%, transparent 60%),
var(--shell);
}
.hero__head {
text-align: center;
max-width: 760px;
max-width: 720px;
}
h1 {
font-size: clamp(2.6rem, 1.6rem + 4.4vw, 4.5rem);
font-size: clamp(2.4rem, 1.6rem + 3.4vw, 3.9rem);
letter-spacing: -0.02em;
margin-bottom: 22px;
margin-bottom: 18px;
}
.hero__lede {
margin-inline: auto;
max-width: 56ch;
max-width: 50ch;
}
.hero__cta {
.hero__art {
margin-top: clamp(32px, 4vw, 48px);
max-width: 1000px;
}
.hero__get {
margin-top: clamp(24px, 3vw, 32px);
scroll-margin-top: 96px;
}
.hero__buttons {
display: flex;
flex-wrap: wrap;
justify-content: center;
margin-top: 34px;
gap: 12px;
}
.hero__cta :global(.dl) {
.osbtn {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 0.82em 1.35em;
background: #ffffff;
border: 1px solid var(--line-strong);
border-radius: 999px;
color: #23201b;
font-family: var(--font-ui);
font-size: 1rem;
font-weight: 600;
box-shadow: var(--shadow-page);
transition:
transform 0.18s var(--ease),
box-shadow 0.18s var(--ease),
border-color 0.18s var(--ease);
}
.hero__cta :global(.dl__os) {
justify-content: center;
.osbtn:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-pop);
}
.osbtn.is-detected {
border-color: var(--ink);
}
.osbtn__icon {
width: 18px;
height: 18px;
flex: none;
object-fit: contain;
}
.hero__meta {
margin: 16px 0 0;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.8rem;
color: var(--ink-faint);
text-align: center;
}
.hero__offer {
display: flex;
justify-content: center;
margin-top: 30px;
margin-top: 22px;
}
.hero__art {
margin-top: clamp(44px, 6vw, 72px);
max-width: 1000px;
@media (max-width: 480px) {
.hero__buttons {
flex-direction: column;
align-items: stretch;
}
.osbtn {
justify-content: center;
}
}
</style>
+14
View File
@@ -19,6 +19,12 @@ import Logo from "./Logo.astro";
</nav>
<div class="nav__actions">
<a class="btn nav__cta" href="#download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
Download
</a>
<button class="iconlink" id="theme-toggle" aria-label="Toggle theme" type="button">
<svg class="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true">
<circle cx="12" cy="12" r="4"></circle>
@@ -89,6 +95,14 @@ import Logo from "./Logo.astro";
align-items: center;
gap: 10px;
}
.nav__cta {
padding: 0.5em 1.05em;
font-size: 0.9rem;
}
.nav__cta svg {
width: 1em;
height: 1em;
}
.iconlink {
display: inline-flex;
align-items: center;
-1
View File
@@ -20,7 +20,6 @@ const { cap, left } = site.launch;
border-radius: 999px;
background: color-mix(in srgb, var(--good) 12%, var(--paper));
border: 1px solid color-mix(in srgb, var(--good) 32%, transparent);
box-shadow: var(--shadow-page);
font-size: 0.9rem;
font-weight: 600;
color: var(--ink);
-6
View File
@@ -182,12 +182,6 @@ const included = [
display: flex;
justify-content: center;
}
.price-card__cta :global(.dl) {
align-items: center;
}
.price-card__cta :global(.dl__os) {
justify-content: center;
}
.price-card__note {
margin-top: 26px;
font-size: 0.84rem;
+1 -1
View File
@@ -16,7 +16,7 @@ export const site = {
left: 12,
},
supportEmail: "[email protected]",
supportEmail: "[email protected]",
// Repo that hosts the release files. Used only to resolve the right download
// per OS at runtime. It is never shown to the reader.