Compare commits

34 Commits

Author SHA1 Message Date
Julien Calixte
f89c2fa776 style: just changed the view latest button
All checks were successful
CI / verify (push) Successful in 2m7s
2026-07-12 12:36:54 +02:00
Julien Calixte
c21619c53a feat(notes): drop the redundant Close button from share popup
All checks were successful
CI / verify (push) Successful in 1m6s
The backdrop already dismisses the dialog on outside click.
2026-07-11 12:57:44 +02:00
Julien Calixte
b34b5851cd fix(notes): color the share trigger icon with accent
Scoped styles in HeaderNote don't cross into the ShareNote child
component, so text-base-content painted the icon white/black instead
of matching the other header icons.
2026-07-11 12:57:41 +02:00
Julien Calixte
9581dc88aa fix(notes): don't save empty content over a note that hasn't loaded
All checks were successful
CI / verify (push) Successful in 1m9s
Entering and exiting edit mode while a note was still loading (or after a
failed fetch) triggered a save of the empty placeholder, clobbering the
real file on GitHub with an empty commit. Gate the dirty check, performSave,
and the edit button on a loaded baseline.
2026-07-11 12:51:27 +02:00
Julien Calixte
40b0af6b01 style(welcome): tighten landing copy and reduce em-dash use
All checks were successful
CI / verify (push) Successful in 1m7s
Rework the lede parenthetical and thin the calm/quiet/pool
metaphor where it doubled up on itself, to soften AI-writing tells.
2026-07-11 12:21:15 +02:00
Julien Calixte
2592b19c8f docs: require Tabler icons for all icons
All checks were successful
CI / verify (push) Successful in 1m7s
2026-07-11 12:12:33 +02:00
Julien Calixte
99edff828a feat(notes): use Tabler pin/repeat icons in the share popup
Replace the 📌/🔄 emoji with inline Tabler SVGs colored via --color-accent
to match the note-header icons, and reword the always-latest description so
the two options no longer share the same em-dash phrasing.
2026-07-11 12:12:30 +02:00
Julien Calixte
b720759c89 fix(macroplan): pack grid rows to content, don't stretch to fill
All checks were successful
CI / verify (push) Successful in 1m9s
The mobile 100svh note height cascades into the grid and stretched the
auto row tracks; align-content: start keeps rows at their content height.
2026-07-11 11:13:29 +02:00
Julien Calixte
a88d0dbac7 feat(notes): share an always-latest link, not just a snapshot
Some checks failed
CI / verify (push) Has been cancelled
The Share button now offers two links: the pinned snapshot (default) and
an "always latest" link that carries note paths in `?liveNotes` instead of
blob shas. On open they resolve to the current shas against the HEAD file
list and the URL is rewritten to the ordinary pinned form, so freshness,
editing and the snapshot banner keep working unchanged.
2026-07-11 11:11:51 +02:00
Julien Calixte
70fbe5156f fix(notes): apply shikiji dark palette in dark theme
All checks were successful
CI / verify (push) Successful in 1m4s
Shikiji emits the dark colors as --shiki-dark* inline vars but the
activation CSS was missing, so code blocks kept the vitesse-light
white background and the base-content line numbers were white-on-white.
2026-07-08 00:40:40 +02:00
Julien Calixte
049ce01ab5 feat(notes): scroll to the last stacked note on page load
All checks were successful
CI / verify (push) Successful in 1m5s
Shared links with stackedNotes landed on the readme, forcing readers to
scroll past every note to reach the one that matters — especially long
on mobile where notes stack vertically.
2026-07-08 00:30:56 +02:00
Julien Calixte
f9906cba30 feat(notes): highlight rust code blocks and .rs file views
All checks were successful
CI / verify (push) Successful in 1m7s
2026-07-08 00:12:17 +02:00
Julien Calixte
1b6c7542d7 feat(notes): render macroplan code blocks as a delivery plan grid
All checks were successful
CI / verify (push) Successful in 2m9s
Follows the tikz pattern: the fence emits a base64 placeholder, then
the post-render hook lazily imports the parser and grid (separate
chunks) and mounts the grid only when a placeholder exists. Mono font
forced on the block because the flag-stacking math assumes Fira Code.
2026-07-08 00:01:21 +02:00
Julien Calixte
70f9a6b718 feat(macroplan): vendor plan model and grid from the macroplan app
Copied verbatim (imports resorted) from jclab/macroplan so future
upstream syncs stay copy-paste; extraction to a shared package is
deferred until drift hurts.
2026-07-08 00:01:12 +02:00
Julien Calixte
5386fa66ea feat(notes): show line numbers on non-markdown file views
All checks were successful
CI / verify (push) Successful in 2m8s
CSS counters on the per-line spans, scoped to .code-file so fenced
blocks inside notes stay untouched. The gutter width adapts to the
file's line count and numbers are excluded from text selection.
2026-07-03 16:57:09 +02:00
Julien Calixte
84a252e926 feat(notes): swap heading and paragraph fonts with a button
All checks were successful
CI / verify (push) Successful in 1m7s
Replace the heading/paragraph toggle with the intended UX: keep both font
dropdowns visible and add a button that exchanges the two fonts via a new
swapFonts store action.
2026-06-30 00:47:10 +02:00
Julien Calixte
30eadaf21b feat(notes): toggle one font picker between heading and paragraph
All checks were successful
CI / verify (push) Successful in 1m4s
Replace the two side-by-side font dropdowns with a single picker plus a
small h/p switch that flips which font it edits, keeping the modal compact.
2026-06-30 00:42:10 +02:00
Julien Calixte
4104e138c1 refactor(notes): rename title font to heading font
These fonts apply to markdown headings (h1-h6), so "heading" is more
accurate than "title". Renames the chosen*Font field, store action, and
the --heading-font-family CSS variable.

Keeps backward compatibility: .remanso.json still honors the legacy `t`
key (alongside the new `h`), and a saved `chosenTitleFont` in
localStorage is migrated on load so existing preferences survive.
2026-06-30 00:41:54 +02:00
Julien Calixte
b8c9d07930 feat(notes): auto-merge non-overlapping edits on conflict
All checks were successful
CI / verify (push) Successful in 1m13s
When a save hits a GitHub conflict, try a three-way merge first: if our
edits and the remote ones don't overlap, commit the result silently with
a tailored toast. Only genuinely overlapping edits open the conflict
modal. Adds an optional successMessage override to updateFile.
2026-06-30 00:27:07 +02:00
Julien Calixte
cb22c755df feat(notes): resolve base and remote sources for merge
resolveMergeSources reads the edited-from snapshot as the merge base
(its own immutable cache entry, never the path pointer) and the current
remote blob, returning the decoded text a three-way merge needs.
2026-06-30 00:27:03 +02:00
Julien Calixte
3a528c63e0 feat(notes): add line-level three-way merge util
Wraps node-diff3 to merge two edits derived from a common ancestor.
Conservative by design: edits to immediately-adjacent lines are
reported as conflicts so the caller can fall back to manual resolution
rather than risk a wrong auto-merge.
2026-06-30 00:26:59 +02:00
Julien Calixte
c2764deb49 chore(deps): add node-diff3 for three-way merge 2026-06-30 00:26:54 +02:00
Julien Calixte
57ef4a9e50 style(notes): constrain public note list to a centered column
All checks were successful
CI / verify (push) Successful in 2m4s
Full-bleed rows flung the author/date meta to the far edge on wide
screens. Cap the list and tabs to a 42rem centered column, stack the
meta tight under the title, and drop the button padding on titles.
2026-06-29 21:38:43 +02:00
Julien Calixte
9d27aa024f feat(notes): banner when viewing an older shared version
All checks were successful
CI / verify (push) Successful in 1m3s
Cache the note path on each snapshot so an old shared sha can find its
current version, and show a calm "older shared version — View latest"
banner. The exact snapshot is shown as-is; the banner only offers a jump
to the latest, never swapping content.
2026-06-29 01:04:51 +02:00
Julien Calixte
2f8d3c24a4 feat(notes): keep old shas as immutable snapshots
All checks were successful
CI / verify (push) Successful in 1m5s
Cache content under its own sha (write-once) with the path key as the
latest pointer, and advance the stack handle to the new sha after an
edit or pull. A shared link keeps showing exactly what was shared, while
the live view follows your changes.
2026-06-29 00:43:56 +02:00
Julien Calixte
08d2d804ff feat(notes): show loading and failed states for stacked notes
All checks were successful
CI / verify (push) Successful in 1m3s
A note that couldn't load rendered a silent blank div. Add a calm
NoteState placeholder (spinner while loading, message + retry on
failure) and gate the read view on it; content still wins when present.
2026-06-29 00:31:09 +02:00
Julien Calixte
8f50259efe docs: record two-key cache strategy for reference modes
Decide the C3/C4 cache shape: content keyed by its own SHA (write-once,
immutable snapshot store) plus a path key holding the latest content
(live pointer). Notes the current immutability violation to fix.
2026-06-29 00:31:04 +02:00
Julien Calixte
a04d169bd8 fix(notes): show raw-text fallback when markdown render fails
All checks were successful
CI / verify (push) Successful in 1m2s
A throwing markdown-it plugin propagated uncaught and left the note
silently blank. Wrap the central render in try/catch and degrade to the
note's escaped raw text with a gentle notice, so content stays readable.
2026-06-29 00:00:20 +02:00
Julien Calixte
1b47101340 fix(notes): persist new SHA and dedup files by path on edit
All checks were successful
CI / verify (push) Successful in 2m2s
Editing a note in a stacked note saved cache without the path, so
store.files never received the new SHA and lookups went stale. Pass the
path through, and make addFile dedup by path as well as SHA so an edit
(same path, new SHA) replaces the old entry instead of duplicating it.
2026-06-28 23:52:37 +02:00
Julien Calixte
a7d846aa33 docs: add language glossary, QFD design, and ADRs
Capture the walk-with-me + qfd session: CONTEXT.md ubiquitous-language
glossary, DESIGN.md goal-driven cascade (Beauty / Pride & Peace /
public voice), and ADRs for Note identity (Path) and the two reference
modes (Live vs Snapshot).
2026-06-28 23:52:27 +02:00
Julien Calixte
fac05af862 feat: keep faved repos in list
All checks were successful
CI / verify (push) Successful in 1m3s
2026-06-21 12:07:19 +02:00
Julien Calixte
ad0a388f57 Merge branch 'main' of ssh://git.apoena.dev:22222/julien/remanso
All checks were successful
CI / verify (push) Successful in 2m3s
2026-06-21 10:29:23 +02:00
Julien Calixte
d76d63a198 feat(todo): auto-schedule next occurrence on recurring task completion
When a task with rec:Nd/w/m/y is checked off, a new open task is
appended with the updated due date. Non-strict (rec:1m) bases the
next due on today; strict (rec:+1m) bases it on the existing due date.
2026-06-21 10:26:37 +02:00
Julien Calixte
7575e34f8b style: inset lightbox image with a small dim margin
All checks were successful
CI / verify (push) Successful in 1m5s
Cap the fullscreen image at 96vh/96vw so a thin dim margin frames the
white image instead of bleeding to the viewport edges.
2026-06-18 00:23:38 +02:00
61 changed files with 3178 additions and 103 deletions

View File

