57 Commits
Author SHA1 Message Date
pj 7a3c04f170 commit messages are one plain line 2026-09-01 23:18:14 +05:30
pj e8555c7ef9 send app store builds to the store for updates 2026-09-01 23:17:33 +05:30
pj e8772e6699 app store scripts 2026-09-01 22:32:34 +05:30
pj 585933c8a1 feat(appstore): invite internal testers as scoped account users
An internal tester has to be a user on the App Store Connect account, which is access to the
account rather than to a build. The invitation is narrowed as far as the API allows: one visible
app, provisioning refused, and the Developer role rather than anything that can change the listing.

The bulk assignment endpoint reports success for an address it then declines to add, which is what
happens when the person has not accepted their account invitation yet, so the group is read back
instead of trusted.

Claude-Session: https://claude.ai/code/session_018HwEWvoE1NkkUjJCMLSnup
2026-08-31 18:00:27 +05:30
pj b59052a95c feat(appstore): script inviting TestFlight testers
Addresses come from the command line rather than a file in the repo, because a list of testers is a
list of people's email addresses and this repository is public.

Defaults to the external group. The internal one only accepts people who already have an App Store
Connect account on the team, which is a much bigger thing to hand somebody than a build.

Claude-Session: https://claude.ai/code/session_018HwEWvoE1NkkUjJCMLSnup
2026-08-31 17:27:59 +05:30
pj 36291540e5 docs: describe how releases are published
Three channels, and they are not the same app: the direct download is unrestricted and updates
itself, the Homebrew cask is the same file with a command instead of a browser, and the App Store
copy is sandboxed with the updater taken out because Apple requires both.

Written down because most of it is only discoverable by getting it wrong once. Attaching a build to
TestFlight and attaching it to the store version are separate operations, and a Mac listing takes
its icon from the attached build, so until that second step happens App Store Connect shows the
listing with no icon and does not say why.

Claude-Session: https://claude.ai/code/session_018HwEWvoE1NkkUjJCMLSnup
2026-08-31 17:26:38 +05:30
pj 7eeb54f653 refactor(ui): call a book a project
A book is the use case Margin was built for, not the limit of it. Blog posts published straight to
the web are coming, and "New book" on the shelf is the wrong invitation for that. Project covers
both without promising anything the app does not do.

This is user-visible strings only. Internal identifiers keep saying book, because renaming bookId
and setBooks is churn nobody reads.

Claude-Session: https://claude.ai/code/session_018HwEWvoE1NkkUjJCMLSnup
2026-08-31 17:26:31 +05:30
pj 42996804bb feat(appstore): add the listing screenshots
Five 2560x1600 frames. The app window inside each is a CSS rebuild of the real interface rather
than a bitmap, laid out at the app's own 1440x900 and scaled once, so the type is real text at
render time instead of a resampled capture. The only Macs to hand are 1x, and an upscaled screen
capture looks like one.

The website's own AppWindow mockup was the obvious source and turned out to be wrong in several
places: the sidebar is 248px not 196, the proofing marks are straight underlines rather than wavy,
and the trim default differs. The rebuild follows shared/css/tokens.css and src/styles/app.css
instead, so these are more accurate than the marketing imagery.

Sample prose is original and written for this listing, since it goes in a public store page.

Claude-Session: https://claude.ai/code/session_018HwEWvoE1NkkUjJCMLSnup
2026-08-31 17:26:22 +05:30
pj 6ddb3ac43e feat(appstore): script the provisioning, listing and TestFlight setup
The listing copy lives in text files rather than in a web form, so changing a description is a diff
someone can read and the store listing is reviewable next to the code it describes. Field lengths
are checked before sending, because Apple rejects an over-length field with an error that never
names the limit.

apple-provision.rb drives the Developer Portal through spaceship and is find-or-create throughout.
That matters most for the Developer ID certificate: an account may hold only a handful, they cannot
be un-revoked, and every copy of the app already signed by one stops verifying if it goes away.

Both scripts pin the App Store Connect team. This Apple ID can see more than one, and the other
belongs to somebody else entirely, so letting spaceship choose is how a listing ends up on the
wrong account.

The reviewer phone number and email are deliberately not in here. Apple requires a real number and
this repo is public.

Claude-Session: https://claude.ai/code/session_018HwEWvoE1NkkUjJCMLSnup
2026-08-31 17:26:14 +05:30
pj 92b446973f feat(appstore): add a Mac App Store build track
Tauri has no App Store target, so mas-package.sh covers the distance between the .app and something
App Store Connect will take. The order is load-bearing: the provisioning profile goes in before
codesign runs because the signature covers it, which is also why Tauri's own signing is switched
off for this build.

The sandbox costs three things, all Apple's rules rather than choices. The updater is gone, and
falls out for free because the plugin was already conditional on the config declaring it; only the
release overlay does. The Check for Updates menu item is gated on the same condition, since a menu
item that errors when clicked is its own rejection risk. The library moves into the container, and
the system spelling dictionary becomes unreadable.

Two things the first upload taught us. The profile is kept owner-only where it lives next to the
signing keys, and cp carried that mode into the bundle; Apple rejects a package containing anything
a non-root user cannot read. And altool exits 0 after printing UPLOAD FAILED, so its exit status
cannot be trusted and the transcript is the only reliable signal.

Claude-Session: https://claude.ai/code/session_018HwEWvoE1NkkUjJCMLSnup
2026-08-31 17:26:03 +05:30
pj 39d4097773 ci(release): sign and notarize the macOS bundle, and bump the Homebrew cask
An unsigned bundle on a current macOS opens to a malware warning with no obvious way past it, and
the way past it that does exist teaches people to click through exactly the warning worth reading.
The build now signs with a Developer ID certificate and notarizes with an App Store Connect API
key, which is also what does the App Store upload, so there is one credential to rotate.

The verification step is the point. codesign only says a signature is internally consistent;
spctl is what a person double-clicking the file actually meets, and it does not pass until the
notarization ticket is stapled.

A final job rewrites the version and sha256 in the tap's cask, using an SSH deploy key rather than
a token so a leak from a release job cannot reach the app repositories.

Also fixes Cargo.lock drifting a version behind on every release: the bump wrote Cargo.toml but
never staged the lock, so any fresh build dirtied the tree.

Claude-Session: https://claude.ai/code/session_018HwEWvoE1NkkUjJCMLSnup
2026-08-31 17:25:51 +05:30
pj d8c47bb6f0 chore: add FSL-1.1-MIT licence and fill in package metadata
The repo had no licence at all, which legally means all rights reserved. FSL grants free use for
anything except building a competing product, and each version becomes MIT two years after its
release. AGPL was the other candidate and is ruled out by the App Store, whose terms impose
restrictions the GPL forbids.

Cargo.toml still carried the Tauri scaffold defaults, and that description ends up in the deb and
rpm metadata.

Claude-Session: https://claude.ai/code/session_018HwEWvoE1NkkUjJCMLSnup
2026-08-31 17:25:42 +05:30
pj d4318554f6 refactor changes 2026-08-30 16:59:00 +05:30
github-actions[bot] df1a447a79 chore(release): v0.1.17 2026-08-24 04:11:47 +00:00
pj bcd5eb9ebc group of bug fixes 2026-08-24 09:17:26 +05:30
github-actions[bot] cfc93fc97d chore(release): v0.1.16 2026-08-10 15:00:26 +00:00
pj b954a3135d Straight through shortcut 2026-08-10 18:44:46 +05:30
pj 9e310a34bb feat(macos): trigger Apple Intelligence writing tools from the editor 2026-08-10 18:37:23 +05:30
pj fe39274ed7 feat(nav): peel overlays with esc, close the book on a second esc 2026-08-10 18:37:23 +05:30
pj 2e15dc2dc9 feat(editor): open the link popover with cmd+k 2026-08-10 18:36:52 +05:30
pj dad0ac86d0 feat(chapters): focus the title field when adding a chapter 2026-08-10 18:36:39 +05:30
pj efd45cf61e feat(library): show last edited time on book cards 2026-08-10 18:36:30 +05:30
pj 742e42d65b feat(editor): keep the caret clear of the floating toolbar 2026-08-10 18:36:18 +05:30
pj abcd133b19 chore: sync Cargo.lock with package version 2026-08-10 18:36:18 +05:30
github-actions[bot] 4fa14306d1 chore(release): v0.1.15 2026-07-15 04:02:28 +00:00
pj acf78dfe29 feat(menu): reorganize native menu bar with discoverable actions
Move Check for Updates into the macOS app menu and add Settings (Cmd+,),
Save (Cmd+S), Find (Cmd+F), chapter navigation, and a Help > Report an
Issue link. Route new menu actions through menu-action events and trim
duplicate editor keybindings.
2026-07-15 09:31:57 +05:30
pj 866a971729 feat(updater): show in-app progress during app update
Replace native update dialogs with an in-app modal that shows a live
download progress bar and installing state, so updates no longer feel
abrupt. Drives the plugin's downloadAndInstall progress events through a
zustand store.
2026-07-15 09:31:57 +05:30
github-actions[bot] 7ea33b5bfb chore(release): v0.1.14 2026-07-15 04:00:48 +00:00
github-actions[bot] 0abeb3c5d4 chore(release): v0.1.13 2026-07-14 10:37:34 +00:00
pj a31f99cb76 fix keyboard fixed 2026-07-14 16:07:05 +05:30
pj 20365dd02d fix(proofing): stop flagging bare URLs as misspelled on macOS
NSSpellChecker's implicit URL handling is unreliable for URLs alone on a
line (a bookmark list), flagging the whole URL as one misspelling. Request
the link checking type alongside spelling so the data detector claims URL
ranges, then keep only spelling results. Real misspellings are unaffected.
2026-07-14 16:07:05 +05:30
github-actions[bot] af389c7097 chore(release): v0.1.12 2026-07-13 15:00:54 +00:00
pj f11ddd290f fix(proofing): use macOS NSSpellChecker for spelling on macOS
The bundled Hunspell dictionary (~49k SCOWL roots) lacked common words like
"cybersecurity", "scalable", and "assistantship", and checked words inside
URLs. On macOS, spelling now uses the system NSSpellChecker (same engine as
TextEdit/Safari): far better vocabulary, skips URLs, and respects words the
user has taught macOS. The custom/tech dictionaries and Harper grammar are
unchanged; non-macOS builds keep the Hunspell fallback.
2026-07-13 20:27:58 +05:30
github-actions[bot] 4975e76358 chore(release): v0.1.11 2026-07-13 14:51:25 +00:00
github-actions[bot] efeb64d289 chore(release): v0.1.10 2026-06-27 13:49:24 +00:00
pj cd41a02737 changes 2026-06-27 09:48:42 -04:00
pj 5a2ada9e9e ci(release): pin macOS runner to macos-26 2026-06-27 09:48:42 -04:00
github-actions[bot] 782a4c3dee chore(release): v0.1.9 2026-06-27 13:48:19 +00:00
github-actions[bot] f8742c440f chore(release): v0.1.8 2026-06-27 13:32:54 +00:00
pj c090fb595f changes 2026-06-27 09:32:22 -04:00
pj 11df8697b1 changes 2026-06-23 11:29:54 -04:00
pj f47e822497 changes 2026-06-22 21:26:03 -04:00
github-actions[bot] 9e714c8997 chore(release): v0.1.7 2026-06-22 21:14:30 +00:00
pj cc26e1d51c changes 2026-06-22 17:12:44 -04:00
github-actions[bot] 530e260903 chore(release): v0.1.6 2026-06-22 20:52:01 +00:00
pj 74a684498a changes 2026-06-22 16:51:39 -04:00
pj 3fccb17060 fix(ci): provision google-credentials.json before release build
include_str! embeds google-credentials.json at compile time, but the
file is gitignored, so CI checkouts had no file and the Rust crate
failed to compile on all three platforms. Write it from the
GOOGLE_CREDENTIALS secret, falling back to the committed example
placeholders so the build always succeeds.
2026-06-22 16:50:28 -04:00
pj 9acb3aae3c changes 2026-06-22 16:42:03 -04:00
github-actions[bot] fb6f14165f chore(release): v0.1.5 2026-06-22 20:20:02 +00:00
pj c79897c3db changes 2026-06-22 16:19:24 -04:00
pj 5cc083d670 changes 2026-06-22 16:11:43 -04:00
pj ecc94e6c31 refactor(fonts): use DEFAULT_FONTS as settings fallback
Avoids leaking a pairing's id/label into the stored book fonts.
2026-06-22 14:34:45 -04:00
pj baf1154683 feat(fonts): embed selected fonts in EPUB export
Embed each bundled font the book uses (fetched in the webview) with its
own @font-face, point body text and headings at the chosen families, and
drop the hardcoded Literata injection in the packager. System fonts are
referenced by name with a serif fallback since they cannot be embedded.
2026-06-22 14:30:36 -04:00
pj 7a2d7c6aff feat(fonts): honor selected fonts in PDF export
Interpolate the book's body and heading families into the Typst
preamble, title page, cover, and contents. Embed the needed bundled
fonts and load any chosen system fonts (via fontdb) into the Typst
engine so PDF preview and export match the on-screen fonts.
2026-06-22 14:27:51 -04:00
pj 9a38976000 feat(fonts): add font picker to book setup
Add a Typography section to book setup with one-click preset pairings,
an advanced body/heading picker, and a live sample. Enumerate installed
fonts via a new list_system_fonts Tauri command (fontdb) so they appear
alongside the bundled set, with a note that system fonts may not embed
in exports.
2026-06-22 14:24:09 -04:00
pj 28367b6670 feat(fonts): apply per-book body and heading fonts on screen
Split heading typography onto a new --font-heading token and set both
--font-book and --font-heading from the active book's fonts when it
opens or its setup changes. Declare @font-face for the new bundled
fonts.
2026-06-22 14:19:01 -04:00
pj 124c1947c1 feat(fonts): add bundled fonts and per-book font model
Add EB Garamond, Lora, Source Serif 4, and Fraunces (OFL variable
fonts) and a font catalog with body/heading pairings. Store the chosen
fonts per book in BookSettings.fonts, defaulting to Literata so existing
books are unchanged.
2026-06-22 14:18:57 -04:00
200 changed files with 62488 additions and 537 deletions

No files matched your search

