6 Commits
Author SHA1 Message Date
github-actions[bot] b4097cc47f chore(release): v0.1.3 2026-06-22 16:50:04 +00:00
pj 72d698e427 changes 2026-06-22 12:40:29 -04:00
pj 8b83f80d52 fix(editor): persist per-chapter cursor and scroll across restarts
Positions were kept in an in-memory Map, so they were lost on quit and
the cursor jumped to the top on reopen. Store them in localStorage
(keyed by book + chapter, like theme/width) and restore on app open,
not just on in-session chapter switches. Also save the current chapter
continuously (debounced on selection + scroll) so closing the app
records where you left off, and re-apply scroll after fonts load.
2026-06-22 12:09:34 -04:00
pj 39609fbd09 perf(proofing): run proof_text on a blocking thread
proof_text loaded the dictionary and ran spell/grammar checks
synchronously inside an async command, blocking an async-runtime worker
and holding the engine mutex for the whole call. Move the work to
spawn_blocking so IPC stays responsive under load.
2026-06-22 12:01:25 -04:00
pj 3177b2f1eb fix(preview): show render errors instead of a blank pane
The render loop swallowed getDocument/render rejections, leaving the
preview silently blank with no signal about what failed. Surface the
error in the pane and recover on the next compile. Also drop the
redundant canvasContext arg (pdf.js v6 renders via the canvas itself).
2026-06-22 12:01:25 -04:00
pj 19c4823d3d changes 2026-06-22 11:56:31 -04:00
26 changed files with 1056 additions and 268 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "margin-app",
"private": true,
"version": "0.1.2",
"version": "0.1.3",
"type": "module",
"scripts": {
"dev": "vite",
+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",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "margin-app"
version = "0.1.2"
version = "0.1.3"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
+27 -23
View File
@@ -176,36 +176,40 @@ fn collect_grammar(harper: &mut Harper, text: &str, chars: &[char], issues: &mut
#[tauri::command]
pub async fn proof_text(
app: tauri::AppHandle,
state: tauri::State<'_, ProofState>,
text: String,
spelling: bool,
grammar: bool,
) -> Result<Vec<Issue>, String> {
let mut guard = state.inner().lock().map_err(|e| e.to_string())?;
if guard.is_none() {
*guard = Some(Engine {
speller: None,
harper: None,
custom: load_custom(&app),
});
}
let engine = guard.as_mut().unwrap();
tauri::async_runtime::spawn_blocking(move || -> Result<Vec<Issue>, String> {
let state = app.state::<ProofState>();
let mut guard = state.lock().map_err(|e| e.to_string())?;
if guard.is_none() {
*guard = Some(Engine {
speller: None,
harper: None,
custom: load_custom(&app),
});
}
let engine = guard.as_mut().unwrap();
let chars: Vec<char> = text.chars().collect();
let mut issues = Vec::new();
if spelling {
if engine.speller.is_none() {
engine.speller = Some(build_speller(&app)?);
let chars: Vec<char> = text.chars().collect();
let mut issues = Vec::new();
if spelling {
if engine.speller.is_none() {
engine.speller = Some(build_speller(&app)?);
}
collect_spelling(engine.speller.as_ref().unwrap(), &engine.custom, &chars, &mut issues);
}
collect_spelling(engine.speller.as_ref().unwrap(), &engine.custom, &chars, &mut issues);
}
if grammar {
if engine.harper.is_none() {
engine.harper = Some(build_harper());
if grammar {
if engine.harper.is_none() {
engine.harper = Some(build_harper());
}
collect_grammar(engine.harper.as_mut().unwrap(), &text, &chars, &mut issues);
}
collect_grammar(engine.harper.as_mut().unwrap(), &text, &chars, &mut issues);
}
Ok(issues)
Ok(issues)
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "margin",
"version": "0.1.2",
"version": "0.1.3",
"identifier": "studio.margin.app",
"build": {
"beforeDevCommand": "pnpm dev",
+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>
);
}
+1
View File
@@ -263,6 +263,7 @@ export function EditorView() {
)}
</header>
<Editor
bookId={book.id}
chapterId={chapter.id}
content={chapter.content}
onChange={(content) => setChapterContent(chapter.id, content)}
+34 -22
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import * as pdfjs from "pdfjs-dist";
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
@@ -6,6 +6,7 @@ pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
export function PdfPreview({ data }: { data: Uint8Array }) {
const ref = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const container = ref.current;
@@ -13,29 +14,35 @@ export function PdfPreview({ data }: { data: Uint8Array }) {
let cancelled = false;
(async () => {
const doc = await pdfjs.getDocument({ data: data.slice() }).promise;
if (cancelled) return;
const width = container.clientWidth;
const dpr = window.devicePixelRatio || 1;
const canvases: HTMLCanvasElement[] = [];
try {
const doc = await pdfjs.getDocument({ data: data.slice() }).promise;
if (cancelled) return;
const width = container.clientWidth;
const dpr = window.devicePixelRatio || 1;
const canvases: HTMLCanvasElement[] = [];
for (let n = 1; n <= doc.numPages; n++) {
const page = await doc.getPage(n);
for (let n = 1; n <= doc.numPages; n++) {
const page = await doc.getPage(n);
if (cancelled) return;
const base = page.getViewport({ scale: 1 });
const scale = width / base.width;
const viewport = page.getViewport({ scale: scale * dpr });
const canvas = document.createElement("canvas");
canvas.className = "pdf-page";
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = "100%";
await page.render({ canvas, viewport }).promise;
if (cancelled) return;
canvases.push(canvas);
}
container.replaceChildren(...canvases);
setError(null);
} catch (e) {
if (cancelled) return;
const base = page.getViewport({ scale: 1 });
const scale = width / base.width;
const viewport = page.getViewport({ scale: scale * dpr });
const canvas = document.createElement("canvas");
canvas.className = "pdf-page";
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = "100%";
const context = canvas.getContext("2d")!;
await page.render({ canvas, canvasContext: context, viewport }).promise;
if (cancelled) return;
canvases.push(canvas);
container.replaceChildren();
setError(String(e));
}
container.replaceChildren(...canvases);
})();
return () => {
@@ -43,5 +50,10 @@ export function PdfPreview({ data }: { data: Uint8Array }) {
};
}, [data]);
return <div className="pdf-pages" ref={ref} />;
return (
<>
{error && <pre className="dock-error">{error}</pre>}
<div className="pdf-pages" ref={ref} />
</>
);
}
+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);
}
+39 -18
View File
@@ -2,25 +2,20 @@ import { useEffect, useRef } from "react";
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react";
import type { JSONContent } from "@tiptap/core";
import { editorExtensions } from "./extensions";
import { loadPosition, savePosition } from "./positions";
interface EditorProps {
bookId: string;
chapterId: string;
content: JSONContent;
onChange: (content: JSONContent) => void;
onReady: (editor: TiptapEditor | null) => void;
}
interface ChapterPosition {
from: number;
to: number;
scroll: number;
}
export function Editor({ chapterId, content, onChange, onReady }: EditorProps) {
export function Editor({ bookId, chapterId, content, onChange, onReady }: EditorProps) {
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const positions = useRef(new Map<string, ChapterPosition>());
const openChapter = useRef(chapterId);
const editor = useEditor({
@@ -37,32 +32,58 @@ export function Editor({ chapterId, content, onChange, onReady }: EditorProps) {
useEffect(() => {
if (!editor) return;
let active = true;
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
const previous = openChapter.current;
if (previous !== chapterId) {
const { from, to } = editor.state.selection;
positions.current.set(previous, { from, to, scroll: scroller?.scrollTop ?? 0 });
savePosition(bookId, previous, { from, to, scroll: scroller?.scrollTop ?? 0 });
}
openChapter.current = chapterId;
editor.commands.setContent(content, { emitUpdate: false });
const saved = positions.current.get(chapterId);
const saved = loadPosition(bookId, chapterId);
const size = editor.state.doc.content.size;
editor.commands.setTextSelection(
saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0
);
const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
editor.chain().setTextSelection(selection).focus(undefined, { scrollIntoView: false }).run();
if (scroller) {
const top = saved?.scroll ?? 0;
scroller.scrollTop = top;
requestAnimationFrame(() => {
scroller.scrollTop = top;
});
const restore = () => {
if (active) scroller.scrollTop = top;
};
restore();
requestAnimationFrame(restore);
document.fonts?.ready.then(restore).catch(() => {});
}
return () => {
active = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chapterId, editor]);
}, [chapterId, editor, bookId]);
useEffect(() => {
if (!editor) return;
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
let timer: ReturnType<typeof setTimeout>;
const persist = () => {
clearTimeout(timer);
timer = setTimeout(() => {
const { from, to } = editor.state.selection;
savePosition(bookId, openChapter.current, { from, to, scroll: scroller?.scrollTop ?? 0 });
}, 400);
};
editor.on("selectionUpdate", persist);
scroller?.addEventListener("scroll", persist, { passive: true });
return () => {
clearTimeout(timer);
editor.off("selectionUpdate", persist);
scroller?.removeEventListener("scroll", persist);
};
}, [editor, bookId]);
return <EditorContent editor={editor} className="editor-host" />;
}
+27
View File
@@ -0,0 +1,27 @@
export interface ChapterPosition {
from: number;
to: number;
scroll: number;
}
const KEY = "margin-positions";
type Store = Record<string, Record<string, ChapterPosition>>;
function readAll(): Store {
try {
return JSON.parse(localStorage.getItem(KEY) || "{}") as Store;
} catch {
return {};
}
}
export function loadPosition(bookId: string, chapterId: string): ChapterPosition | null {
return readAll()[bookId]?.[chapterId] ?? null;
}
export function savePosition(bookId: string, chapterId: string, position: ChapterPosition): void {
const all = readAll();
(all[bookId] ||= {})[chapterId] = position;
localStorage.setItem(KEY, JSON.stringify(all));
}
+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;
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default defineConfig(async () => ({
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
ignored: ["**/src-tauri/**", "**/website/**"],
},
},
}));
+1 -1
View File
@@ -3,5 +3,5 @@ import { defineConfig } from "astro/config";
// The canonical URL is only used for absolute links (OG tags, sitemap).
// Change it to the real domain when the site goes live.
export default defineConfig({
site: "https://margin.studio",
site: "https://margin.73ai.org",
});
+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;
+2 -2
View File
@@ -5,7 +5,7 @@ export const site = {
tagline: "Write your book. Own every word.",
description:
"Margin is a calm, offline writing studio for authors. Write your book and send it to print and to every ebook store, all from your own machine. Buy once, own it forever.",
domain: "https://margin.studio",
domain: "https://margin.73ai.org",
// The regular one-time price, shown struck through during the launch offer.
price: "$10",
@@ -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.