@@ -38,6 +38,10 @@ Tailwind v4 uses the modern CSS-based config in `src/styles/app.css`:
- DaisyUI configured via `@plugin 'daisyui' { themes: retro --default, coffee --prefersdark; }`
- `@tailwindcss/typography` for prose styling
### Icons
Use [Tabler icons](https://tabler.io/icons) for every icon. Paste the outline SVG inline and keep `stroke="currentColor"` so it inherits color from its parent (icons in the note header render in `--color-accent`).
### Directory Structure
```sh

125
CONTEXT.md Normal file
View File

@@ -0,0 +1,125 @@
# Remanso
Remanso displays markdown **Notes** from GitHub repositories with Zettelkasten-style backlinks, and publishes/reads decentralized public notes over ATProto. This glossary is the shared vocabulary for the domain — it must appear verbatim in code, tests, commits, and docs.
**Naming convention:** Themed river/Amazonian names (after *Remanso*, a still backwater) are used for **user-facing surfaces and places** (e.g. **Igarapé**). **Core technical concepts** keep their standard Zettelkasten/Obsidian names (**Note, Backlink, Path, Card, Draft, Fleeting**) for discoverability.
## Language
### Core note model
**Note**:
A single markdown document in a repository, identified by its **Path**. Editing a Note does not change its identity.
_Avoid_: "file" when you mean the semantic document.
**Path**:
A Note's stable identity — its location within the repo (e.g. `ideas/zettel.md`). What authors link to and what survives edits.
**SHA**:
The git blob hash of a Note's content — a *content version*, not the Note's identity. Changes on every edit. Legitimately used as the address of a **Snapshot reference** (an immutable shared version).
_Avoid_: treating SHA as the Note's *identity* (identity is **Path**); but SHA *is* the right address for an immutable snapshot.
**File** / **RepoFile**:
The repo artifact carrying a Note's metadata (`path`, `sha`, `size`, …) as returned by the GitHub API. A File is how a Note appears in a repo listing, not a separate domain entity.
### Note kinds
A Note's kind is inferred from its folder, not stored as a field.
**Fleeting Note**:
A transient, quick-capture Note you later distill into a permanent one (Zettelkasten sense). Stored under the `inbox` / `_inbox` folder.
_Avoid_: "Inbox Note" for the concept — `inbox` is only the storage folder convention; the domain term is Fleeting Note.
**Draft Note**:
A work-in-progress Note, stored under the `drafts` / `_drafts` folder.
**Task**:
An actionable item in `todo.txt` (todo.txt format); may recur. Completing a Task archives it to `done.txt`, and for a recurring Task schedules the next occurrence. A Task is not a Note.
_Avoid_: "Todo Note" — Tasks live in a single todo.txt file, not as Notes.
### Reading & navigation
**Igarapé**:
The primary reading surface — the channel you travel as you read a Note and follow its backlinks (Amazonian/Tupi: a narrow navigable waterway through the forest). Any kind of Note appears in the Igarapé. Named to extend the river metaphor set by *Remanso* (a still backwater).
_Avoid_: "Flux" (dropped — internal jargon, no domain meaning).
**Index**:
A flat listing of every Note in a repo (path, sha, size). A catalogue view, unrelated to time or edits.
_Avoid_: "History" / "Historic Notes" for this — it is an index, not a chronological record.
**History**:
The chronological log of repos the user has recently visited (powers the "last visited" navigation).
_Avoid_: using "history" for the Note **Index**, or for edit/version tracking (no such feature exists; there is no "edit history").
**Backlink**:
An inbound reference to a Note — another Note that links to it. The Zettelkasten/Obsidian standard term (kept plain per the naming convention).
**Stacked Notes**:
The navigation pattern in the **Igarapé** where following a Backlink opens the target Note alongside the current one, accumulating a horizontal stack you can read across.
**Live reference**:
A reference to a Note **by Path** that resolves to its *current* content (e.g. a Backlink you follow in your own repo). Reflects edits.
**Snapshot reference**:
A reference to a Note **by SHA** that resolves to that *exact, immutable* version (e.g. a shared/bookmarked stack link). Content-addressed; deliberately unaffected by later edits, so what was shared cannot change underneath a reader. Not a fragility — an integrity feature.
### Spaced repetition
**Card**:
A flashcard reviewed via spaced repetition — its content (front, back, references) and its review schedule together form one Card. Content lives in `_cards/` markdown; the schedule lives in the local DB (an implementation split, not two domain concepts).
_Avoid_: using "Card" to mean only the content, or only the schedule. The pair (`Repetition` in code) **is** the Card.
**Level**:
A Card's mastery level (18); higher levels space reviews further apart.
**Need-review**:
A Card the user has manually flagged to resurface for review, independent of its scheduled date.
### Decentralized publishing (ATProto)
**Published Note**:
A Note published to the decentralized ATProto network as a `space.remanso.note` record, addressed by **DID** + **rkey** (not by Path). A distinct entity created by publishing a Note; carries its own snapshot (title, images, content, theme, language).
_Avoid_: "Public Note" — reserve "public" for a Note in a public GitHub repo (see below).
**Publish**:
The act of making a Note public as a **Published Note**, via a single low-friction gesture — suffixing the file `*.pub.md` in the IDE — rather than an in-app form. "Lower every wall to make your voice public."
**DID**:
The decentralized identifier of a Published Note's **Author** (e.g. `did:plc:…`). Standard ATProto term.
**rkey**:
The record key identifying one Published Note within an Author's PDS repo. Standard ATProto term.
**Author**:
The publisher of a Published Note — a `handle` plus a **PDS**, resolved from a DID.
**PDS**:
Personal Data Server — where an Author's ATProto records (and image blobs) are hosted.
## Relationships
- A **Note** is identified by exactly one **Path**.
- A **Note** has one current **SHA**, which changes on every edit.
- A **File** is the repo-listing view of a **Note** (it carries the Note's Path and current SHA).
- A **Note** can be published as a **Published Note**, which is then addressed by **DID** + **rkey** instead of Path.
- A **Published Note** has exactly one **Author**; an **Author** is identified by a **DID** and hosted on a **PDS**.
- Following a **Backlink** in the **Igarapé** produces **Stacked Notes**.
## Example dialogue
> **Dev:** "When I follow a **Backlink** in the **Igarapé**, do I navigate by **Path** or **SHA**?"
> **Maintainer:** "The identity is the **Path** — that's what the link author wrote. The **SHA** is just the current content version; we happen to use it as a runtime handle, but if it ever disagrees with the Path, the Path wins."
>
> **Dev:** "And if I publish this **Note**?"
> **Maintainer:** "It becomes a **Published Note** — a separate ATProto record addressed by **DID** + **rkey**, not by Path. The repo **Note** stays as it is."
## Flagged ambiguities
- **Identity = Path; two reference modes (refined 2026-06-28)** — see [ADR-0001](docs/adr/0001-note-identity-is-path.md). A Note's *identity* is its **Path**, so **Live references** (Backlinks in your repo) must resolve to current content. But a **Snapshot reference** (a shared `?stackedNotes=sha` link) deliberately pins a **SHA** for immutability — that is an integrity feature, not a fragility. The earlier framing of "SHA-as-handle = fragility" was too absolute; ADR-0001 is to be amended to record both modes.
- `useNotes()` / `useFolderNotes()` are named "notes" but return `RepoFile[]`. Per this glossary they return **Files** (the repo-listing view), not `Note` objects — a naming drift to reconcile in code.
- `FluxNote` / `FluxNoteView` should be renamed to **Igarapé** (`Igarape` / `IgarapeView`) in code — "Flux" is dropped jargon.
- `HistoricNotes` view (route `/history`) is the **Index**, not history — rename to `NoteIndex` and move off the `/history` path.
- `CLAUDE.md` describes `modules/history/` as "Edit history tracking" — incorrect; it is the visited-repos **History**. Correct the doc; there is no edit-history feature.
- Card cluster: `Repetition` (the content+schedule pair) **is** the **Card** — rename `Repetition``Card`. The content-only struct and `RepetitionCard` (schedule) become named sub-parts (e.g. `CardContent`, `ReviewSchedule`). The hook already calls the pairs `cards` — make the types agree.
- ATProto cluster: rename `PublicNote` / `PublicNoteRecord` / `PublicNoteListItem``PublishedNote*`, and move `PublicNoteListItem` out of `Note.ts` into the `atproto` module (it is not a repo-Note concept).
- "Public" is overloaded: a Note in a **public GitHub repo** (auth-free access) vs the ATProto entity (now **Published Note**). Resolved — "public" refers only to repo visibility.

420
DESIGN.md Normal file
View File

@@ -0,0 +1,420 @@
# Remanso — Design (QFD)
Goal-driven design for what Remanso is *for*: a **beautiful, primarily read-only** viewer for notes authored in the IDE (git push) and read as a Matuschak-style stacked-notes web — built to make notes feel **good-looking**, to evoke **pride and peace** (including reading *gracefully on a flaky mobile/metro connection*), and to **lower every wall** to making your voice public. Relies on [CONTEXT.md](CONTEXT.md) for vocabulary (Note, Path, SHA, Backlink, Igarapé, Stacked Notes, Live/Snapshot reference, Published Note, Publish), [ADR-0001](docs/adr/0001-note-identity-is-path.md) (Note identity = Path) and [ADR-0002](docs/adr/0002-two-reference-modes.md) (Live vs Snapshot reference). The earlier "Path↔SHA resolution layer" is not a goal in itself — it folds in under Peace (live references that never break) and integrity (immutable Snapshot references).
Strength weights used in matrices: **9** strong, **3** medium, **1** weak, blank none.
---
## House of Quality
```tikz
\usetikzlibrary{arrows.meta, positioning, shapes.geometric, shapes.misc, calc, fit, backgrounds}
% Toggles
\newif\ifqfdshowroof \qfdshowrooftrue
\newif\ifqfdshowbasement \qfdshowbasementtrue
\newif\ifqfdshowcompetitive \qfdshowcompetitivetrue
\newif\ifqfdshowlegend \qfdshowlegendtrue
\newif\ifqfdshowimportance \qfdshowimportancetrue
\newif\ifqfdshowcorrlegend \qfdshowcorrlegendtrue
\newif\ifqfdshowevallegend \qfdshowevallegendtrue
\newif\ifqfdshowtitle \qfdshowtitletrue
% Dimensions
\def\qfdNW{5}
\def\qfdNH{5}
\def\qfdWhatW{4.0}
\def\qfdImpW{0.9}
\def\qfdCmpW{3}
\def\qfdHdrH{2.6}
\def\qfdBasementN{4}
% Titles & labels
\def\qfdWhatsTitle{Customer needs}
\def\qfdImpTitle{Imp.\ \%}
\def\qfdPerceptionTitle{Comparative evaluation}
\def\qfdPoorLabel{poor}
\def\qfdExcellentLabel{excellent}
\def\qfdAltOneLabel{Our product}
\def\qfdAltTwoLabel{Competitor A}
\def\qfdAltThreeLabel{Competitor B}
\def\qfdRelTitle{Relation}
\def\qfdCorrTitle{Correlation}
\def\qfdEvalTitle{Evaluation}
\def\qfdProjectTitle{}
\def\qfdConcept{}
\tikzset{
qfdthin/.style ={line width=0.35pt},
qfdmed/.style ={line width=0.7pt},
qfdstrong/.style={circle, draw, fill=black, minimum size=7pt, inner sep=0pt},
qfdmod/.style ={circle, draw, minimum size=7pt, inner sep=0pt, line width=0.8pt},
qfdweak/.style ={regular polygon, regular polygon sides=3, draw, minimum size=8.5pt, inner sep=0pt, line width=0.7pt},
qfdrel/.is choice,
qfdrel/S/.style={qfdstrong},
qfdrel/M/.style={qfdmod},
qfdrel/W/.style={qfdweak},
qfdalt1mk/.style={circle, draw, fill=black, minimum size=6pt, inner sep=0pt, line width=1pt},
qfdalt1ln/.style={line width=1.2pt},
qfdalt2mk/.style={regular polygon, regular polygon sides=3, draw, fill=black, minimum size=6pt, inner sep=0pt, line width=0.7pt},
qfdalt2ln/.style={line width=0.7pt, dashed},
qfdalt3mk/.style={rectangle, draw, fill=black, minimum size=5pt, inner sep=0pt, line width=0.7pt},
qfdalt3ln/.style={line width=0.7pt, dotted},
}
\newcommand{\qfdDrawGrid}{%
\foreach \c in {1,...,\qfdNHm} \draw[qfdthin] (\c, 0) -- (\c, -\qfdNW);
\foreach \r in {1,...,\qfdNWm} \draw[qfdthin] (0, -\r) -- (\qfdNH, -\r);
\foreach \r in {1,...,\qfdNWm} \draw[qfdthin] (\qfdLeftEdge, -\r) -- (0, -\r);
\ifqfdshowroof
\foreach \c in {1,...,\qfdNHm} \draw[qfdthin] (\c, 0) -- (\c, \qfdHdrH);
\fi
\ifqfdshowcompetitive
\foreach \r in {1,...,\qfdNWm} \draw[qfdthin] (\qfdNH, -\r) -- (\qfdNH+\qfdCmpW, -\r);
\fi
\ifqfdshowbasement
\foreach \r in {1,...,\qfdBasementN} \draw[qfdthin] (0, -\qfdNW-\r) -- (\qfdNH, -\qfdNW-\r);
\foreach \c in {1,...,\qfdNHm} \draw[qfdthin] (\c, -\qfdNW) -- (\c, -\qfdNW-\qfdBasementN);
\fi
}
\newcommand{\qfdDrawRoof}{%
\ifqfdshowroof
\foreach \k in {1,...,\qfdNHm} {%
\pgfmathsetmacro{\rx}{(\k+\qfdNH)/2}
\pgfmathsetmacro{\ry}{\qfdHdrH + (\qfdNH-\k)/2}
\pgfmathsetmacro{\lx}{\k/2}
\pgfmathsetmacro{\ly}{\qfdHdrH + \k/2}
\draw[qfdthin] (\k, \qfdHdrH) -- (\rx, \ry);
\draw[qfdthin] (\k, \qfdHdrH) -- (\lx, \ly);
}%
\draw[qfdmed] (0, \qfdHdrH) -- (\qfdNH/2, \qfdApexY) -- (\qfdNH, \qfdHdrH);
\foreach \i in {1,...,\qfdNH}
\foreach \k in {1,...,\qfdNH} {%
\pgfmathtruncatemacro{\jj}{\i+\k}
\ifnum\jj>\qfdNH\relax\else
\pgfmathsetmacro{\xx}{\i + \k/2 - 0.5}
\pgfmathsetmacro{\yy}{\qfdHdrH + \k/2}
\coordinate (C-\i-\jj) at (\xx, \yy);
\fi
}%
\fi
}
\newcommand{\qfdDrawScale}{%
\ifqfdshowcompetitive
\foreach \tk in {0,1,2,3,4,5} {%
\pgfmathsetmacro{\tx}{\qfdNH + (\tk+0.5)*\qfdCmpW/6}
\node[anchor=south, font=\scriptsize] at (\tx, 0.02) {\tk};
}%
\node[anchor=south, font=\scriptsize\bfseries, align=center] at ({\qfdNH + \qfdCmpW/2}, 0.7) {\qfdPerceptionTitle};
\node[anchor=north, font=\scriptsize\itshape] at ({\qfdNH + 0.45}, -\qfdNW) {\qfdPoorLabel};
\node[anchor=north, font=\scriptsize\itshape] at ({\qfdNH + \qfdCmpW - 0.45}, -\qfdNW) {\qfdExcellentLabel};
\fi
}
\newcommand{\qfdDrawZoneTitles}{%
\ifqfdshowimportance
\node[rotate=90, anchor=west, font=\footnotesize\bfseries] at ({-\qfdImpW/2}, 0.12) {\qfdImpTitle};
\fi
\node[font=\scriptsize\bfseries, align=center, text width=\qfdWhatW cm] at ({\qfdLeftEdge + \qfdWhatW/2}, {\ifqfdshowroof \qfdHdrH/2 \else 0.6 \fi}) {\qfdWhatsTitle};
}
\newcommand{\qfdDrawTitle}{%
\ifqfdshowtitle
\ifx\qfdProjectTitle\empty\else
\pgfmathsetmacro{\qfdTitleX}{\qfdNH/2}
\pgfmathsetmacro{\qfdTitleY}{\ifqfdshowroof \qfdApexY \else \qfdHdrH \fi + 0.9}
\pgfmathsetmacro{\qfdSubW}{\qfdNH + 2}
\node[anchor=south, font=\large\bfseries, align=center] at (\qfdTitleX, \qfdTitleY) {\qfdProjectTitle};
\ifx\qfdConcept\empty\else
\node[anchor=north, font=\footnotesize\itshape, align=center, text width=\qfdSubW cm] at (\qfdTitleX, {\qfdTitleY - 0.1}) {\qfdConcept};
\fi
\fi
\fi
}
\newcommand{\qfdDrawFrames}{%
\begin{scope}[qfdmed]
\draw (\qfdLeftEdge, 0) rectangle (\qfdNH, -\qfdNW);
\ifqfdshowimportance \draw (-\qfdImpW, 0) -- (-\qfdImpW, -\qfdNW); \fi
\draw (0, 0) -- (0, -\qfdNW);
\ifqfdshowroof \draw (0, 0) rectangle (\qfdNH, \qfdHdrH); \fi
\ifqfdshowbasement \draw (0, -\qfdNW) rectangle (\qfdNH, -\qfdNW-\qfdBasementN); \fi
\ifqfdshowcompetitive \draw (\qfdNH, 0) rectangle (\qfdNH+\qfdCmpW, -\qfdNW); \fi
\end{scope}
}
\newcommand{\qfdDrawLegend}{%
\ifqfdshowlegend
\pgfmathsetmacro{\qfdLegX}{\qfdNH + \ifqfdshowcompetitive \qfdCmpW + 0.7 \else 0.7 \fi}
\pgfmathsetmacro{\qfdLegBottom}{-2.05 \ifqfdshowroof \ifqfdshowcorrlegend - 2.55 \fi \fi \ifqfdshowcompetitive \ifqfdshowevallegend - 2.20 \fi \fi}
\pgfmathsetmacro{\qfdLegY}{\qfdHdrH - 0.4}
\begin{scope}[shift={(\qfdLegX, \qfdLegY)}]
\draw[qfdmed, rounded corners=2pt] (-0.15, 0.4) rectangle (4.5, \qfdLegBottom);
\node[anchor=west, font=\footnotesize\bfseries] at (0, 0.1) {\qfdRelTitle};
\draw[qfdthin] (0, -0.15) -- (4.35, -0.15);
\node[qfdstrong] at (0.22, -0.5) {}; \node[anchor=west] at (0.5, -0.5) {Strong (9)};
\node[qfdmod] at (0.22, -0.95) {}; \node[anchor=west] at (0.5, -0.95) {Medium (3)};
\node[qfdweak] at (0.22, -1.4) {}; \node[anchor=west] at (0.5, -1.4) {Weak (1)};
\ifqfdshowroof \ifqfdshowcorrlegend
\node[anchor=west, font=\footnotesize\bfseries] at (0, -2.10) {\qfdCorrTitle};
\draw[qfdthin] (0, -2.35) -- (4.35, -2.35);
\node[anchor=west] at (0, -2.70) {{$+\!+$}\quad very positive};
\node[anchor=west] at (0, -3.05) {{$+$\phantom{$+$}}\quad positive};
\node[anchor=west] at (0, -3.40) {{$-$\phantom{$-$}}\quad negative};
\node[anchor=west] at (0, -3.75) {{$-\!-$}\quad very negative};
\fi \fi
\ifqfdshowcompetitive \ifqfdshowevallegend
\pgfmathsetmacro{\qfdEvalTop}{-2.10 \ifqfdshowroof\ifqfdshowcorrlegend - 2.55 \fi\fi}
\node[anchor=west, font=\footnotesize\bfseries] at (0, \qfdEvalTop) {\qfdEvalTitle};
\pgfmathsetmacro{\qfdEvalSep}{\qfdEvalTop - 0.25}
\draw[qfdthin] (0, \qfdEvalSep) -- (4.35, \qfdEvalSep);
\pgfmathsetmacro{\qfdLegA}{\qfdEvalTop - 0.55}
\draw[qfdalt1ln] (0.05, \qfdLegA) -- (0.45, \qfdLegA); \node[qfdalt1mk] at (0.25, \qfdLegA) {}; \node[anchor=west, font=\bfseries] at (0.55, \qfdLegA) {\qfdAltOneLabel};
\pgfmathsetmacro{\qfdLegB}{\qfdEvalTop - 0.95}
\draw[qfdalt2ln] (0.05, \qfdLegB) -- (0.45, \qfdLegB); \node[qfdalt2mk] at (0.25, \qfdLegB) {}; \node[anchor=west] at (0.55, \qfdLegB) {\qfdAltTwoLabel};
\pgfmathsetmacro{\qfdLegC}{\qfdEvalTop - 1.35}
\draw[qfdalt3ln] (0.05, \qfdLegC) -- (0.45, \qfdLegC); \node[qfdalt3mk] at (0.25, \qfdLegC) {}; \node[anchor=west] at (0.55, \qfdLegC) {\qfdAltThreeLabel};
\fi \fi
\end{scope}
\fi
}
\newenvironment{qfdhouse}{%
\begin{tikzpicture}[x=1cm, y=1cm, font=\scriptsize, line cap=round, line join=round]
\ifqfdshowimportance
\pgfmathsetmacro{\qfdLeftEdge}{-\qfdWhatW-\qfdImpW}
\else
\pgfmathsetmacro{\qfdLeftEdge}{-\qfdWhatW}
\fi
\pgfmathsetmacro{\qfdApexY}{\qfdHdrH + \qfdNH/2}
\pgfmathtruncatemacro{\qfdNHm}{\qfdNH - 1}
\pgfmathtruncatemacro{\qfdNWm}{\qfdNW - 1}
\qfdDrawGrid
\qfdDrawRoof
\qfdDrawScale
\qfdDrawZoneTitles
\qfdDrawTitle
}{%
\qfdDrawFrames
\qfdDrawLegend
\end{tikzpicture}%
}
\begin{document}
\def\qfdNW{3}
\def\qfdNH{8}
\def\qfdWhatW{4.5}
\def\qfdHdrH{3.6}
\def\qfdImpTitle{Wt}
\def\qfdWhatsTitle{Goals}
\def\qfdProjectTitle{Remanso}
\def\qfdConcept{A \textbf{beautiful}, read-only notes web for \textbf{pride} and \textbf{peace}, with one-gesture \textbf{public} publishing.}
\qfdshowcompetitivefalse
\begin{qfdhouse}
\pgfmathsetmacro{\qfdWhatTextW}{\qfdWhatW - 0.2}
\foreach \r/\t in {1/{G1 Beauty}, 2/{G2 Pride \& Peace (graceful on mobile)}, 3/{G3 Effortless public voice}}
\node[anchor=west, font=\scriptsize, text width=\qfdWhatTextW cm, align=left] at ({\qfdLeftEdge + 0.1}, {-\r + 0.5}) {\t};
\foreach \r/\imp in {1/9, 2/10, 3/8}
\node[font=\scriptsize] at ({-\qfdImpW/2}, {-\r + 0.5}) {\imp};
\foreach \c/\t in {1/{F1 Render fidelity}, 2/{F2 Typography}, 3/{F3 No broken refs}, 4/{F4 Snapshot integrity}, 5/{F5 Offline-resilient}, 6/{F6 Calm surface}, 7/{F7 Reveal web}, 8/{F8 1-gesture publish}}
\node[rotate=90, anchor=west, font=\scriptsize] at ({\c - 0.5}, 0.15) {\t};
% Relations — G1 (row 1)
\node[qfdrel/S] at ({1-0.5},{-1+0.5}){}; \node[qfdrel/S] at ({2-0.5},{-1+0.5}){};
\node[qfdrel/M] at ({3-0.5},{-1+0.5}){}; \node[qfdrel/W] at ({4-0.5},{-1+0.5}){};
\node[qfdrel/W] at ({5-0.5},{-1+0.5}){}; \node[qfdrel/M] at ({6-0.5},{-1+0.5}){};
\node[qfdrel/W] at ({7-0.5},{-1+0.5}){}; \node[qfdrel/W] at ({8-0.5},{-1+0.5}){};
% G2 (row 2)
\node[qfdrel/M] at ({1-0.5},{-2+0.5}){}; \node[qfdrel/M] at ({2-0.5},{-2+0.5}){};
\node[qfdrel/S] at ({3-0.5},{-2+0.5}){}; \node[qfdrel/S] at ({4-0.5},{-2+0.5}){};
\node[qfdrel/S] at ({5-0.5},{-2+0.5}){}; \node[qfdrel/S] at ({6-0.5},{-2+0.5}){};
\node[qfdrel/S] at ({7-0.5},{-2+0.5}){}; \node[qfdrel/M] at ({8-0.5},{-2+0.5}){};
% G3 (row 3)
\node[qfdrel/M] at ({1-0.5},{-3+0.5}){}; \node[qfdrel/M] at ({2-0.5},{-3+0.5}){};
\node[qfdrel/W] at ({3-0.5},{-3+0.5}){}; \node[qfdrel/W] at ({4-0.5},{-3+0.5}){};
\node[qfdrel/W] at ({5-0.5},{-3+0.5}){}; \node[qfdrel/W] at ({6-0.5},{-3+0.5}){};
\node[qfdrel/M] at ({7-0.5},{-3+0.5}){}; \node[qfdrel/S] at ({8-0.5},{-3+0.5}){};
% Roof correlations
\node[font=\scriptsize] at (C-1-2) {$+$};
\node[font=\scriptsize] at (C-1-5) {$-\!-$};
\node[font=\scriptsize] at (C-3-4) {$-$};
\node[font=\scriptsize] at (C-3-5) {$+\!+$};
\node[font=\scriptsize] at (C-2-6) {$-$};
\node[font=\scriptsize] at (C-6-7) {$-$};
\node[font=\scriptsize] at (C-2-8) {$+$};
% Basement: target / difficulty / abs / rel
\foreach \c/\tgt/\diff/\abs/\rel in {1/{all}/3/135/14, 2/{taste}/2/135/14, 3/{0 brk}/3/125/13, 4/{100\%}/3/107/11, 5/{offln}/4/107/11, 6/{calm}/2/125/13, 7/{on-dmd}/2/123/13, 8/{1-gest}/3/111/12} {
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 0.5}) {\tgt};
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 1.5}) {\diff};
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 2.5}) {\abs};
\node[font=\scriptsize\bfseries] at ({\c - 0.5}, {-\qfdNW - 3.5}) {\rel};
}
\end{qfdhouse}
\end{document}
```
Basement rows per function: **target / difficulty (15) / absolute weight / relative weight %**.
---
## 1. Goals — the WHATs
| ID | Goal | Weight | Source |
|-----|---------------------------------------------------------------------------------------|:------:|--------|
| G1 | **Beauty** — notes render gorgeously; the page is something you want to look at | 9 | Author intent (this session) |
| G2 | **Pride & Peace** — calm, trustworthy, quietly proud: nothing breaks, nothing misleads, nothing clutters; reads gracefully on a flaky mobile/metro connection | 10 | Author intent (this session) |
| G3 | **Effortless public voice** — lower *every* wall to making your voice public: one gesture (`.pub.md`), open decentralized rails, no forms | 8 | Author intent (this session) |
## 2. Functions — the HOWs
| ID | Function | Dir | Target (now) | Target (future) |
|-----|-------------------------------------------------------------------------|:---:|--------------|-----------------|
| F1 | **Render rich content faithfully** (math, code, diagrams, images, alerts, checkboxes) — for repo Notes *and* Published Notes | ↑ | every supported type renders correctly; failures degrade gracefully (never raw error dumps) | — |
| F2 | **Present beautiful typography & layout** (fonts, width, prose, theme, lightbox); Published Notes carry their own theme/fonts/language | ↑ | tasteful defaults + per-repo/per-note tuning | — |
| F3 | **Never show a broken live reference** (backlinks/images/notes resolve to current, or degrade gracefully) | ↓ | 0 visible broken refs in normal use; graceful placeholder on flaky network | 0 incl. edge cases |
| F4 | **Preserve Snapshot references immutably** (a shared SHA link renders the exact shared version; never silently substitutes current content) | → | 100% snapshot fidelity; on uncached miss, latest-cached + disclosure banner | — |
| F5 | **Serve instantly, offline & resilient on mobile/flaky networks** (PouchDB cache-first; no spinner anxiety in the metro) | ↓ | full offline read; cached render fast | cached render ≤ ~100 ms; survives mid-read connection loss |
| F6 | **Keep the surface calm & uncluttered** (reading-first, settings tucked away, the Remanso stillness) | → | minimal chrome; no nags/noise | — |
| F7 | **Reveal the web of connections** (backlinks + stacked notes) — *calm by default, on demand* | ↑ | every Note shows inbound links; stacking fluid; nothing flooded | — |
| F8 | **Publish in one gesture to open rails** (`.pub.md` → ATProto Published Note; no in-app form; decentralized, author-owned) | ↓ | publish = rename + push (1 gesture), 0 in-app steps | — |
## 3. Cascade — Goals → Functions → How → Components
- **G1 Beauty** _W:9_
- **F1** Render rich content faithfully _Dir↑_
- **How**: markdown-it pipeline with plugins; each renderer wrapped so a parse failure yields a styled fallback, not a stack trace
- **Component**: C1 `useMarkdown` + plugins (KaTeX, Shikiji, Mermaid, TikZ, GitHub alerts, checkboxes)
- **F2** Beautiful typography & layout _Dir↑_
- **How**: DaisyUI theme + `@tailwindcss/typography` prose + per-repo `UserSettings`; Published Notes apply their stored theme/fonts
- **Component**: C2 Theme & typography system (themes, fonts/width, image lightbox)
- **G2 Pride & Peace** _W:10_
- **F3** Never show a broken live reference _Dir↓ Target 0 broken_
- **How**: a single bidirectional Path↔SHA index; resolve Live references by Path; fix the in-stack edit bug; graceful placeholder when a target is genuinely missing
- **Component**: C3 Reference resolver (Path↔SHA index over `store.files`)
- **F4** Preserve Snapshot references immutably _Dir→_ — see [ADR-0002](docs/adr/0002-two-reference-modes.md)
- **How**: keep SHA-addressed stack URLs; render the pinned version; on cache miss show honest "unavailable", never substitute current
- **Component**: C4 Snapshot pinning + unavailable state
- **F5** Serve instantly, offline & resilient _Dir↓ Target offline read_
- **How**: cache-first reads from PouchDB via the worker; render from cache before/without network; tolerate mid-read connection loss
- **Component**: C5 PouchDB cache + `DataApi` worker; C9 Freshness/pull
- **F6** Keep the surface calm & uncluttered _Dir→_
- **How**: reading-first Igarapé; settings/chrome tucked away; no nags
- **Component**: C6 Igarapé surface + calm chrome
- **F7** Reveal the web of connections — calm by default _Dir↑_
- **How**: backlinks present but quiet; a Note opens into the stack only when its Backlink is summoned (progressive disclosure)
- **Component**: C7 Backlinks + Stacked Notes (reveal-on-demand)
- **G3 Effortless public voice** _W:8_
- **F8** Publish in one gesture to open rails _Dir↓ friction_
- **How**: `.pub.md` suffix → external/CI publish to ATProto as a Published Note; in-app surfaces read those records, styled like private notes
- **Component**: C8 Publish pipeline + ATProto read plumbing (`getUrl`, `withATProtoImages`, `Public*``Published*` views)
## 4. House — Goals × Functions
Cells: link strength (9/3/1/blank). Importance row = Σ(weight × strength).
| | F1 | F2 | F3 | F4 | F5 | F6 | F7 | F8 |
|-----------------------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| G1 Beauty (9) | 9 | 9 | 3 | 1 | 1 | 3 | 1 | 1 |
| G2 Pride & Peace (10) | 3 | 3 | 9 | 9 | 9 | 9 | 9 | 3 |
| G3 Public voice (8) | 3 | 3 | 1 | 1 | 1 | 1 | 3 | 9 |
| **Σ** | 135 | 135 | 125 | 107 | 107 | 125 | 123 | 111 |
**Top engineering priorities:** F1/F2 (rendering beauty) lead on raw Σ because all three goals touch them; F3 and F6 follow on the strength of Pride & Peace. **Caveat the matrix hides:** the *primary read context is mobile on a flaky metro connection*, which makes F3 (graceful) + F5 (offline-resilient) the **reliability spine to watch hardest** — a broken note in the metro is far more peace-destroying than its mid-pack Σ suggests. Treat F3+F5 as critical despite F5's rank (see §7).
## 5. Roof — Function × Function tradeoffs
`◎` strong reinforce · `○` mild reinforce · `×` mild conflict · `⊗` strong conflict.
| | F1 | F2 | F3 | F4 | F5 | F6 | F7 | F8 |
|--------|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|
| **F1** | — | ○ | | | ⊗ | | | |
| **F2** | | — | | | | × | | ○ |
| **F3** | | | — | × | ◎ | | | |
| **F4** | | | | — | | | | |
| **F5** | | | | | — | | | |
| **F6** | | | | | | — | × | |
| **F7** | | | | | | | — | |
| **F8** | | | | | | | | — |
**Conflicts that actually shape the design:**
- **F1 ⊗ F5** (rich rendering vs instant/offline-on-mobile) — heavy renderers fight load speed and battery. Mitigation: lazy-load renderers, cache rendered HTML, render-from-cache first (T2).
- **F3 × F4** (never-broken vs immutable snapshot) — resolved by [ADR-0002](docs/adr/0002-two-reference-modes.md): pre-cache aggressively; on a true miss, show latest-cached + a disclosure banner — integrity via *disclosure, not refusal*, so the metro never dead-ends (T3).
- **F6 × F7** (calm vs the web) — resolved: **calm by default, reveal on demand** (T1).
- **F3 ◎ F5** (graceful refs reinforce offline resilience) — cache-first resolution makes both true at once; design them together.
- **F2 × F6** (customization vs clutter) — keep settings tucked away, defaults tasteful.
## 6. Components & Function → Component map
| ID | Component | ADR |
|-----|------------------------------------------------------------|----------|
| C1 | `useMarkdown` + markdown-it plugins (math/code/diagrams/…) | — |
| C2 | Theme & typography system (themes, fonts/width, lightbox) | — |
| C3 | Reference resolver — Path↔SHA index over `store.files` | ADR-0001 |
| C4 | Snapshot pinning + "unavailable" state | ADR-0002 |
| C5 | PouchDB cache + `DataApi` worker (cache-first reads) | — |
| C6 | Igarapé reading surface + calm chrome | — |
| C7 | Backlinks + Stacked Notes (reveal-on-demand) | — |
| C8 | Publish pipeline + ATProto read plumbing | — |
| C9 | Freshness / pull (external-commit detection) | ADR-0001 |
| | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 |
|-----|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|
| F1 | 9 | 3 | | | 3 | | | 1 | |
| F2 | 1 | 9 | | | | 3 | | 1 | |
| F3 | | | 9 | 1 | 3 | | 3 | | 9 |
| F4 | | | 1 | 9 | 3 | | | | |
| F5 | | | 3 | 3 | 9 | | | | 3 |
| F6 | | 3 | | | | 9 | 3 | | |
| F7 | | | 3 | | | 3 | 9 | | |
| F8 | | 1 | | | | | | 9 | |
## 7. Critical performance budget
| Rank | Function | Target | Watched on | If we miss it |
|------|----------|--------|------------|---------------|
| 1 | F3 (never broken) | 0 visible broken live refs | manual mobile run + a "broken-ref" dev assertion; fix the StackedNote `saveCacheNote` path bug | render a calm placeholder ("note unavailable"), never a stack trace or empty column |
| 2 | F5 (offline/mobile) | full offline read; survive mid-read connection loss | DevTools offline/slow-3G throttling on the stacked-notes flow | cache-first always; if uncached + offline, honest "not downloaded yet" placeholder |
| 3 | F1 (render fidelity) | all content types render; failures degrade | snapshot-render a fixture note (math/code/mermaid/tikz/alerts) | per-block styled fallback; the rest of the note still renders |
| 4 | F4 (snapshot integrity) | 100% pinned-version fidelity | test: edit a Note, re-open an old `?stackedNotes=sha` link | show pinned version; on a true miss, latest-cached + disclosure banner — never *silent* substitution (ADR-0002) |
| 5 | F8 (publish friction) | 1 gesture, 0 in-app steps | end-to-end: `.pub.md` push → appears in `/notes` | surface the failure plainly; do not require an in-app fallback form (would re-raise the wall) |
## 8. Tradeoffs — Got / Paid / ADR
| ID | Tradeoff | Got | Paid | ADR |
|-----|----------|-----|------|-----|
| T1 | Calm-by-default over web-forward (F6 > F7 prominence) | Stillness; the Remanso peace | Connections less immediately visible (mitigated by reveal-on-demand) | — |
| T2 | Lazy-load + cache rendered HTML (F1 ⊗ F5) | Fidelity *and* instant/offline render | Cache complexity; possible stale-render window | — |
| T3 | Integrity via disclosure, not refusal (F3 + F4 in the unavailable edge) | No silent rewrite *and* no metro dead-end: latest-cached + a banner | A banner caveat instead of a guaranteed exact view when uncached | [ADR-0002](docs/adr/0002-two-reference-modes.md) |
| T4 | Filename-suffix publishing (`.pub.md`) over in-app publish UI (F8) | Near-zero friction; publishing is a gesture, not a feature | Less in-app preview/control; coupling to a naming convention | — |
### Tensions being watched (unresolved by design)
- **Rename/move changes a Note's Path → its Live references break.** Under ADR-0001 that's a new identity; we accept it for now. **Trigger to revisit:** if reorganizing notes becomes common enough that broken backlinks hurt Peace, add git-rename following.
- **F1 ⊗ F5 residual cost on low-end mobile.** Even lazy + cached, very heavy notes (large TikZ/Mermaid) may stutter in the metro. **Trigger to revisit:** if real mobile runs show jank, pre-render heavy blocks to images at publish/cache time.
## 9. Inconsistencies spotted and fixed
- **In-stack edit bug:** `StackedNote.performSave()` called `saveCacheNote()` without `path`, so `store.files` never got the new SHA after an in-Igarapé edit (`FleetingNotes` passed it correctly). Broke F3. **Fixed**`StackedNote.vue` now passes `path`.
- **Asymmetric file index:** `addFile` deduped by SHA only, `registerUploadedFile` by PATH — so an edit (same path, new SHA) left a stale duplicate entry. **Fixed**`addFile` now dedups by SHA *and* path (a path is unique), with a regression test in `userRepo.store.spec.ts`. (Full bidirectional index C3 still the longer-term direction.)
- **ADR-0001 over-absolute:** it called SHA-keyed references "a fragility". **Refined** by ADR-0002 into two intentional modes (Live/Snapshot).
- **`CLAUDE.md` "edit history tracking"** mislabels the visited-repos History; no edit-history feature exists. (Flagged in CONTEXT.md.)
- **Snapshot cache not immutable:** editing writes new content under the *viewed* (old) SHA's cache key (`prepareNoteCache` keys by the viewed sha), so a re-opened old-SHA snapshot can serve new content from cache. **Decided fix (ADR-0002):** key the immutable store by the content's *own* SHA (write-once), keep the Path key as the latest pointer. *(Not yet implemented — the C3/C4 cache slice; unlocks the snapshot banner.)*
- **Naming drift carried from the language session:** `Flux``Igarapé`, `HistoricNotes``Index`, `Repetition``Card`, `PublicNote*``PublishedNote*`, `useNotes()` returns Files not Notes. (Tracked in CONTEXT.md "Flagged ambiguities".)
---
## How to keep this honest
- When a new ADR lands → add its components to §6 and re-score affected rows.
- When a spike / measurement returns numbers → update §7 `Target` / `Watched on`.
- WHATs change rarely; HOWs change with each release; matrices are recomputed when either side changes.
- If a section becomes empty after edits, delete it — empty sections lie.

View File

@@ -0,0 +1,15 @@
# Note identity is Path, not SHA
A **Note**'s stable identity is its **Path** (its location in the repo, e.g. `ideas/zettel.md`), not its content **SHA**. We decided this because authors link to Notes by path (markdown `[[…]]` resolved via `resolvePath()`) and expect those links to survive edits — whereas a Note's SHA changes on every edit.
This is surprising in the current code, which keys **Backlinks** (`Backlink { sha }`) and the stacked-notes URL (`?stackedNotes=sha1;sha2`) by SHA. Those are runtime *handles* that must resolve *through* the Path; if a SHA-handle ever disagrees with the Path, the Path wins.
## Considered options
- **Path as identity (chosen).** Survives edits; matches how authors write links. Cost: needs a resolution layer mapping Path → current SHA for lookup/navigation, and the SHA-keyed code is a known fragility to reconcile.
- **SHA as identity (rejected).** Matches the current code literally and needs no resolution layer, but every edit produces a new identity — backlinks and open-note URLs would break on edit. Unacceptable for a notes app where editing is routine.
## Consequences
- Backlinks and stacked-note navigation should ultimately be expressed in terms of Path; SHA-keying stays only as an internal lookup handle.
- Caching by SHA remains valid (it is content-addressed versioning), but Note identity must never be conflated with it.

View File

@@ -0,0 +1,21 @@
---
status: accepted (refines ADR-0001)
---
# Two reference modes: Live (Path) and Snapshot (SHA)
A reference to a Note resolves in one of two deliberate modes. A **Live reference** (by **Path**) resolves to the Note's *current* content — this is a Backlink you follow in your own repo, and it reflects edits (Note identity = Path, per ADR-0001). A **Snapshot reference** (by **SHA**) resolves to that *exact, immutable* version — this is a shared or bookmarked stack link (`?stackedNotes=sha`), content-addressed so what was shared cannot change underneath a reader.
This refines ADR-0001, which framed SHA-keyed references as "a fragility." That framing was too absolute: SHA-pinning is the **correct** tool for a Snapshot reference. Pinning is an *integrity* feature — it guarantees a shared view can't be silently rewritten, so no reader is misled and no author is misrepresented.
## Considered options
- **Two explicit modes (chosen).** Live=Path, Snapshot=SHA, each with a clear purpose. Cost: the resolver must support both, and the UI must make the mode legible (you should know whether you're reading "now" or "as shared").
- **Single mode, Path only (rejected).** Simpler, but a shared link would always re-resolve to current content — destroying the integrity guarantee that makes sharing safe.
- **Single mode, SHA only (rejected by ADR-0001).** In-repo navigation would break on every edit.
## Consequences
- The system **pre-caches** aggressively (content is cached by both SHA and Path on every fetch / freshness pull) so the pinned version is usually present, including offline.
- **Cache shape (C3/C4):** content is cached under two keys — its own **content SHA** (write-once, immutable → the snapshot store) and its **Path** (overwritten with the latest content → the live pointer). Both hold the full content, so the latest survives even if a SHA entry is evicted, maximizing offline availability for any version already encountered. (Today the code violates this by writing edited content under the *viewed* old SHA key; the fix is to write under the content's *new* SHA and never overwrite an existing SHA entry.)
- When a Snapshot reference's pinned content is genuinely unavailable (never-fetched + offline — common on a flaky mobile connection), the system **falls back to the most up-to-date cached version and shows a banner** disclosing "this is the latest available, not the exact shared version." Integrity is preserved by **disclosure, not refusal**: the reader is never silently shown different content, but is also never dead-ended on the metro. (This chooses graceful continuity over a hard "unavailable" stop, given mobile is the primary read context.)

View File

@@ -49,6 +49,7 @@
"markdown-it-shikiji": "^0.10.2",
"mermaid": "^11.12.1",
"nanoid": "^5.1.6",
"node-diff3": "^3.2.1",
"notyf": "^3.10.0",
"pastel-color": "^1.0.3",
"pinia": "^2.2.6",
@@ -59,6 +60,8 @@
"retrobus": "^1.9.4",
"sanitize-html": "^2.17.0",
"shikiji-core": "0.10.2",
"smol-toml": "^1.7.0",
"valibot": "^1.4.2",
"vue": "^3.5.18",
"vue-i18n": "^11.1.11",
"vue-router": "^4.5.1",

33
pnpm-lock.yaml generated
View File

@@ -104,6 +104,9 @@ importers:
nanoid:
specifier: ^5.1.6
version: 5.1.6
node-diff3:
specifier: ^3.2.1
version: 3.2.1
notyf:
specifier: ^3.10.0
version: 3.10.0
@@ -134,6 +137,12 @@ importers:
shikiji-core:
specifier: 0.10.2
version: 0.10.2
smol-toml:
specifier: ^1.7.0
version: 1.7.0
valibot:
specifier: ^1.4.2
version: 1.4.2(typescript@5.9.3)
vue:
specifier: ^3.5.18
version: 3.5.18(typescript@5.9.3)
@@ -5130,6 +5139,10 @@ packages:
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-diff3@3.2.1:
resolution: {integrity: sha512-eKZcJ8RtMQ3cIaA15EgmtwG927fYnRlhtdA4Q9HAcCpAZPQhhU2XptnTH9GeAkMiX2bAXxlJSAFa9shFbztPgw==}
engines: {bun: '>=1.3.10'}
node-fetch@2.6.12:
resolution: {integrity: sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==}
engines: {node: 4.x || >=6.0.0}
@@ -5926,6 +5939,10 @@ packages:
resolution: {integrity: sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==}
engines: {node: '>=20.0.0'}
smol-toml@1.7.0:
resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==}
engines: {node: '>= 18'}
snapdragon-node@2.1.1:
resolution: {integrity: sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==}
engines: {node: '>=0.10.0'}
@@ -6442,6 +6459,14 @@ packages:
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
valibot@1.4.2:
resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==}
peerDependencies:
typescript: '>=5'
peerDependenciesMeta:
typescript:
optional: true
validate-npm-package-license@3.0.4:
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
@@ -12079,6 +12104,8 @@ snapshots:
node-addon-api@7.1.1:
optional: true
node-diff3@3.2.1: {}
node-fetch@2.6.12:
dependencies:
whatwg-url: 5.0.0
@@ -12996,6 +13023,8 @@ snapshots:
smob@1.6.1: {}
smol-toml@1.7.0: {}
snapdragon-node@2.1.1:
dependencies:
define-property: 1.0.0
@@ -13524,6 +13553,10 @@ snapshots:
uuid@8.3.2: {}
valibot@1.4.2(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
validate-npm-package-license@3.0.4:
dependencies:
spdx-correct: 3.2.0

View File

@@ -11,6 +11,7 @@ import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
import { useNoteView } from "@/hooks/useNoteView.hook"
import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
import { useResolveLiveNotes } from "@/hooks/useResolveLiveNotes.hook"
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
import { useVisitRepo } from "@/modules/history/hooks/useVisitRepo.hook"
import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue"
@@ -43,7 +44,12 @@ useUserSettings()
const { visitRepo } = useVisitRepo({ user: user, repo: repo })
const { toHTML } = markdownBuilder(repo)
const { listenToClick } = useLinks("note-display")
const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
const { stackedNotes, scrollToFocusedNote, scrollToLastStackedNote } =
useRouteQueryStackedNotes()
// A living link arrives with paths in `?liveNotes`; resolve them to the latest
// shas, then focus the deepest note just like an ordinary shared link does.
useResolveLiveNotes(() => scrollToLastStackedNote())
const { titles } = useNoteView()
const { isLogged } = useGitHubLogin()
@@ -62,7 +68,8 @@ const hasContent = computed(() => !!renderedContent.value)
useMarkdownPostRender(renderedContent, () => ".note-display", {
onReady: () => listenToClick(),
tikz: true
tikz: true,
macroplan: true
})
watch(
@@ -77,7 +84,10 @@ const retryLoad = () => {
store.setUserRepo(props.user, props.repo)
}
onMounted(() => visitRepo())
onMounted(() => {
visitRepo()
scrollToLastStackedNote()
})
onUnmounted(() => {
store.resetFiles()

View File

@@ -25,16 +25,16 @@ const fontFamilies = computed(
const sortedFontFamilies = computed(() => {
const base = fontFamilies.value
const extras = [
store.userSettings?.chosenTitleFont,
store.userSettings?.chosenHeadingFont,
store.userSettings?.chosenBodyFont
].filter((f): f is string => !!f && !base.includes(f))
return [...base, ...extras].sort((a, b) => a.localeCompare(b))
})
const fontSizes = Array.from({ length: 7 }, (_, i) => `${9 + i * 2}pt`)
const titleFont = computed({
get: () => store.userSettings?.chosenTitleFont,
set: (value) => store.setTitleFont(value!)
const headingFont = computed({
get: () => store.userSettings?.chosenHeadingFont,
set: (value) => store.setHeadingFont(value!)
})
const bodyFont = computed({
get: () => store.userSettings?.chosenBodyFont,
@@ -49,13 +49,35 @@ const fontSize = computed({
<template>
<div class="font-change">
<div>
<label for="title-font" class="font-label">t</label>
<select id="title-font" class="select" v-model="titleFont">
<label for="heading-font" class="font-label">h</label>
<select id="heading-font" class="select" v-model="headingFont">
<option v-for="font in sortedFontFamilies" :key="font" :value="font">
{{ font }}
</option>
</select>
<button
type="button"
class="btn btn-ghost btn-sm btn-circle"
aria-label="Swap heading and paragraph fonts"
@click="store.swapFonts()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M7 10h14l-4 -4" />
<path d="M17 14h-14l4 4" />
</svg>
</button>
<label for="body-font" class="font-label">p</label>
<select id="body-font" class="select" v-model="bodyFont">
<option v-for="font in sortedFontFamilies" :key="font" :value="font">

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import FontChange from "@/components/FontChange.vue"
import HomeButton from "@/components/HomeButton.vue"
import ShareNote from "@/components/ShareNote.vue"
defineProps<{ user: string; repo: string }>()
</script>
@@ -55,6 +56,7 @@ defineProps<{ user: string; repo: string }>()
<path d="M5 20l6 -16l2 0l7 16" />
</svg>
</button>
<share-note />
<router-link
class="btn btn-ghost btn-circle"
:to="{ name: 'FluxNoteView', params: { user, repo } }"

View File

@@ -48,7 +48,7 @@ watch(isOpen, (value) => {
v-if="src"
:src="src"
:alt="alt"
class="max-h-[100vh] max-w-[100vw] rounded-lg bg-white p-3 cursor-zoom-out"
class="max-h-[96vh] max-w-[96vw] rounded-lg bg-white p-3 cursor-zoom-out"
/>
</dialog>
</template>

View File

@@ -0,0 +1,29 @@
import { mount } from "@vue/test-utils"
import { describe, expect, it } from "vitest"
import NoteState from "./NoteState.vue"
describe("NoteState", () => {
it("shows a spinner while loading and no retry", () => {
const wrapper = mount(NoteState, { props: { status: "loading" } })
expect(wrapper.find(".loading-spinner").exists()).toBe(true)
expect(wrapper.find(".note-state-retry").exists()).toBe(false)
})
it("shows a message and retry button when failed", () => {
const wrapper = mount(NoteState, { props: { status: "failed" } })
expect(wrapper.find(".loading-spinner").exists()).toBe(false)
expect(wrapper.text()).toContain("Couldn't load this note")
expect(wrapper.find(".note-state-retry").exists()).toBe(true)
})
it("emits retry when the retry button is clicked", async () => {
const wrapper = mount(NoteState, { props: { status: "failed" } })
await wrapper.find(".note-state-retry").trigger("click")
expect(wrapper.emitted("retry")).toHaveLength(1)
})
})

View File

@@ -0,0 +1,48 @@
<script lang="ts" setup>
defineProps<{ status: "loading" | "failed" }>()
defineEmits<{ retry: [] }>()
</script>
<template>
<div class="note-state">
<span
v-if="status === 'loading'"
class="loading loading-spinner loading-md"
aria-label="Loading note"
></span>
<template v-else>
<p class="note-state-message">Couldn't load this note.</p>
<button type="button" class="note-state-retry" @click="$emit('retry')">
Retry
</button>
</template>
</div>
</template>
<style scoped lang="scss">
.note-state {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem 1rem;
color: var(--color-base-content);
opacity: 0.55;
text-align: center;
}
.note-state-message {
margin: 0;
}
.note-state-retry {
padding: 0.25rem 0.85rem;
border: 1px solid currentColor;
border-radius: 0.375rem;
background: transparent;
color: inherit;
cursor: pointer;
}
</style>

View File

@@ -45,8 +45,9 @@ defineSlots<{
<style scoped lang="scss">
ul {
padding-left: 1rem;
padding-right: 1rem;
width: 100%;
max-width: 42rem;
margin-inline: auto;
}
li {
@@ -57,17 +58,19 @@ li {
}
a {
padding-left: 0;
padding: 0;
min-height: 0;
height: auto;
text-align: left;
font-size: 1.2rem;
line-height: 1.5rem;
}
.alias {
text-align: right;
text-align: left;
display: flex;
justify-content: flex-end;
margin-top: 0.5rem;
justify-content: flex-start;
margin-top: 0.125rem;
}
}
</style>

View File

@@ -0,0 +1,197 @@
<script lang="ts" setup>
import { computed } from "vue"
import { LocationQueryRaw, useRoute, useRouter } from "vue-router"
import { stackToLivePaths } from "@/modules/note/liveNotes"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { confirmMessage, errorMessage } from "@/utils/notif"
const route = useRoute()
const router = useRouter()
const store = useUserRepoStore()
const stackedShas = computed<string[]>(() => {
const raw = route.query.stackedNotes
if (!raw) return []
return (Array.isArray(raw) ? raw : [raw]).filter(
(sha): sha is string => typeof sha === "string"
)
})
const hasStack = computed(() => stackedShas.value.length > 0)
const absoluteUrl = (query: LocationQueryRaw): string => {
const { href } = router.resolve({ path: route.path, query })
return `${window.location.origin}${href}`
}
// The exact snapshot: the current URL, with each note pinned to its blob sha.
const snapshotUrl = (): string => absoluteUrl({ ...route.query })
// The living link: the same notes referenced by path, so the recipient always
// lands on the latest version. See useResolveLiveNotes for the read side.
const livingUrl = (): string => {
const query = { ...route.query }
delete query.stackedNotes
return absoluteUrl({
...query,
liveNotes: stackToLivePaths(stackedShas.value, store.files)
})
}
const close = () =>
(document.getElementById("share_modal") as HTMLDialogElement | null)?.close()
const copy = async (url: string, label: string) => {
close()
try {
await navigator.clipboard.writeText(url)
confirmMessage(`🔗 ${label} link copied`)
} catch {
errorMessage("❌ Couldn't copy the link")
}
}
</script>
<template>
<button
class="btn btn-ghost btn-circle text-accent"
title="Share this view"
aria-label="Share this view"
onclick="share_modal.showModal()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-share"
width="30"
height="30"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="6" r="3" />
<circle cx="18" cy="18" r="3" />
<line x1="8.7" y1="10.7" x2="15.3" y2="7.3" />
<line x1="8.7" y1="13.3" x2="15.3" y2="16.7" />
</svg>
</button>
<dialog id="share_modal" class="modal">
<div class="modal-box max-w-md">
<h3 class="text-lg font-bold">Share this view</h3>
<button
type="button"
class="share-option"
@click="copy(snapshotUrl(), 'Snapshot')"
>
<span class="share-option-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-pin"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M15 4.5l-4 4l-4 1.5l-1.5 1.5l7 7l1.5 -1.5l1.5 -4l4 -4" />
<path d="M9 15l-4.5 4.5" />
<path d="M14.5 4l5.5 5.5" />
</svg>
</span>
<span class="share-option-text">
<span class="share-option-title">This exact version</span>
<span class="share-option-desc">
Pinned to what you see now it never changes.
</span>
</span>
</button>
<button
v-if="hasStack"
type="button"
class="share-option"
@click="copy(livingUrl(), 'Always-latest')"
>
<span class="share-option-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-repeat"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 12v-3a3 3 0 0 1 3 -3h13m-3 -3l3 3l-3 3" />
<path d="M20 12v3a3 3 0 0 1 -3 3h-13m3 3l-3 -3l3 -3" />
</svg>
</span>
<span class="share-option-text">
<span class="share-option-title">Always latest</span>
<span class="share-option-desc">
Follows these notes, so the reader always sees the current version.
</span>
</span>
</button>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</template>
<style scoped lang="scss">
.share-option {
display: flex;
align-items: flex-start;
gap: 0.75rem;
width: 100%;
margin-top: 1rem;
padding: 0.75rem 1rem;
text-align: left;
border: 1px solid var(--color-base-300);
border-radius: var(--radius-box, 0.5rem);
background-color: var(--color-base-100);
color: var(--color-base-content);
cursor: pointer;
&:hover {
border-color: var(--color-primary);
}
}
.share-option-icon {
flex-shrink: 0;
color: var(--color-accent);
}
.share-option-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.share-option-title {
font-weight: 600;
}
.share-option-desc {
font-size: 0.85em;
opacity: 0.75;
}
</style>

View File

@@ -1,11 +1,5 @@
<script lang="ts" setup>
import {
computed,
defineAsyncComponent,
onMounted,
ref,
watch
} from "vue"
import { computed, defineAsyncComponent, onMounted, ref, watch } from "vue"
import { useEditionMode } from "@/hooks/useEditionMode"
import { useFile } from "@/hooks/useFile.hook"
@@ -23,6 +17,7 @@ import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
import { getFileLanguage, isMarkdownPath } from "@/utils/fileLanguage"
import { filenameToNoteTitle } from "@/utils/noteTitle"
import { errorMessage } from "@/utils/notif"
import { threeWayMerge } from "@/utils/threeWayMerge"
const LinkedNotes = defineAsyncComponent(
() => import("@/components/LinkedNotes.vue")
@@ -40,6 +35,10 @@ const EditNote = defineAsyncComponent(
() => import("@/modules/note/components/EditNote.vue")
)
const NoteState = defineAsyncComponent(
() => import("@/components/NoteState.vue")
)
const props = defineProps<{
user: string
repo: string
@@ -53,16 +52,28 @@ const repo = computed(() => props.repo)
const sha = computed(() => props.sha)
const index = computed(() => props.index)
const { scrollToFocusedNote } = useRouteQueryStackedNotes()
const { scrollToFocusedNote, replaceStackedNote } = useRouteQueryStackedNotes()
// When this note's content changes (edit / pull) its sha changes too; advance
// the stack handle so the live view follows it, leaving the old sha as an
// immutable snapshot for any link already shared.
const advanceStackTo = (newSha: string | null) => {
if (newSha) replaceStackedNote(sha.value, newSha)
}
const {
path,
newerSha,
content,
rawContent,
getRawContent,
saveCacheNote,
getEditedSha
} = useFile(sha)
// When this is an older snapshot of a still-existing note, jump the stack to
// its current version (the exact snapshot is never swapped underneath you).
const viewLatest = () => advanceStackTo(newerSha.value)
const initialRawContent = ref<string | null>(null)
const isMarkdown = computed(() =>
path.value ? isMarkdownPath(path.value) : true
@@ -144,7 +155,8 @@ const {
lastCheckedAt,
latestSha,
check: checkFreshness,
pullLatest
pullLatest,
resolveMergeSources
} = useNoteFreshness({
user: user.value,
repo: repo.value,
@@ -154,10 +166,34 @@ const {
})
const conflictOpen = ref(false)
const loadStatus = ref<"loading" | "ready" | "failed">("loading")
onMounted(async () => {
initialRawContent.value = await getRawContent()
})
const loadNote = async () => {
loadStatus.value = "loading"
const raw = await getRawContent()
if (raw === null) {
loadStatus.value = "failed"
return
}
rawContent.value = raw
initialRawContent.value = raw
loadStatus.value = "ready"
}
onMounted(loadNote)
// A note only has a trustworthy baseline once it has finished loading. While
// it is still loading (or after a failed load) rawContent holds the empty
// placeholder and initialRawContent is null, so a naive
// `rawContent !== initialRawContent` check reads as a huge edit — and saving
// that would clobber the real file on GitHub with an empty commit. Gate every
// "is this dirty?" decision on a loaded baseline.
const isDirty = computed(
() =>
loadStatus.value === "ready" &&
initialRawContent.value !== null &&
rawContent.value !== initialRawContent.value
)
watch(
path,
@@ -172,10 +208,10 @@ const { mode, toggleMode } = useEditionMode()
useMarkdownPostRender(content, () => `.note-${sha.value}`, {
onReady: () => listenToClick(),
tikz: true,
macroplan: true,
mermaid: () => rawContent.value.includes("```mermaid"),
shikiji: () => isMarkdown.value && rawContent.value.includes("```"),
images: () =>
/\!\[.*?\]\(.*?\)/.test(rawContent.value) ? props.sha : null,
images: () => (/\!\[.*?\]\(.*?\)/.test(rawContent.value) ? props.sha : null),
triggers: [mode]
})
@@ -185,6 +221,13 @@ const performSave = async (overrideSha?: string) => {
return
}
// Defence in depth: never push content we didn't successfully load, or we'd
// overwrite the file on GitHub with the empty placeholder.
if (loadStatus.value !== "ready" || initialRawContent.value === null) {
console.warn("refusing to save a note that hasn't finished loading")
return
}
const editedSha = overrideSha ?? (await getEditedSha()) ?? sha.value
const { sha: newSha, conflict } = await updateFile({
content: rawContent.value,
@@ -193,9 +236,7 @@ const performSave = async (overrideSha?: string) => {
})
if (conflict) {
await checkFreshness()
conflictOpen.value = true
if (mode.value === "read") toggleMode()
await handleConflict()
return
}
@@ -205,9 +246,52 @@ const performSave = async (overrideSha?: string) => {
}
await saveCacheNote(encodeUTF8ToBase64(rawContent.value), {
editedSha: newSha
editedSha: newSha,
path: path.value
})
initialRawContent.value = rawContent.value
advanceStackTo(newSha)
}
// On a save/freshness conflict, try a 3-way merge first: if our edits and the
// remote ones don't overlap, commit the merge silently (just a toast) so the
// user is never interrupted. Only genuinely overlapping edits open the modal.
const handleConflict = async () => {
if (!path.value) return
const sources = await resolveMergeSources()
if (sources) {
const { clean, merged } = threeWayMerge(
sources.base,
rawContent.value,
sources.theirs
)
if (clean) {
const { sha: newSha, conflict } = await updateFile({
content: merged,
path: path.value,
sha: sources.remoteSha,
successMessage: "✅ Merged remote changes & saved"
})
if (!conflict && newSha) {
rawContent.value = merged
await saveCacheNote(encodeUTF8ToBase64(merged), {
editedSha: newSha,
path: path.value
})
initialRawContent.value = merged
advanceStackTo(newSha)
return
}
// Remote moved again between fetch and commit — fall back to the modal.
}
}
// Overlapping edits, or merge sources unavailable — let the user decide.
// Ensure latestSha is set so the modal's "overwrite" has a target.
if (!latestSha.value) await checkFreshness()
conflictOpen.value = true
if (mode.value === "read") toggleMode()
}
watch(mode, async (newMode) => {
@@ -216,8 +300,7 @@ watch(mode, async (newMode) => {
return
}
const hasUserFinishedToEdit =
newMode === "read" && rawContent.value !== initialRawContent.value
const hasUserFinishedToEdit = newMode === "read" && isDirty.value
if (!hasUserFinishedToEdit) {
return
@@ -229,7 +312,7 @@ watch(mode, async (newMode) => {
await checkFreshness()
if (freshnessStatus.value === "outdated") {
conflictOpen.value = true
await handleConflict()
return
}
@@ -241,6 +324,7 @@ const onConflictDiscard = async () => {
if (raw !== null) {
rawContent.value = raw
initialRawContent.value = raw
advanceStackTo(latestSha.value)
}
}
@@ -264,9 +348,9 @@ const onBadgeClick = async () => {
return
}
const hasUnsavedEdits = rawContent.value !== initialRawContent.value
const hasUnsavedEdits = isDirty.value
if (hasUnsavedEdits) {
conflictOpen.value = true
await handleConflict()
return
}
@@ -274,6 +358,7 @@ const onBadgeClick = async () => {
if (raw !== null) {
rawContent.value = raw
initialRawContent.value = raw
advanceStackTo(latestSha.value)
return
}
if (failureStatus === "unauthorized") {
@@ -304,7 +389,7 @@ const onBadgeClick = async () => {
class="action"
/>
<button
v-if="isMarkdown && canPush"
v-if="isMarkdown && canPush && loadStatus === 'ready'"
class="action button is-text is-light"
:class="{ 'is-link': mode === 'edit' }"
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''"
@@ -411,11 +496,28 @@ const onBadgeClick = async () => {
<div v-if="mode === 'edit' && isMarkdown" class="edit">
<edit-note :key="editKey" v-model="rawContent" />
</div>
<div
v-if="mode === 'read'"
class="note-content"
v-html="displayedContent"
></div>
<template v-else-if="mode === 'read'">
<div v-if="newerSha" class="snapshot-banner">
<span>You're viewing an older shared version.</span>
<button
type="button"
class="btn bt-sm btn-soft btn-info"
@click="viewLatest"
>
View latest
</button>
</div>
<div
v-if="displayedContent"
class="note-content"
v-html="displayedContent"
></div>
<note-state
v-else
:status="loadStatus === 'failed' ? 'failed' : 'loading'"
@retry="loadNote"
/>
</template>
</section>
<linked-notes v-if="hasBacklinks && content" :sha="sha" />
<note-conflict-modal
@@ -486,6 +588,18 @@ $border-color: rgba(18, 19, 58, 0.2);
display: none;
}
.snapshot-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin: 0 0 1rem;
padding: 0.4rem 0.75rem;
border: 1px solid $border-color;
border-radius: 0.375rem;
font-size: 0.85em;
}
.action {
margin: 0;

View File

@@ -79,7 +79,8 @@ const content = computed(() =>
useMarkdownPostRender(content, () => `.note-${classNameId.value}`, {
onReady: () => listenToClick(),
tikz: true
tikz: true,
macroplan: true
})
</script>

View File

@@ -381,10 +381,10 @@ onMounted(() => {
comes to rest.
</h1>
<p class="lede">
Point it at any markdown a GitHub repo of your own, or public
notes from the open ATProto network and Remanso turns it into a
calm, stackable notebook with backlinks where your thinking
finally runs clear.
Point it at any markdown: a GitHub repo of your own, or public
notes from the open ATProto network. Remanso turns it into a
stackable notebook with backlinks where your thinking finally
runs clear.
</p>
<div class="hero-ed-paths">
<!-- CTA 01: GitHub repo -->
@@ -472,7 +472,7 @@ onMounted(() => {
</div>
</div>
<div v-else-if="publicNotesLoadFailed" class="network-empty">
The pool is quiet right now.
Nothing has drifted in yet.
</div>
<router-link
:to="{ name: 'PublicNoteListView' }"

View File

@@ -2,6 +2,7 @@ import { computed, Ref, ref, toValue } from "vue"
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
import { latestShaIfOlder } from "@/modules/note/snapshotStatus"
import { queryFileContent } from "@/modules/repo/services/repo"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
@@ -14,6 +15,16 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
return file?.path
})
// Path recovered from the cached note doc — lets us locate the latest version
// even when this (old) sha is no longer in store.files.
const cachedNotePath = ref<string | undefined>()
// When viewing an older snapshot of a known note, the note's current sha
// (null when already viewing the latest).
const newerSha = computed(() =>
latestShaIfOlder(shaValue, path.value ?? cachedNotePath.value, store.files)
)
const {
render,
renderFromUTF8,
@@ -47,6 +58,8 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
fromCache.value = !!cachedNote
if (cachedNote) {
cachedNotePath.value = cachedNote.path ?? cachedNotePath.value
if (from === "path") {
queryFileContent(store.user, store.repo, shaValue).then(
(fileContent) => {
@@ -104,6 +117,7 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
return {
path,
newerSha,
content,
rawContent,
getRawContent,

View File

@@ -88,18 +88,20 @@ export const useGitHubContent = ({
const putFile = async ({
content,
path,
sha
sha,
successMessage = "✅ Note saved"
}: {
content: string
path: string
sha?: string
successMessage?: string
}): Promise<{ sha: string | null; conflict: boolean }> =>
putRaw({
contentBase64: encodeUTF8ToBase64(content),
path,
sha,
message: `Updating ${path} from Remanso`,
successMessage: "✅ Note saved",
successMessage,
conflictMessage: "⚠ Conflict: this note changed on GitHub",
failureMessage: "❌ Note could not be saved"
})
@@ -126,6 +128,7 @@ export const useGitHubContent = ({
content: string
path: string
sha: string
successMessage?: string
}) => putFile(props),
createFile: async (props: { content: string; path: string }) =>
putFile(props),

View File

@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest"
vi.mock("@/data/data", () => ({
data: {},
generateId: (type: string, id: string) => `${type}-${id}`
}))
import { renderCodeFile } from "@/hooks/useMarkdown.hook"
describe("renderCodeFile", () => {
it("wraps highlighted files in a code-file container with a sized gutter", async () => {
const html = await renderCodeFile({
rawContent: "const a = 1\nconst b = 2\n",
lang: "typescript",
filename: "example.ts"
})
expect(html).toContain('<div class="code-file"')
expect(html).toContain("--line-number-width:1ch")
expect(html.match(/class="line"/g)).toHaveLength(2)
})
it("wraps each line of unknown-language files in line spans", async () => {
const html = await renderCodeFile({
rawContent: "line one\nline <two>\n",
lang: null
})
expect(html).toContain('<span class="line">line one</span>')
expect(html).toContain('<span class="line">line &lt;two&gt;</span>')
expect(html.match(/class="line"/g)).toHaveLength(2)
})
it("sizes the gutter for files with many lines", async () => {
const html = await renderCodeFile({
rawContent: Array.from({ length: 120 }, (_, i) => `line ${i}`).join("\n"),
lang: null
})
expect(html).toContain("--line-number-width:3ch")
})
})

View File

@@ -25,6 +25,7 @@ import {
} from "@/utils/decodeBase64ToUTF8"
import { html5Media } from "@/utils/markdown/markdown-html5-media"
import { markdownItTablerIcons } from "@/utils/markdown/markdown-it-tabler-icons"
import { renderFallback } from "@/utils/markdown/renderFallback"
const TIKZ_BUNDLE_URL =
"https://cdn.jsdelivr.net/gh/artisticat1/obsidian-tikzjax@0.5.2/tikzjax.js"
@@ -94,6 +95,37 @@ const markdownItTikzExtractor = (md: MarkdownIt) => {
}
}
const markdownItMacroplanExtractor = (md: MarkdownIt) => {
const defaultFence =
md.renderer.rules.fence ||
function (
tokens: Array<Token>,
index: number,
options: Options,
_: unknown,
self: Renderer
) {
return self.renderToken(tokens, index, options)
}
md.renderer.rules.fence = function (
tokens: Array<Token>,
index: number,
options: Options,
env: unknown,
self: Renderer
) {
const token = tokens[index]
if (token.info.trim() === "macroplan") {
const encoded = encodeUTF8ToBase64(token.content)
return `<div class="macroplan-block" data-macroplan-source="${encoded}"><span class="macroplan-loading">Rendering macroplan…</span></div>\n`
}
return defaultFence(tokens, index, options, env, self)
}
}
const slugger = new GithubSlugger()
let tabGroupCounter = 0
@@ -106,6 +138,7 @@ const md = new MarkdownIt({
})
.use(markdownItMermaidExtractor)
.use(markdownItTikzExtractor)
.use(markdownItMacroplanExtractor)
.use(html5Media)
.use(blockEmbedPlugin, {
youtube: {
@@ -161,6 +194,7 @@ export const useShikiji = (): Promise<void> => {
"html",
"css",
"json",
"rust",
{
...alloyGrammar,
name: "alloy",
@@ -399,7 +433,14 @@ const renderMarkdown = (content: string, env?: Record<string, unknown>) => {
// unhighlighted because nothing else invalidates the computed.
if (content.includes("```")) void shikijiReady.value
slugger.reset()
return env ? md.render(content, env) : md.render(content)
try {
return env ? md.render(content, env) : md.render(content)
} catch (error) {
// Never let a plugin failure blank the note or dump a raw error —
// degrade to the note's raw text.
console.error("markdown render failed", error)
return renderFallback(content)
}
}
export const renderCodeFile = async ({
@@ -413,10 +454,22 @@ export const renderCodeFile = async ({
}): Promise<string> => {
await useShikiji()
const heading = filename ? `# ${filename}\n\n` : ""
// Drop the trailing newline so the final empty line isn't numbered.
const lines = rawContent.replace(/\n$/, "").split("\n")
// Sized gutter so line numbers stay right-aligned past 3 digits.
const wrapper = `<div class="code-file" style="--line-number-width:${String(lines.length).length}ch">`
if (lang !== null) {
return renderMarkdown(`${heading}\`\`\`\`${lang}\n${rawContent}\n\`\`\`\``)
const rendered = renderMarkdown(
`${heading}\`\`\`\`${lang}\n${lines.join("\n")}\n\`\`\`\``
)
// Shikiji always appends an empty line span; drop it so it isn't numbered.
return `${wrapper}${rendered.replace('\n<span class="line"></span></code>', "</code>")}</div>`
}
return `${renderMarkdown(heading)}<pre><code>${md.utils.escapeHtml(rawContent)}</code></pre>`
// Mirror Shikiji's per-line spans so the line-number CSS applies uniformly.
const code = lines
.map((line) => `<span class="line">${md.utils.escapeHtml(line)}</span>`)
.join("\n")
return `${wrapper}${renderMarkdown(heading)}<pre><code>${code}</code></pre></div>`
}
export const markdownBuilder = (defaultPrefix?: Ref<string> | string) => {

View File

@@ -6,11 +6,13 @@ import {
runTikz,
useShikiji
} from "@/hooks/useMarkdown.hook"
import { runMacroplan } from "@/modules/macroplan/runMacroplan"
import { attachSvgDownloads } from "@/utils/svgDownload"
interface MarkdownPostRenderOptions {
onReady?: () => void
tikz?: boolean
macroplan?: boolean
mermaid?: () => boolean
shikiji?: () => boolean
images?: () => string | null | undefined
@@ -44,6 +46,10 @@ export const useMarkdownPostRender = (
renderJobs.push(runMermaid(`${scope} .mermaid`))
}
if (options.macroplan) {
renderJobs.push(runMacroplan(`${scope} .macroplan-block`))
}
if (options.shikiji?.()) {
void useShikiji()
}

View File

@@ -2,18 +2,27 @@ import { createPinia, setActivePinia } from "pinia"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { ref } from "vue"
import { DataType } from "@/data/DataType.enum"
const {
fetchLatestSha,
addFile,
getRawContent,
saveCacheNote,
queryFileContent
queryFileContent,
dataGet
} = vi.hoisted(() => ({
fetchLatestSha: vi.fn(),
addFile: vi.fn(),
getRawContent: vi.fn((s: string) => s),
saveCacheNote: vi.fn(),
queryFileContent: vi.fn()
queryFileContent: vi.fn(),
dataGet: vi.fn()
}))
vi.mock("@/data/data", () => ({
data: { get: dataGet },
generateId: (type: string, id: string) => `${type}-${id}`
}))
vi.mock("@/hooks/useGitHubContent.hook", () => ({
@@ -232,3 +241,87 @@ describe("useNoteFreshness.pullLatest", () => {
expect(queryFileContent).toHaveBeenCalledWith("alice", "notes", "cached-sha")
})
})
describe("useNoteFreshness.resolveMergeSources", () => {
beforeEach(() => {
fetchLatestSha.mockReset()
queryFileContent.mockReset()
dataGet.mockReset()
getRawContent.mockReset()
getRawContent.mockImplementation((s: string) => s)
skipSpinnerWait()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("returns null when path is empty", async () => {
const { resolveMergeSources } = setup({ path: "" })
expect(await resolveMergeSources()).toBeNull()
})
it("resolves base (from cache snapshot) + theirs + remoteSha", async () => {
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
queryFileContent.mockResolvedValue("THEIRS_B64") // theirs blob
dataGet.mockResolvedValue({ content: "BASE_B64" }) // base snapshot in cache
const { resolveMergeSources, latestSha } = setup({
sha: "live",
edited: "base-sha"
})
const result = await resolveMergeSources()
expect(result).toEqual({
base: "BASE_B64",
theirs: "THEIRS_B64",
remoteSha: "remote-sha"
})
// base must be read from its own immutable snapshot, never the path pointer
expect(dataGet).toHaveBeenCalledWith(`${DataType.Note}-base-sha`)
expect(queryFileContent).toHaveBeenCalledWith("alice", "notes", "remote-sha")
expect(latestSha.value).toBe("remote-sha")
})
it("falls back to fetching the base blob when not cached", async () => {
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
dataGet.mockResolvedValue(null) // cache miss for base
queryFileContent.mockImplementation((_u, _r, s: string) =>
Promise.resolve(s === "remote-sha" ? "THEIRS_B64" : "BASE_FROM_BLOB")
)
const { resolveMergeSources } = setup({ sha: "base-sha" })
const result = await resolveMergeSources()
expect(result?.base).toBe("BASE_FROM_BLOB")
expect(queryFileContent).toHaveBeenCalledWith("alice", "notes", "base-sha")
})
it("returns null when the remote sha cannot be resolved", async () => {
fetchLatestSha.mockResolvedValue({ kind: "offline" })
const { resolveMergeSources } = setup()
expect(await resolveMergeSources()).toBeNull()
})
it("returns null when the remote (theirs) blob fetch fails", async () => {
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
queryFileContent.mockResolvedValue(null)
const { resolveMergeSources, latestSha } = setup()
expect(await resolveMergeSources()).toBeNull()
// latestSha is still surfaced so a modal fallback's overwrite has a target
expect(latestSha.value).toBe("remote-sha")
})
it("returns null when the base blob is unavailable (cache miss + blob null)", async () => {
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
dataGet.mockResolvedValue(null)
queryFileContent.mockImplementation((_u, _r, s: string) =>
Promise.resolve(s === "remote-sha" ? "THEIRS_B64" : null)
)
const { resolveMergeSources } = setup({ sha: "base-sha" })
expect(await resolveMergeSources()).toBeNull()
})
})

View File

@@ -1,8 +1,11 @@
import { Ref, ref } from "vue"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
import { Note } from "@/modules/note/models/Note"
import { queryFileContent } from "@/modules/repo/services/repo"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
@@ -114,11 +117,52 @@ export const useNoteFreshness = ({
return { raw: getRawContent(fileContent), failureStatus: null }
}
// The base blob (common ancestor) must come from its own immutable snapshot,
// never from the path pointer — that holds the latest, which is "theirs".
const getCachedBlob = async (blobSha: string): Promise<string | null> => {
const note = await data.get<DataType.Note, Note>(
generateId(DataType.Note, blobSha)
)
return note?.content ?? null
}
// Resolves the decoded raw text needed for a 3-way merge: `base` is the
// version we edited from (the rejected sha), `theirs` is the current remote.
// Returns null when any piece can't be resolved (offline, missing base).
const resolveMergeSources = async (): Promise<{
base: string
theirs: string
remoteSha: string
} | null> => {
if (!path.value) return null
const { sha: remoteSha } = await resolveRemoteSha(path.value)
if (!remoteSha) return null
// Surface the resolved sha so a modal fallback's Overwrite can use it.
latestSha.value = remoteSha
const theirsBlob = await queryFileContent(user, repo, remoteSha)
if (!theirsBlob) return null
const baseSha = (await getEditedSha()) ?? sha.value
const baseBlob =
(await getCachedBlob(baseSha)) ??
(await queryFileContent(user, repo, baseSha))
if (!baseBlob) return null
const { getRawContent } = markdownBuilder(sha.value)
return {
base: getRawContent(baseBlob),
theirs: getRawContent(theirsBlob),
remoteSha
}
}
return {
status,
lastCheckedAt,
latestSha,
check,
pullLatest
pullLatest,
resolveMergeSources
}
}

View File

@@ -0,0 +1,50 @@
import { watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { resolveLivePathsToShas } from "@/modules/note/liveNotes"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
// A living link (`?liveNotes=path&liveNotes=path`) carries file paths instead of
// pinned blob shas. On open we resolve each path to the latest sha against the
// HEAD file list and rewrite the URL to the ordinary pinned `stackedNotes` form
// — so the recipient lands on the current version, and from there everything
// (freshness, editing, the snapshot banner) behaves exactly as a shared snapshot
// would. The rewrite is a `replace`, so no history entry and no view transition.
export const useResolveLiveNotes = (onResolved?: () => void) => {
const route = useRoute()
const router = useRouter()
const store = useUserRepoStore()
let resolved = false
const tryResolve = () => {
if (resolved) return
const raw = route.query.liveNotes
if (!raw) {
resolved = true
return
}
// Wait for the HEAD file list before resolving paths to shas.
if (store.files.length === 0) return
resolved = true
const entries = (Array.isArray(raw) ? raw : [raw]).filter(
(entry): entry is string => typeof entry === "string"
)
const shas = resolveLivePathsToShas(entries, store.files)
const query = { ...route.query }
delete query.liveNotes
if (shas.length) {
query.stackedNotes = shas
} else {
delete query.stackedNotes
}
router.replace({ path: route.path, query }).then(() => onResolved?.())
}
watch(() => store.files.length, tryResolve, { immediate: true })
}

View File

@@ -111,6 +111,16 @@ export const useRouteQueryStackedNotes = () => {
})
}
// Focus the deepest note when opening a shared link with stacked notes,
// instead of landing on the readme and forcing a manual scroll (especially
// long on mobile where notes stack vertically).
const scrollToLastStackedNote = () => {
const lastNote = stackedNotes.value.at(-1)
if (lastNote) {
scrollToFocusedNote({ noteId: lastNote })
}
}
const addStackedNote = (
currentSha: string,
sha: string,
@@ -149,9 +159,26 @@ export const useRouteQueryStackedNotes = () => {
scrollToFocusedNote({ noteId: selector ?? sha, hash, anchorTop })
}
// Advance a note's handle in place when its content changes (edit / pull):
// the live view follows to the new sha, while any previously-shared link
// keeps pointing at the old, now-immutable snapshot.
const replaceStackedNote = (oldSha: string, newSha: string) => {
if (!oldSha || !newSha || oldSha === newSha) {
return
}
if (!stackedNotes.value.includes(oldSha)) {
return
}
stackedNotes.value = stackedNotes.value.map((note) =>
note === oldSha ? newSha : note
)
}
return {
stackedNotes: readonly(stackedNotes),
addStackedNote,
scrollToFocusedNote
replaceStackedNote,
scrollToFocusedNote,
scrollToLastStackedNote
}
}

View File

@@ -0,0 +1,436 @@
<script setup lang="ts">
import { computed } from "vue"
import type { FeatureRow, MarkerKind,Plan } from "../model/types"
import { type WeekId,weekLabel } from "../model/week"
const props = defineProps<{ plan: Plan }>()
type Tone = "success" | "warning" | "error" | "neutral"
const GLYPH: Record<MarkerKind, string> = {
original: "◯",
reestimate: "△",
"delivered-on-time": "◉",
"delivered-late": "▲",
}
const MARKER_CLASS: Record<MarkerKind, string> = {
original: "text-base-content/50",
reestimate: "text-warning",
"delivered-on-time": "text-success",
"delivered-late": "text-error",
}
// When two markers land on the same week, the higher rank owns the cell.
const RANK: Record<MarkerKind, number> = {
"delivered-late": 3,
"delivered-on-time": 3,
reestimate: 2,
original: 1,
}
const TONE_TEXT: Record<Tone, string> = {
success: "text-success",
warning: "text-warning",
error: "text-error",
neutral: "text-primary",
}
const TONE_BORDER: Record<Tone, string> = {
success: "border-success",
warning: "border-warning",
error: "border-error",
neutral: "border-primary",
}
const TONE_DOT: Record<Tone, string> = {
success: "bg-success",
warning: "bg-warning",
error: "bg-error",
neutral: "bg-base-300",
}
// Layout constants (must match the CSS vars --name-w / --wk) for stacking math.
const NAME_W = 9 * 16
const WK = 3.5 * 16
const BAND_CHAR = 6.6 // ≈ Fira Code advance (px) at the band font size
const weeks = computed(() => props.plan.weeks)
const gridStyle = computed(() => ({
gridTemplateColumns: `var(--name-w) repeat(${weeks.value.length}, var(--wk)) minmax(9rem, 1fr)`,
}))
function tone(row: FeatureRow): Tone {
if (row.delivered) return row.onTime ? "success" : "error"
if (row.status === "on-track") return "success"
if (row.status === "at-risk") return "warning"
if (row.status === "off-track") return "error"
return "neutral"
}
function statusWord(row: FeatureRow): string {
return row.status ? row.status.replace("-", " ") : "in flight"
}
function markerAt(row: FeatureRow, w: WeekId): MarkerKind | null {
let best: MarkerKind | null = null
for (const m of row.markers) {
if (m.week === w && (best === null || RANK[m.kind] > RANK[best])) best = m.kind
}
return best
}
interface Cell {
// how far the bar line runs within this cell: none, center→right,
// left→center, or full width
line: "none" | "right" | "left" | "full"
isStart: boolean
glyph: string
glyphCls: string
}
// rows × weeks render matrix, computed once.
const matrix = computed<Cell[][]>(() =>
props.plan.rows.map((row) =>
weeks.value.map((w) => {
const m = markerAt(row, w)
const inBar = w >= row.startWeek && w <= row.barEndWeek
let line: Cell["line"] = "none"
if (inBar) {
const isStart = w === row.startWeek
const isEnd = w === row.barEndWeek
line = isStart && isEnd ? "none" : isStart ? "right" : isEnd ? "left" : "full"
}
return {
line,
isStart: inBar && w === row.startWeek,
glyph: m ? GLYPH[m] : "",
glyphCls: m ? MARKER_CLASS[m] : "",
}
}),
),
)
// Per-week column metadata for the header + the now / milestone column rules.
const cols = computed(() =>
weeks.value.map((w) => ({
w,
label: weekLabel(w),
isNow: props.plan.nowInRange && w === props.plan.nowWeek,
isMilestone: props.plan.milestones.some((m) => m.week === w),
})),
)
function colClass(i: number): string {
const c = cols.value[i]
if (c.isNow) return "col-now"
if (c.isMilestone) return "col-ms"
return ""
}
// Milestone label flags above the axis, greedily stacked onto extra rows so
// labels in nearby weeks never overlap. `col` is the 1-based grid column.
const milestoneFlags = computed(() => {
const items = props.plan.milestones
.map((m) => ({ m, col: weeks.value.indexOf(m.week) + 2 }))
.filter((x) => x.col >= 2)
.sort((a, b) => a.col - b.col)
const rowEnd: number[] = [] // px x-extent of the last flag placed in each row
return items.map((x) => {
const startX = NAME_W + (x.col - 2) * WK
const text = `${x.m.name}` + (x.m.unmet.length ? ` · ${x.m.unmet.length} unmet` : "")
const width = text.length * BAND_CHAR + 14
let row = 0
while (row < rowEnd.length && rowEnd[row] > startX - 6) row++
rowEnd[row] = startX + width
const title = x.m.unmet.length
? `${x.m.name} — unmet: ${x.m.unmet.join(", ")}`
: `${x.m.name} — all required features met`
return { name: x.m.name, unmet: x.m.unmet, col: x.col, row: row + 1, title }
})
})
const bandRows = computed(() => milestoneFlags.value.reduce((n, f) => Math.max(n, f.row), 0))
const bandStyle = computed(() => ({
gridTemplateColumns: `var(--name-w) repeat(${weeks.value.length}, var(--wk))`,
gridTemplateRows: `repeat(${bandRows.value}, 1.15rem)`,
}))
</script>
<template>
<div class="macroplan overflow-auto rounded-box border border-base-300 bg-base-100">
<!-- milestone band: stacked label flags with leader lines down to the axis -->
<div v-if="milestoneFlags.length" class="ms-band" :style="bandStyle">
<template v-for="f in milestoneFlags" :key="f.name + '-' + f.col">
<i class="ms-lead" :style="{ gridColumn: f.col, gridRow: f.row + ' / -1' }" />
<span class="ms-flag" :style="{ gridColumn: f.col, gridRow: f.row }" :title="f.title">
<span class="ms-dia"></span> {{ f.name
}}<span v-if="f.unmet.length" class="ms-unmet"> · {{ f.unmet.length }} unmet</span>
</span>
</template>
</div>
<div class="plan-grid" :style="gridStyle">
<!-- header row -->
<div class="hcell corner">Feature</div>
<div
v-for="(c, ci) in cols"
:key="'h-' + c.w"
class="hcell wkhead"
:class="[colClass(ci), { 'now-head': c.isNow }]"
>
<span class="wklabel">{{ c.label }}</span>
<span v-if="c.isNow" class="badge-now">now</span>
</div>
<div class="hcell learnhead">Learning / Status</div>
<!-- feature rows -->
<template v-for="(row, ri) in plan.rows" :key="row.name">
<div class="namecell border-l-4" :class="TONE_BORDER[tone(row)]">
<span class="truncate" :title="row.name">{{ row.name }}</span>
</div>
<div
v-for="(cell, ci) in matrix[ri]"
:key="row.name + '-' + ci"
class="cell"
:class="colClass(ci)"
>
<i v-if="cell.line !== 'none'" class="bar" :class="[cell.line, TONE_TEXT[tone(row)]]"></i>
<i v-if="cell.isStart" class="riser" :class="TONE_TEXT[tone(row)]"></i>
<span v-if="cell.glyph" class="glyph" :class="cell.glyphCls">{{ cell.glyph }}</span>
</div>
<div class="learncell">
<template v-if="row.delivered">
<span v-if="row.learning" class="note">{{ row.learning }}</span>
<span v-else class="muted">{{
row.onTime ? "delivered on time" : "delivered late"
}}</span>
</template>
<template v-else>
<span class="dot" :class="TONE_DOT[tone(row)]"></span>
<span class="note" :title="row.note || ''">{{ row.note || statusWord(row) }}</span>
</template>
</div>
</template>
</div>
</div>
<!-- legend -->
<div class="legend">
<span><b class="text-base-content/50"></b> original estimate</span>
<span><b class="text-warning"></b> re-estimate</span>
<span><b class="text-success"></b> on time</span>
<span><b class="text-error"></b> late</span>
<span><b></b> feature bar</span>
<span><b class="ms-dia"></b> milestone</span>
<span><b class="now-swatch"></b> today</span>
</div>
</template>
<style scoped>
.macroplan {
--name-w: 9rem;
--wk: 3.5rem;
}
.plan-grid {
display: grid;
width: max-content;
min-width: 100%;
/* Never let a too-tall container (e.g. the mobile 100svh note height that
cascades in) stretch the auto row tracks — pack rows to their content
height at the top. */
align-content: start;
font-variant-ligatures: none;
}
/* ── Milestone band ───────────────────────────────────────────────── */
.ms-band {
display: grid;
width: max-content;
min-width: 100%;
padding-top: 0.35rem;
font-variant-ligatures: none;
}
.ms-flag {
align-self: center;
white-space: nowrap;
overflow: visible;
font-size: 0.66rem;
font-weight: 600;
line-height: 1;
color: var(--color-base-content);
z-index: 1;
}
.ms-dia {
color: color-mix(in oklab, var(--color-base-content) 55%, var(--color-base-100));
}
.ms-unmet {
color: var(--color-error); /* unmet required features = a problem = red */
font-weight: 700;
}
.ms-lead {
justify-self: start;
width: 0;
border-left: 2px dashed color-mix(in oklab, var(--color-base-content) 30%, var(--color-base-100));
pointer-events: none;
}
.hcell {
position: sticky;
top: 0;
z-index: 20;
background: var(--color-base-200);
border-bottom: 1px solid var(--color-base-300);
padding: 0.4rem 0.5rem;
font-size: 0.7rem;
font-weight: 600;
white-space: nowrap;
}
.corner {
left: 0;
z-index: 30;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
.wkhead {
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.15;
padding-inline: 0.15rem;
}
.wklabel {
font-variant-numeric: tabular-nums;
}
.now-head {
color: var(--color-primary);
}
.badge-now {
font-size: 0.58rem;
font-weight: 700;
color: var(--color-primary);
}
.learnhead {
text-align: left;
}
.namecell {
position: sticky;
left: 0;
z-index: 10;
background: var(--color-base-100);
display: flex;
align-items: center;
min-height: 2rem;
padding: 0 0.6rem;
font-size: 0.8rem;
font-weight: 500;
border-bottom: 1px solid var(--color-base-200);
}
.namecell .truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 2rem;
border-bottom: 1px solid var(--color-base-200);
}
.bar {
position: absolute;
top: 50%;
height: 2px;
transform: translateY(-50%);
background: currentColor;
opacity: 0.55;
}
.bar.full {
left: 0;
right: 0;
}
.bar.left {
left: 0;
right: 50%;
} /* enters from the left, stops at the glyph */
.bar.right {
left: 50%;
right: 0;
} /* starts at the first glyph, runs right */
.riser {
position: absolute;
left: 50%;
top: 28%;
bottom: 28%;
width: 2px;
transform: translateX(-50%);
background: currentColor;
opacity: 0.55;
}
.glyph {
position: relative;
z-index: 1;
font-size: 0.95rem;
line-height: 1;
padding: 0 0.18rem;
/* halo so the bar reads as passing behind, not through, the symbol */
background: var(--color-base-100);
border-radius: 0.3rem;
}
.learncell {
display: flex;
align-items: center;
gap: 0.4rem;
min-height: 2rem;
padding: 0 0.6rem;
font-size: 0.72rem;
border-bottom: 1px solid var(--color-base-200);
}
.learncell .muted {
opacity: 0.5;
}
.learncell .note {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.8;
}
.dot {
width: 0.6rem;
height: 0.6rem;
border-radius: 9999px;
flex: none;
}
/* "today" — a neutral grey column (red is reserved for problems) */
.cell.col-now,
.cell.col-now .glyph {
background: color-mix(in oklab, var(--color-base-content) 7%, var(--color-base-100));
}
.col-ms {
border-left: 2px dashed color-mix(in oklab, var(--color-base-content) 30%, var(--color-base-100));
}
.legend {
margin-top: 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1.25rem;
font-size: 0.72rem;
opacity: 0.85;
}
.legend b {
font-weight: 700;
}
.now-swatch {
display: inline-block;
width: 0.8rem;
height: 0.8rem;
vertical-align: -0.1rem;
border-radius: 0.15rem;
background: color-mix(in oklab, var(--color-base-content) 7%, var(--color-base-100));
border: 1px solid color-mix(in oklab, var(--color-base-content) 22%, var(--color-base-100));
}
</style>

View File

@@ -0,0 +1,153 @@
import { parse as parseToml } from "smol-toml"
import * as v from "valibot"
import type { RawPlan, StatusLevel } from "./types"
import { toYmd } from "./week"
/** Thrown for any malformed source — message is safe to show the author. */
export class PlanParseError extends Error {
constructor(message: string) {
super(message)
this.name = "PlanParseError"
}
}
const STATUSES = ["on-track", "at-risk", "off-track"] as const satisfies readonly StatusLevel[]
// ── Field schemas ──────────────────────────────────────────────────────────
// A TOML date (smol-toml returns a Date subclass) or a yyyy-mm-dd string,
// normalized to yyyy-mm-dd via `toYmd`.
const Ymd = v.pipe(
v.union([v.date(), v.string()], "must be a date (e.g. 2026-06-01)"),
v.transform((value: string | Date) => toYmd(value)),
)
const Status = v.picklist(STATUSES, `must be one of ${STATUSES.join(", ")}`)
const Name = v.pipe(v.string("is required"), v.nonEmpty("is required"))
const FeatureSchema = v.object({
name: Name,
start: Ymd,
original: Ymd,
reestimates: v.optional(v.array(Ymd, "must be a list of dates"), []),
delivered: v.optional(Ymd),
learning: v.optional(v.string()),
status: v.optional(Status),
note: v.optional(v.string()),
})
const MilestoneSchema = v.object({
name: Name,
week: Ymd,
requires: v.optional(
v.array(v.string("must be a feature name"), "must be a list of feature names"),
[],
),
})
/** The Macroplan format version this build understands (see docs/format.md).
* Files may declare `macroplan_version`; a newer version is rejected rather
* than silently mis-rendered against a schema we don't know. */
export const FORMAT_VERSION = 1
/** Parse + validate a Macroplan TOML source into the raw model. */
export function parseMacroplan(source: string): RawPlan {
let data: Record<string, unknown>
try {
data = parseToml(source) as Record<string, unknown>
} catch (e) {
throw new PlanParseError(e instanceof Error ? e.message : String(e))
}
checkVersion(data.macroplan_version)
const features = asBlocks(data.feature, "feature").map((f, i) =>
check(FeatureSchema, f, blockWhere("feature", f, i)),
)
requireUniqueFeatureNames(features)
return {
title: data.title != null ? String(data.title) : "Untitled Macroplan",
start: data.start != null ? check(Ymd, data.start, "plan", "start") : undefined,
end: data.end != null ? check(Ymd, data.end, "plan", "end") : undefined,
features,
milestones: asBlocks(data.milestone, "milestone").map((m, i) =>
check(MilestoneSchema, m, blockWhere("milestone", m, i)),
),
}
}
/** Validate the optional `macroplan_version` marker. Absent means the current
* version; a future version we don't understand is rejected rather than
* silently mis-rendered. */
function checkVersion(value: unknown): void {
if (value == null) return
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
throw new PlanParseError("`macroplan_version` must be a positive integer")
}
if (value > FORMAT_VERSION) {
throw new PlanParseError(
`unsupported macroplan_version ${value} — this build understands up to ${FORMAT_VERSION}`,
)
}
}
/** Feature names are the join key for a Milestone's `requires`, so they must be
* unique — otherwise a requirement resolves ambiguously to the first match. */
function requireUniqueFeatureNames(features: readonly { name: string }[]): void {
const seen = new Set<string>()
for (const f of features) {
if (seen.has(f.name)) {
throw new PlanParseError(`duplicate feature name "${f.name}" — feature names must be unique`)
}
seen.add(f.name)
}
}
function asBlocks(value: unknown, key: string): unknown[] {
if (value == null) return []
if (!Array.isArray(value)) {
throw new PlanParseError(`\`${key}\` must be written as [[${key}]] blocks`)
}
return value
}
/** "feature \"Payments\"" when the block carries a name, else "feature #2". */
function blockWhere(kind: string, block: unknown, i: number): string {
const name =
block != null && typeof block === "object" && "name" in block
? (block as { name: unknown }).name
: undefined
return name != null && name !== "" ? `${kind} "${String(name)}"` : `${kind} #${i + 1}`
}
/** Validate `value` against `schema`, raising a contextual PlanParseError. */
function check<S extends v.GenericSchema>(
schema: S,
value: unknown,
where: string,
field?: string,
): v.InferOutput<S> {
const result = v.safeParse(schema, value)
if (!result.success) {
throw new PlanParseError(`${where}: ${friendly(result.issues[0], field)}`)
}
return result.output
}
type Issue = {
readonly message: string
readonly received: string
readonly path?: ReadonlyArray<{ readonly key: unknown }>
}
/** Render an issue against the offending field, e.g. "`start` must be a date"
* — or "missing `original`" when the key is absent (valibot reports its own
* "Invalid key" wording for that, which isn't fit to show the author). */
function friendly(issue: Issue, fallbackField?: string): string {
const key = issue.path?.[0]?.key
const field = typeof key === "string" ? key : fallbackField
if (issue.received === "undefined") return field ? `missing \`${field}\`` : "missing value"
return field ? `\`${field}\` ${issue.message}` : issue.message
}

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest"
import { parseMacroplan, PlanParseError } from "./parse"
import { buildPlan } from "./plan"
const TODAY = "2026-06-17" // a Wednesday → week of Mon 2026-06-15
const SOURCE = `
title = "Q3 plan"
[[feature]]
name = "Auth"
start = 2026-06-01
original = 2026-06-15
delivered = 2026-06-15
[[feature]]
name = "Payments"
start = 2026-06-01
original = 2026-06-08
reestimates = [2026-06-22]
status = "at-risk"
[[milestone]]
name = "MVP"
week = 2026-06-15
requires = ["Auth", "Payments"]
`
describe("macroplan model", () => {
it("derives a render-ready plan from TOML", () => {
const plan = buildPlan(parseMacroplan(SOURCE), TODAY)
expect(plan.title).toBe("Q3 plan")
expect(plan.weeks).toEqual([
"2026-06-01",
"2026-06-08",
"2026-06-15",
"2026-06-22"
])
expect(plan.nowWeek).toBe("2026-06-15")
const auth = plan.rows.find((r) => r.name === "Auth")
expect(auth?.onTime).toBe(true)
expect(auth?.markers).toContainEqual({
week: "2026-06-15",
kind: "delivered-on-time"
})
const payments = plan.rows.find((r) => r.name === "Payments")
expect(payments?.delivered).toBe(false)
expect(payments?.slipCount).toBe(1)
expect(plan.milestones[0].unmet).toEqual(["Payments"])
})
it("rejects malformed sources with an author-safe error", () => {
expect(() => parseMacroplan('[[feature]]\nname = "No dates"')).toThrow(
PlanParseError
)
})
})

View File

@@ -0,0 +1,87 @@
import type { FeatureRow, Marker, MilestoneLine,Plan, RawFeature, RawPlan } from "./types"
import { mondayOf, type WeekId,weekRange } from "./week"
/**
* Derive the render-ready Plan from the raw model (component C2).
*
* On-time vs. late is judged ONLY against the Original Estimate, never a
* Re-estimate (ADR-0001). The full slip history (`◯` + every `△`) is preserved.
*/
export function buildPlan(raw: RawPlan, today: Date | string = new Date()): Plan {
const nowWeek = mondayOf(today)
const rows = raw.features.map((f) => buildRow(f, nowWeek))
const milestones = raw.milestones.map((m): MilestoneLine => {
const week = mondayOf(m.week)
const unmet = m.requires.filter((name) => {
const row = rows.find((r) => r.name === name)
if (!row) return true // references an unknown Feature → can't be met
const delivery = row.markers.find(
(x) => x.kind === "delivered-on-time" || x.kind === "delivered-late",
)
return !delivery || delivery.week > week // undelivered, or delivered after the milestone
})
return { name: m.name, week, requires: m.requires, unmet }
})
// Range: earliest start to last marker/milestone (CONTEXT.md). Empty weeks drawn.
// Optional authored `start`/`end` widen this span with lead-in / trailing weeks;
// they only ever extend it — a Feature or marker outside them is never clipped.
const allWeeks: WeekId[] = []
if (raw.start != null) allWeeks.push(mondayOf(raw.start))
if (raw.end != null) allWeeks.push(mondayOf(raw.end))
for (const r of rows) {
allWeeks.push(r.startWeek, r.barEndWeek)
for (const mk of r.markers) allWeeks.push(mk.week)
}
for (const m of milestones) allWeeks.push(m.week)
let weeks: WeekId[] = []
if (allWeeks.length) {
const start = allWeeks.reduce((a, b) => (a < b ? a : b))
const end = allWeeks.reduce((a, b) => (a > b ? a : b))
weeks = weekRange(start, end)
}
const nowInRange = weeks.length > 0 && nowWeek >= weeks[0] && nowWeek <= weeks[weeks.length - 1]
return { title: raw.title, weeks, rows, milestones, nowWeek, nowInRange }
}
function buildRow(f: RawFeature, nowWeek: WeekId): FeatureRow {
const startWeek = mondayOf(f.start)
const originalWeek = mondayOf(f.original)
const deliveredWeek = f.delivered ? mondayOf(f.delivered) : undefined
const delivered = deliveredWeek != null
// ADR-0001: compare the Delivery week to the Original Estimate week only.
const onTime = delivered ? deliveredWeek! <= originalWeek : null
const markers: Marker[] = []
for (const re of f.reestimates) markers.push({ week: mondayOf(re), kind: "reestimate" })
if (delivered) {
markers.push({ week: deliveredWeek!, kind: onTime ? "delivered-on-time" : "delivered-late" })
}
// The Original Estimate `◯` stands unless an on-time/early delivery already
// occupies (or precedes) it — then the delivery marker speaks for it.
if (!(delivered && onTime)) {
markers.push({ week: originalWeek, kind: "original" })
}
const intrinsicEnd = [startWeek, ...markers.map((m) => m.week)].reduce((a, b) => (a > b ? a : b))
// A delivered Feature's bar ends at its delivery. An undelivered Feature that
// is already past its furthest estimate keeps "running" up to now (overdue),
// so its bar extends to the now week; otherwise it ends at the last marker.
const barEndWeek = delivered || intrinsicEnd > nowWeek ? intrinsicEnd : nowWeek
return {
name: f.name,
startWeek,
barEndWeek,
markers,
delivered,
onTime,
status: f.status,
note: f.note,
learning: f.learning,
slipCount: f.reestimates.length,
}
}

View File

@@ -0,0 +1,72 @@
import type { WeekId } from "./week"
export type StatusLevel = "on-track" | "at-risk" | "off-track"
// ── Raw model: as authored, after TOML parse + validation, before derivation ──
export interface RawFeature {
name: string
start: string // yyyy-mm-dd
original: string // yyyy-mm-dd — the Original Estimate
reestimates: string[] // yyyy-mm-dd[]
delivered?: string // yyyy-mm-dd
learning?: string
status?: StatusLevel
note?: string
}
export interface RawMilestone {
name: string
week: string // yyyy-mm-dd
requires: string[] // Feature names
}
export interface RawPlan {
title: string
start?: string // yyyy-mm-dd — optional authored left edge of the plan's span
end?: string // yyyy-mm-dd — optional authored right edge of the plan's span
features: RawFeature[]
milestones: RawMilestone[]
}
// ── Derived model: render-ready (C2 output) ──
export type MarkerKind =
| "original" // ◯ Original Estimate, not yet delivered
| "reestimate" // △ a slip to a later week
| "delivered-on-time" // ◉ delivered on/before the Original Estimate
| "delivered-late" // ▲ delivered after the Original Estimate
export interface Marker {
week: WeekId
kind: MarkerKind
}
export interface FeatureRow {
name: string
startWeek: WeekId
barEndWeek: WeekId
markers: Marker[]
delivered: boolean
onTime: boolean | null // null while in-flight
status?: StatusLevel
note?: string
learning?: string
slipCount: number // number of Re-estimates
}
export interface MilestoneLine {
name: string
week: WeekId
requires: string[]
unmet: string[] // required Features not delivered on/before this week
}
export interface Plan {
title: string
weeks: WeekId[]
rows: FeatureRow[]
milestones: MilestoneLine[]
nowWeek: WeekId
nowInRange: boolean
}

View File

@@ -0,0 +1,59 @@
// Week math. A "Week" is identified by the ISO date (yyyy-mm-dd) of its Monday.
// ISO yyyy-mm-dd strings sort lexicographically == chronologically, so plain
// string comparison (`<`, `<=`) is valid week ordering — we lean on that.
export type WeekId = string // 'yyyy-mm-dd', always a Monday
/**
* Extract a yyyy-mm-dd string from a TOML date (smol-toml's `TomlDate.toISOString()`
* returns the authored local date, e.g. "2026-06-01") or a plain string.
*/
export function toYmd(value: unknown): string {
if (value instanceof Date) return value.toISOString().slice(0, 10)
if (typeof value === "string") return value.slice(0, 10)
throw new Error(`expected a date, got ${JSON.stringify(value)}`)
}
// Anchor at UTC noon so day-of-week / day arithmetic never crosses a DST or
// timezone boundary.
function utcNoon(ymd: string): Date {
const [y, m, d] = ymd.split("-").map(Number)
return new Date(Date.UTC(y, m - 1, d, 12))
}
function fmt(dt: Date): string {
return dt.toISOString().slice(0, 10)
}
/** The Monday (yyyy-mm-dd) of the ISO week containing the given date. */
export function mondayOf(value: unknown): WeekId {
const dt = utcNoon(toYmd(value))
const dow = dt.getUTCDay() // 0=Sun .. 6=Sat
const shift = dow === 0 ? -6 : 1 - dow
dt.setUTCDate(dt.getUTCDate() + shift)
return fmt(dt)
}
/** Add n weeks to a Monday WeekId (n may be negative). */
export function addWeeks(week: WeekId, n: number): WeekId {
const dt = utcNoon(week)
dt.setUTCDate(dt.getUTCDate() + n * 7)
return fmt(dt)
}
/** Inclusive list of Monday WeekIds from start..end (both must already be Mondays). */
export function weekRange(start: WeekId, end: WeekId): WeekId[] {
if (start > end) return [start]
const weeks: WeekId[] = []
for (let w = start; w <= end; w = addWeeks(w, 1)) weeks.push(w)
return weeks
}
/** Short column label for a week, e.g. "Jun 15". */
export function weekLabel(week: WeekId): string {
return utcNoon(week).toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
timeZone: "UTC",
})
}

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest"
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
import { runMacroplan } from "./runMacroplan"
const placeholder = (source: string): HTMLElement => {
const el = document.createElement("div")
el.className = "macroplan-block"
el.dataset.macroplanSource = encodeUTF8ToBase64(source)
document.body.appendChild(el)
return el
}
describe("runMacroplan", () => {
it("mounts the grid onto a valid placeholder", async () => {
const el = placeholder(
'title = "Plan"\n\n[[feature]]\nname = "Auth"\nstart = 2026-06-01\noriginal = 2026-06-15\n'
)
await runMacroplan(".macroplan-block")
expect(el.dataset.macroplanRendered).toBe("true")
expect(el.querySelector(".macroplan")).not.toBeNull()
expect(el.textContent).toContain("Auth")
el.remove()
})
it("renders the parse error inline for malformed sources", async () => {
const el = placeholder('[[feature]]\nname = "No dates"')
await runMacroplan(".macroplan-block")
expect(el.dataset.macroplanRendered).toBe("error")
expect(el.querySelector(".macroplan-error")?.textContent).toContain(
"missing `start`"
)
el.remove()
})
})

View File

@@ -0,0 +1,55 @@
import { createApp } from "vue"
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
const renderMacroplanError = (el: HTMLElement, err: unknown): void => {
const msg = err instanceof Error ? err.message : String(err)
el.textContent = ""
const box = document.createElement("div")
box.className = "macroplan-error"
const title = document.createElement("strong")
title.textContent = "Macroplan error"
const detail = document.createElement("pre")
detail.textContent = msg
box.append(title, detail)
el.appendChild(box)
}
/**
* Mount a MacroplanGrid onto every ```macroplan placeholder in scope.
* The parser (smol-toml + valibot) and the grid component only load
* when a placeholder actually exists, so notes without a plan pay nothing.
*/
export const runMacroplan = async (querySelector: string): Promise<void> => {
const elements = Array.from(
document.querySelectorAll<HTMLElement>(querySelector)
).filter((el) => !el.dataset.macroplanRendered)
if (elements.length === 0) return
const [{ parseMacroplan }, { buildPlan }, { default: MacroplanGrid }] =
await Promise.all([
import("./model/parse"),
import("./model/plan"),
import("./components/MacroplanGrid.vue")
])
for (const el of elements) {
el.dataset.macroplanRendered = "pending"
const encoded = el.dataset.macroplanSource
if (!encoded) {
el.dataset.macroplanRendered = "error"
continue
}
try {
const plan = buildPlan(parseMacroplan(decodeBase64ToUTF8(encoded)))
el.textContent = ""
createApp(MacroplanGrid, { plan }).mount(el)
el.dataset.macroplanRendered = "true"
} catch (err) {
renderMacroplanError(el, err)
el.dataset.macroplanRendered = "error"
}
}
}

View File

@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
vi.mock("@/data/data", () => ({
data: {
get: vi.fn().mockResolvedValue(null),
update: vi.fn().mockResolvedValue(undefined)
},
generateId: (type: string, id: string) => `${type}-${id}`
}))
const addFile = vi.fn()
vi.mock("@/modules/repo/store/userRepo.store", () => ({
useUserRepoStore: () => ({ addFile })
}))
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { prepareNoteCache } from "./prepareNoteCache"
const writtenIds = () =>
vi.mocked(data.update).mock.calls.map((c) => (c[0] as { _id: string })._id)
describe("prepareNoteCache.saveCacheNote", () => {
beforeEach(() => {
vi.mocked(data.update).mockClear()
addFile.mockClear()
})
it("on edit, keys content by the NEW sha and leaves the viewed sha untouched", async () => {
const { saveCacheNote } = prepareNoteCache("oldSha", "notes/a.md")
await saveCacheNote("new content", {
editedSha: "newSha",
path: "notes/a.md"
})
const ids = writtenIds()
// immutable snapshot under the content's own (new) sha
expect(ids).toContain(generateId(DataType.Note, "newSha"))
// latest pointer under the path
expect(ids).toContain(generateId(DataType.Note, "notes/a.md"))
// the previously-viewed sha stays immutable
expect(ids).not.toContain(generateId(DataType.Note, "oldSha"))
})
it("on a fresh load (no editedSha), keys content by the viewed sha", async () => {
const { saveCacheNote } = prepareNoteCache("sha0", "notes/a.md")
await saveCacheNote("content")
expect(writtenIds()).toContain(generateId(DataType.Note, "sha0"))
})
it("registers the new sha against the path in the store", async () => {
const { saveCacheNote } = prepareNoteCache("oldSha", "notes/a.md")
await saveCacheNote("new content", {
editedSha: "newSha",
path: "notes/a.md"
})
expect(addFile).toHaveBeenCalledWith({ path: "notes/a.md", sha: "newSha" })
})
})

View File

@@ -21,7 +21,8 @@ export const buildNoteDocs = (
_id: generateId(DataType.Note, sha),
$type: DataType.Note,
content,
editedSha
editedSha,
path
}
return path
? [base, { ...base, _id: generateId(DataType.Note, path) }]
@@ -61,11 +62,18 @@ export const prepareNoteCache = (sha: string, path?: string) => {
content: string,
params?: { editedSha?: string; path?: string }
) => {
// Content is addressed by its OWN sha so snapshots stay immutable: an edit
// writes under the new sha and never overwrites the previously-viewed one.
// The path key (notePath) always holds the latest content (live pointer).
const contentId = params?.editedSha
? generateId(DataType.Note, params.editedSha)
: noteId
const newNote: Note = {
_id: noteId,
_id: contentId,
$type: DataType.Note,
content,
editedSha: params?.editedSha
editedSha: params?.editedSha,
path: params?.path ?? path
}
if (params && params.path) {

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest"
import { resolveLivePathsToShas, stackToLivePaths } from "./liveNotes"
const files = [
{ path: "README.md", sha: "a".repeat(40) },
{ path: "notes/one.md", sha: "b".repeat(40) },
{ path: "notes/two.md", sha: "c".repeat(40) }
]
describe("stackToLivePaths", () => {
it("maps each stacked sha to its current file path", () => {
expect(stackToLivePaths(["b".repeat(40), "c".repeat(40)], files)).toEqual([
"notes/one.md",
"notes/two.md"
])
})
it("keeps a sha verbatim when no path resolves (drifted snapshot)", () => {
const orphan = "d".repeat(40)
expect(stackToLivePaths(["b".repeat(40), orphan], files)).toEqual([
"notes/one.md",
orphan
])
})
it("preserves order", () => {
expect(stackToLivePaths(["c".repeat(40), "a".repeat(40)], files)).toEqual([
"notes/two.md",
"README.md"
])
})
})
describe("resolveLivePathsToShas", () => {
it("resolves each path to its latest sha", () => {
expect(
resolveLivePathsToShas(["notes/one.md", "notes/two.md"], files)
).toEqual(["b".repeat(40), "c".repeat(40)])
})
it("passes a sha-shaped entry through as a pinned fallback", () => {
const orphan = "d".repeat(40)
expect(resolveLivePathsToShas(["notes/one.md", orphan], files)).toEqual([
"b".repeat(40),
orphan
])
})
it("drops a renamed or deleted path so the view degrades gracefully", () => {
expect(
resolveLivePathsToShas(["notes/gone.md", "notes/two.md"], files)
).toEqual(["c".repeat(40)])
})
it("round-trips a live-shared stack back to the same shas", () => {
const shas = ["a".repeat(40), "b".repeat(40)]
expect(resolveLivePathsToShas(stackToLivePaths(shas, files), files)).toEqual(
shas
)
})
})

View File

@@ -0,0 +1,38 @@
// A shared link pins each stacked note to a blob sha, so the recipient sees the
// exact snapshot forever. A "living" link trades that pin for freshness: notes
// are referenced by their file path instead, and re-resolved to the latest blob
// sha against the repo's HEAD file list every time the link is opened.
const SHA_PATTERN = /^[0-9a-f]{40}$/i
/**
* Encode the current stack (blob shas) as a living reference list. Each note
* becomes its file path when one resolves from the HEAD file list, so the link
* re-resolves to the latest version on open. A sha with no known path (e.g. an
* already-drifted snapshot the sharer never pulled) is kept verbatim, staying
* pinned — order is preserved either way.
*/
export const stackToLivePaths = (
shas: ReadonlyArray<string>,
files: ReadonlyArray<{ path?: string; sha?: string }>
): string[] =>
shas.map((sha) => files.find((file) => file.sha === sha)?.path ?? sha)
/**
* Resolve a living reference list back to current blob shas against the HEAD
* file list: a path maps to its latest sha; an entry already shaped like a sha
* is passed through (the pinned fallback above); anything else — a renamed or
* deleted path — is dropped so the view degrades gracefully instead of trying
* to fetch a blob that no longer exists.
*/
export const resolveLivePathsToShas = (
entries: ReadonlyArray<string>,
files: ReadonlyArray<{ path?: string; sha?: string }>
): string[] =>
entries
.map((entry) => {
const latestSha = files.find((file) => file.path === entry)?.sha
if (latestSha) return latestSha
return SHA_PATTERN.test(entry) ? entry : null
})
.filter((sha): sha is string => sha !== null)

View File

@@ -4,6 +4,9 @@ import { Model } from "@/data/models/Model"
export interface Note extends Model<DataType.Note> {
content: string
editedSha?: string
// The note's path, stored so a cached snapshot can find its latest version
// even when its (old) sha is no longer in the repo file list.
path?: string
}
export interface PublicNoteListItem {

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest"
import { latestShaIfOlder } from "./snapshotStatus"
const files = [
{ path: "notes/a.md", sha: "current" },
{ path: "notes/b.md", sha: "other" }
]
describe("latestShaIfOlder", () => {
it("returns the current sha when viewing an older version of a known note", () => {
expect(latestShaIfOlder("old", "notes/a.md", files)).toBe("current")
})
it("returns null when viewing the current version", () => {
expect(latestShaIfOlder("current", "notes/a.md", files)).toBeNull()
})
it("returns null when the path is unknown", () => {
expect(latestShaIfOlder("old", undefined, files)).toBeNull()
expect(latestShaIfOlder("old", "notes/missing.md", files)).toBeNull()
})
})

View File

@@ -0,0 +1,25 @@
/**
* Return the note's current (latest) sha when `viewedSha` is NOT it — i.e. you
* are viewing an older version — otherwise null. "Older" is not inferred from
* the sha (a content hash has no order); it means "not the current sha for this
* path" per the repo file list.
*
* The caller must supply `notePath`. For a current sha it comes from the file
* list; for an older sha it comes from the cached snapshot's stored `path`
* (notes viewed while current carry it). When the path can't be resolved
* (foreign / evicted / pre-upgrade snapshot) this returns null and no banner is
* shown — graceful, never a false claim. Content is never swapped.
*/
export const latestShaIfOlder = (
viewedSha: string,
notePath: string | undefined,
files: ReadonlyArray<{ path?: string; sha?: string }>
): string | null => {
if (!notePath) {
return null
}
const currentSha = files.find((file) => file.path === notePath)?.sha
return currentSha && currentSha !== viewedSha ? currentSha : null
}

View File

@@ -8,7 +8,7 @@ export interface UserSettings extends Model<DataType.UserSettings> {
fontSize?: string
chosenFontSize?: string
backlink?: boolean
chosenTitleFont?: string
chosenHeadingFont?: string
chosenBodyFont?: string
pageWidth?: string
}

View File

@@ -25,6 +25,7 @@ import { getOctokit, runWithAuthRetry } from "./octo"
import {
getFiles,
getRepoPermission,
getUserSettingsContent,
queryFileContent
} from "./repo"
@@ -165,3 +166,58 @@ describe("queryFileContent", () => {
expect(warn).toHaveBeenCalled()
})
})
describe("getUserSettingsContent", () => {
beforeEach(() => {
vi.mocked(runWithAuthRetry).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
const configFiles = [
{ path: ".remanso.json", type: "blob", sha: "CFG" }
] as never
const withConfig = (json: object) => {
const base64 = btoa(JSON.stringify(json))
vi.mocked(runWithAuthRetry).mockImplementation(async (call) => {
const octokit = {
request: vi.fn().mockResolvedValue({ data: { content: base64 } })
}
return call(octokit as never)
})
}
it("maps the `h` key to chosenHeadingFont and `p` to chosenBodyFont", async () => {
withConfig({ h: "Lora", p: "Inter" })
const settings = await getUserSettingsContent("owner", "repo", configFiles)
expect(settings?.chosenHeadingFont).toBe("Lora")
expect(settings?.chosenBodyFont).toBe("Inter")
})
it("falls back to the legacy `t` key for chosenHeadingFont", async () => {
withConfig({ t: "Merriweather", p: "Inter" })
const settings = await getUserSettingsContent("owner", "repo", configFiles)
expect(settings?.chosenHeadingFont).toBe("Merriweather")
})
it("prefers `h` over the legacy `t` when both are present", async () => {
withConfig({ h: "Lora", t: "Merriweather" })
const settings = await getUserSettingsContent("owner", "repo", configFiles)
expect(settings?.chosenHeadingFont).toBe("Lora")
})
it("returns null when there is no .remanso.json", async () => {
expect(
await getUserSettingsContent("owner", "repo", [] as never)
).toBeNull()
})
})

View File

@@ -123,12 +123,14 @@ export const getUserSettingsContent = async (
const raw = JSON.parse(atob(content)) as UserSettings & {
t?: string
h?: string
p?: string
}
const { t, p, ...rest } = raw
const { t, h, p, ...rest } = raw
return {
...rest,
chosenTitleFont: t,
// `h` (heading) is the current key; `t` (title) is kept for back-compat.
chosenHeadingFont: h ?? t,
chosenBodyFont: p
}
}

View File

@@ -105,6 +105,18 @@ describe("userRepo store — synchronous mutations", () => {
expect(vi.mocked(data.update)).not.toHaveBeenCalled()
})
it("addFile replaces the entry at the same path when content (sha) changed", () => {
const store = useUserRepoStore()
store.user = "alice"
store.repo = "notes"
store.files = [{ sha: "old", path: "a.md", type: "blob" }] as never
store.addFile({ sha: "new", path: "a.md", type: "blob" } as never)
expect(store.files).toEqual([{ sha: "new", path: "a.md", type: "blob" }])
expect(vi.mocked(data.update)).toHaveBeenCalled()
})
it("setFontFamily initializes userSettings when absent and persists to localStorage", () => {
const store = useUserRepoStore()
store.user = "alice"
@@ -119,22 +131,40 @@ describe("userRepo store — synchronous mutations", () => {
expect(persisted.chosenFontFamily).toBe("Inter")
})
it("setFontSize, setTitleFont, setBodyFont each persist their respective field", () => {
it("setFontSize, setHeadingFont, setBodyFont each persist their respective field", () => {
const store = useUserRepoStore()
store.user = "alice"
store.repo = "notes"
store.setFontSize("18px")
store.setTitleFont("Serif")
store.setHeadingFont("Serif")
store.setBodyFont("Sans")
const persisted = JSON.parse(
localStorage.getItem("remanso:layout:alice:notes") as string
)
expect(persisted.chosenFontSize).toBe("18px")
expect(persisted.chosenTitleFont).toBe("Serif")
expect(persisted.chosenHeadingFont).toBe("Serif")
expect(persisted.chosenBodyFont).toBe("Sans")
})
it("swapFonts exchanges the heading and body fonts and persists", () => {
const store = useUserRepoStore()
store.user = "alice"
store.repo = "notes"
store.setHeadingFont("Lora")
store.setBodyFont("Inter")
store.swapFonts()
expect(store.userSettings?.chosenHeadingFont).toBe("Inter")
expect(store.userSettings?.chosenBodyFont).toBe("Lora")
const persisted = JSON.parse(
localStorage.getItem("remanso:layout:alice:notes") as string
)
expect(persisted.chosenHeadingFont).toBe("Inter")
expect(persisted.chosenBodyFont).toBe("Lora")
})
})
describe("userRepo store — setUserRepo", () => {
@@ -161,6 +191,25 @@ describe("userRepo store — setUserRepo", () => {
expect(store.loadError).toBeNull()
})
it("migrates the legacy chosenTitleFont localStorage key to chosenHeadingFont", async () => {
localStorage.setItem(
"remanso:layout:alice:notes",
JSON.stringify({ chosenTitleFont: "Lora" })
)
const store = useUserRepoStore()
await store.setUserRepo("alice", "notes")
await flushAsync()
await flushAsync()
expect(store.userSettings?.chosenHeadingFont).toBe("Lora")
const persisted = JSON.parse(
localStorage.getItem("remanso:layout:alice:notes") as string
)
expect(persisted.chosenHeadingFont).toBe("Lora")
expect(persisted.chosenTitleFont).toBeUndefined()
})
it("populates files from getFiles on success", async () => {
vi.mocked(getFiles).mockResolvedValue([
{ sha: "a", path: "x.md", type: "blob" } as never

View File

@@ -56,7 +56,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
if (!this.userSettings) return
try {
const {
chosenTitleFont,
chosenHeadingFont,
chosenBodyFont,
chosenFontSize,
chosenFontFamily,
@@ -65,7 +65,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
localStorage.setItem(
`remanso:layout:${this.user}:${this.repo}`,
JSON.stringify({
chosenTitleFont,
chosenHeadingFont,
chosenBodyFont,
chosenFontSize,
chosenFontFamily,
@@ -86,7 +86,17 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
let lsLayout: Partial<UserSettings> = {}
try {
const lsRaw = localStorage.getItem(`remanso:layout:${user}:${repo}`)
if (lsRaw) lsLayout = JSON.parse(lsRaw)
if (lsRaw) {
const parsed = JSON.parse(lsRaw) as Partial<UserSettings> & {
chosenTitleFont?: string
}
// Migrate the pre-rename key so a saved heading font survives.
if (parsed.chosenTitleFont && !parsed.chosenHeadingFont) {
parsed.chosenHeadingFont = parsed.chosenTitleFont
}
delete parsed.chosenTitleFont
lsLayout = parsed
}
} catch {
// ignore
}
@@ -157,9 +167,9 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
: userSettings?.fontFamily
const chosenFontSize =
this.userSettings?.chosenFontSize ?? userSettings?.fontSize
const chosenTitleFont =
this.userSettings?.chosenTitleFont ??
userSettings?.chosenTitleFont ??
const chosenHeadingFont =
this.userSettings?.chosenHeadingFont ??
userSettings?.chosenHeadingFont ??
chosenFontFamily
const chosenBodyFont =
this.userSettings?.chosenBodyFont ??
@@ -175,14 +185,14 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
chosenFontFamily ?? this.userSettings.fontFamily
this.userSettings.chosenFontSize =
chosenFontSize ?? this.userSettings.fontSize
this.userSettings.chosenTitleFont = chosenTitleFont
this.userSettings.chosenHeadingFont = chosenHeadingFont
this.userSettings.chosenBodyFont = chosenBodyFont
this._persistLayout()
// Persist only repo config fields — chosen* are localStorage-only
const {
chosenTitleFont: _t,
chosenHeadingFont: _h,
chosenBodyFont: _b,
chosenFontSize: _s,
chosenFontFamily: _f,
@@ -233,7 +243,9 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
`${this.user}-${this.repo}`
)
const newFiles = [
...toRaw(this.files).filter((f) => f.sha !== file.sha),
...toRaw(this.files).filter(
(f) => f.sha !== file.sha && f.path !== file.path
),
toRaw(file)
]
data.update<DataType.SavedRepo, SavedRepo>({
@@ -292,11 +304,11 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
this.userSettings.chosenFontSize = fontSize
this._persistLayout()
},
setTitleFont(font: string) {
setHeadingFont(font: string) {
if (!this.userSettings) {
this.userSettings = { $type: DataType.UserSettings }
}
this.userSettings.chosenTitleFont = font
this.userSettings.chosenHeadingFont = font
this._persistLayout()
},
setBodyFont(font: string) {
@@ -305,6 +317,13 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
}
this.userSettings.chosenBodyFont = font
this._persistLayout()
},
swapFonts() {
if (!this.userSettings) return
const { chosenHeadingFont, chosenBodyFont } = this.userSettings
this.userSettings.chosenHeadingFont = chosenBodyFont
this.userSettings.chosenBodyFont = chosenHeadingFont
this._persistLayout()
}
}
})

View File

@@ -16,12 +16,12 @@ export const useUserSettings = () => {
const fontSize = store.userSettings?.chosenFontSize
const bodyFont = store.userSettings?.chosenBodyFont
const titleFont = store.userSettings?.chosenTitleFont
const headingFont = store.userSettings?.chosenHeadingFont
downloadFont(bodyFont || DEFAULT_FONT_POLICY, "--font-family")
downloadFont(
titleFont || bodyFont || DEFAULT_FONT_POLICY,
"--title-font-family"
headingFont || bodyFont || DEFAULT_FONT_POLICY,
"--heading-font-family"
)
root.style.setProperty("--font-size", fontSize || DEFAULT_FONT_SIZE)

View File

@@ -6,7 +6,7 @@
:root {
--primary-color: #ffa4c0;
--font-family: "Libertinus Serif", serif;
--title-font-family: "Libertinus Serif", serif;
--heading-font-family: "Libertinus Serif", serif;
--font-size: 13pt;
--font-color: #4a4a4a;
--link: #445fb9;
@@ -186,6 +186,46 @@ pre {
overflow-wrap: anywhere;
}
/* Line numbers for whole-file code views (non-markdown files).
Scoped to .code-file so fenced blocks inside notes stay untouched. */
.code-file pre code {
counter-reset: line;
}
.code-file pre code .line::before {
counter-increment: line;
content: counter(line);
display: inline-block;
width: var(--line-number-width, 3ch);
margin-right: 1.5ch;
text-align: right;
color: var(--color-base-content);
opacity: 0.35;
user-select: none;
}
/* Shikiji renders dual-theme tokens but only inlines the light colors;
the dark palette lives in --shiki-dark* vars that we must apply ourselves.
Without this, dark theme keeps the white code background and the
base-content line numbers become white-on-white. */
[data-theme="dracula"] :is(.shiki, .shiki span) {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) :is(.shiki, .shiki span) {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
}
pre.mermaid {
display: flex;
justify-content: center;
@@ -218,11 +258,20 @@ pre.tikz svg {
}
}
.tikz-loading {
.tikz-loading,
.macroplan-loading {
opacity: 0.6;
font-style: italic;
}
/* The grid's milestone-flag stacking math assumes a monospace advance
(BAND_CHAR in MacroplanGrid.vue), and the macroplan app renders in
Fira Code — don't let the note's serif leak in. */
.macroplan-block {
font-family: "Fira Code", ui-monospace, SFMono-Regular, Menlo, Consolas,
monospace;
}
.svg-download-host {
position: relative;
display: inline-block;
@@ -264,14 +313,16 @@ pre.tikz svg {
filter: none;
}
.tikz-error {
.tikz-error,
.macroplan-error {
border-left: 3px solid var(--color-error);
padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--color-error) 10%, transparent);
text-align: left;
}
.tikz-error pre {
.tikz-error pre,
.macroplan-error pre {
white-space: pre-wrap;
font-size: 0.85em;
}

View File

@@ -15,6 +15,7 @@ const EXT_TO_LANG: Record<string, string> = {
scss: "css",
json: "json",
jsonc: "json",
rs: "rust",
als: "alloy"
}

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest"
import { renderFallback } from "./renderFallback"
describe("renderFallback", () => {
it("shows the raw content inside a pre with a gentle notice", () => {
const html = renderFallback("# hello")
expect(html).toContain("note-render-fallback")
expect(html).toContain("couldn't be fully rendered")
expect(html).toContain("<pre># hello</pre>")
})
it("escapes HTML so raw content can't inject markup into v-html", () => {
const html = renderFallback('<img src=x onerror="alert(1)"> & "q"')
expect(html).toContain(
"&lt;img src=x onerror=&quot;alert(1)&quot;&gt; &amp; &quot;q&quot;"
)
expect(html).not.toContain("<img src=x")
})
it("escapes ampersands before other entities", () => {
expect(renderFallback("&lt;")).toContain("<pre>&amp;lt;</pre>")
})
})

View File

@@ -0,0 +1,14 @@
const escapeHtml = (value: string): string =>
value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
/**
* Calm degradation for a note that failed to render: never a raw error dump
* or a silent blank — show the note's raw text (escaped) with a gentle notice,
* so the content is still readable.
*/
export const renderFallback = (content: string): string =>
`<div class="note-render-fallback"><p><em>This note couldn't be fully rendered — showing its raw text.</em></p><pre>${escapeHtml(content)}</pre></div>`

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest"
import { threeWayMerge } from "./threeWayMerge"
const base = "line1\nline2\nline3\nline4\nline5"
describe("threeWayMerge", () => {
it("merges non-overlapping edits cleanly, keeping both changes", () => {
const ours = "OURS1\nline2\nline3\nline4\nline5"
const theirs = "line1\nline2\nline3\nline4\nTHEIRS5"
const { clean, merged } = threeWayMerge(base, ours, theirs)
expect(clean).toBe(true)
expect(merged).toBe("OURS1\nline2\nline3\nline4\nTHEIRS5")
})
it("treats identical edits on both sides as clean (false conflict)", () => {
const edited = "line1\nCHANGED\nline3\nline4\nline5"
const { clean, merged } = threeWayMerge(base, edited, edited)
expect(clean).toBe(true)
expect(merged).toBe(edited)
})
it("flags overlapping edits on the same line as not clean", () => {
const ours = "line1\nline2\nOURS3\nline4\nline5"
const theirs = "line1\nline2\nTHEIRS3\nline4\nline5"
const { clean } = threeWayMerge(base, ours, theirs)
expect(clean).toBe(false)
})
it("returns the base unchanged when neither side edited", () => {
const { clean, merged } = threeWayMerge(base, base, base)
expect(clean).toBe(true)
expect(merged).toBe(base)
})
it("returns our edits when only we changed", () => {
const ours = "OURS1\nline2\nline3\nline4\nline5"
const { clean, merged } = threeWayMerge(base, ours, base)
expect(clean).toBe(true)
expect(merged).toBe(ours)
})
it("returns their edits when only they changed", () => {
const theirs = "line1\nline2\nline3\nline4\nTHEIRS5"
const { clean, merged } = threeWayMerge(base, base, theirs)
expect(clean).toBe(true)
expect(merged).toBe(theirs)
})
it("preserves a trailing newline", () => {
const baseNl = "a\nb\nc\n"
const ours = "A\nb\nc\n"
const theirs = "a\nb\nC\n"
const { clean, merged } = threeWayMerge(baseNl, ours, theirs)
expect(clean).toBe(true)
expect(merged).toBe("A\nb\nC\n")
})
it("conservatively flags edits on immediately-adjacent lines as not clean", () => {
// No unchanged line separates the two edits, so node-diff3 groups them
// into one region and reports a conflict (git would merge this).
const ours = "A\nline2\nline3\nline4\nline5"
const theirs = "line1\nB\nline3\nline4\nline5"
const { clean } = threeWayMerge(base, ours, theirs)
expect(clean).toBe(false)
})
it("merges added lines on different sides without conflict", () => {
const ours = "line1\nOURS-NEW\nline2\nline3\nline4\nline5"
const theirs = "line1\nline2\nline3\nline4\nline5\nTHEIRS-NEW"
const { clean, merged } = threeWayMerge(base, ours, theirs)
expect(clean).toBe(true)
expect(merged).toBe(
"line1\nOURS-NEW\nline2\nline3\nline4\nline5\nTHEIRS-NEW"
)
})
})

View File

@@ -0,0 +1,40 @@
import { diff3Merge } from "node-diff3"
export interface ThreeWayMergeResult {
clean: boolean
merged: string
}
/**
* Line-level 3-way merge. `ours` and `theirs` are both derived from `base`
* (the common ancestor). When the two sets of edits are separated by at least
* one unchanged line they merge automatically (`clean: true`); when they
* overlap, `clean` is false and the caller should fall back to manual
* resolution.
*
* Conservative by design: node-diff3 groups *consecutive* changed lines into a
* single region, so edits to immediately-adjacent lines (no unchanged line
* between them) are reported as a conflict even when they don't truly overlap.
* This errs toward asking the user rather than risking a wrong auto-merge.
*
* `merged` is only meaningful when `clean` is true — conflicting regions are
* dropped from it, so it must not be committed on a non-clean merge.
*/
export const threeWayMerge = (
base: string,
ours: string,
theirs: string
): ThreeWayMergeResult => {
// diff3Merge(a, o, b): a = ours, o = ancestor, b = theirs. The default
// excludeFalseConflicts folds identical edits made on both sides.
const regions = diff3Merge(ours, base, theirs, { stringSeparator: /\r?\n/ })
const lines: string[] = []
let clean = true
for (const region of regions) {
if (region.ok) lines.push(...region.ok)
else clean = false
}
return { clean, merged: lines.join("\n") }
}

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"
import {
applyRecurrence,
contextsOf,
isBlank,
parseFile,
@@ -216,6 +217,79 @@ Task two
})
})
describe("todotxt applyRecurrence", () => {
it("returns null when task has no rec tag", () => {
expect(applyRecurrence(parseLine("Buy milk"))).toBeNull()
})
it("returns null for an invalid rec value", () => {
expect(applyRecurrence(parseLine("Task rec:bad"))).toBeNull()
})
it("non-strict: bases next due on today, replacing existing due", () => {
const now = new Date(2026, 0, 15) // Jan 15
const task = parseLine("Pay rent due:2025-12-15 rec:1m")
const next = applyRecurrence(task, now)!
expect(next.completed).toBe(false)
expect(next.body).toContain("due:2026-02-15")
expect(next.body).toContain("rec:1m")
expect(next.body).not.toContain("due:2025-12-15")
})
it("strict: bases next due on existing due date", () => {
const now = new Date(2026, 0, 15) // Jan 15
const task = parseLine("Pay rent due:2025-12-15 rec:+1m")
const next = applyRecurrence(task, now)!
expect(next.body).toContain("due:2026-01-15")
expect(next.body).toContain("rec:+1m")
})
it("strict with no due: falls back to today", () => {
const now = new Date(2026, 0, 15)
const task = parseLine("Daily standup rec:+1d")
const next = applyRecurrence(task, now)!
expect(next.body).toContain("due:2026-01-16")
})
it("appends due tag when task has none", () => {
const now = new Date(2026, 0, 15)
const task = parseLine("Daily standup rec:1d")
const next = applyRecurrence(task, now)!
expect(next.body).toContain("due:2026-01-16")
})
it("preserves projects, contexts, and rec tag in new body", () => {
const now = new Date(2026, 0, 15)
const task = parseLine("Pay rent +finance @home due:2026-01-01 rec:1m")
const next = applyRecurrence(task, now)!
expect(next.body).toContain("+finance")
expect(next.body).toContain("@home")
expect(next.body).toContain("rec:1m")
})
it("preserves priority on the new task", () => {
const now = new Date(2026, 0, 15)
const task = parseLine("(A) Pay rent rec:1w")
const next = applyRecurrence(task, now)!
expect(next.priority).toBe("A")
expect(next.body).toContain("due:2026-01-22")
})
it("handles week interval", () => {
const now = new Date(2026, 0, 15)
expect(applyRecurrence(parseLine("Task rec:2w"), now)!.body).toContain(
"due:2026-01-29"
)
})
it("handles year interval", () => {
const now = new Date(2026, 0, 15)
expect(applyRecurrence(parseLine("Task rec:1y"), now)!.body).toContain(
"due:2027-01-15"
)
})
})
describe("todotxt toggleCompleted", () => {
it("marks a task complete with today's date", () => {
// Construct via local-time fields so the test doesn't depend on the

View File

@@ -210,6 +210,74 @@ const todayIso = (now: Date): string => {
return `${y}-${m}-${d}`
}
const parseDateStr = (s: string): Date | null => {
if (!DATE_RE.test(s)) return null
const [y, mo, d] = s.split("-").map(Number)
return new Date(y, mo - 1, d)
}
const addInterval = (base: Date, n: number, unit: string): Date => {
const d = new Date(base)
switch (unit.toLowerCase()) {
case "d":
d.setDate(d.getDate() + n)
break
case "w":
d.setDate(d.getDate() + n * 7)
break
case "m":
d.setMonth(d.getMonth() + n)
break
case "y":
d.setFullYear(d.getFullYear() + n)
break
}
return d
}
const REC_RE = /^\+?(\d+)([dwmyDWMY])$/
// When a recurring task is completed, build a new open task with the next due date.
// Strict mode (rec:+Nd): base date is the existing due: date (or today if absent).
// Non-strict (rec:Nd): base date is today (completion date).
// Returns null when the task has no valid rec: tag.
export const applyRecurrence = (task: Task, now = new Date()): Task | null => {
const tags = tagsOf(task)
const rec = tags["rec"]
if (!rec) return null
const m = rec.match(REC_RE)
if (!m) return null
const strict = rec.startsWith("+")
const n = parseInt(m[1], 10)
const unit = m[2]
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
let base = today
if (strict && tags["due"]) {
const parsed = parseDateStr(tags["due"])
if (parsed) base = parsed
}
const nextDate = addInterval(base, n, unit)
const nextDue = todayIso(nextDate)
let newBody = task.body
if (tags["due"]) {
newBody = removeTagFromBody(newBody, "due", tags["due"])
}
newBody = (newBody.trim() + ` due:${nextDue}`).trim()
return {
raw: "",
completed: false,
priority: task.priority,
creationDate: todayIso(now),
completionDate: undefined,
body: newBody
}
}
// Toggle the completed state of a task.
// On complete: stamp today's completion date. Priority stays in place per spec.
// On uncomplete: drop completion date but leave the rest of the line as-is —

View File

@@ -130,6 +130,12 @@ const openProfile = () => {
gap: 1rem;
}
.tabs {
width: 100%;
max-width: 42rem;
margin-inline: auto;
}
h1 {
flex: 1;
text-align: center;

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computedAsync } from "@vueuse/core"
import { useTitle } from "@vueuse/core"
import { computed, ref, watch } from "vue"
import { computed, onMounted, ref, watch } from "vue"
import { useRouter } from "vue-router"
import HomeButton from "@/components/HomeButton.vue"
@@ -112,13 +112,19 @@ const language = computed(() =>
const mainNoteId = computed(() => `${props.shortDid}-${props.rkey}`)
const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
const { stackedNotes, scrollToFocusedNote, scrollToLastStackedNote } =
useRouteQueryStackedNotes()
const { listenToClick } = useATProtoLinks("note-display", { mainNoteId })
useResizeContainer("note-container", stackedNotes)
onMounted(() => {
scrollToLastStackedNote()
})
useMarkdownPostRender(content, () => ".public-note-view .note-display", {
onReady: () => listenToClick(),
tikz: true
tikz: true,
macroplan: true
})
</script>

View File

@@ -7,15 +7,13 @@ import { computed, ref } from "vue"
import ProfileModal from "@/components/ProfileModal.vue"
import SignInGithub from "@/components/SignInGithub.vue"
import UserPill from "@/components/UserPill.vue"
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
import { useRepos } from "@/hooks/useRepos.hook"
import { useRepoList } from "@/modules/repo/hooks/useRepoList.hook"
import type { RepoBase } from "@/modules/repo/interfaces/RepoBase"
const { username, accessToken } = useGitHubLogin()
const { isLoggedIn: isATProtoLoggedIn } = useATProtoLogin()
const { isReady, hasCredentialError } = useRepos()
const { isReady, repos, hasCredentialError } = useRepos()
const {
favoriteRepos,
otherRepos,
@@ -25,11 +23,6 @@ const {
loadMore
} = useRepoList()
const isGitHubLoggedIn = computed(() => !!accessToken.value)
const isAnyUserLoggedIn = computed(
() => isGitHubLoggedIn.value || isATProtoLoggedIn.value
)
const openProfile = () => {
;(document.getElementById("profile_modal") as HTMLDialogElement)?.showModal()
}
@@ -44,7 +37,7 @@ const filteredFavoriteRepos = computed<RepoBase[]>(() =>
favoriteRepos.value.filter((r) => matchesQuery(r.name))
)
const filteredOtherRepos = computed<RepoBase[]>(() =>
otherRepos.value.filter((r) => matchesQuery(r.name))
repos.value.filter((r) => matchesQuery(r.name))
)
const groupedOtherRepos = computed<

View File

@@ -9,6 +9,7 @@ import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
import { errorMessage } from "@/utils/notif"
import {
applyRecurrence,
contextsOf,
FileLine,
isBlank,
@@ -237,7 +238,15 @@ const filteredEntries = computed(() => {
})
const updateTask = (index: number, next: Task) => {
mutate((current) => current.map((line, i) => (i === index ? next : line)))
mutate((current) => {
const prev = current[index]
const updated = current.map((line, i) => (i === index ? next : line))
if (!isBlank(prev) && !prev.completed && next.completed) {
const recurring = applyRecurrence(next)
if (recurring) return [...updated, recurring]
}
return updated
})
}
const deleteTask = (index: number) => {

View File

@@ -3,7 +3,7 @@ const dotenv = require("dotenv")
dotenv.config()
const defaultTitleStyles = Array.from(
const defaultHeadingStyles = Array.from(
{ length: 6 },
(_, k) => `h${k + 1}`
).reduce(
@@ -12,7 +12,7 @@ const defaultTitleStyles = Array.from(
[heading]: {
"margin-top": "0",
"margin-bottom": "0.5em",
"font-family": "var(--title-font-family)"
"font-family": "var(--heading-font-family)"
}
}),
{}
@@ -27,7 +27,7 @@ module.exports = {
typography: () => ({
DEFAULT: {
css: {
...defaultTitleStyles,
...defaultHeadingStyles,
"font-size": "13pt",
"font-family": '"Libertinus Serif", serif',
p: {