+130
View File
@@ -0,0 +1,130 @@
name: App Store
on:
workflow_dispatch:
inputs:
tag:
description: "Tag to build, e.g. v0.1.18. Defaults to the latest release."
required: false
type: string
upload:
description: "Upload to App Store Connect. Off means build and sign only."
required: false
default: true
type: boolean
permissions:
contents: read
jobs:
build:
runs-on: macos-26
steps:
- name: Resolve the tag
id: tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
TAG="${{ inputs.tag }}"
if [ -z "$TAG" ]; then
TAG=$(gh release view --repo "$REPO" --json tagName --jq .tagName)
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Building $TAG for the App Store"
- uses: actions/checkout@v7
with:
ref: ${{ steps.tag.outputs.tag }}
- uses: actions/setup-node@v6
with:
node-version: 26
- uses: pnpm/action-setup@v6
with:
version: 10
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- uses: swatinem/rust-cache@v2
with:
workspaces: src-tauri -> target
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Provision Google credentials
env:
GOOGLE_CREDENTIALS: ${{ secrets.GOOGLE_CREDENTIALS }}
run: |
if [ -n "$GOOGLE_CREDENTIALS" ]; then
printf '%s' "$GOOGLE_CREDENTIALS" > google-credentials.json
else
echo "::error::GOOGLE_CREDENTIALS is not set; an App Store build with placeholder credentials would ship a broken backup feature."
exit 1
fi
- name: Import the App Store certificates
env:
APP_CERT: ${{ secrets.MAS_APP_CERTIFICATE }}
APP_CERT_PASSWORD: ${{ secrets.MAS_APP_CERTIFICATE_PASSWORD }}
INSTALLER_CERT: ${{ secrets.MAS_INSTALLER_CERTIFICATE }}
INSTALLER_CERT_PASSWORD: ${{ secrets.MAS_INSTALLER_CERTIFICATE_PASSWORD }}
PROFILE: ${{ secrets.MAS_PROVISION_PROFILE }}
run: |
keychain="$RUNNER_TEMP/appstore.keychain-db"
password=$(uuidgen)
security create-keychain -p "$password" "$keychain"
security set-keychain-settings -lut 3600 "$keychain"
security unlock-keychain -p "$password" "$keychain"
import_p12() {
printf '%s' "$1" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$2" \
-T /usr/bin/codesign -T /usr/bin/productbuild
rm -f "$RUNNER_TEMP/cert.p12"
}
import_p12 "$APP_CERT" "$APP_CERT_PASSWORD"
import_p12 "$INSTALLER_CERT" "$INSTALLER_CERT_PASSWORD"
# Without this, codesign on a headless runner blocks on a keychain prompt nobody can
# answer and the job hangs until it times out.
security set-key-partition-list -S apple-tool:,apple: -k "$password" "$keychain" > /dev/null
security list-keychains -d user -s "$keychain" login.keychain-db
printf '%s' "$PROFILE" | base64 --decode > "$RUNNER_TEMP/margin.provisionprofile"
security find-identity -v "$keychain"
- name: Build the sandboxed bundle
run: |
# No APPLE_SIGNING_IDENTITY here on purpose: mas-package.sh signs, because the
# provisioning profile has to be inside the bundle before codesign runs.
pnpm tauri build --target universal-apple-darwin --config src-tauri/tauri.appstore.conf.json --bundles app
- name: Sign, package and upload
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
MAS_APP_IDENTITY: ${{ secrets.MAS_APP_IDENTITY }}
MAS_INSTALLER_IDENTITY: ${{ secrets.MAS_INSTALLER_IDENTITY }}
MAS_PROVISION_PROFILE: ${{ runner.temp }}/margin.provisionprofile
MAS_BUILD_NUMBER: ${{ github.run_number }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
MAS_UPLOAD: ${{ inputs.upload && '1' || '' }}
run: |
mkdir -p ~/private_keys
printf '%s' "$APPLE_API_KEY_P8" | base64 --decode > ~/private_keys/AuthKey_$APPLE_API_KEY_ID.p8
chmod 600 ~/private_keys/AuthKey_$APPLE_API_KEY_ID.p8
./scripts/mas-package.sh
- uses: actions/upload-artifact@v4
if: always()
with:
name: margin-appstore-pkg
path: target-mas/*.pkg
if-no-files-found: warn
+93 -3
View File
@@ -44,6 +44,12 @@ jobs:
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > "$tmp" && mv "$tmp" src-tauri/tauri.conf.json
jq --arg v "$VERSION" '.version = $v' package.json > "$tmp" && mv "$tmp" package.json
sed -i "0,/^version = \".*\"/s//version = \"$VERSION\"/" src-tauri/Cargo.toml
# Cargo.lock records margin-app's own version, so bumping only Cargo.toml leaves the
# lock a release behind and the next build rewrites it under whoever checked it out.
awk -v v="$VERSION" '
/^name = "margin-app"$/ { print; getline; sub(/^version = ".*"/, "version = \"" v "\""); print; next }
{ print }
' src-tauri/Cargo.lock > "$tmp" && mv "$tmp" src-tauri/Cargo.lock
- name: Commit and tag
env:
@@ -51,7 +57,7 @@ jobs:
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add src-tauri/tauri.conf.json package.json src-tauri/Cargo.toml
git add src-tauri/tauri.conf.json package.json src-tauri/Cargo.toml src-tauri/Cargo.lock
git commit -m "chore(release): $TAG"
for attempt in 1 2 3 4 5; do
git fetch origin main
@@ -85,7 +91,7 @@ jobs:
fail-fast: false
matrix:
include:
- os: macos-latest
- os: macos-26
args: "--target universal-apple-darwin --config src-tauri/tauri.release.conf.json"
rust-targets: "aarch64-apple-darwin,x86_64-apple-darwin"
- os: ubuntu-latest
@@ -137,16 +143,59 @@ jobs:
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Provision Google credentials
shell: bash
env:
GOOGLE_CREDENTIALS: ${{ secrets.GOOGLE_CREDENTIALS }}
run: |
if [ -n "$GOOGLE_CREDENTIALS" ]; then
printf '%s' "$GOOGLE_CREDENTIALS" > google-credentials.json
echo "Wrote google-credentials.json from GOOGLE_CREDENTIALS secret."
else
cp google-credentials.example.json google-credentials.json
echo "::warning::GOOGLE_CREDENTIALS secret not set, embedding placeholder credentials; Google Drive backup will be disabled in this release."
fi
- name: Provision Apple notarization key
if: runner.os == 'macOS'
shell: bash
env:
KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
run: |
if [ -z "$KEY_P8" ]; then
echo "::warning::APPLE_API_KEY_P8 is not set, so the macOS bundle will be ad-hoc signed and Gatekeeper will refuse to open it."
exit 0
fi
printf '%s' "$KEY_P8" | base64 --decode > "$RUNNER_TEMP/apple-api-key.p8"
chmod 600 "$RUNNER_TEMP/apple-api-key.p8"
echo "APPLE_API_KEY_PATH=$RUNNER_TEMP/apple-api-key.p8" >> "$GITHUB_ENV"
- name: Build and upload
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY_ID }}
with:
releaseId: ${{ needs.prepare.outputs.release_id }}
args: ${{ matrix.args }}
- name: Verify the bundle is signed and notarized
if: runner.os == 'macOS' && env.APPLE_API_KEY_PATH != ''
shell: bash
run: |
app="src-tauri/target/universal-apple-darwin/release/bundle/macos/Margin.app"
codesign --verify --deep --strict --verbose=2 "$app"
# Gatekeeper only says "accepted" once the notarization ticket is stapled to the bundle,
# so this is the check that a user double-clicking the dmg will actually get past.
spctl --assess --type execute --verbose=4 "$app"
xcrun stapler validate "$app"
publish:
needs: [prepare, build]
runs-on: ubuntu-latest
@@ -162,8 +211,49 @@ jobs:
jq '.platforms | keys' latest.json
for key in darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64; do
if ! jq -e ".platforms[\"$key\"].url" latest.json > /dev/null; then
echo "::error::latest.json is missing platform '$key' — refusing to publish a partial update manifest. Re-run the release."
echo "::error::latest.json is missing platform '$key'. Refusing to publish a partial update manifest; re-run the release."
exit 1
fi
done
gh release edit "$TAG" --repo "$REPO" --draft=false --latest
homebrew:
needs: [prepare, publish]
runs-on: ubuntu-latest
steps:
- name: Point the cask at the release that just went out
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}
REPO: ${{ github.repository }}
TAG: ${{ needs.prepare.outputs.tag }}
VERSION: ${{ needs.prepare.outputs.version }}
TAP: priyanshujain/homebrew-margin
run: |
if [ -z "$DEPLOY_KEY" ]; then
echo "::warning::HOMEBREW_TAP_DEPLOY_KEY is not set, so $TAG is published but the Homebrew cask still points at the previous version."
exit 0
fi
dmg="Margin_${VERSION}_universal.dmg"
gh release download "$TAG" --repo "$REPO" --pattern "$dmg" --output "$dmg"
sha=$(sha256sum "$dmg" | cut -d' ' -f1)
# A deploy key rather than a token: it reaches the tap and nothing else, so a leak from
# this job cannot touch the app repos.
mkdir -p ~/.ssh
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/tap_key
chmod 600 ~/.ssh/tap_key
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
export GIT_SSH_COMMAND="ssh -i ~/.ssh/tap_key -o IdentitiesOnly=yes"
git clone --depth 1 "[email protected]:$TAP.git" tap
cd tap
sed -i -E "s|^ version \".*\"| version \"$VERSION\"|" Casks/margin.rb
sed -i -E "s|^ sha256 \".*\"| sha256 \"$sha\"|" Casks/margin.rb
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Casks/margin.rb
git commit -m "margin $VERSION"
git push
+11
View File
@@ -26,6 +26,17 @@ dist-ssr
# Tauri build output
src-tauri/target/
# Google OAuth desktop client (real values, never commit)
/google-credentials.json
/client_secret_*.json
# Screenshots & Playwright MCP artifacts
.playwright-mcp/
/*.png
# App Store packaging output
target-mas/
# Beta app review contact details. Apple requires a real phone number and this repo is public.
appstore/metadata/review_phone.txt
appstore/metadata/review_email.txt
+2 -2
View File
@@ -10,7 +10,7 @@
## Git Commit Rules
- Use conventional commit format: `feat|fix|refactor|docs|test|chore|ci(scope): message`
- Do not make branches, commit in main only
- Commit message is one plain lowercase line. No type prefix, no scope, no body.
- Never use `git add .` or `git add -A`. Always stage specific files by name.
- Don't batch multiple unrelated changes into one commit.
- Commit early and often. A working 5-line change is better than a pending 200-line change.
+110
View File
@@ -0,0 +1,110 @@
# Functional Source License, Version 1.1, MIT Future License
## Abbreviation
FSL-1.1-MIT
## Notice
Copyright 2026 Priyanshu Jain
## Terms and Conditions
### Licensor ("We")
The party offering the Software under these Terms and Conditions.
### The Software
The "Software" is each version of the software that we make available under
these Terms and Conditions, as indicated by our inclusion of these Terms and
Conditions with the Software.
### License Grant
Subject to your compliance with this License Grant and the Patents,
Redistribution and Trademark clauses below, we hereby grant you the right to
use, copy, modify, create derivative works, publicly perform, publicly display
and redistribute the Software for any Permitted Purpose identified below.
### Permitted Purpose
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
means making the Software available to others in a commercial product or
service that:
1. substitutes for the Software;
2. substitutes for any other product or service we offer using the Software
that exists as of the date we make the Software available; or
3. offers the same or substantially similar functionality as the Software.
Permitted Purposes specifically include using the Software:
1. for your internal use and access;
2. for non-commercial education;
3. for non-commercial research; and
4. in connection with professional services that you provide to a licensee
using the Software in accordance with these Terms and Conditions.
### Patents
To the extent your use for a Permitted Purpose would necessarily infringe our
patents, the license grant above includes a license under our patents. If you
make a claim against any party that the Software infringes or contributes to
the infringement of any patent, then your patent license to the Software ends
immediately.
### Redistribution
The Terms and Conditions apply to all copies, modifications and derivatives of
the Software.
If you redistribute any copies, modifications or derivatives of the Software,
you must include a copy of or a link to these Terms and Conditions and not
remove any copyright notices provided in or with the Software.
### Disclaimer
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
### Trademarks
Except for displaying the License Details and identifying us as the origin of
the Software, you have no right under these Terms and Conditions to use our
trademarks, trade names, service marks or product names.
## Grant of Future License
We hereby irrevocably grant you an additional license to use the Software under
the MIT license that is effective on the second anniversary of the date we make
the Software available. On or after that date, you may use the Software under
the MIT license, in which case the following will apply:
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+8 -30
View File
@@ -1,37 +1,15 @@
# margin
An offline desktop app for writing books and exporting them to print-ready PDF and reflowable EPUB — a focused, free alternative to InDesign for text-first books (novels, non-fiction).
Write your book. Own every word. A calm, offline studio to write, format, and publish your book to every store and to print.
## Stack
Download it from [margin.73ai.org](https://margin.73ai.org), or install it with Homebrew:
- **Tauri 2** (Rust shell) — offline by construction; the entire export pipeline runs locally, nothing hits the network.
- **React + TipTap** — the semantic writing surface.
- **Typst** (embedded as a Rust crate) — print-ready PDF with real book typography.
- **Built-in EPUB3** generator + zip packager.
One semantic document feeds three renderers — the editor, the PDF, and the EPUB — from a single design-token theme (**Quiet Press**: Literata for the page, Hanken Grotesk for the chrome). Both fonts are bundled.
## Develop
```sh
pnpm install
pnpm tauri dev # the real desktop app: live PDF dock, file dialogs, PDF/EPUB export
pnpm dev # browser-only UI preview (Tauri APIs are inert)
```
brew tap priyanshujain/margin
brew trust priyanshujain/margin
brew install --cask margin
```
The first `tauri dev` build is slow because it compiles Typst.
How releases are signed and where they go is in [docs/publishing.md](docs/publishing.md).
## Using it
- A book saves as a single `.margin` file (JSON). Open / Save with ⌘O / ⌘S.
- Click the title in the top bar for **Book setup** (title, author, ISBN, trim size, language).
- Insert images from the toolbar and choose a placement (inline / full-width / full-page / float).
- Export to **PDF** or **EPUB** from the titlebar menu.
## Layout
- `src/editor/` — TipTap editor, extensions, figure block, floating toolbar.
- `src/components/` — app shell, preview dock, book-setup panel, pdf.js renderer.
- `src/export/` — `typst.ts` (book → Typst source), `epub.ts` (book → EPUB3 files), `exporters.ts`.
- `src/store/`, `src/model/` — document model and state.
- `src-tauri/src/` — `pdf.rs` (Typst compile), `epub.rs` (zip packager), `project.rs` (file IO).
Licensed [FSL-1.1-MIT](LICENSE): use it for anything except building a competing product, and every version turns MIT two years after its release.
@@ -0,0 +1 @@
[email protected]
+1
View File
@@ -0,0 +1 @@
2026 Priyanshu Jain
@@ -0,0 +1,5 @@
Margin is an offline writing studio. Write without anything in the way, see your words set in real typography as you go, and export a print-ready PDF or an EPUB when you are ready.
This build runs in the App Store sandbox, which is new, so the things most worth trying are the ones that touch the file system: importing, exporting a PDF or EPUB, and the optional Google Drive backup. If any of those fail where they used to work, that is the bug worth reporting.
There is no account and nothing to sign up for.
+13
View File
@@ -0,0 +1,13 @@
Margin is a calm, offline writing studio. Write without anything in the way, see your words set in real typography as you go, and keep every one of them on your own machine.
WRITE
A quiet editor that stays out of the way, with your work down one side and a live page preview down the other, showing your words as they will actually appear rather than as a word processor imagines them.
PROOF
Spelling and grammar are checked as you write, using the same system engine as the rest of macOS. Your words are never sent anywhere to be checked.
PUBLISH
When you are ready, export a print-ready PDF set in real typography, with proper margins, page numbers and running heads. Or export EPUB, ready for Apple Books, Kindle, Kobo and the rest.
YOURS
No account. No sign-up. No subscription. Your work is a single file on your computer that you can copy, rename, back up, or move to another machine. Nothing is locked inside a library you cannot get out of. If you would like a backup, Margin can save one to your own Google Drive, in your account and under your control.
+1
View File
@@ -0,0 +1 @@
writing,writer,editor,text,document,offline,grammar,typography,epub,pdf,author,manuscript,draft
@@ -0,0 +1 @@
https://margin.73ai.org
+1
View File
@@ -0,0 +1 @@
Margin: The Writing App
+1
View File
@@ -0,0 +1 @@
https://margin.73ai.org/privacy
@@ -0,0 +1 @@
Write without anything in the way. Real typography, spelling and grammar on your own machine, and export to PDF or EPUB when you are ready.
@@ -0,0 +1 @@
First release.
+1
View File
@@ -0,0 +1 @@
A calm, offline writing studio
+1
View File
@@ -0,0 +1 @@
https://margin.73ai.org
+1
View File
@@ -0,0 +1 @@
PRODUCTIVITY
+1
View File
@@ -0,0 +1 @@
Priyanshu
+1
View File
@@ -0,0 +1 @@
Jain
+33
View File
@@ -0,0 +1,33 @@
Margin is a writing app. There are no accounts and no demo credentials are needed: open it and
start writing.
com.apple.security.network.server
This is for the optional Google Drive backup, and that is the only thing in the app that uses it.
Google's OAuth flow for installed apps returns the authorization code by redirecting the user's
browser to a loopback address. Margin binds a listener on 127.0.0.1 on an ephemeral port, opens the
consent page in the default browser, accepts the one redirect that comes back from that local
browser, reads the code and closes the socket. The App Sandbox refuses that bind without
com.apple.security.network.server, and sign-in then hangs on a browser tab with nowhere to return
to. Google withdrew the out-of-band alternative, so loopback is the only flow still available to a
desktop app.
The socket is bound to 127.0.0.1 only and never to a routable interface, so nothing off this Mac
can reach it. It exists only while a sign-in is in progress, times out after 120 seconds, and
rejects any request whose state parameter does not match the one just generated.
To see it: the cloud icon at the top of the opening library screen opens Backup and Sync, and
"Connect Google Drive" there starts the flow. Finishing it needs a Google account of your own.
Everything else in the app works without one.
com.apple.security.network.client
Used only to reach googleapis.com for that same backup, which stays off until the user connects
their own Google account. Nothing else Margin does touches the network: writing, the page preview,
spelling, grammar and both exports all run on the machine.
com.apple.security.files.user-selected.read-write
Importing an EPUB and exporting a PDF or EPUB go through the standard open and save panels, so the
app only ever reaches the file the user picked. The library itself lives in the app container.
+51
View File
@@ -0,0 +1,51 @@
# Mac App Store screenshots
Five 2560x1600 frames for the Margin listing: `frame-1.png` through `frame-5.png`.
Each frame is an HTML page in `src/`, rendered at a 2560x1600 viewport with a device pixel ratio of
1. The app window inside the frame is not a bitmap capture. It is a CSS rebuild of the real app in
`src/app.css`, with rules and values lifted from `shared/css/tokens.css` and `src/styles/app.css`,
laid out at the app's own 1440x900 and scaled once into the 1600x1000 window well. That is why the
type is sharp: it is real text at render time, not a resampled screenshot.
| File | Frame | Shows |
| --- | --- | --- |
| `frame-1.png` | Write | The writing surface, preview dock closed |
| `frame-2.png` | Preview | The editor beside the live page preview |
| `frame-3.png` | Proof | Three proofing marks and an open spelling popover |
| `frame-4.png` | Publish | The PDF export preview panel over the editor |
| `frame-5.png` | Yours | The library, no sign-in anywhere |
## Re-rendering
Serve this directory and screenshot each page at a 2560x1600 viewport, dpr 1:
```
python3 -m http.server 8731
```
Load `http://127.0.0.1:8731/src/frame-1.html` and capture the viewport to `frame-1.png` here. Check
every output with `sips -g pixelWidth -g pixelHeight frame-*.png`; App Store Connect rejects
anything that is not exactly 2560x1600.
Type comes from Google Fonts (Literata for headlines and prose, Hanken Grotesk for the interface),
so the render needs a network connection.
## Editing
`src/frame.css` is the frame surface: the eyebrow, headline, supporting line and the window well.
Every length in it is a real output pixel.
`src/app.css` is the app window. Every length in it is an app pixel, so a value copied out of the
product's own stylesheet can go in unchanged. Keep it that way; the whole point is that the
screenshots stay honest about what the app looks like.
Copy lives in each `src/frame-N.html`, along with that frame's window markup, since every frame
shows a different state of the app.
## Sample content
The frames use "The Quiet Hours" by Elena Marsh, an invented author. The prose is original and
written for this listing, so it is safe to publish. If you change it, keep it general non-fiction
rather than novel writing: the listing positions Margin as a writing app, not as software for one
genre.
Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

+807
View File
@@ -0,0 +1,807 @@
/* The Margin window, rebuilt in CSS so it renders crisply at any scale.
Rules and values are lifted from the app itself: shared/css/tokens.css and
src/styles/app.css. Authored at the app's own pixel sizes in a 1440x900
window, then scaled once by frame.css to fill the 1600x1000 window well. */
:root {
--font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-book: "Literata", Georgia, "Times New Roman", serif;
--font-heading: "Literata", Georgia, "Times New Roman", serif;
--r-sm: 5px;
--r-md: 8px;
--r-lg: 12px;
--pane-sidebar: 248px;
--pane-dock: 384px;
--measure: 46em;
--titlebar-h: 46px;
--t-1: 11px;
--t-2: 12px;
--t-3: 13px;
--t-4: 15px;
--paper: #fcfbf7;
--shell: #f1ece2;
--sidebar: #ece6da;
--raised: #fbfaf6;
--ink: #23201b;
--ink-soft: #6b6458;
--ink-faint: #9b9484;
--line: #e3ddce;
--line-strong: #d6cfbd;
--accent: #2a2622;
--accent-ink: #100e0b;
--accent-wash: rgba(35, 32, 27, 0.08);
--accent-contrast: #faf7f0;
--danger: #b4453a;
--danger-ink: #963327;
--shadow-page: 0 1px 2px rgba(35, 32, 27, 0.06), 0 14px 30px rgba(35, 32, 27, 0.09);
--shadow-pop: 0 8px 24px rgba(35, 32, 27, 0.14);
}
.app {
position: relative;
width: 1440px;
height: 900px;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--shell);
color: var(--ink);
font-family: var(--font-ui);
font-size: var(--t-3);
line-height: normal;
text-align: left;
}
.app svg {
flex: none;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Title bar ---------------------------------------------------------------- */
.titlebar {
flex: none;
position: relative;
z-index: 45;
height: var(--titlebar-h);
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 0 14px 0 84px;
background: var(--shell);
border-bottom: 1px solid var(--line);
}
.lights {
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-50%);
display: flex;
gap: 8px;
}
.lights i {
width: 12px;
height: 12px;
border-radius: 50%;
}
.lights i:nth-child(1) {
background: #ff5f57;
}
.lights i:nth-child(2) {
background: #febc2e;
}
.lights i:nth-child(3) {
background: #28c840;
}
.titlebar .lead {
grid-column: 1;
justify-self: start;
display: flex;
gap: 4px;
}
.titlebar .doc-title {
grid-column: 2;
font-size: var(--t-2);
letter-spacing: 0.02em;
color: var(--ink-soft);
}
.titlebar .actions {
grid-column: 3;
justify-self: end;
display: flex;
gap: 4px;
}
.icon-btn {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: var(--r-sm);
color: var(--ink-soft);
}
.icon-btn[data-on="true"] {
color: var(--accent);
background: var(--accent-wash);
box-shadow: inset 0 0 0 1px var(--line-strong);
}
.body {
flex: 1;
display: flex;
min-height: 0;
}
/* Sidebar ------------------------------------------------------------------ */
.sidebar {
width: var(--pane-sidebar);
flex: none;
background: var(--sidebar);
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
padding: 6px 0 10px;
overflow: hidden;
}
.brand {
display: flex;
align-items: center;
gap: 6px;
padding: 12px 18px 16px;
color: var(--ink-faint);
}
.brand .back-label {
font-size: var(--t-3);
font-weight: 600;
color: var(--ink-soft);
}
.cover-item {
position: relative;
display: flex;
align-items: center;
gap: 11px;
margin: 2px 10px 4px;
padding: 8px 12px;
border-radius: var(--r-sm);
color: var(--ink-soft);
font-size: var(--t-3);
}
.cover-item .title {
font-weight: 500;
}
.cover-item svg {
color: var(--ink-faint);
}
.add-page {
margin: 2px 12px;
padding: 8px 10px;
display: flex;
align-items: center;
gap: 8px;
border-radius: var(--r-sm);
font-size: var(--t-3);
color: var(--ink-faint);
}
.chapters {
list-style: none;
margin: 6px 0 0;
padding: 0 10px;
flex: 1;
overflow: hidden;
}
.nav-label {
padding: 4px 22px;
margin: 0;
font-size: var(--t-1);
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-faint);
}
.nav-section + .nav-section {
margin-top: 12px;
}
.chapter {
position: relative;
display: flex;
align-items: baseline;
gap: 11px;
padding: 8px 12px;
border-radius: var(--r-sm);
color: var(--ink-soft);
}
.chapter[data-active="true"] {
background: var(--paper);
color: var(--ink);
box-shadow: 0 1px 2px rgba(35, 32, 27, 0.05);
}
.chapter[data-active="true"]::before {
content: "";
position: absolute;
left: -10px;
top: 8px;
bottom: 8px;
width: 3px;
background: var(--accent);
border-radius: 0 2px 2px 0;
}
.chapter .num {
font-size: var(--t-1);
color: var(--ink-faint);
width: 13px;
font-variant-numeric: tabular-nums;
}
.chapter[data-active="true"] .num {
color: var(--accent);
}
.chapter .text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.chapter .title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chapter .meta {
font-size: var(--t-1);
letter-spacing: 0.01em;
color: var(--ink-faint);
}
.chapter .grip {
display: grid;
place-items: center;
align-self: center;
margin: 0 -5px 0 -6px;
color: var(--ink-faint);
opacity: 0;
}
.chapter[data-active="true"] .grip {
opacity: 0.55;
}
.add-chapter {
margin: 8px 10px 4px;
width: calc(100% - 20px);
padding: 9px 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: var(--r-sm);
color: var(--ink-faint);
border: 1px dashed var(--line-strong);
}
/* Writing surface ---------------------------------------------------------- */
.editor-pane {
flex: 1;
min-width: 0;
background: var(--paper);
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
}
.sheet {
width: 100%;
max-width: var(--measure);
padding: 104px 24px 200px;
}
.sheet[data-width="wide"] {
max-width: 62em;
}
.chapter-opener {
text-align: center;
margin: 0 0 3.4rem;
}
.chapter-num {
font-family: var(--font-ui);
font-size: var(--t-1);
font-weight: 600;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--ink-faint);
margin: 0;
}
.chapter-title {
font-family: var(--font-heading);
font-weight: 500;
font-size: 31px;
line-height: 1.16;
letter-spacing: -0.01em;
margin: 15px 0 0;
}
.prose {
font-family: var(--font-book);
color: var(--ink);
font-optical-sizing: auto;
}
.prose p {
font-size: 19px;
line-height: 1.68;
margin: 0;
text-align: justify;
hyphens: auto;
-webkit-hyphens: auto;
}
.prose p[data-indent="true"] {
text-indent: 1.3em;
}
.prose .first::first-letter {
float: left;
font-size: 3.1em;
line-height: 0.82;
padding: 0.05em 0.09em 0 0;
font-weight: 500;
}
.caret {
display: inline-block;
width: 1.5px;
height: 1.05em;
background: var(--ink);
vertical-align: -0.18em;
margin-left: 1px;
}
/* Proofing ----------------------------------------------------------------- */
.proof-mark {
border-radius: 2px;
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 3px;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
.proof-mark.sev-error {
background: rgba(180, 69, 58, 0.13);
text-decoration-color: #b4453a;
}
.proof-mark.sev-warn {
background: rgba(168, 119, 24, 0.16);
text-decoration-color: #9c6e16;
}
.proof-mark.sev-suggest {
background: rgba(47, 110, 79, 0.13);
text-decoration-color: #2f6e4f;
}
.proof-anchor {
position: relative;
}
.proof-pop {
position: absolute;
top: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
z-index: 50;
width: 264px;
padding: 8px;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--shadow-pop);
font-family: var(--font-ui);
text-align: left;
text-indent: 0;
}
.proof-pop-kind {
display: inline-block;
margin: 0 0 6px 2px;
padding: 2px 8px;
border-radius: 999px;
font-size: var(--t-1);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.proof-pop-kind.sev-error {
background: rgba(180, 69, 58, 0.14);
color: var(--danger-ink);
}
.proof-pop-msg {
display: block;
padding: 2px 4px 8px;
font-size: var(--t-2);
color: var(--ink-soft);
line-height: 1.4;
}
.proof-pop-suggestions {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.proof-suggestion {
padding: 5px 10px;
border-radius: var(--r-sm);
background: var(--accent-wash);
color: var(--ink);
font-size: var(--t-2);
font-weight: 600;
}
.proof-suggestion.is-hover {
background: var(--accent);
color: var(--accent-contrast);
}
.proof-pop-actions {
display: flex;
gap: 4px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--line);
}
.proof-action {
flex: 1;
padding: 6px 8px;
border-radius: var(--r-sm);
font-size: var(--t-1);
color: var(--ink-soft);
text-align: center;
}
/* Preview dock ------------------------------------------------------------- */
.dock {
width: var(--pane-dock);
flex: none;
background: var(--shell);
border-left: 1px solid var(--line);
overflow: hidden;
padding: 26px 28px 80px;
}
.dock-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
}
.dock-head .label {
font-size: var(--t-1);
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-faint);
}
.dock-head .meta {
font-size: var(--t-1);
color: var(--ink-faint);
font-variant-numeric: tabular-nums;
}
.dock-tools {
display: flex;
align-items: center;
gap: 10px;
}
.trim-select {
display: inline-flex;
align-items: center;
gap: 7px;
font-family: var(--font-ui);
font-size: var(--t-2);
color: var(--ink-soft);
background: var(--raised);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 4px 8px;
}
.trim-select svg {
width: 11px;
height: 11px;
color: var(--ink-faint);
}
.dock-col {
display: flex;
flex-direction: column;
gap: 16px;
}
.page {
position: relative;
background: #fffefb;
border: 1px solid var(--line);
border-radius: 2px;
box-shadow: var(--shadow-page);
aspect-ratio: 6 / 9;
padding: 11% 12% 13%;
overflow: hidden;
flex: none;
}
.page .p-opener {
text-align: center;
margin-bottom: 15px;
}
.page .p-num {
font-family: var(--font-ui);
font-size: 6px;
font-weight: 600;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--ink-faint);
margin: 0;
}
.page .p-title {
font-family: var(--font-heading);
font-size: 12px;
margin: 5px 0 0;
}
.page p {
font-family: var(--font-book);
font-size: 6.6px;
line-height: 1.5;
text-align: justify;
hyphens: auto;
-webkit-hyphens: auto;
margin: 0;
color: #2b2720;
}
.page p[data-indent="true"] {
text-indent: 1.2em;
}
.page .folio {
position: absolute;
left: 12%;
right: 12%;
bottom: 5.5%;
text-align: center;
font-family: var(--font-book);
font-size: 6px;
color: var(--ink-faint);
}
/* Export preview panel ----------------------------------------------------- */
.overlay {
position: absolute;
inset: 0;
z-index: 55;
background: rgba(35, 32, 27, 0.28);
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
}
.preview-panel {
position: absolute;
z-index: 56;
display: flex;
flex-direction: column;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-pop);
overflow: hidden;
}
.preview-bar {
flex: none;
height: 52px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
background: var(--paper);
border-bottom: 1px solid var(--line);
}
.preview-title {
flex: 1;
min-width: 0;
font-family: var(--font-book);
font-size: 15px;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-count {
font-size: var(--t-2);
color: var(--ink-faint);
font-variant-numeric: tabular-nums;
}
.preview-zoom {
display: flex;
align-items: center;
gap: 2px;
}
.preview-zoom span {
width: 44px;
text-align: center;
font-size: var(--t-2);
color: var(--ink-soft);
font-variant-numeric: tabular-nums;
}
.btn-primary {
font-family: var(--font-ui);
font-size: var(--t-3);
font-weight: 500;
padding: 9px 20px;
border-radius: var(--r-sm);
background: var(--accent);
color: var(--accent-contrast);
}
.preview-stage {
flex: 1;
overflow: hidden;
padding: 28px;
background: var(--shell);
}
.preview-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.preview-page {
position: relative;
flex: none;
width: 460px;
aspect-ratio: 6 / 9;
background: #fff;
border-radius: 2px;
box-shadow: var(--shadow-page);
overflow: hidden;
padding: 11% 12% 13%;
color: #23201b;
}
.preview-page .p-opener {
text-align: center;
margin-bottom: 22px;
}
.preview-page .p-num {
font-family: var(--font-ui);
font-size: 8.4px;
font-weight: 600;
letter-spacing: 0.24em;
text-transform: uppercase;
color: #9b9484;
margin: 0;
}
.preview-page .p-title {
font-family: var(--font-heading);
font-size: 16.8px;
font-weight: 500;
margin: 7px 0 0;
}
.preview-page p {
font-family: var(--font-book);
font-size: 9.2px;
line-height: 1.62;
text-align: justify;
hyphens: auto;
-webkit-hyphens: auto;
margin: 0;
color: #2b2720;
}
.preview-page p[data-indent="true"] {
text-indent: 1.2em;
}
.preview-page .first::first-letter {
float: left;
font-size: 3.1em;
line-height: 0.82;
padding: 0.05em 0.09em 0 0;
font-weight: 500;
}
.preview-page .folio {
position: absolute;
left: 12%;
right: 12%;
bottom: 5.5%;
text-align: center;
font-family: var(--font-book);
font-size: 8.4px;
color: #b3ab9c;
}
/* Library ------------------------------------------------------------------ */
.library {
position: relative;
width: 1440px;
height: 900px;
overflow: hidden;
background: var(--shell);
color: var(--ink);
font-family: var(--font-ui);
font-size: var(--t-3);
line-height: normal;
text-align: left;
}
.library svg {
flex: none;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.library-head {
position: relative;
height: 44px;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 14px;
}
.shelf {
max-width: 1040px;
margin: 0 auto;
padding: 26px 44px 80px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: 28px;
}
.card {
position: relative;
aspect-ratio: 5 / 7;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 9px;
padding: 22px 18px;
text-align: center;
border-radius: 4px;
}
.card-book {
background: var(--paper);
border: 1px solid var(--line);
box-shadow: var(--shadow-page);
}
.card-title {
font-family: var(--font-book);
font-size: 20px;
font-weight: 500;
line-height: 1.18;
color: var(--ink);
}
.card-author {
font-size: var(--t-2);
color: var(--ink-soft);
}
.card-meta {
position: absolute;
left: 14px;
right: 14px;
bottom: 14px;
font-size: var(--t-1);
color: var(--ink-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-action {
background: transparent;
border: 1px dashed var(--line-strong);
color: var(--ink-faint);
font-size: var(--t-3);
}
+155
View File
@@ -0,0 +1,155 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Margin App Store frame 1</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:[email protected]&family=Literata:opsz,[email protected],200..900&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="frame.css" />
<link rel="stylesheet" href="app.css" />
</head>
<body>
<div class="frame">
<p class="eyebrow">Write</p>
<h1 class="headline">A calm writing studio.</h1>
<p class="support">Your words, set in real typography, with nothing else asking for attention.</p>
<div class="shot"><div class="shot__scale">
<div class="app">
<header class="titlebar">
<span class="lights"><i></i><i></i><i></i></span>
<span class="lead">
<span class="icon-btn" data-on="true"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM9 4.5v15" /></svg></span>
</span>
<span class="doc-title">The Quiet Hours</span>
<span class="actions">
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.6-1.3A3.5 3.5 0 0 1 18 18H7z" /></svg></span>
<span class="icon-btn"><svg width="16" height="16" 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="icon-btn" data-on="true"><svg width="16" height="16" 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="icon-btn"><svg width="16" height="16" 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="icon-btn"><svg width="16" height="16" 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="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" /></svg></span>
<span class="icon-btn"><svg width="16" height="16" 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="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM14 4.5v15" /></svg></span>
</span>
</header>
<div class="body">
<aside class="sidebar">
<span class="brand">
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M14 7l-5 5 5 5" /></svg>
<span class="back-label">All projects</span>
</span>
<span class="cover-item">
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M5 4h11l3 3v13H5zM16 4v4h3" /></svg>
<span class="title">Cover</span>
</span>
<span class="add-page">
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>
<span>Add page</span>
</span>
<ul class="chapters">
<li class="nav-section">
<p class="nav-label">Front matter</p>
<div class="chapter">
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
<span class="text"><span class="title">Title Page</span></span>
</div>
<div class="chapter">
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
<span class="text"><span class="title">Epigraph</span></span>
</div>
</li>
<li class="nav-section">
<p class="nav-label">Chapters</p>
<div class="chapter" data-active="true">
<span class="grip"><svg width="14" height="14" 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 Long Morning</span>
<span class="meta">Edited just now</span>
</span>
</div>
<div class="chapter">
<span class="grip"><svg width="14" height="14" 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">2</span>
<span class="text"><span class="title">On Interruption</span></span>
</div>
<div class="chapter">
<span class="grip"><svg width="14" height="14" 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">3</span>
<span class="text"><span class="title">What the Draft Knows</span></span>
</div>
<div class="chapter">
<span class="grip"><svg width="14" height="14" 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">4</span>
<span class="text"><span class="title">A Note on Endings</span></span>
</div>
<span class="add-chapter">
<svg width="16" height="16" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>
<span>New chapter</span>
</span>
</li>
<li class="nav-section">
<p class="nav-label">Back matter</p>
<div class="chapter">
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
<span class="text"><span class="title">About the Author</span></span>
</div>
</li>
</ul>
</aside>
<main class="editor-pane">
<div class="sheet">
<div class="chapter-opener">
<p class="chapter-num">Chapter 1</p>
<h1 class="chapter-title">The Long Morning</h1>
</div>
<div class="prose">
<p class="first">
The best hours are the ones nobody asks for. They arrive before the day has decided
what it wants from you, and they leave without ceremony, and what happens in them is
almost never what you planned.
</p>
<p data-indent="true">
I have kept the same desk for eleven years. It is not a good desk. One leg is
shorter than the others and I have never fixed it, because the small tilt reminds
me, every time I sit down, that the work is not going to be perfect either, and that
this has never once stopped anybody.
</p>
<p data-indent="true">
What I have learned about working slowly is that it is not slow. It only looks that
way from the outside. From inside it feels like the only speed at which anything
holds together.
</p>
<p data-indent="true">
There is a particular hour, somewhere between the first coffee and the second, when
the work stops feeling like a performance. Nothing on the page has changed. What has
changed is that you have stopped listening for applause.
</p>
<p data-indent="true">
The trick, if there is one, is to start before you are ready. Readiness is a story
you tell yourself about a future morning that will be calmer than this one, and it
will not be. The calm is something you make, badly at first, out of whatever the day
has left lying around.
</p>
<p data-indent="true">
I write the first sentence standing up. I do not know why this works and I have
stopped asking. Somewhere in the business of not sitting down the sentence loses its
self-consciousness, and by the time I take the chair there is something on the page
to argue with.
</p>
</div>
</div>
</main>
</div>
</div>
</div></div>
</div>
</body>
</html>
+268
View File
@@ -0,0 +1,268 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Margin App Store frame 2</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:[email protected]&family=Literata:opsz,[email protected],200..900&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="frame.css" />
<link rel="stylesheet" href="app.css" />
</head>
<body>
<div class="frame">
<p class="eyebrow">Preview</p>
<h1 class="headline">Typeset as you write.</h1>
<p class="support">A live page preview shows every sentence exactly as it will come out in print.</p>
<div class="shot"><div class="shot__scale">
<div class="app">
<header class="titlebar">
<span class="lights"><i></i><i></i><i></i></span>
<span class="lead">
<span class="icon-btn" data-on="true"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM9 4.5v15" /></svg></span>
</span>
<span class="doc-title">The Quiet Hours</span>
<span class="actions">
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.6-1.3A3.5 3.5 0 0 1 18 18H7z" /></svg></span>
<span class="icon-btn"><svg width="16" height="16" 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="icon-btn" data-on="true"><svg width="16" height="16" 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="icon-btn"><svg width="16" height="16" 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="icon-btn"><svg width="16" height="16" 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="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" /></svg></span>
<span class="icon-btn"><svg width="16" height="16" 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="icon-btn" data-on="true"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM14 4.5v15" /></svg></span>
</span>
</header>
<div class="body">
<aside class="sidebar">
<span class="brand">
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M14 7l-5 5 5 5" /></svg>
<span class="back-label">All projects</span>
</span>
<span class="cover-item">
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M5 4h11l3 3v13H5zM16 4v4h3" /></svg>
<span class="title">Cover</span>
</span>
<span class="add-page">
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>
<span>Add page</span>
</span>
<ul class="chapters">
<li class="nav-section">
<p class="nav-label">Front matter</p>
<div class="chapter">
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
<span class="text"><span class="title">Title Page</span></span>
</div>
<div class="chapter">
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
<span class="text"><span class="title">Epigraph</span></span>
</div>
</li>
<li class="nav-section">
<p class="nav-label">Chapters</p>
<div class="chapter" data-active="true">
<span class="grip"><svg width="14" height="14" 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 Long Morning</span>
<span class="meta">Edited just now</span>
</span>
</div>
<div class="chapter">
<span class="grip"><svg width="14" height="14" 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">2</span>
<span class="text"><span class="title">On Interruption</span></span>
</div>
<div class="chapter">
<span class="grip"><svg width="14" height="14" 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">3</span>
<span class="text"><span class="title">What the Draft Knows</span></span>
</div>
<div class="chapter">
<span class="grip"><svg width="14" height="14" 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">4</span>
<span class="text"><span class="title">A Note on Endings</span></span>
</div>
<span class="add-chapter">
<svg width="16" height="16" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>
<span>New chapter</span>
</span>
</li>
<li class="nav-section">
<p class="nav-label">Back matter</p>
<div class="chapter">
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
<span class="text"><span class="title">About the Author</span></span>
</div>
</li>
</ul>
</aside>
<main class="editor-pane">
<div class="sheet">
<div class="chapter-opener">
<p class="chapter-num">Chapter 1</p>
<h1 class="chapter-title">The Long Morning</h1>
</div>
<div class="prose">
<p class="first">
The best hours are the ones nobody asks for. They arrive before the day has decided
what it wants from you, and they leave without ceremony, and what happens in them is
almost never what you planned.
</p>
<p data-indent="true">
I have kept the same desk for eleven years. It is not a good desk. One leg is
shorter than the others and I have never fixed it, because the small tilt reminds
me, every time I sit down, that the work is not going to be perfect either, and that
this has never once stopped anybody.
</p>
<p data-indent="true">
What I have learned about working slowly is that it is not slow. It only looks that
way from the outside. From inside it feels like the only speed at which anything
holds together.
</p>
<p data-indent="true">
There is a particular hour, somewhere between the first coffee and the second, when
the work stops feeling like a performance. Nothing on the page has changed. What has
changed is that you have stopped listening for applause.
</p>
<p data-indent="true">
The trick, if there is one, is to start before you are ready. Readiness is a story
you tell yourself about a future morning that will be calmer than this one, and it
will not be. The calm is something you make, badly at first, out of whatever the day
has left lying around.
</p>
<p data-indent="true">
I write the first sentence standing up. I do not know why this works and I have
stopped asking. Somewhere in the business of not sitting down the sentence loses its
self-consciousness, and by the time I take the chair there is something on the page
to argue with.
</p>
</div>
</div>
</main>
<section class="dock">
<div class="dock-head">
<span class="label">Preview</span>
<span class="dock-tools">
<span class="meta">1 of 4</span>
<span class="trim-select">
6 &times; 9 in
<svg viewBox="0 0 24 24"><path d="M8 10l4-4 4 4M8 14l4 4 4-4" /></svg>
</span>
</span>
</div>
<div class="dock-col">
<div class="page">
<div class="p-opener">
<p class="p-num">Chapter 1</p>
<p class="p-title">The Long Morning</p>
</div>
<p>
The best hours are the ones nobody asks for. They arrive before the day has decided
what it wants from you, and they leave without ceremony, and what happens in them is
almost never what you planned.
</p>
<p data-indent="true">
I have kept the same desk for eleven years. It is not a good desk. One leg is
shorter than the others and I have never fixed it, because the small tilt reminds
me, every time I sit down, that the work is not going to be perfect either, and that
this has never once stopped anybody.
</p>
<p data-indent="true">
What I have learned about working slowly is that it is not slow. It only looks that
way from the outside. From inside it feels like the only speed at which anything
holds together.
</p>
<p data-indent="true">
There is a particular hour, somewhere between the first coffee and the second, when
the work stops feeling like a performance. Nothing on the page has changed. What has
changed is that you have stopped listening for applause.
</p>
<p data-indent="true">
The trick, if there is one, is to start before you are ready. Readiness is a story
you tell yourself about a future morning that will be calmer than this one, and it
will not be. The calm is something you make, badly at first, out of whatever the day
has left lying around.
</p>
<p data-indent="true">
I write the first sentence standing up. I do not know why this works and I have
stopped asking. Somewhere in the business of not sitting down the sentence loses its
self-consciousness, and by the time I take the chair there is something on the page
to argue with.
</p>
<p data-indent="true">
Everything after that is argument. You put a thing down, you read it back, you tell
it that it is not quite true, and you try again. Nine times out of ten the second
attempt is worse. The tenth is why anybody does this.
</p>
<p data-indent="true">
By eleven the light has moved across the desk and the day has started making its
claims. I stop where I am, mid-sentence if I can manage it, because a sentence left
open is a door left open, and tomorrow I will walk straight back through it.
</p>
<p data-indent="true">
None of this is a method. A method is something you can hand to somebody else, and I
have tried, and what they get is a description of a room they do not live in.
</p>
<p data-indent="true">
What I can hand over is smaller. Sit down before the day has an opinion. Write badly
on purpose for ten minutes. Stop while it is still going well. That is the whole of
it, and it has taken me eleven years to be able to say it in three sentences.
</p>
<p class="folio">7</p>
</div>
<div class="page">
<p>
What the desk knows, and I keep forgetting, is that the work is not a mood. It does
not care whether I arrived at it happily. It only registers whether I arrived, and
how long I stayed, and whether I was honest about the sentence in front of me while
I was there.
</p>
<p data-indent="true">
The second hour is quieter than the first, which nobody warns you about. You expect
momentum. What you get is a kind of patience, and it turns out that patience is
faster.
</p>
<p data-indent="true">
I have never once regretted a morning spent this way. I have regretted almost every
morning spent otherwise, which is not the same as saying that I stopped having them.
</p>
<p data-indent="true">
There is a version of this essay that argues for discipline, and I have read it many
times, and it has never once got me out of bed.
</p>
<p data-indent="true">
The version that works is duller. Make the room boring. Put the phone in another
room, not face down on the desk, because face down on the desk is still a decision
you have to keep making. Decide once, in advance, while you are calm, and then let
the morning be simple.
</p>
<p data-indent="true">
I am not a fast writer. I have watched fast writers with the particular envy of
someone who has done the arithmetic and knows it does not matter. Over a year the
slow ones and the quick ones arrive at roughly the same place, and the slow ones
seem to enjoy the journey more, which is not nothing.
</p>
<p data-indent="true">
What I want from a morning is not very much. Two hours. One paragraph I did not have
when I sat down. The sense, at the end, that I told the truth about something small.
</p>
</div>
</div>
</section>
</div>
</div>
</div></div>
</div>
</body>
</html>
+161
View File
@@ -0,0 +1,161 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Margin App Store frame 3</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:[email protected]&family=Literata:opsz,[email protected],200..900&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="frame.css" />
<link rel="stylesheet" href="app.css" />
</head>
<body>
<div class="frame">
<p class="eyebrow">Proof</p>
<h1 class="headline">Proofed on your machine.</h1>
<p class="support">Spelling and grammar as you type, checked on the Mac in front of you. Nothing is sent away to be read.</p>
<div class="shot"><div class="shot__scale">
<div class="app">
<header class="titlebar">
<span class="lights"><i></i><i></i><i></i></span>
<span class="lead">
<span class="icon-btn" data-on="true"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM9 4.5v15" /></svg></span>
</span>
<span class="doc-title">The Quiet Hours</span>
<span class="actions">
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.6-1.3A3.5 3.5 0 0 1 18 18H7z" /></svg></span>
<span class="icon-btn"><svg width="16" height="16" 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="icon-btn" data-on="true"><svg width="16" height="16" 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="icon-btn" data-on="true"><svg width="16" height="16" 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="icon-btn"><svg width="16" height="16" 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="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" /></svg></span>
<span class="icon-btn"><svg width="16" height="16" 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="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM14 4.5v15" /></svg></span>
</span>
</header>
<div class="body">
<aside class="sidebar">
<span class="brand">
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M14 7l-5 5 5 5" /></svg>
<span class="back-label">All projects</span>
</span>
<span class="cover-item">
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M5 4h11l3 3v13H5zM16 4v4h3" /></svg>
<span class="title">Cover</span>
</span>
<span class="add-page">
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>
<span>Add page</span>
</span>
<ul class="chapters">
<li class="nav-section">
<p class="nav-label">Front matter</p>
<div class="chapter">
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
<span class="text"><span class="title">Title Page</span></span>
</div>
<div class="chapter">
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
<span class="text"><span class="title">Epigraph</span></span>
</div>
</li>
<li class="nav-section">
<p class="nav-label">Chapters</p>
<div class="chapter">
<span class="grip"><svg width="14" height="14" 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 Long Morning</span></span>
</div>
<div class="chapter" data-active="true">
<span class="grip"><svg width="14" height="14" 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">2</span>
<span class="text">
<span class="title">On Interruption</span>
<span class="meta">Edited 6 minutes ago</span>
</span>
</div>
<div class="chapter">
<span class="grip"><svg width="14" height="14" 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">3</span>
<span class="text"><span class="title">What the Draft Knows</span></span>
</div>
<div class="chapter">
<span class="grip"><svg width="14" height="14" 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">4</span>
<span class="text"><span class="title">A Note on Endings</span></span>
</div>
<span class="add-chapter">
<svg width="16" height="16" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>
<span>New chapter</span>
</span>
</li>
<li class="nav-section">
<p class="nav-label">Back matter</p>
<div class="chapter">
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
<span class="text"><span class="title">About the Author</span></span>
</div>
</li>
</ul>
</aside>
<main class="editor-pane">
<div class="sheet">
<div class="chapter-opener">
<p class="chapter-num">Chapter 2</p>
<h1 class="chapter-title">On Interruption</h1>
</div>
<div class="prose">
<p class="first">
An interruption costs more than the minutes it takes. It costs the shape of the
thought you were holding, which does not come back in the same form, and
<span class="proof-anchor"><span class="proof-mark sev-error">occassionally</span><span class="proof-pop">
<span class="proof-pop-kind sev-error">Spelling</span>
<span class="proof-pop-msg">&ldquo;occassionally&rdquo; may be misspelled</span>
<span class="proof-pop-suggestions">
<span class="proof-suggestion is-hover">occasionally</span>
<span class="proof-suggestion">occasional</span>
</span>
<span class="proof-pop-actions">
<span class="proof-action">Ignore</span>
<span class="proof-action">Remember</span>
</span>
</span></span>
does not come back at all.
</p>
<p data-indent="true">
So I have made the room boring. There is nothing on these walls. The window faces a
brick wall two metres away, which sounds like a punishment and is in fact a gift,
because there is nothing out there to look at and so I look at the page.
</p>
<p data-indent="true">
People assume the point is discipline. It is not. The point is to make the right
thing the easiest thing, so that discipline is never called upon, because discipline
is a small and unreliable fuel and it runs out
<span class="proof-mark sev-warn">at around</span> eleven in the morning.
</p>
<p data-indent="true">
<span class="proof-mark sev-suggest">There is a thing that happens</span> when you
let the morning break in half. The second half is never the first half continued. It
is a new morning, shorter, with the good hour already spent, and you will spend it
trying to remember what you were about to say.
</p>
<p data-indent="true">
I keep a card by the door with one line on it, in my own handwriting, so that I
cannot pretend somebody else set the rule: nothing before the work. Not the post,
not the news, not the small useful errand that would only take a minute.
</p>
</div>
</div>
</main>
</div>
</div>
</div></div>
</div>
</body>
</html>
+241
View File
@@ -0,0 +1,241 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Margin App Store frame 4</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:[email protected]&family=Literata:opsz,[email protected],200..900&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="frame.css" />
<link rel="stylesheet" href="app.css" />
</head>
<body>
<div class="frame">
<p class="eyebrow">Publish</p>
<h1 class="headline">Export PDF and EPUB.</h1>
<p class="support">Read every page before you save it. Send the PDF to a printer, or the EPUB to any store.</p>
<div class="shot"><div class="shot__scale">
<div class="app">
<header class="titlebar">
<span class="lights"><i></i><i></i><i></i></span>
<span class="lead">
<span class="icon-btn" data-on="true"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM9 4.5v15" /></svg></span>
</span>
<span class="doc-title">The Quiet Hours</span>
<span class="actions">
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.6-1.3A3.5 3.5 0 0 1 18 18H7z" /></svg></span>
<span class="icon-btn"><svg width="16" height="16" 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="icon-btn" data-on="true"><svg width="16" height="16" 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="icon-btn"><svg width="16" height="16" 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="icon-btn"><svg width="16" height="16" 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="icon-btn" data-on="true"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" /></svg></span>
<span class="icon-btn"><svg width="16" height="16" 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="icon-btn" data-on="true"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M3 4.5h18v15H3zM14 4.5v15" /></svg></span>
</span>
</header>
<div class="body">
<aside class="sidebar">
<span class="brand">
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M14 7l-5 5 5 5" /></svg>
<span class="back-label">All projects</span>
</span>
<span class="cover-item">
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M5 4h11l3 3v13H5zM16 4v4h3" /></svg>
<span class="title">Cover</span>
</span>
<span class="add-page">
<svg width="15" height="15" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>
<span>Add page</span>
</span>
<ul class="chapters">
<li class="nav-section">
<p class="nav-label">Front matter</p>
<div class="chapter">
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
<span class="text"><span class="title">Title Page</span></span>
</div>
<div class="chapter">
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
<span class="text"><span class="title">Epigraph</span></span>
</div>
</li>
<li class="nav-section">
<p class="nav-label">Chapters</p>
<div class="chapter" data-active="true">
<span class="grip"><svg width="14" height="14" 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 Long Morning</span>
<span class="meta">Edited just now</span>
</span>
</div>
<div class="chapter">
<span class="grip"><svg width="14" height="14" 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">2</span>
<span class="text"><span class="title">On Interruption</span></span>
</div>
<div class="chapter">
<span class="grip"><svg width="14" height="14" 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">3</span>
<span class="text"><span class="title">What the Draft Knows</span></span>
</div>
<div class="chapter">
<span class="grip"><svg width="14" height="14" 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">4</span>
<span class="text"><span class="title">A Note on Endings</span></span>
</div>
<span class="add-chapter">
<svg width="16" height="16" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>
<span>New chapter</span>
</span>
</li>
<li class="nav-section">
<p class="nav-label">Back matter</p>
<div class="chapter">
<span class="grip"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" /></svg></span>
<span class="text"><span class="title">About the Author</span></span>
</div>
</li>
</ul>
</aside>
<main class="editor-pane">
<div class="sheet">
<div class="chapter-opener">
<p class="chapter-num">Chapter 1</p>
<h1 class="chapter-title">The Long Morning</h1>
</div>
<div class="prose">
<p class="first">
The best hours are the ones nobody asks for. They arrive before the day has decided
what it wants from you, and they leave without ceremony, and what happens in them is
almost never what you planned.
</p>
</div>
</div>
</main>
<section class="dock">
<div class="dock-head">
<span class="label">Preview</span>
<span class="dock-tools">
<span class="meta">1 of 4</span>
<span class="trim-select">
6 &times; 9 in
<svg viewBox="0 0 24 24"><path d="M8 10l4-4 4 4M8 14l4 4 4-4" /></svg>
</span>
</span>
</div>
<div class="dock-col">
<div class="page">
<div class="p-opener">
<p class="p-num">Chapter 1</p>
<p class="p-title">The Long Morning</p>
</div>
<p>
The best hours are the ones nobody asks for. They arrive before the day has decided
what it wants from you, and they leave without ceremony, and what happens in them is
almost never what you planned.
</p>
<p data-indent="true">
I have kept the same desk for eleven years. It is not a good desk. One leg is
shorter than the others and I have never fixed it, because the small tilt reminds
me, every time I sit down, that the work is not going to be perfect either, and that
this has never once stopped anybody.
</p>
</div>
</div>
</section>
</div>
<div class="overlay"></div>
<div class="preview-panel" style="left: 248px; top: 46px; width: 808px; height: 854px;">
<div class="preview-bar">
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" /></svg></span>
<span class="preview-title">The Quiet Hours</span>
<span class="preview-count">96 pages</span>
<span class="preview-zoom">
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M5 12h14" /></svg></span>
<span>Fit</span>
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg></span>
</span>
<span class="btn-primary">Save PDF&hellip;</span>
</div>
<div class="preview-stage">
<div class="preview-col">
<div class="preview-page">
<div class="p-opener">
<p class="p-num">Chapter 1</p>
<p class="p-title">The Long Morning</p>
</div>
<p class="first">
The best hours are the ones nobody asks for. They arrive before the day has decided
what it wants from you, and they leave without ceremony, and what happens in them is
almost never what you planned.
</p>
<p data-indent="true">
I have kept the same desk for eleven years. It is not a good desk. One leg is
shorter than the others and I have never fixed it, because the small tilt reminds
me, every time I sit down, that the work is not going to be perfect either, and that
this has never once stopped anybody.
</p>
<p data-indent="true">
What I have learned about working slowly is that it is not slow. It only looks that
way from the outside. From inside it feels like the only speed at which anything
holds together.
</p>
<p data-indent="true">
There is a particular hour, somewhere between the first coffee and the second, when
the work stops feeling like a performance. Nothing on the page has changed. What has
changed is that you have stopped listening for applause.
</p>
<p data-indent="true">
The trick, if there is one, is to start before you are ready. Readiness is a story
you tell yourself about a future morning that will be calmer than this one, and it
will not be. The calm is something you make, badly at first, out of whatever the day
has left lying around.
</p>
<p data-indent="true">
I write the first sentence standing up. I do not know why this works and I have
stopped asking. Somewhere in the business of not sitting down the sentence loses its
self-consciousness, and by the time I take the chair there is something on the page
to argue with.
</p>
<p data-indent="true">
Everything after that is argument. You put a thing down, you read it back, you tell
it that it is not quite true, and you try again. Nine times out of ten the second
attempt is worse. The tenth is why anybody does this.
</p>
<p class="folio">7</p>
</div>
<div class="preview-page">
<p>
By eleven the light has moved across the desk and the day has started making its
claims. I stop where I am, mid-sentence if I can manage it, because a sentence left
open is a door left open, and tomorrow I will walk straight back through it.
</p>
<p data-indent="true">
None of this is a method. A method is something you can hand to somebody else, and I
have tried, and what they get is a description of a room they do not live in.
</p>
<p data-indent="true">
What I can hand over is smaller. Sit down before the day has an opinion. Write badly
on purpose for ten minutes. Stop while it is still going well.
</p>
</div>
</div>
</div>
</div>
</div>
</div></div>
</div>
</body>
</html>
+105
View File
@@ -0,0 +1,105 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Margin App Store frame 5</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:[email protected]&family=Literata:opsz,[email protected],200..900&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="frame.css" />
<link rel="stylesheet" href="app.css" />
</head>
<body>
<div class="frame">
<p class="eyebrow">Yours</p>
<h1 class="headline">No account, no subscription.</h1>
<p class="support">Nothing to sign in to. Every piece is a plain file on your own Mac, yours to move, copy and back up.</p>
<div class="shot"><div class="shot__scale">
<div class="library">
<header class="library-head">
<span class="lights"><i></i><i></i><i></i></span>
<span class="icon-btn"><svg width="16" height="16" viewBox="0 0 24 24"><path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.6-1.3A3.5 3.5 0 0 1 18 18H7z" /></svg></span>
</header>
<div class="shelf">
<span class="card card-action">
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>
<span>New project</span>
</span>
<span class="card card-action">
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M12 3v10m0 0l-4-4m4 4l4-4M5 19h14" /></svg>
<span>Import EPUB</span>
</span>
<span class="card card-book">
<span class="card-title">The Quiet Hours</span>
<span class="card-author">Elena Marsh</span>
<span class="card-meta">Edited just now</span>
</span>
<span class="card card-book">
<span class="card-title">Field Notes</span>
<span class="card-author">Elena Marsh</span>
<span class="card-meta">Edited yesterday</span>
</span>
<span class="card card-book">
<span class="card-title">The Tuesday Letter</span>
<span class="card-author">Elena Marsh</span>
<span class="card-meta">Edited 3 days ago</span>
</span>
<span class="card card-book">
<span class="card-title">Small Repairs</span>
<span class="card-author">Elena Marsh</span>
<span class="card-meta">Edited 6 days ago</span>
</span>
<span class="card card-book">
<span class="card-title">A Year of Mondays</span>
<span class="card-author">Elena Marsh</span>
<span class="card-meta">Edited Aug 12, 2026</span>
</span>
<span class="card card-book">
<span class="card-title">Nothing on These Walls</span>
<span class="card-author">Elena Marsh</span>
<span class="card-meta">Edited Aug 4, 2026</span>
</span>
<span class="card card-book">
<span class="card-title">Twelve Openings</span>
<span class="card-author">Elena Marsh</span>
<span class="card-meta">Edited Jul 28, 2026</span>
</span>
<span class="card card-book">
<span class="card-title">Rooms I Have Worked In</span>
<span class="card-author">Elena Marsh</span>
<span class="card-meta">Edited Jul 9, 2026</span>
</span>
<span class="card card-book">
<span class="card-title">The Long Way Round</span>
<span class="card-author">Elena Marsh</span>
<span class="card-meta">Edited Jun 30, 2026</span>
</span>
<span class="card card-book">
<span class="card-title">Working Slowly</span>
<span class="card-author">Elena Marsh</span>
<span class="card-meta">Edited Jun 2, 2026</span>
</span>
<span class="card card-book">
<span class="card-title">Every Second Sentence</span>
<span class="card-author">Elena Marsh</span>
<span class="card-meta">Edited May 19, 2026</span>
</span>
<span class="card card-book">
<span class="card-title">Letters I Did Not Send</span>
<span class="card-author">Elena Marsh</span>
<span class="card-meta">Edited Apr 27, 2026</span>
</span>
<span class="card card-book">
<span class="card-title">The Brick Wall</span>
<span class="card-author">Elena Marsh</span>
<span class="card-meta">Edited Mar 14, 2026</span>
</span>
</div>
</div>
</div></div>
</div>
</body>
</html>
+111
View File
@@ -0,0 +1,111 @@
/* Shared surface for every Mac App Store frame. Rendered at exactly 2560x1600,
so every length here is a real output pixel. */
:root {
--paper: #fcfbf7;
--shell: #f1ece2;
--ink: #23201b;
--ink-soft: #6b6458;
--ink-faint: #9b9484;
--line-strong: #d6cfbd;
--font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-book: "Literata", Georgia, "Times New Roman", serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
width: 2560px;
height: 1600px;
overflow: hidden;
background:
radial-gradient(128% 92% at 50% -14%, var(--paper) 0%, rgba(252, 251, 247, 0) 62%),
var(--shell);
color: var(--ink);
font-family: var(--font-ui);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.frame {
width: 2560px;
height: 1600px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
overflow: hidden;
padding: 120px 100px 116px;
}
.eyebrow {
margin: 0 0 24px;
font-size: 22px;
font-weight: 650;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-faint);
}
.headline {
margin: 0;
font-family: var(--font-book);
font-size: 92px;
font-weight: 560;
line-height: 1.06;
letter-spacing: -0.022em;
color: var(--ink);
font-optical-sizing: auto;
}
.support {
margin: 30px 0 0;
max-width: 1700px;
font-size: 32px;
line-height: 1.5;
color: var(--ink-soft);
text-wrap: balance;
}
/* The app window sitting on the surface, never full bleed. The window itself is
live markup authored at the app's own 1440x900 (see app.css) and scaled up to
fill this well, so the type stays vector-sharp instead of being resampled. */
.shot {
position: relative;
width: 1600px;
height: 1000px;
flex: none;
margin-top: 100px;
border-radius: 22px;
background: var(--paper);
overflow: hidden;
box-shadow:
0 2px 4px rgba(35, 32, 27, 0.06),
0 10px 26px -8px rgba(35, 32, 27, 0.14),
0 46px 96px -26px rgba(35, 32, 27, 0.36);
}
.shot__scale {
width: 1440px;
height: 900px;
transform: scale(1.1111111111);
transform-origin: 0 0;
}
.shot::after {
content: "";
position: absolute;
inset: 0;
border-radius: 22px;
box-shadow: inset 0 0 0 1px var(--line-strong);
pointer-events: none;
}
+229
View File
@@ -0,0 +1,229 @@
# Publishing
Margin goes out through three doors, and they are not the same app.
The **direct download** from [margin.73ai.org](https://margin.73ai.org) is the full one. It is not
sandboxed, it updates itself, and nobody stands between it and the person using it. The **Homebrew
cask** is the same file with a command instead of a browser. The **Mac App Store** copy is a
sandboxed build with the updater taken out, because Apple requires both. It exists because most
people will never download a `.dmg` from GitHub, and telling them to is how you end up with no
users rather than principled ones.
## Signing and notarization
Every macOS bundle is signed with a Developer ID Application certificate and notarized by Apple
before it is published. This is not optional any more: an unsigned bundle on a current macOS opens
to "Apple could not verify this app is free of malware" with no obvious way past it, and the way
past it that does exist teaches people to click through exactly the warning that is worth reading.
The release workflow does it. `tauri-action` picks up `APPLE_CERTIFICATE` and
`APPLE_CERTIFICATE_PASSWORD` (a base64 `.p12` and its password), signs with
`APPLE_SIGNING_IDENTITY`, and notarizes with an App Store Connect API key: `APPLE_API_ISSUER`,
`APPLE_API_KEY_ID`, and `APPLE_API_KEY_P8`, the last being the base64 of the `.p8` file. The key is
used rather than an Apple ID and app-specific password because the same key does the App Store
upload, so there is one credential to rotate instead of two.
The step after the build is the one that matters. `codesign --verify` only says the signature is
internally consistent; `spctl --assess` is what a person double-clicking the file actually meets,
and it does not pass until `stapler` has attached the notarization ticket to the bundle. If that
step goes green the download works on a machine that has never heard of Margin. If it is skipped
because no key was configured, the workflow says so in a warning rather than shipping something
that looks fine and is not.
## Homebrew
```
brew tap priyanshujain/margin
brew trust priyanshujain/margin
brew install --cask margin
```
Homebrew 6 refuses to load a cask from a tap it has not been told to trust, and the error it raises
instead says nothing about installing, so the trust line belongs in every set of instructions
rather than being left for people to discover.
The tap is [priyanshujain/homebrew-margin](https://github.com/priyanshujain/homebrew-margin) rather
than upstream `homebrew-cask`, which has a notability bar Margin does not clear yet. What that
costs is the two setup lines above, and the tap keeps working as a fallback if the cask ever does
go upstream.
The `homebrew` job at the end of the release workflow downloads the `.dmg` that was just published,
takes its sha256, and rewrites the version and hash in `Casks/margin.rb`. It runs after the publish
gate, so the cask can never point at a release that is still a draft. Without
`HOMEBREW_TAP_DEPLOY_KEY` it warns and does nothing, which leaves the cask on the previous version
rather than failing a release that otherwise succeeded.
That secret is an SSH deploy key registered on the tap, not a personal access token. A token would
carry the whole account; the deploy key reaches the tap and nothing else, so a leak from a release
job cannot touch the app repositories.
## Updates
Both builds check on launch and from the same "Check for Updates…" menu item, and they ask
different questions. The direct download asks the updater endpoint in `tauri.release.conf.json`,
downloads the new bundle and restarts. The App Store copy asks `itunes.apple.com/lookup` which
version is live under the bundle id, and if that is ahead of the one running it offers to open the
store page, because an App Store app may not install code and would be rejected for trying.
Which of the two runs is decided in `updates.rs` by the marker the config carries: the release
overlay declares an `updater` plugin, the App Store overlay declares an `appstore` one. A
`_MASReceipt` inside the bundle overrides both. That is the check that matters, because it means a
bundle which came from the store cannot self-update even if it was built with the updater in it,
and the decision does not rest on a config file alone.
The prompt is a native alert rather than a window the app draws, and it appears once per version.
"Later" means later, not later today: nothing is raised again until there is a new version to raise,
and the menu item is there in the meantime. Whether to update is the reader's call, and an app that
asks the same question at every launch is answering it for them.
The store copy trails the direct one by however long review takes, so an App Store user being told
they are up to date while GitHub has something newer is correct rather than a bug. Each channel
compares against its own. Apple's lookup endpoint is also edge cached and can sit a few hours behind
a release going live, which is what the timestamp on the request is for.
## The Mac App Store
Tauri has no App Store target, so `scripts/mas-package.sh` covers the distance between the `.app`
and something App Store Connect will take. It embeds the provisioning profile, resolves the team
identifier into the entitlements, signs, and wraps the result with `productbuild`.
The order is load-bearing. The profile goes in before `codesign` runs, because the signature covers
it. Tauri's own signing is switched off in this build for the same reason: it would sign a bundle
with no profile in it. Nested code, if there ever is any, is signed before the bundle that contains
it, and never with `--deep`, which would apply the app's entitlements to everything inside.
`CFBundleVersion` comes from the workflow run number. App Store Connect refuses an upload whose
build number it has seen before, so a rejected build cannot be resubmitted under the same one, and
tying it to the run number means it goes up on its own.
### The listing and TestFlight
The store copy lives in `appstore/metadata` as one text file per field, and
`scripts/appstore-listing.rb` pushes it. Keeping it in files rather than in the script means
changing a description is a diff someone can read, and the listing is reviewable next to the code
it describes. The script checks each field against Apple's length limit before sending, because
Apple rejects an over-length field with a validation error that never mentions the number.
The store version is taken from `tauri.conf.json`, the same place every other version in the repo
comes from. This matters because App Store Connect rejects a build whose `CFBundleShortVersionString`
does not match the version it is uploaded against, and a record created by `produce` starts life at
1.0 regardless of what the app actually is.
Release notes are the one field it will not write on a first version. They describe what changed
since the last release, so Apple refuses them when there is no last release, and there would be
nothing truthful to say.
`scripts/testflight-setup.rb` does the beta side: the blurb testers read, the details Beta App
Review asks for, and the two groups. None of it needs a build to exist, so it can all be in place
before the first upload. Internal testers get builds within minutes of processing. External testers
go through Beta App Review, which needs a contact phone number in
`appstore/metadata/review_phone.txt`; without it the script says so and carries on, because
internal testing does not need it.
`scripts/appstore-review-detail.rb` writes the store submission's App Review Information: the same
contact details and the notes in `appstore/metadata/review_notes.txt`. That is a different record
from the TestFlight one, and the two do not share anything. An explanation that only went to
TestFlight is invisible both to the reviewer looking at the store submission and to the automated
check that runs before a human sees it at all, which is how the first submission was rejected.
`scripts/appstore-compliance.rb` answers the age rating questionnaire and declares App Privacy.
Every content answer is NONE and the privacy answer is that nothing is collected, which is true:
there is no telemetry, no account and no server. The Drive backup sends bytes to the account of the
person who switched it on, which is not the developer collecting anything. If that ever stops being
true, that script is the thing that has to change with it.
`scripts/appstore-screenshots.rb` uploads the frames in `appstore/screenshots`, replacing whatever
is already attached rather than adding to it, so a listing cannot quietly accumulate ten frames
across five runs. How the frames themselves are built is in
[appstore/screenshots/README.md](../appstore/screenshots/README.md); they are a CSS rebuild of the
app rendered at 2560x1600, not a screen capture, because the only Macs to hand have 1x displays and
an upscaled capture looks like one.
Screenshots gate submitting for review, and nothing else: not the app record, not a build upload,
and not either tier of TestFlight.
`scripts/mas-upload-local.sh` builds, signs, packages and uploads from a developer's own machine
rather than CI, using a keychain that exists only for the length of the run. `scripts/testflight-release.rb`
then waits for Apple to finish processing, assigns the build to the external group, submits it for
beta app review, and attaches it to the store version.
That last step is easy to miss and does not announce itself. Assigning a build to TestFlight and
attaching it to the store version are separate operations, and a Mac listing takes its app icon
from the attached build. Until it is attached, App Store Connect shows the listing with no icon and
does not say why.
The Apple ID this is driven with can see more than one App Store Connect team, and the other one
belongs to somebody else, so both scripts pin `FASTLANE_ITC_TEAM_ID` rather than letting spaceship
choose. Do not remove that.
### What the sandbox costs
The App Store build declares four entitlements, in `src-tauri/entitlements.mas.plist`, and each one
is there for a reason worth being able to defend in review. `network.client` is the Google Drive
API and the version lookup above. `network.server` is the loopback listener the Drive OAuth flow
redirects to, which is the only installed-app flow Google still supports.
`network.server` is not a theoretical risk. An automated check rejects any submission that declares
it, before review, unless the App Review Information says what listens and why, so
`appstore/metadata/review_notes.txt` explains the loopback bind first and at length: that it is on
127.0.0.1 and never a routable interface, that it lives only for the duration of a sign-in, that it
times out, and how to reach the feature in the app. A rejection on this also has to be answered in
Resolution Center by hand, since that is not in the App Store Connect API.
`files.user-selected.read-write` covers EPUB import and PDF and EPUB export, all of which go
through a panel, so the app only ever reaches the one file that was pointed at. The library needs
nothing: it lives in the container.
Three things are different in that build, and all three are Apple's rules rather than choices:
The updater is gone. `lib.rs` registers the updater plugin only when the config declares it, and
only `tauri.release.conf.json` does, so building against `tauri.appstore.conf.json` leaves it out
by construction. The menu item stays, pointed at the App Store instead, which is what the previous
section is about.
The library moves. Sandboxed, `app_data_dir()` resolves inside
`~/Library/Containers/studio.margin.app`, not `~/Library/Application Support`. Someone who switches
from the direct download to the App Store copy sees an empty library. There is no migration and no
plan for one; if that ever matters to a real person it is a first-run import, not a sync.
System spelling additions are gone. `proofing.rs` reads `~/Library/Spelling/LocalDictionary` to
pick up words added elsewhere on the Mac, and the sandbox denies it. The read already fails
quietly, so the app is fine, it just knows fewer of your words.
## Certificates
Three, and they do different jobs. **Developer ID Application** signs the direct download and is
what notarization checks. **Mac App Distribution** signs the App Store bundle. **Mac Installer
Distribution** signs the `.pkg` that wraps it. A Developer ID certificate is not accepted by the
App Store and a Mac App Distribution one will not notarize, so there is no combining them.
`scripts/apple-provision.rb` creates all three, registers the App ID, and makes the provisioning
profile, driving the Developer Portal through fastlane's spaceship rather than the website. It is
find-or-create throughout, so rerunning it is safe. That matters most for the Developer ID
certificate: an account may hold only a handful, they cannot be un-revoked, and every copy of the
app already signed by one stops verifying if it goes away.
It has to be run by a person, not by CI or an agent, because it prompts for the Apple ID password
and a two-factor code:
```
[email protected] BUNDLE_ID=studio.margin.app APP_NAME=Margin ruby scripts/apple-provision.rb
```
The private keys live in `~/.margin-signing` and are never sent to Apple, the way the updater key
lives in `~/.tauri`. They were generated with `openssl` up front so that Apple only ever sees a
certificate signing request, and so the `.p12` that CI uses can be assembled locally. Each `.p12`
carries Apple's intermediate certificate alongside the leaf, because a fresh CI keychain holding
only the leaf fails to build a chain to the root and `codesign` stops with an error that does not
say so.
`scripts/apple-secrets.sh` then pipes all of it into the repository's Actions secrets without
printing any of it. The one secret it cannot produce is `HOMEBREW_TAP_DEPLOY_KEY`, which is set up
alongside the tap rather than by anything Apple issued.
One step has no API and has to be done on the website: an App Store Connect API key, under Users
and Access, Integrations. The `.p8` downloads exactly once. Put it at `~/.margin-signing/AuthKey.p8`
with its key ID and issuer ID in `AuthKey.env` beside it, and rerun the secrets script.
Certificates expire after five years and are replaceable; the keys are not backed up anywhere else,
so back them up somewhere that is not this machine.
+11
View File
@@ -0,0 +1,11 @@
{
"installed": {
"client_id": "YOUR_CLIENT_ID.apps.googleusercontent.com",
"project_id": "your-project-id",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_secret": "YOUR_CLIENT_SECRET",
"redirect_uris": ["http://127.0.0.1"]
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<link rel="icon" type="image/png" href="/margin-mark.png" />
<script>
(function () {
+8 -3
View File
@@ -1,14 +1,16 @@
{
"name": "margin-app",
"private": true,
"version": "0.1.4",
"version": "0.1.17",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"dmg": "tauri build --bundles dmg"
"dmg": "tauri build --bundles dmg",
"fonts:sync": "node node_modules/margin-shared/bin/sync-fonts.mjs .",
"fonts:check": "node node_modules/margin-shared/bin/sync-fonts.mjs . --check"
},
"dependencies": {
"@tauri-apps/api": "^2",
@@ -21,6 +23,7 @@
"@tiptap/pm": "^3.27.1",
"@tiptap/react": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1",
"margin-shared": "file:./shared",
"pdfjs-dist": "^6.0.227",
"react": "^19.1.0",
"react-dom": "^19.1.0",
@@ -33,5 +36,7 @@
"@vitejs/plugin-react": "^4.6.0",
"typescript": "~5.8.3",
"vite": "^7.0.4"
}
},
"description": "A calm, offline writing studio for authors",
"license": "SEE LICENSE IN LICENSE"
}
+9
View File
@@ -38,6 +38,9 @@ importers:
'@tiptap/starter-kit':
specifier: ^3.27.1
version: 3.27.1
margin-shared:
specifier: file:./shared
version: file:shared
pdfjs-dist:
specifier: ^6.0.227
version: 6.0.227
@@ -887,6 +890,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
margin-shared@file:shared:
resolution: {directory: shared, type: directory}
hasBin: true
[email protected]:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1784,6 +1791,8 @@ snapshots:
dependencies:
yallist: 3.1.1
margin-shared@file:shared: {}
[email protected]: {}
[email protected]: {}
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2014 The Source Serif 4 Project Authors (https://github.com/adobe-fonts/source-serif)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env ruby
# Create the Apple Developer resources a release needs, and turn them into files CI can use.
#
# Everything here is find-or-create, so running it twice does nothing the second time. That matters
# most for the Developer ID certificate: an account may hold only a handful, they cannot be
# un-revoked, and every copy of the app already signed by one stops verifying if it goes away.
#
# The private keys are never sent to Apple and never leave ~/.margin-signing. Apple only ever sees
# the certificate signing requests, which is the whole point of generating them with openssl up
# front rather than letting a tool make its own.
#
# BUNDLE_ID=studio.margin.app APP_NAME=Margin ruby scripts/apple-provision.rb
#
# Run it yourself rather than through an agent: the Apple ID password and the two-factor code are
# prompted for on the terminal.
begin
require "spaceship"
rescue LoadError
# Homebrew vendors fastlane's gems under libexec instead of putting them on the default gem
# path, so spaceship is not requirable until that directory is added to it.
libexec = Dir["/opt/homebrew/Cellar/fastlane/*/libexec", "/usr/local/Cellar/fastlane/*/libexec"].max
abort "spaceship is not installed. `brew install fastlane` and rerun." unless libexec
ENV["GEM_PATH"] = [libexec, ENV["GEM_PATH"]].compact.join(":")
Gem.clear_paths
require "spaceship"
end
require "openssl"
require "fileutils"
require "securerandom"
require "net/http"
require "tmpdir"
# Listing profiles otherwise goes through developerservices2.apple.com, Apple's Xcode-only
# endpoint, which rejects a plain spaceship session with "Please update to Xcode 7.3 or later"
# no matter how current Xcode actually is. This routes it back to the ordinary portal API.
ENV["SPACESHIP_AVOID_XCODE_API"] = "1"
DIR = File.expand_path("~/.margin-signing")
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
APP_NAME = ENV.fetch("APP_NAME", "Margin")
EMAIL = ENV["APPLE_EMAIL"]
# Apple's intermediates. codesign builds a chain from the leaf up, so a .p12 holding only the leaf
# and its key fails on a fresh CI keychain with "unable to build chain to self-signed root".
INTERMEDIATES = {
"AppleWWDRCAG3" => "https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer",
"DeveloperIDG2CA" => "https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer",
}
CERTS = [
{ key: "developer-id", klass: Spaceship::Portal::Certificate::DeveloperIdApplication,
label: "Developer ID Application (direct download, notarized)", ca: "DeveloperIDG2CA" },
{ key: "apple-distribution", klass: Spaceship::Portal::Certificate::MacAppDistribution,
label: "Mac App Distribution (App Store .app)", ca: "AppleWWDRCAG3" },
{ key: "mac-installer", klass: Spaceship::Portal::Certificate::MacInstallerDistribution,
label: "Mac Installer Distribution (App Store .pkg)", ca: "AppleWWDRCAG3" },
]
def common_name(cert)
cert.subject.to_a.find { |n, _, _| n == "CN" }&.at(1)
end
def fetch_intermediate(name)
path = File.join(DIR, "#{name}.cer")
unless File.exist?(path)
uri = URI(INTERMEDIATES.fetch(name))
File.binwrite(path, Net::HTTP.get(uri))
end
OpenSSL::X509::Certificate.new(File.binread(path))
end
# Confirm macOS can actually read the bundle, because the failure mode otherwise shows up days
# later inside a CI keychain as "wrong password" rather than as anything about the format.
def importable?(p12_path, password)
keychain = File.join(Dir.tmpdir, "margin-p12-check-#{SecureRandom.hex(4)}.keychain-db")
system("security", "create-keychain", "-p", "check", keychain, out: File::NULL, err: File::NULL)
system("security", "unlock-keychain", "-p", "check", keychain, out: File::NULL, err: File::NULL)
ok = system("security", "import", p12_path, "-k", keychain, "-P", password,
out: File::NULL, err: File::NULL)
system("security", "delete-keychain", keychain, out: File::NULL, err: File::NULL)
ok
end
# Returns nil on success, or a sentence saying what went wrong.
def write_p12(spec, cert)
key_path = File.join(DIR, "#{spec[:key]}.key")
unless cert.check_private_key(OpenSSL::PKey::RSA.new(File.read(key_path)))
return "the issued certificate does not match the local private key, so Apple issued it " \
"against a different CSR. Revoke it in the portal and rerun."
end
password = SecureRandom.hex(24)
p12_path = File.join(DIR, "#{spec[:key]}.p12")
built = Dir.mktmpdir do |tmp|
leaf = File.join(tmp, "leaf.pem")
ca = File.join(tmp, "ca.pem")
File.write(leaf, cert.to_pem)
File.write(ca, fetch_intermediate(spec[:ca]).to_pem)
# Ruby links OpenSSL 3, whose PKCS12 default MAC is SHA-256. Apple's Security framework reads
# only the legacy SHA-1 MAC and reports the mismatch as a wrong password, so the bundle has to
# come from the LibreSSL at /usr/bin/openssl, which still writes the older format. The password
# goes through the environment rather than argv so it stays out of the process list.
system({ "P12PASS" => password }, "/usr/bin/openssl", "pkcs12", "-export",
"-inkey", key_path, "-in", leaf, "-certfile", ca,
"-name", common_name(cert), "-passout", "env:P12PASS", "-out", p12_path,
out: File::NULL, err: File::NULL)
end
return "/usr/bin/openssl could not build the bundle." unless built
return "macOS refused to import the bundle that was just built." unless importable?(p12_path, password)
File.write(File.join(DIR, "#{spec[:key]}.p12.pass"), password)
File.chmod(0o600, p12_path, File.join(DIR, "#{spec[:key]}.p12.pass"))
nil
end
FileUtils.mkdir_p(DIR)
Spaceship::Portal.login(EMAIL)
Spaceship::Portal.select_team
team_id = Spaceship::Portal.client.team_id
puts "Team ID: #{team_id}"
puts
identities = {}
CERTS.each do |spec|
existing = spec[:klass].all.select { |c| c.status == "Issued" }
cert_obj = existing.first
if cert_obj
puts "#{spec[:label]}: already exists (#{cert_obj.id}), not creating another."
else
csr = File.read(File.join(DIR, "#{spec[:key]}.csr"))
cert_obj = spec[:klass].create!(csr: csr)
puts "#{spec[:label]}: created (#{cert_obj.id})."
end
x509 = cert_obj.download
File.binwrite(File.join(DIR, "#{spec[:key]}.cer"), x509.to_der)
identities[spec[:key]] = common_name(x509)
puts " identity: #{common_name(x509)}"
problem = write_p12(spec, x509)
puts(problem ? " no p12: #{problem}" : " wrote #{spec[:key]}.p12")
puts
end
app = Spaceship::Portal::App.find(BUNDLE_ID, mac: true)
if app
puts "App ID #{BUNDLE_ID}: already registered."
else
app = Spaceship::Portal::App.create!(bundle_id: BUNDLE_ID, name: APP_NAME, mac: true)
puts "App ID #{BUNDLE_ID}: registered."
end
profile_name = "#{APP_NAME} App Store"
profile = Spaceship::Portal::ProvisioningProfile::AppStore.all(mac: true).find do |p|
p.app.bundle_id == BUNDLE_ID && p.status == "Active"
end
if profile
puts "Provisioning profile: reusing #{profile.name}."
else
mas_cert = Spaceship::Portal::Certificate::MacAppDistribution.all.first
profile = Spaceship::Portal::ProvisioningProfile::AppStore.create!(
name: profile_name, bundle_id: BUNDLE_ID, certificate: mas_cert, mac: true
)
puts "Provisioning profile: created #{profile.name}."
end
profile_path = File.join(DIR, "#{BUNDLE_ID}.provisionprofile")
File.binwrite(profile_path, profile.download)
File.chmod(0o600, profile_path)
File.write(File.join(DIR, "#{BUNDLE_ID}.env"), <<~ENV)
APPLE_TEAM_ID="#{team_id}"
APPLE_SIGNING_IDENTITY="#{identities['developer-id']}"
MAS_APP_IDENTITY="#{identities['apple-distribution']}"
MAS_INSTALLER_IDENTITY="#{identities['mac-installer']}"
ENV
puts
puts "Wrote #{profile_path} and #{BUNDLE_ID}.env into #{DIR}."
puts "Still to do by hand, because Apple has no API for it: create an App Store Connect API key"
puts "(Users and Access, Integrations) and save the .p8 as #{DIR}/AuthKey.p8."
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Push everything apple-provision.rb produced into this repo's GitHub Actions secrets.
#
# Values are piped from the files straight into `gh`, never echoed, so running this in a shared
# terminal or through an agent does not leak a signing key into the scrollback.
set -euo pipefail
DIR="${MARGIN_SIGNING_DIR:-$HOME/.margin-signing}"
BUNDLE_ID="${BUNDLE_ID:-studio.margin.app}"
REPO="${REPO:-priyanshujain/margin}"
ENV_FILE="$DIR/$BUNDLE_ID.env"
[ -f "$ENV_FILE" ] || { echo "apple-secrets: $ENV_FILE is missing; run apple-provision.rb first." >&2; exit 1; }
# shellcheck source=/dev/null
set -a; . "$ENV_FILE"; set +a
set_plain() {
printf '%s' "$2" | gh secret set "$1" --repo "$REPO" --body -
echo " set $1"
}
set_b64() {
[ -f "$2" ] || { echo " skipped $1 ($2 is missing)"; return; }
base64 -i "$2" | tr -d '\n' | gh secret set "$1" --repo "$REPO" --body -
echo " set $1"
}
set_file() {
[ -f "$2" ] || { echo " skipped $1 ($2 is missing)"; return; }
gh secret set "$1" --repo "$REPO" < "$2"
echo " set $1"
}
echo "Signing identities and team:"
set_plain APPLE_TEAM_ID "$APPLE_TEAM_ID"
set_plain APPLE_SIGNING_IDENTITY "$APPLE_SIGNING_IDENTITY"
set_plain MAS_APP_IDENTITY "$MAS_APP_IDENTITY"
set_plain MAS_INSTALLER_IDENTITY "$MAS_INSTALLER_IDENTITY"
echo "Certificates:"
set_b64 APPLE_CERTIFICATE "$DIR/developer-id.p12"
set_file APPLE_CERTIFICATE_PASSWORD "$DIR/developer-id.p12.pass"
set_b64 MAS_APP_CERTIFICATE "$DIR/apple-distribution.p12"
set_file MAS_APP_CERTIFICATE_PASSWORD "$DIR/apple-distribution.p12.pass"
set_b64 MAS_INSTALLER_CERTIFICATE "$DIR/mac-installer.p12"
set_file MAS_INSTALLER_CERTIFICATE_PASSWORD "$DIR/mac-installer.p12.pass"
set_b64 MAS_PROVISION_PROFILE "$DIR/$BUNDLE_ID.provisionprofile"
echo "App Store Connect API key:"
if [ -f "$DIR/AuthKey.p8" ] && [ -f "$DIR/AuthKey.env" ]; then
# shellcheck source=/dev/null
set -a; . "$DIR/AuthKey.env"; set +a
# An empty value here would be accepted by `gh` and then fail notarization as an auth error that
# says nothing about a missing issuer, so refuse it at the point the mistake is still visible.
if [ -z "${APPLE_API_KEY_ID:-}" ] || [ -z "${APPLE_API_ISSUER:-}" ]; then
echo " refused: AuthKey.env is missing APPLE_API_KEY_ID or APPLE_API_ISSUER." >&2
exit 1
fi
set_plain APPLE_API_KEY_ID "$APPLE_API_KEY_ID"
set_plain APPLE_API_ISSUER "$APPLE_API_ISSUER"
set_b64 APPLE_API_KEY_P8 "$DIR/AuthKey.p8"
else
echo " skipped: put the .p8 at $DIR/AuthKey.p8 and write $DIR/AuthKey.env with"
echo " APPLE_API_KEY_ID= and APPLE_API_ISSUER=, then rerun."
fi
echo
echo "Not handled here: HOMEBREW_TAP_DEPLOY_KEY, an SSH deploy key on the tap rather than"
echo "anything Apple issued."
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env ruby
# Declare the age rating and set pricing. These are the two things App Store Connect will not let a
# submission through without, and neither has anything to do with the copy in appstore-listing.rb.
#
# [email protected] ruby scripts/appstore-compliance.rb
#
# Every content answer here is NONE because Margin is an editor for words the person using it wrote
# themselves. It ships no media, has no feed, no other users, and no in-app browser. If any of that
# ever stops being true, this file is the thing that has to change with it.
begin
require "spaceship"
rescue LoadError
libexec = Dir["/opt/homebrew/Cellar/fastlane/*/libexec", "/usr/local/Cellar/fastlane/*/libexec"].max
abort "spaceship is not installed. `brew install fastlane` and rerun." unless libexec
ENV["GEM_PATH"] = [libexec, ENV["GEM_PATH"]].compact.join(":")
Gem.clear_paths
require "spaceship"
end
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371")
NONE = Spaceship::ConnectAPI::AgeRatingDeclaration::Rating::NONE
Spaceship::ConnectAPI.login(ENV["APPLE_EMAIL"], nil, use_portal: false, use_tunes: true)
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
abort "No app for #{BUNDLE_ID}." unless app
puts "#{app.name} (#{app.id})"
info = app.fetch_edit_app_info
abort "No editable app info." unless info
declaration = info.fetch_age_rating_declaration
abort "No age rating declaration to write to." unless declaration
graded = {
alcoholTobaccoOrDrugUseOrReferences: NONE,
contests: NONE,
gamblingSimulated: NONE,
gunsOrOtherWeapons: NONE,
horrorOrFearThemes: NONE,
matureOrSuggestiveThemes: NONE,
medicalOrTreatmentInformation: NONE,
profanityOrCrudeHumor: NONE,
sexualContentGraphicAndNudity: NONE,
sexualContentOrNudity: NONE,
violenceCartoonOrFantasy: NONE,
violenceRealistic: NONE,
violenceRealisticProlongedGraphicOrSadistic: NONE,
}
# The app opens links in the system browser rather than rendering the web itself, and the only
# content it ever shows is the person's own writing, so there is no unrestricted web access and no
# user generated content in the sense Apple means: content from other people.
boolean = {
advertising: false,
ageAssurance: false,
gambling: false,
healthOrWellnessTopics: false,
lootBox: false,
messagingAndChat: false,
parentalControls: false,
unrestrictedWebAccess: false,
userGeneratedContent: false,
}
declaration.update(attributes: graded.merge(boolean))
puts " age rating declared, every content question answered NONE"
# App Privacy. Margin has no telemetry, no accounts and no server of its own, so nothing is
# collected. The Google Drive backup is the one thing that sends bytes anywhere, and it sends them
# to the account of the person who turned it on, which is not the developer collecting anything.
usages = Spaceship::ConnectAPI::AppDataUsage.all(
app_id: app.id, includes: "category,grouping,purpose,dataProtection"
)
if usages.any?(&:is_not_collected?)
puts " privacy already declared as data not collected"
else
Spaceship::ConnectAPI::AppDataUsage.create(app_id: app.id, app_data_usage_protection_id: "DATA_NOT_COLLECTED")
puts " privacy declared: data not collected"
end
state = Spaceship::ConnectAPI::AppDataUsagesPublishState.get(app_id: app.id)
if state.published
puts " privacy declaration already published"
else
state.publish!
puts " privacy declaration published"
end
# Free, everywhere. Territory availability is left alone: the default is all of them.
app.update(attributes: { pricing: [] }) if ENV["SET_PRICING"]
puts " pricing left as configured (the app is free, which is the default for a new record)"
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env ruby
# Push the listing copy in appstore/metadata to App Store Connect.
#
# The copy lives in text files rather than in here so that changing a description is a diff someone
# can read, and so the store listing is reviewable in the same place as the code it describes.
#
# [email protected] ruby scripts/appstore-listing.rb
#
# Run it yourself: the Apple ID login prompts for a two-factor code the first time each month.
begin
require "spaceship"
rescue LoadError
libexec = Dir["/opt/homebrew/Cellar/fastlane/*/libexec", "/usr/local/Cellar/fastlane/*/libexec"].max
abort "spaceship is not installed. `brew install fastlane` and rerun." unless libexec
ENV["GEM_PATH"] = [libexec, ENV["GEM_PATH"]].compact.join(":")
Gem.clear_paths
require "spaceship"
end
require "json"
DIR = ENV.fetch("METADATA_DIR", "appstore/metadata")
LOCALE = "en-US"
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
# This Apple ID can see more than one App Store Connect team, and the wrong one belongs to someone
# else entirely. Pin it rather than letting spaceship pick.
ITC_TEAM_ID = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371")
ENV["FASTLANE_ITC_TEAM_ID"] = ITC_TEAM_ID
# Apple rejects an over-length field with a validation error that does not name the limit, so the
# check belongs here where the number is visible.
LIMITS = {
"name" => 30, "subtitle" => 30, "keywords" => 100,
"promotional_text" => 170, "description" => 4000,
}.freeze
def field(name, localized: true)
path = localized ? File.join(DIR, LOCALE, "#{name}.txt") : File.join(DIR, "#{name}.txt")
return nil unless File.exist?(path)
value = File.read(path).strip
limit = LIMITS[name]
abort "#{name} is #{value.length} characters, over Apple's limit of #{limit}." if limit && value.length > limit
value
end
Spaceship::ConnectAPI.login(ENV["APPLE_EMAIL"], nil, use_portal: false, use_tunes: true)
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
abort "No app on team #{ITC_TEAM_ID} for #{BUNDLE_ID}. Create it with produce first." unless app
puts "#{app.name} (#{app.id})"
info = app.fetch_edit_app_info
abort "No editable app info; the listing may already be in review." unless info
localization = info.get_app_info_localizations.find { |l| l.locale == LOCALE }
localization ||= info.create_app_info_localization(attributes: { locale: LOCALE })
# The name is the one field a person is likely to change by hand in App Store Connect, and losing
# somebody's naming decision to a stale text file is not a good trade. So divergence is reported
# and the live value kept, rather than overwritten.
attributes = { subtitle: field("subtitle"), privacyPolicyUrl: field("privacy_url") }
wanted_name = field("name")
if wanted_name && localization.name && wanted_name != localization.name
puts " keeping the name set in App Store Connect (#{localization.name.inspect});"
puts " #{DIR}/#{LOCALE}/name.txt says #{wanted_name.inspect}. Update the file to match, or pass"
puts " FORCE_NAME=1 to make the file win."
attributes[:name] = wanted_name if ENV["FORCE_NAME"]
else
attributes[:name] = wanted_name
end
localization.update(attributes: attributes)
puts " subtitle and privacy policy set"
category = field("primary_category", localized: false)
if category
info.update_categories(category_id_map: { primary_category_id: category })
puts " primary category set to #{category}"
end
version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS)
abort "No editable macOS version to write to." unless version
# The store version has to match the CFBundleShortVersionString of the build that will be uploaded
# against it, and that comes from tauri.conf.json like every other version in the repo. Left alone,
# a record created by `produce` sits at 1.0 and rejects the first build with a version mismatch.
app_version = JSON.parse(File.read("src-tauri/tauri.conf.json"))["version"]
if version.version_string != app_version
version.update(attributes: { versionString: app_version })
puts " version corrected from #{version.version_string} to #{app_version}"
version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS)
end
version_localization = version.get_app_store_version_localizations.find { |l| l.locale == LOCALE }
version_localization ||= version.create_app_store_version_localization(attributes: { locale: LOCALE })
attributes = {
description: field("description"),
keywords: field("keywords"),
promotionalText: field("promotional_text"),
supportUrl: field("support_url"),
marketingUrl: field("marketing_url"),
}
# Release notes describe what changed since the last release, so Apple refuses them on a first
# version and there is nothing truthful to put there anyway.
if app.get_live_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS)
attributes[:whatsNew] = field("release_notes")
else
puts " skipping release notes: nothing has shipped yet for them to be relative to"
end
version_localization.update(attributes: attributes)
puts " description, keywords and links set on version #{version.version_string}"
copyright = field("copyright", localized: false)
version.update(attributes: { copyright: copyright }) if copyright
puts " copyright set to #{copyright}" if copyright
puts
puts "Screenshots are not set here. They are required to submit for review, but not to upload a"
puts "build or to run either tier of TestFlight."
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env ruby
# Push the App Review Information for the store submission: who to contact, and the notes that
# explain anything a reviewer would otherwise have to guess at.
#
# [email protected] ruby scripts/appstore-review-detail.rb
#
# This is a different record from the TestFlight one that testflight-setup.rb writes. Beta review
# and store review do not share notes, so an explanation that only went to TestFlight is invisible
# to the reviewer looking at the store submission, and to the automated entitlement check that runs
# before a human sees it at all.
begin
require "spaceship"
rescue LoadError
libexec = Dir["/opt/homebrew/Cellar/fastlane/*/libexec", "/usr/local/Cellar/fastlane/*/libexec"].max
abort "spaceship is not installed. `brew install fastlane` and rerun." unless libexec
ENV["GEM_PATH"] = [libexec, ENV["GEM_PATH"]].compact.join(":")
Gem.clear_paths
require "spaceship"
end
DIR = ENV.fetch("METADATA_DIR", "appstore/metadata")
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371")
NOTES_LIMIT = 4000
def field(name)
path = File.join(DIR, "#{name}.txt")
return nil unless File.exist?(path)
File.read(path).strip
end
notes = field("review_notes")
abort "#{DIR}/review_notes.txt is missing." unless notes
abort "review_notes is #{notes.length} characters, over Apple's limit of #{NOTES_LIMIT}." if notes.length > NOTES_LIMIT
phone = field("review_phone")
abort "#{DIR}/review_phone.txt is missing and Apple requires a contact number." unless phone
Spaceship::ConnectAPI.login(ENV["APPLE_EMAIL"], nil, use_portal: false, use_tunes: true)
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
abort "No app for #{BUNDLE_ID}." unless app
puts "#{app.name} (#{app.id})"
version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS)
abort "No editable macOS version; the submission may already be in review." unless version
puts " version #{version.version_string} (#{version.app_store_state})"
attributes = {
contactFirstName: field("review_first_name"),
contactLastName: field("review_last_name"),
contactEmail: field("review_email"),
contactPhone: phone,
# Margin has no accounts at all, so there is nothing for a reviewer to sign in to. Saying so
# explicitly is what stops the review coming back asking for credentials.
demoAccountRequired: false,
notes: notes,
}
detail = version.fetch_app_store_review_detail
if detail
Spaceship::ConnectAPI.patch_app_store_review_detail(
app_store_review_detail_id: detail.id,
attributes: attributes,
)
else
Spaceship::ConnectAPI.post_app_store_review_detail(
app_store_version_id: version.id,
attributes: attributes,
)
end
# Apple accepts a patch it then stores as something else often enough to be worth reading back, and
# an empty notes field is exactly the state that got this submission rejected in the first place.
written = version.fetch_app_store_review_detail&.notes.to_s
if written.empty?
abort " the notes field came back empty; nothing was saved."
elsif written != notes
puts " saved, but what came back differs from what was sent. Check it in App Store Connect."
else
puts " contact and #{notes.length} characters of review notes saved"
end
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env ruby
# Upload the rendered frames in appstore/screenshots to the App Store listing.
#
# [email protected] ruby scripts/appstore-screenshots.rb
#
# The frames are rendered by the HTML under appstore/screenshots/src, so this only ever moves
# finished PNGs. Rerunning replaces what is there rather than appending, because a listing that
# quietly accumulated ten frames across five runs would be worse than one that is simply current.
begin
require "spaceship"
rescue LoadError
libexec = Dir["/opt/homebrew/Cellar/fastlane/*/libexec", "/usr/local/Cellar/fastlane/*/libexec"].max
abort "spaceship is not installed. `brew install fastlane` and rerun." unless libexec
ENV["GEM_PATH"] = [libexec, ENV["GEM_PATH"]].compact.join(":")
Gem.clear_paths
require "spaceship"
end
require "shellwords"
DIR = ENV.fetch("SCREENSHOT_DIR", "appstore/screenshots")
LOCALE = "en-US"
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371")
# The only size App Store Connect takes for a Mac app, out of the four it documents, that this
# pipeline renders. Anything else is a mistake worth stopping for.
EXPECTED = [2560, 1600].freeze
frames = Dir[File.join(DIR, "frame-*.png")].sort
abort "No frames in #{DIR}." if frames.empty?
frames.each do |path|
dimensions = `sips -g pixelWidth -g pixelHeight #{path.shellescape} 2>/dev/null`
.scan(/pixel(?:Width|Height):\s*(\d+)/).flatten.map(&:to_i)
next if dimensions == EXPECTED
abort "#{path} is #{dimensions.join('x')}, and Apple wants #{EXPECTED.join('x')}."
end
puts "#{frames.size} frames, all #{EXPECTED.join('x')}"
Spaceship::ConnectAPI.login(ENV["APPLE_EMAIL"], nil, use_portal: false, use_tunes: true)
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
abort "No app for #{BUNDLE_ID}." unless app
puts "#{app.name} (#{app.id})"
version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS)
abort "No editable macOS version." unless version
localization = version.get_app_store_version_localizations.find { |l| l.locale == LOCALE }
abort "No #{LOCALE} localization; run appstore-listing.rb first." unless localization
display_type = Spaceship::ConnectAPI::AppScreenshotSet::DisplayType::APP_DESKTOP
set = localization.get_app_screenshot_sets.find { |s| s.screenshot_display_type == display_type }
if set
set.app_screenshots.each(&:delete!)
puts " cleared #{set.app_screenshots.size} existing screenshots"
else
set = localization.create_app_screenshot_set(attributes: { screenshotDisplayType: display_type })
puts " created the desktop screenshot set"
end
frames.each_with_index do |path, index|
set.upload_screenshot(path: path, position: index)
puts " uploaded #{File.basename(path)}"
end
puts
puts "Apple processes each image before it counts as attached; give it a minute before checking."
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Turn the .app that `tauri build` produced into a signed .pkg the App Store will accept, and
# optionally hand it to App Store Connect.
#
# Tauri has no App Store target, so everything after the bundle is done here: the provisioning
# profile goes in before signing (codesign hashes it), the entitlements carry the team identifier,
# and productbuild wraps the result. Tauri's own signing is deliberately not used, because it
# cannot embed a profile and would sign the app before the profile was in place.
set -euo pipefail
# CI builds universal; a local check against a single-arch build only needs to override this.
app="${APP_PATH:-src-tauri/target/universal-apple-darwin/release/bundle/macos/Margin.app}"
pkg="${PKG_PATH:-target-mas/Margin.pkg}"
: "${APPLE_TEAM_ID:?set APPLE_TEAM_ID to the 10-character team identifier}"
: "${MAS_PROVISION_PROFILE:?set MAS_PROVISION_PROFILE to the .provisionprofile path}"
: "${MAS_APP_IDENTITY:?set MAS_APP_IDENTITY, e.g. '3rd Party Mac Developer Application: Priyanshu Jain (TEAMID)'}"
: "${MAS_INSTALLER_IDENTITY:?set MAS_INSTALLER_IDENTITY, e.g. '3rd Party Mac Developer Installer: Priyanshu Jain (TEAMID)'}"
[ -d "$app" ] || { echo "mas-package: $app does not exist; run the build first." >&2; exit 1; }
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
mkdir -p "$(dirname "$pkg")"
# App Store Connect rejects an upload whose CFBundleVersion it has already seen, so a rejected
# build has to come back with a higher one. The marketing version stays put.
if [ -n "${MAS_BUILD_NUMBER:-}" ]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $MAS_BUILD_NUMBER" "$app/Contents/Info.plist"
fi
cp "$MAS_PROVISION_PROFILE" "$app/Contents/embedded.provisionprofile"
# The profile is kept owner-only where it lives, because it sits next to signing keys, and cp
# carries that mode across. Apple rejects a package containing anything a non-root user cannot
# read, since the code signature could not then be verified at launch. Widen everything rather
# than just the profile, and only ever add permission bits, never remove one.
find "$app" -type d -exec chmod go+rx {} +
find "$app" -type f -exec chmod go+r {} +
sed "s/__TEAM_ID__/$APPLE_TEAM_ID/g" src-tauri/entitlements.mas.plist > "$work/entitlements.plist"
plutil -lint "$work/entitlements.plist" > /dev/null
# Nested code has to be signed before the bundle that contains it, and --deep is the wrong tool
# for signing (it applies the outer entitlements to everything inside). Tauri bundles carry no
# frameworks today, so this loop is usually empty, and it stays here so that stops being silent
# the day one appears.
while IFS= read -r -d '' nested; do
codesign --force --timestamp --options runtime --sign "$MAS_APP_IDENTITY" "$nested"
done < <(find "$app/Contents/Frameworks" "$app/Contents/XPCServices" -maxdepth 1 -mindepth 1 -print0 2>/dev/null)
codesign --force --timestamp --options runtime \
--sign "$MAS_APP_IDENTITY" \
--entitlements "$work/entitlements.plist" \
"$app"
codesign --verify --deep --strict --verbose=2 "$app"
echo "Entitlements on the signed bundle:"
codesign --display --entitlements - --xml "$app" | plutil -convert xml1 -o - -
productbuild --component "$app" /Applications --sign "$MAS_INSTALLER_IDENTITY" "$pkg"
pkgutil --check-signature "$pkg"
echo "mas-package: wrote $pkg"
# The upload needs the .p8 where altool looks for it; the caller places it and sets these.
# altool exits 0 even when it has just printed UPLOAD FAILED, so its exit status cannot be
# trusted and the transcript is the only reliable signal.
run_altool() {
local action="$1" output
output=$(xcrun altool "$action" -f "$pkg" -t macos \
--apiKey "$APPLE_API_KEY_ID" --apiIssuer "$APPLE_API_ISSUER" 2>&1) || true
printf '%s\n' "$output"
if printf '%s' "$output" | grep -qE "VERIFY FAILED|UPLOAD FAILED|ERROR:"; then
echo "mas-package: $action failed, see the errors above." >&2
return 1
fi
}
if [ -n "${MAS_UPLOAD:-}" ]; then
: "${APPLE_API_KEY_ID:?}" "${APPLE_API_ISSUER:?}"
run_altool --validate-app
run_altool --upload-app
echo "mas-package: uploaded to App Store Connect; the build appears once processing finishes."
fi
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Sign, package and upload an App Store build from this machine, using the certificates in
# ~/.margin-signing rather than the ones in CI.
#
# The certificates go into a keychain that exists only for the length of this run, and the login
# keychain is never written to. That keeps a machine that has signed once from quietly being able
# to sign forever, and it means this leaves nothing behind to go stale.
#
# ./scripts/mas-upload-local.sh # build, sign, package, upload
# MAS_UPLOAD= ./scripts/mas-upload-local.sh # stop after the .pkg
set -euo pipefail
cd "$(dirname "$0")/.."
DIR="${MARGIN_SIGNING_DIR:-$HOME/.margin-signing}"
BUNDLE_ID="${BUNDLE_ID:-studio.margin.app}"
KEYCHAIN="$(mktemp -d)/margin-mas.keychain-db"
for f in "$BUNDLE_ID.env" "$BUNDLE_ID.provisionprofile" apple-distribution.p12 mac-installer.p12; do
[ -f "$DIR/$f" ] || { echo "mas-upload-local: $DIR/$f is missing; run apple-provision.rb first." >&2; exit 1; }
done
# shellcheck source=/dev/null
set -a; . "$DIR/$BUNDLE_ID.env"; set +a
export MAS_PROVISION_PROFILE="$DIR/$BUNDLE_ID.provisionprofile"
# App Store Connect refuses an upload whose build number it has already seen, and seconds since the
# epoch is both unique and monotonic without needing anything to be remembered between runs.
export MAS_BUILD_NUMBER="${MAS_BUILD_NUMBER:-$(date +%s)}"
export MAS_UPLOAD="${MAS_UPLOAD-1}"
if [ -n "${MAS_UPLOAD:-}" ]; then
# shellcheck source=/dev/null
set -a; . "$DIR/AuthKey.env"; set +a
mkdir -p ~/private_keys
cp "$DIR/AuthKey.p8" ~/private_keys/"AuthKey_$APPLE_API_KEY_ID.p8"
chmod 600 ~/private_keys/"AuthKey_$APPLE_API_KEY_ID.p8"
fi
original_keychains=$(security list-keychains -d user | sed 's/^ *"//;s/"$//')
restore() {
# shellcheck disable=SC2086
security list-keychains -d user -s $original_keychains
security delete-keychain "$KEYCHAIN" 2>/dev/null || true
}
trap restore EXIT
security create-keychain -p margin "$KEYCHAIN"
security unlock-keychain -p margin "$KEYCHAIN"
for cert in apple-distribution mac-installer; do
security import "$DIR/$cert.p12" -k "$KEYCHAIN" -P "$(cat "$DIR/$cert.p12.pass")" \
-T /usr/bin/codesign -T /usr/bin/productbuild > /dev/null
done
# Without this, codesign stops on a keychain prompt rather than signing.
security set-key-partition-list -S apple-tool:,apple: -k margin "$KEYCHAIN" > /dev/null
# shellcheck disable=SC2086
security list-keychains -d user -s "$KEYCHAIN" $original_keychains
if [ -z "${SKIP_BUILD:-}" ]; then
pnpm tauri build --target universal-apple-darwin \
--config src-tauri/tauri.appstore.conf.json --bundles app
fi
./scripts/mas-package.sh
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env ruby
# Put the most recently uploaded build in front of testers.
#
# Apple takes somewhere between a few minutes and an hour to process an upload, and nothing can be
# assigned until it has. So this waits rather than failing, and says what it is waiting for.
#
# [email protected] ruby scripts/testflight-release.rb
#
# Internal testers get the build as soon as it is assigned. External testers are gated on Beta App
# Review, which this submits for and which usually comes back within a day.
begin
require "spaceship"
rescue LoadError
libexec = Dir["/opt/homebrew/Cellar/fastlane/*/libexec", "/usr/local/Cellar/fastlane/*/libexec"].max
abort "spaceship is not installed. `brew install fastlane` and rerun." unless libexec
ENV["GEM_PATH"] = [libexec, ENV["GEM_PATH"]].compact.join(":")
Gem.clear_paths
require "spaceship"
end
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371")
WAIT_SECONDS = Integer(ENV.fetch("WAIT_SECONDS", "1800"))
Spaceship::ConnectAPI.login(ENV["APPLE_EMAIL"], nil, use_portal: false, use_tunes: true)
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
abort "No app for #{BUNDLE_ID}." unless app
puts "#{app.name} (#{app.id})"
deadline = Time.now + WAIT_SECONDS
build = nil
loop do
builds = Spaceship::ConnectAPI::Build.all(app_id: app.id, sort: "-uploadedDate", limit: 5)
ready = builds.reject(&:expired).find { |b| b.processing_state == "VALID" }
if ready
build = ready
break
end
pending = builds.find { |b| b.processing_state == "PROCESSING" }
if Time.now > deadline
abort "Timed out after #{WAIT_SECONDS}s. #{pending ? 'The build is still processing.' : 'No build has appeared yet.'}"
end
puts(pending ? " waiting: build #{pending.version} is still processing" : " waiting: no build has appeared yet")
sleep(30)
end
puts " build #{build.app_version} (#{build.version}) is ready"
groups = app.get_beta_groups
# A group created with hasAccessToAllBuilds receives every build the moment it processes, and Apple
# rejects an explicit assignment to one rather than treating it as a no-op. The internal group is
# exactly that, so there is nothing to do for it and nothing to report either.
assignable = groups.reject { |g| g.is_internal_group || g.has_access_to_all_builds }
already = (build.get_beta_groups.map(&:id) rescue [])
to_add = assignable.reject { |g| already.include?(g.id) }
if to_add.empty?
puts " nothing to assign: #{groups.map(&:name).join(', ')} already have this build"
else
build.add_beta_groups(beta_groups: to_add)
puts " assigned to #{to_add.map(&:name).join(', ')}"
end
# Only external groups are gated on review, so an app with internal testers only never needs this.
if groups.any? { |g| !g.is_internal_group }
begin
Spaceship::ConnectAPI.post_beta_app_review_submissions(build_id: build.id)
puts " submitted for beta app review, which gates the external testers"
rescue => e
# Resubmitting an already submitted build is not an error worth failing the run over.
puts " beta app review not submitted: #{e.message.lines.first.to_s.strip}"
end
end
# The store version needs the build attached too, and that is a separate thing from TestFlight.
# Until it is, App Store Connect shows the listing with no app icon, because for a Mac app the icon
# is read out of the attached build rather than uploaded alongside the other artwork.
version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS)
if version.nil?
puts " no editable store version to attach the build to"
elsif version.build&.id == build.id
puts " already attached to store version #{version.version_string}"
else
version.select_build(build_id: build.id)
puts " attached to store version #{version.version_string}, which is what surfaces the app icon"
end
puts
puts "Internal testers can install now. External testers wait on beta app review."
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env ruby
# Set up TestFlight for the app: the tester-facing blurb, the details Beta App Review asks for, and
# the two groups. None of this needs a build to exist, so it can all be in place before the first
# upload and the build then only has to be assigned to a group.
#
# [email protected] ruby scripts/testflight-setup.rb
begin
require "spaceship"
rescue LoadError
libexec = Dir["/opt/homebrew/Cellar/fastlane/*/libexec", "/usr/local/Cellar/fastlane/*/libexec"].max
abort "spaceship is not installed. `brew install fastlane` and rerun." unless libexec
ENV["GEM_PATH"] = [libexec, ENV["GEM_PATH"]].compact.join(":")
Gem.clear_paths
require "spaceship"
end
DIR = ENV.fetch("METADATA_DIR", "appstore/metadata")
LOCALE = "en-US"
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371")
def field(name, localized: true)
path = localized ? File.join(DIR, LOCALE, "#{name}.txt") : File.join(DIR, "#{name}.txt")
File.exist?(path) ? File.read(path).strip : nil
end
Spaceship::ConnectAPI.login(ENV["APPLE_EMAIL"], nil, use_portal: false, use_tunes: true)
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
abort "No app for #{BUNDLE_ID}." unless app
puts "#{app.name} (#{app.id})"
localization = app.get_beta_app_localizations.find { |l| l.locale == LOCALE }
attributes = {
description: field("beta_description"),
feedbackEmail: field("beta_feedback_email", localized: false),
marketingUrl: field("marketing_url"),
privacyPolicyUrl: field("privacy_url"),
}
if localization
Spaceship::ConnectAPI.patch_beta_app_localizations(localization_id: localization.id, attributes: attributes)
else
Spaceship::ConnectAPI.post_beta_app_localizations(app_id: app.id, attributes: attributes.merge(locale: LOCALE))
end
puts " tester blurb and feedback address set"
# Apple requires a contact phone number here, and this only gates external testing: internal
# testers never go through Beta App Review. So a missing number is a warning, not a failure.
if field("review_phone", localized: false)
Spaceship::ConnectAPI.patch_beta_app_review_detail(app_id: app.id, attributes: {
contactFirstName: field("review_first_name", localized: false),
contactLastName: field("review_last_name", localized: false),
contactEmail: field("review_email", localized: false),
contactPhone: field("review_phone", localized: false),
# Margin has no accounts at all, so there is nothing for a reviewer to sign in to. Saying so
# explicitly is what stops the review coming back asking for credentials.
demoAccountRequired: false,
notes: field("review_notes", localized: false),
})
puts " beta app review contact and notes set"
else
puts " skipping beta app review details: #{DIR}/review_phone.txt is missing and Apple requires"
puts " a contact number. Internal testing works without it; external testing does not."
end
existing = app.get_beta_groups.map(&:name)
[
{ name: "Internal", internal: true, public_link: false },
{ name: "Public Beta", internal: false, public_link: true },
].each do |group|
if existing.include?(group[:name])
puts " group #{group[:name].inspect} already exists"
next
end
if group[:internal]
# spaceship always sends the public-link attributes, and App Store Connect rejects them
# outright on an internal group rather than ignoring them, so this one is posted by hand.
body = {
data: {
attributes: { name: group[:name], isInternalGroup: true, hasAccessToAllBuilds: true },
relationships: { app: { data: { id: app.id, type: "apps" } } },
type: "betaGroups",
},
}
Spaceship::ConnectAPI.client.test_flight_request_client.post("v1/betaGroups", body)
puts " created internal group #{group[:name].inspect}"
else
created = app.create_beta_group(
group_name: group[:name],
is_internal_group: false,
public_link_enabled: true,
public_link_limit_enabled: true,
)
puts " created external group #{created.name.inspect}"
end
end
puts
app.get_beta_groups.each do |g|
kind = g.is_internal_group ? "internal" : "external"
puts " #{g.name} (#{kind})#{g.public_link ? " #{g.public_link}" : ''}"
end
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env ruby
# Invite people to a TestFlight group.
#
# [email protected] ruby scripts/testflight-testers.rb [email protected] ...
#
# Addresses come from the command line rather than a file in the repo, because a list of testers is
# a list of people's email addresses and this repository is public.
#
# GROUP defaults to the external group, since the internal one only accepts people who already have
# an App Store Connect account on the team, which is a far bigger thing to hand out than a build.
begin
require "spaceship"
rescue LoadError
libexec = Dir["/opt/homebrew/Cellar/fastlane/*/libexec", "/usr/local/Cellar/fastlane/*/libexec"].max
abort "spaceship is not installed. `brew install fastlane` and rerun." unless libexec
ENV["GEM_PATH"] = [libexec, ENV["GEM_PATH"]].compact.join(":")
Gem.clear_paths
require "spaceship"
end
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
GROUP = ENV.fetch("GROUP", "Public Beta")
ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371")
emails = ARGV.reject { |a| a.start_with?("-") }
abort "Usage: ruby scripts/testflight-testers.rb [email protected] [[email protected] ...]" if emails.empty?
Spaceship::ConnectAPI.login(ENV["APPLE_EMAIL"], nil, use_portal: false, use_tunes: true)
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
abort "No app for #{BUNDLE_ID}." unless app
group = app.get_beta_groups.find { |g| g.name == GROUP }
abort "No group named #{GROUP.inspect}." unless group
puts "#{app.name}: #{group.name} (#{group.is_internal_group ? 'internal' : 'external'})"
# An internal tester has to be a user on the App Store Connect account first, which is a far larger
# grant than a build: it is access to the account, not to an app. So the invitation is narrowed as
# far as the API allows, to this one app and with provisioning refused, and the person still has to
# accept it before they can be put in the group.
if group.is_internal_group
known = Spaceship::ConnectAPI::User.all.map { |u| u.email.to_s.downcase }
invited = Spaceship::ConnectAPI::UserInvitation.all.map { |i| i.email.to_s.downcase }
emails.each do |email|
next if known.include?(email.downcase)
if invited.include?(email.downcase)
puts " #{email} has an invitation waiting to be accepted"
next
end
local = email.split("@").first
Spaceship::ConnectAPI::UserInvitation.create(
email: email,
first_name: ENV.fetch("FIRST_NAME", local),
last_name: ENV.fetch("LAST_NAME", "Tester"),
roles: [Spaceship::ConnectAPI::User::UserRole::DEVELOPER],
provisioning_allowed: false,
all_apps_visible: false,
visible_app_ids: [app.id],
)
puts " invited #{email} to App Store Connect, limited to this app, no provisioning access"
invited << email.downcase
end
end
existing = Spaceship::ConnectAPI::BetaTester
.all(filter: { betaGroups: group.id })
.map { |t| t.email.to_s.downcase }
emails.each do |email|
if existing.include?(email.downcase)
puts " #{email} is already in this group"
next
end
Spaceship::ConnectAPI.post_bulk_beta_tester_assignments(
beta_group_id: group.id,
beta_testers: [{ email: email }],
)
# The bulk endpoint reports success for an address it then quietly declines to add, which is what
# happens on an internal group when the person has not accepted their account invitation yet. So
# the group is read back rather than trusted.
landed = Spaceship::ConnectAPI::BetaTester
.all(filter: { betaGroups: group.id })
.any? { |t| t.email.to_s.casecmp?(email) }
if landed
puts " added #{email}"
else
puts " #{email} was not added. An internal tester has to accept the App Store Connect"
puts " invitation first; rerun this once they have."
end
end
puts
if group.is_internal_group
puts "Internal testers can install as soon as a build finishes processing."
else
puts "External testers get the invitation once beta app review passes."
puts "Anyone can also join through #{group.public_link}" if group.public_link
end
+15
View File
@@ -0,0 +1,15 @@
# margin-shared
The faces, palette and glyphs [Margin](../) and Margin Docs both draw from. It is the one copy of
the decisions the two apps have to agree on: which six fonts they offer, what their named pairings
are, the 35 design tokens they share, and the title bar icon paths.
No build step and no dependencies. Both apps resolve the TypeScript source directly through their
bundler, so an edit here is live in both on the next dev server restart.
Margin consumes it as `"margin-shared": "file:./shared"`. Margin Docs, which lives in a separate
repository, uses a relative path to this directory; see its `package.json`.
The font binaries in `fonts/` are the source of truth, and each app keeps a vendored copy under
`public/fonts` because its Rust PDF exporter reads them with `include_bytes!` before any npm install
has run. Run `pnpm fonts:sync` in an app to refresh that copy and `pnpm fonts:check` to verify it.
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env node
// Copies this package's font files into an app's public/fonts, or checks that they already match.
//
// The copies exist because both apps' `src-tauri/src/pdf.rs` reads the same files with
// `include_bytes!`, so the bytes have to be on disk at a path cargo can see before any npm install
// has run. Serving them out of node_modules would mean a cargo build that fails on a fresh clone
// until the front end was installed, which is a worse trade than a vendored copy with a named
// upstream and a check that fails loudly when the two drift.
//
// node bin/sync-fonts.mjs <app-dir> copy, reporting what changed
// node bin/sync-fonts.mjs <app-dir> --check compare only, exit 1 on any difference
import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const source = resolve(here, "..", "fonts");
const args = process.argv.slice(2);
const check = args.includes("--check");
const target = args.find((a) => !a.startsWith("--"));
if (!target) {
console.error("usage: sync-fonts.mjs <app-dir> [--check]");
process.exit(2);
}
const dest = resolve(process.cwd(), target, "public", "fonts");
const files = readdirSync(source).filter((f) => f.endsWith(".ttf") || f.endsWith(".txt")).sort();
if (!check) mkdirSync(dest, { recursive: true });
const wrong = [];
let copied = 0;
for (const file of files) {
const from = join(source, file);
const to = join(dest, file);
const want = readFileSync(from);
const have = existsSync(to) ? readFileSync(to) : null;
if (have !== null && have.equals(want)) continue;
if (check) {
wrong.push(`${have === null ? "missing" : "differs"}: ${file}`);
continue;
}
writeFileSync(to, want);
copied += 1;
}
// A file the app has and the package does not is reported rather than deleted. It is far more
// likely to be a face somebody is in the middle of adding than something to throw away, and this
// script does not get to be the reason an asset disappears.
if (existsSync(dest)) {
for (const file of readdirSync(dest)) {
if (!files.includes(file)) wrong.push(`not in the package, left alone: ${file}`);
}
}
if (check) {
if (wrong.length === 0) {
console.log(`fonts match margin-shared (${files.length} files)`);
process.exit(0);
}
console.error("public/fonts is out of step with margin-shared:");
for (const line of wrong) console.error(` ${line}`);
console.error("\nrun: pnpm fonts:sync");
process.exit(1);
}
console.log(copied === 0 ? `fonts already current (${files.length} files)` : `synced ${copied} file(s)`);
for (const line of wrong) console.log(` note: ${line}`);
+111
View File
@@ -0,0 +1,111 @@
/* The six families src/fonts.ts offers, bound to the variable files in fonts/.
*
* The URLs are absolute site paths, so each app serves these out of its own `public/fonts`. The
* copies there are vendored from this package and kept honest by `bin/sync-fonts.mjs`; they are not
* imported from node_modules, because both apps' `src-tauri/src/pdf.rs` reads the same files with
* `include_bytes!` and a cargo build must not depend on an npm install having run first.
*
* Every one is a variable file with an upright and an italic cut, so a weight anywhere in the
* declared range is a real instance rather than a synthesised one, and italic is the designer's
* italic rather than a slant applied by the browser.
*
* `font-display: block` throughout, and that is the unusual choice. The alternative is a first
* paint in Georgia that reflows into Literata a moment later, and a page of prose that resets its
* line breaks under the caret is worse than a page that arrives a beat late. The files are local to
* the bundle, so the beat is a disk read. */
@font-face {
font-family: "Hanken Grotesk";
src: url("/fonts/HankenGrotesk-VF.ttf") format("truetype-variations");
font-weight: 100 900;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "Hanken Grotesk";
src: url("/fonts/HankenGrotesk-Italic-VF.ttf") format("truetype-variations");
font-weight: 100 900;
font-style: italic;
font-display: block;
}
@font-face {
font-family: "Literata";
src: url("/fonts/Literata-VF.ttf") format("truetype-variations");
font-weight: 200 900;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "Literata";
src: url("/fonts/Literata-Italic-VF.ttf") format("truetype-variations");
font-weight: 200 900;
font-style: italic;
font-display: block;
}
@font-face {
font-family: "EB Garamond";
src: url("/fonts/EBGaramond-VF.ttf") format("truetype-variations");
font-weight: 400 800;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "EB Garamond";
src: url("/fonts/EBGaramond-Italic-VF.ttf") format("truetype-variations");
font-weight: 400 800;
font-style: italic;
font-display: block;
}
@font-face {
font-family: "Lora";
src: url("/fonts/Lora-VF.ttf") format("truetype-variations");
font-weight: 400 700;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "Lora";
src: url("/fonts/Lora-Italic-VF.ttf") format("truetype-variations");
font-weight: 400 700;
font-style: italic;
font-display: block;
}
@font-face {
font-family: "Source Serif 4";
src: url("/fonts/SourceSerif4-VF.ttf") format("truetype-variations");
font-weight: 200 900;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "Source Serif 4";
src: url("/fonts/SourceSerif4-Italic-VF.ttf") format("truetype-variations");
font-weight: 200 900;
font-style: italic;
font-display: block;
}
@font-face {
font-family: "Fraunces";
src: url("/fonts/Fraunces-VF.ttf") format("truetype-variations");
font-weight: 100 900;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "Fraunces";
src: url("/fonts/Fraunces-Italic-VF.ttf") format("truetype-variations");
font-weight: 100 900;
font-style: italic;
font-display: block;
}
+91
View File
@@ -0,0 +1,91 @@
/* The palette, the type scale and the shape constants both apps are built from.
*
* These 35 tokens already held identical values in the two repos before this package existed, which
* is the argument for moving them: they were identical by somebody remembering to copy a hex across,
* and that is not a mechanism. A colour that only exists in one app is not here. Each app layers its
* own sheet on top of this one and adds what only it needs, which is a preview pane width in Margin
* and a document typography scale in Margin Docs.
*
* --font-book and --font-heading are the two a document overrides at runtime, per book or per
* document. What is set here is the default pair, and the only place that decides what an untouched
* page looks like. */
:root {
--font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-book: "Literata", Georgia, "Times New Roman", serif;
--font-heading: "Literata", Georgia, "Times New Roman", serif;
--r-sm: 5px;
--r-md: 8px;
--r-lg: 12px;
--pane-sidebar: 248px;
--measure: 46em;
--titlebar-h: 46px;
--t-1: 11px;
--t-2: 12px;
--t-3: 13px;
--t-4: 15px;
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
}
:root,
:root[data-theme="light"] {
--paper: #fcfbf7;
--shell: #f1ece2;
--sidebar: #ece6da;
--raised: #fbfaf6;
--ink: #23201b;
--ink-soft: #6b6458;
--ink-faint: #9b9484;
--line: #e3ddce;
--line-strong: #d6cfbd;
--accent: #2a2622;
--accent-ink: #100e0b;
--accent-wash: rgba(35, 32, 27, 0.08);
--accent-contrast: #faf7f0;
--selection: rgba(35, 32, 27, 0.14);
--glass: rgba(252, 251, 247, 0.86);
--danger: #b4453a;
--danger-ink: #963327;
--danger-wash: rgba(180, 69, 58, 0.1);
--danger-contrast: #faf7f0;
--shadow-page: 0 1px 2px rgba(35, 32, 27, 0.06), 0 14px 30px rgba(35, 32, 27, 0.09);
--shadow-pop: 0 8px 24px rgba(35, 32, 27, 0.14);
}
:root[data-theme="dark"] {
--paper: #1d1a16;
--shell: #151310;
--sidebar: #1a1713;
--raised: #232019;
--ink: #ece6da;
--ink-soft: #a89f8e;
--ink-faint: #756d5e;
--line: #2c2823;
--line-strong: #3a352d;
--accent: #efe9dc;
--accent-ink: #ffffff;
--accent-wash: rgba(239, 233, 220, 0.12);
--accent-contrast: #1a1714;
--selection: rgba(239, 233, 220, 0.18);
--glass: rgba(33, 30, 25, 0.88);
--danger: #d97a6f;
--danger-ink: #e58e84;
--danger-wash: rgba(217, 122, 111, 0.16);
--danger-contrast: #1a1714;
--shadow-page: 0 1px 2px rgba(0, 0, 0, 0.4), 0 14px 34px rgba(0, 0, 0, 0.5);
--shadow-pop: 0 8px 24px rgba(0, 0, 0, 0.55);
}
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+94
View File
@@ -0,0 +1,94 @@
Copyright 2021 The Hanken Grotesk Project Authors (https://github.com/marcologous/hanken-grotesk)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2017 The Literata Project Authors (https://github.com/googlefonts/literata)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2014 The Source Serif 4 Project Authors (https://github.com/adobe-fonts/source-serif)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
{
"name": "margin-shared",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"description": "The faces, palette and glyphs Margin and Margin Docs both draw from.",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./fonts": "./src/fonts.ts",
"./icons": "./src/icons.ts",
"./css/fonts.css": "./css/fonts.css",
"./css/tokens.css": "./css/tokens.css"
},
"bin": {
"margin-shared-fonts": "./bin/sync-fonts.mjs"
},
"files": ["src", "css", "fonts", "bin"]
}
+233
View File
@@ -0,0 +1,233 @@
// The faces both apps offer, and the two slots they set them into.
//
// This lives in one place because the two apps have to agree about it. A face named here is a
// `@font-face` in css/fonts.css, a file in fonts/, and a family a Typst preamble names on the way
// to a PDF, and those four lists going out of step with each other is a document that renders in
// one app and falls back to Georgia in the other. There is no way to keep four lists in two repos
// honest by hand, so there is one list.
//
// Two slots and not one. Body and heading are the only typographic decision worth a control:
// a document that lets its author pick a face per paragraph is a word processor, and neither of
// these is one. The scale, the leading and the measure belong to each app's own stylesheet, which
// decided them once for every document.
//
// A `FontRef` is stored, not a family name. "Literata" as a string cannot say whether it means the
// file in fonts/ or a copy the user installed themselves, and those are two different faces the
// moment one of them is updated.
export type FontCategory = "serif" | "sans" | "display";
/** One face that ships in fonts/, in the variable file both editors render from. */
export interface BundledFont {
id: string;
label: string;
/** The CSS family name, which is also what a Typst preamble names it by. */
family: string;
category: FontCategory;
regular: string;
italic: string;
/** The weight range the variable file covers, for the `@font-face` in css/fonts.css. */
weight: string;
}
/**
* The six, and the order a picker lists them in: the serifs a page of prose is set in, the one
* display face, then the sans both apps' own chrome already uses.
*
* Literata and Hanken Grotesk are first-class here and also special: they are the two that have
* static instances cut for PDF export, so they are the only pair whose bold really is bold on the
* page. The other four export as their variable file at one weight, which each app's pdf.rs
* explains.
*/
export const BUNDLED_FONTS: readonly BundledFont[] = [
{
id: "literata",
label: "Literata",
family: "Literata",
category: "serif",
regular: "Literata-VF.ttf",
italic: "Literata-Italic-VF.ttf",
weight: "200 900",
},
{
id: "eb-garamond",
label: "EB Garamond",
family: "EB Garamond",
category: "serif",
regular: "EBGaramond-VF.ttf",
italic: "EBGaramond-Italic-VF.ttf",
weight: "400 800",
},
{
id: "lora",
label: "Lora",
family: "Lora",
category: "serif",
regular: "Lora-VF.ttf",
italic: "Lora-Italic-VF.ttf",
weight: "400 700",
},
{
id: "source-serif",
label: "Source Serif 4",
family: "Source Serif 4",
category: "serif",
regular: "SourceSerif4-VF.ttf",
italic: "SourceSerif4-Italic-VF.ttf",
weight: "200 900",
},
{
id: "fraunces",
label: "Fraunces",
family: "Fraunces",
category: "display",
regular: "Fraunces-VF.ttf",
italic: "Fraunces-Italic-VF.ttf",
weight: "100 900",
},
{
id: "hanken",
label: "Hanken Grotesk",
family: "Hanken Grotesk",
category: "sans",
regular: "HankenGrotesk-VF.ttf",
italic: "HankenGrotesk-Italic-VF.ttf",
weight: "100 900",
},
];
/** One of the six, or a family off the machine. */
export type FontRef = { kind: "bundled"; id: string } | { kind: "system"; family: string };
/**
* What one book or one document is set in.
*
* Named for the pair rather than for either app's noun, because the two call the thing it belongs
* to different names. Each app aliases it: `BookFonts` in Margin, `DocumentFonts` in Margin Docs.
*/
export interface FontPair {
body: FontRef;
heading: FontRef;
}
/**
* A named pair, which is what a picker offers first.
*
* Pairing two faces is the part of this that takes an eye, and a list of twelve families with no
* opinion attached is how a document ends up in Fraunces body text. The presets are the answer to
* "make this look like something"; the two selects underneath are for somebody who already knows.
*/
export interface FontPairing {
id: string;
label: string;
body: FontRef;
heading: FontRef;
}
const bundled = (id: string): FontRef => ({ kind: "bundled", id });
export const FONT_PAIRINGS: readonly FontPairing[] = [
{ id: "quiet-press", label: "Quiet Press", body: bundled("literata"), heading: bundled("literata") },
{ id: "classic", label: "Classic", body: bundled("eb-garamond"), heading: bundled("eb-garamond") },
{ id: "editorial", label: "Editorial", body: bundled("source-serif"), heading: bundled("fraunces") },
{ id: "modern", label: "Modern", body: bundled("lora"), heading: bundled("hanken") },
{ id: "contrast", label: "Contrast", body: bundled("literata"), heading: bundled("hanken") },
{ id: "plain", label: "Plain", body: bundled("hanken"), heading: bundled("hanken") },
];
/** What a page is set in until somebody says otherwise: the css/tokens.css pair, in FontRef form. */
export const DEFAULT_FONTS: FontPair = { body: bundled("literata"), heading: bundled("literata") };
// The two tails from css/tokens.css, so a face that fails to load falls back to what the app would
// have used anyway rather than to the webview's default.
const SERIF_FALLBACK = `Georgia, "Times New Roman", serif`;
const SANS_FALLBACK = `ui-sans-serif, system-ui, -apple-system, sans-serif`;
export function bundledFont(id: string): BundledFont | undefined {
return BUNDLED_FONTS.find((f) => f.id === id);
}
/**
* A `FontRef` as one string, which is what a `<select>` value and a stored preference both need.
*
* The two-character tag is the whole point: a system family can be called "Literata" and must not
* come back as the bundled one.
*/
export function encodeRef(ref: FontRef): string {
return ref.kind === "bundled" ? `b:${ref.id}` : `s:${ref.family}`;
}
export function decodeRef(value: string): FontRef {
return value.startsWith("s:")
? { kind: "system", family: value.slice(2) }
: { kind: "bundled", id: value.slice(2) };
}
/** The bare family name, which is what a Typst preamble names a face by. */
export function fontFamilyName(ref: FontRef): string {
if (ref.kind === "system") return ref.family;
return bundledFont(ref.id)?.family ?? "Literata";
}
/** What a picker calls it. Identical to the family for a system face, which has no other name. */
export function fontLabel(ref: FontRef): string {
if (ref.kind === "system") return ref.family;
return bundledFont(ref.id)?.label ?? ref.id;
}
/** A CSS font stack, which is what goes into `--font-book` and `--font-heading`. */
export function fontStack(ref: FontRef): string {
if (ref.kind === "bundled") {
const font = bundledFont(ref.id);
if (!font) return `"Literata", ${SERIF_FALLBACK}`;
return `"${font.family}", ${font.category === "sans" ? SANS_FALLBACK : SERIF_FALLBACK}`;
}
return `"${ref.family}", ${SERIF_FALLBACK}`;
}
export function refsEqual(a: FontRef, b: FontRef): boolean {
if (a.kind === "bundled" && b.kind === "bundled") return a.id === b.id;
if (a.kind === "system" && b.kind === "system") return a.family === b.family;
return false;
}
export function fontsEqual(a: FontPair, b: FontPair): boolean {
return refsEqual(a.body, b.body) && refsEqual(a.heading, b.heading);
}
/** Which preset this pair is, or null for a combination somebody built themselves. */
export function pairingFor(fonts: FontPair): string | null {
const match = FONT_PAIRINGS.find(
(p) => refsEqual(p.body, fonts.body) && refsEqual(p.heading, fonts.heading),
);
return match?.id ?? null;
}
/**
* The faces an export has to be handed, split by where their bytes come from.
*
* Bundled faces come back as ids rather than as records, because the id is what crosses the IPC
* boundary: the backend already has the bytes compiled in and looks them up by id. Deduplicated,
* because the common case is one family in both slots and asking the backend to load a two megabyte
* variable file twice is two megabytes of IPC for nothing.
*/
export function fontsUsed(fonts: FontPair): { bundled: string[]; system: string[] } {
const usedBundled = new Set<string>();
const usedSystem = new Set<string>();
for (const ref of [fonts.body, fonts.heading]) {
if (ref.kind === "bundled") {
if (bundledFont(ref.id)) usedBundled.add(ref.id);
} else {
usedSystem.add(ref.family);
}
}
return { bundled: [...usedBundled], system: [...usedSystem] };
}
/** Anything that is not a `FontRef`, from a stored preference written by another version. */
export function isFontRef(value: unknown): value is FontRef {
if (typeof value !== "object" || value === null) return false;
const ref = value as { kind?: unknown; id?: unknown; family?: unknown };
if (ref.kind === "bundled") return typeof ref.id === "string" && bundledFont(ref.id) !== undefined;
return ref.kind === "system" && typeof ref.family === "string" && ref.family !== "";
}
+61
View File
@@ -0,0 +1,61 @@
// The title bar glyphs, drawn on a 24 unit grid for a 1.6 stroke.
//
// Here because the two apps kept drifting. Each had its own idea of what a search or a moon looked
// like, they were adjusted independently, and the result was two products from the same hand that
// did not look related. A path is a design decision, not a detail, and the fix for two copies of a
// decision is one copy.
//
// Paths and not an icon dependency: a set is six hundred kilobytes for the handful of shapes a
// title bar needs, and every one of these is a few dozen bytes.
//
// Each app renders these through its own `Icon` component. The two components are identical today
// and are deliberately not shared: one is React, which would make this package depend on React for
// twenty four lines, and a component is where an app is entitled to differ.
/** A pane and its divider. Also the preview dock in Margin, mirrored. */
export const SIDEBAR = "M3 4.5h18v15H3zM9 4.5v15";
/** Margin's preview dock: the same pane with the divider on the other side. */
export const DOCK = "M3 4.5h18v15H3zM14 4.5v15";
export const SEARCH = "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4";
/** A capital A with a tick beside it: the letter a checker is looking at, checked. */
export const SPELLING = "M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14";
/** Three lines of a paragraph and a squiggle under the last, which is the mark grammar leaves. */
export const GRAMMAR = "M4 7h16M4 12h12M4 17h7M13.5 18.5c1-1.2 2-1.2 3 0s2 1.2 3 0";
/**
* A capital A beside a lowercase a, which is what a font panel has been called since there were
* font panels.
*
* Two letterforms and not one on a rule: a letter over a full width line is the underline button in
* every editor anybody has used. The other constraint is SPELLING above, which is also built on a
* capital A; what separates them is the shape to its right, a round bowl here and an angular tick
* there, and that difference survives 16px in a way a crossbar's height would not.
*/
export const FONT =
"M2.5 18L6.5 6l4 12M4.3 14.2h4.4M17 11.3a3.2 3.2 0 1 0 0 6.4a3.2 3.2 0 0 0 0-6.4M20.2 11.3v6.7";
/** The page's two edges with a double headed arrow between them. */
export const WIDTH = "M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3";
export const EXPORT = "M5 13v6h14v-6M12 16V3M8 7l4-4 4 4";
export const MOON = "M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z";
/**
* The sun, which is the one glyph here that is not a single path: the disc has to be a circle so
* that it stays round at every size, and the rays have to be a path so that they keep their caps.
* Rendered as two children rather than one `d`.
*/
export const SUN_RAYS =
"M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4";
export const SUN_DISC = { cx: 12, cy: 12, r: 4 } as const;
export const MORE = "M5 12h.01M12 12h.01M19 12h.01";
export const CLOSE = "M6 6l12 12M18 6L6 18";
export const CHECK = "M20 6L9 17l-5-5";
+5
View File
@@ -0,0 +1,5 @@
// Everything both apps draw from. Two entry points as well as this one, `margin-shared/fonts` and
// `margin-shared/icons`, so a module that only wants the glyphs does not pull in the catalogue.
export * from "./fonts";
export * as icons from "./icons";
+161 -3
View File
@@ -3089,8 +3089,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -3754,6 +3756,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -4632,6 +4635,12 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-rs"
version = "0.3.0"
@@ -4704,12 +4713,20 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "margin-app"
version = "0.1.3"
version = "0.1.17"
dependencies = [
"base64 0.22.1",
"fontdb",
"harper-core",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"rand 0.8.6",
"reqwest 0.12.28",
"semver",
"serde",
"serde_json",
"sha2",
"spellbook",
"tauri",
"tauri-build",
@@ -4720,6 +4737,7 @@ dependencies = [
"typst",
"typst-as-lib",
"typst-pdf",
"url",
"zip 2.4.2",
]
@@ -5124,9 +5142,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.0",
"block2",
"libc",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation",
"objc2-quartz-core",
]
[[package]]
@@ -5146,6 +5172,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.13.0",
"objc2",
"objc2-foundation",
]
@@ -5206,6 +5233,19 @@ dependencies = [
"objc2-core-graphics",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.13.0",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -5966,6 +6006,61 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash 2.1.2",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.4",
"ring",
"rustc-hash 2.1.2",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -5993,6 +6088,8 @@ version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
"serde",
]
@@ -6033,6 +6130,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
"serde",
]
@@ -6203,6 +6301,44 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.4"
@@ -6425,6 +6561,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
@@ -6773,6 +6910,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.21.0"
@@ -7421,7 +7570,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.4",
"serde",
"serde_json",
"serde_repr",
@@ -7610,7 +7759,7 @@ dependencies = [
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"reqwest 0.13.4",
"rustls",
"semver",
"serde",
@@ -9149,6 +9298,15 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
+30 -4
View File
@@ -1,8 +1,9 @@
[package]
name = "margin-app"
version = "0.1.4"
description = "A Tauri App"
authors = ["you"]
version = "0.1.17"
description = "A calm, offline writing studio for authors"
authors = ["Priyanshu Jain"]
license = "LicenseRef-FSL-1.1-MIT"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -27,14 +28,39 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
zip = "2"
fontdb = "0.23"
typst = "0.14.2"
typst-pdf = "0.14.2"
typst-as-lib = "0.15.5"
spellbook = "0.4"
# Pinned exactly: the [patch] stubs below are tied to this version's burn/cubecl graph. A minor
# bump could silently invalidate the patch ("unused") and pull the broken CUDA subtree back in,
# so bump deliberately and re-audit the stubs. (see [patch.crates-io])
harper-core = { version = "=2.5.0", features = ["concurrent"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
sha2 = "0.10"
semver = "1"
rand = "0.8"
url = "2"
# Spelling on macOS uses the system NSSpellChecker (same engine as TextEdit/Safari): far better
# vocabulary than the bundled Hunspell dictionary and it skips URLs. These objc2 crates are already
# in the graph via Tauri, so this adds nothing new to the build.
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = [
"NSSpellChecker",
"NSApplication",
"NSEvent",
"NSMenu",
"NSMenuItem",
"NSResponder",
"NSWritingToolsCoordinator",
] }
objc2-foundation = { version = "0.3", features = ["NSString", "NSArray", "NSRange", "NSTextCheckingResult"] }
# Other platforms fall back to the bundled Hunspell dictionary via spellbook.
[target.'cfg(not(target_os = "macos"))'.dependencies]
spellbook = "0.4"
# harper-core transitively declares an optional, disabled `burn-cuda` (CUDA backend).
# Cargo still version-resolves that dead subtree, which currently fails (broken tracel-llvm
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Margin's only cryptography is TLS for the Google Drive API and the SHA-256 that PKCE
needs, both of which are exempt. Declaring it here rather than in App Store Connect means
the question is not asked again on every submission. -->
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
</dict>
</plist>
+2 -3
View File
@@ -6,9 +6,8 @@
"permissions": [
"core:default",
"core:window:allow-destroy",
"core:window:allow-start-dragging",
"opener:default",
"dialog:default",
"updater:default",
"process:allow-restart"
"dialog:default"
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop",
"description": "Desktop-only permissions (updater, process restart)",
"platforms": ["macOS", "windows", "linux"],
"windows": ["main"],
"permissions": ["updater:default", "process:allow-restart"]
}
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- Google Drive backup talks to googleapis.com, and the update check asks itunes.apple.com
which version is live on the store. Nothing else leaves the machine. -->
<key>com.apple.security.network.client</key>
<true/>
<!-- The Drive OAuth flow redirects to a loopback listener on 127.0.0.1 (gdrive.rs), which is
the only installed-app flow Google still supports. Without this the sandbox refuses the
bind and sign-in hangs on the browser tab that has nowhere to come back to. -->
<key>com.apple.security.network.server</key>
<true/>
<!-- Importing an EPUB and exporting a PDF or EPUB both go through NSOpenPanel/NSSavePanel, so
the app only ever reaches the one file the user pointed at. The library itself lives in
the container and needs no entitlement. -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.application-identifier</key>
<string>__TEAM_ID__.studio.margin.app</string>
<key>com.apple.developer.team-identifier</key>
<string>__TEAM_ID__</string>
</dict>
</plist>
+3
View File
@@ -0,0 +1,3 @@
xcuserdata/
build/
Externals/
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Loaded 100 of 200 files, more files were not shown because too many files have changed in this diff. Show more