Compare commits

1 Commits

Author SHA1 Message Date
Julien Calixte
afa9a2f008 fix(todo): smoothly collapse rows on filter leave/enter
Some checks failed
CI / verify (push) Failing after 7s
Switch the TransitionGroup animation from in-flow opacity+translate to
the grid-template-rows 1fr → 0fr pattern so a leaving row's height
shrinks during the transition. Avoids the post-animation snap that
happened when the DOM nodes were removed and the surrounding gap
suddenly closed.
2026-06-13 22:21:35 +02:00
66 changed files with 133 additions and 3455 deletions

View File

@@ -19,7 +19,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with: with:
version: 11.5.2 version: 11.0.9
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@@ -38,10 +38,6 @@ Tailwind v4 uses the modern CSS-based config in `src/styles/app.css`:
- DaisyUI configured via `@plugin 'daisyui' { themes: retro --default, coffee --prefersdark; }` - DaisyUI configured via `@plugin 'daisyui' { themes: retro --default, coffee --prefersdark; }`
- `@tailwindcss/typography` for prose styling - `@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 ### Directory Structure
```sh ```sh

View File

@@ -1,125 +0,0 @@
# 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
View File

@@ -1,420 +0,0 @@
# 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

@@ -1,15 +0,0 @@
# 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

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

33
pnpm-lock.yaml generated
View File

@@ -104,9 +104,6 @@ importers:
nanoid: nanoid:
specifier: ^5.1.6 specifier: ^5.1.6
version: 5.1.6 version: 5.1.6
node-diff3:
specifier: ^3.2.1
version: 3.2.1
notyf: notyf:
specifier: ^3.10.0 specifier: ^3.10.0
version: 3.10.0 version: 3.10.0
@@ -137,12 +134,6 @@ importers:
shikiji-core: shikiji-core:
specifier: 0.10.2 specifier: 0.10.2
version: 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: vue:
specifier: ^3.5.18 specifier: ^3.5.18
version: 3.5.18(typescript@5.9.3) version: 3.5.18(typescript@5.9.3)
@@ -5139,10 +5130,6 @@ packages:
node-addon-api@7.1.1: node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-diff3@3.2.1:
resolution: {integrity: sha512-eKZcJ8RtMQ3cIaA15EgmtwG927fYnRlhtdA4Q9HAcCpAZPQhhU2XptnTH9GeAkMiX2bAXxlJSAFa9shFbztPgw==}
engines: {bun: '>=1.3.10'}
node-fetch@2.6.12: node-fetch@2.6.12:
resolution: {integrity: sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==} resolution: {integrity: sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==}
engines: {node: 4.x || >=6.0.0} engines: {node: 4.x || >=6.0.0}
@@ -5939,10 +5926,6 @@ packages:
resolution: {integrity: sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==} resolution: {integrity: sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
smol-toml@1.7.0:
resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==}
engines: {node: '>= 18'}
snapdragon-node@2.1.1: snapdragon-node@2.1.1:
resolution: {integrity: sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==} resolution: {integrity: sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -6459,14 +6442,6 @@ 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). 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 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: validate-npm-package-license@3.0.4:
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
@@ -12104,8 +12079,6 @@ snapshots:
node-addon-api@7.1.1: node-addon-api@7.1.1:
optional: true optional: true
node-diff3@3.2.1: {}
node-fetch@2.6.12: node-fetch@2.6.12:
dependencies: dependencies:
whatwg-url: 5.0.0 whatwg-url: 5.0.0
@@ -13023,8 +12996,6 @@ snapshots:
smob@1.6.1: {} smob@1.6.1: {}
smol-toml@1.7.0: {}
snapdragon-node@2.1.1: snapdragon-node@2.1.1:
dependencies: dependencies:
define-property: 1.0.0 define-property: 1.0.0
@@ -13553,10 +13524,6 @@ snapshots:
uuid@8.3.2: {} uuid@8.3.2: {}
valibot@1.4.2(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
validate-npm-package-license@3.0.4: validate-npm-package-license@3.0.4:
dependencies: dependencies:
spdx-correct: 3.2.0 spdx-correct: 3.2.0

View File

@@ -1,5 +1,4 @@
<script lang="ts" setup> <script lang="ts" setup>
import ImageLightbox from "@/components/ImageLightbox.vue"
import NewVersion from "@/components/NewVersion.vue" import NewVersion from "@/components/NewVersion.vue"
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook" import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook" import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
@@ -13,7 +12,6 @@ const { isATProtoReady } = useATProtoLogin()
<router-view v-if="isReady && isATProtoReady" /> <router-view v-if="isReady && isATProtoReady" />
<new-version /> <new-version />
<image-lightbox />
</div> </div>
</template> </template>

View File

@@ -11,7 +11,6 @@ import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook" import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
import { useNoteView } from "@/hooks/useNoteView.hook" import { useNoteView } from "@/hooks/useNoteView.hook"
import { useResizeContainer } from "@/hooks/useResizeContainer.hook" import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
import { useResolveLiveNotes } from "@/hooks/useResolveLiveNotes.hook"
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook" import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
import { useVisitRepo } from "@/modules/history/hooks/useVisitRepo.hook" import { useVisitRepo } from "@/modules/history/hooks/useVisitRepo.hook"
import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue" import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue"
@@ -44,12 +43,7 @@ useUserSettings()
const { visitRepo } = useVisitRepo({ user: user, repo: repo }) const { visitRepo } = useVisitRepo({ user: user, repo: repo })
const { toHTML } = markdownBuilder(repo) const { toHTML } = markdownBuilder(repo)
const { listenToClick } = useLinks("note-display") const { listenToClick } = useLinks("note-display")
const { stackedNotes, scrollToFocusedNote, scrollToLastStackedNote } = const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
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 { titles } = useNoteView()
const { isLogged } = useGitHubLogin() const { isLogged } = useGitHubLogin()
@@ -68,8 +62,7 @@ const hasContent = computed(() => !!renderedContent.value)
useMarkdownPostRender(renderedContent, () => ".note-display", { useMarkdownPostRender(renderedContent, () => ".note-display", {
onReady: () => listenToClick(), onReady: () => listenToClick(),
tikz: true, tikz: true
macroplan: true
}) })
watch( watch(
@@ -84,10 +77,7 @@ const retryLoad = () => {
store.setUserRepo(props.user, props.repo) store.setUserRepo(props.user, props.repo)
} }
onMounted(() => { onMounted(() => visitRepo())
visitRepo()
scrollToLastStackedNote()
})
onUnmounted(() => { onUnmounted(() => {
store.resetFiles() store.resetFiles()

View File

@@ -25,16 +25,16 @@ const fontFamilies = computed(
const sortedFontFamilies = computed(() => { const sortedFontFamilies = computed(() => {
const base = fontFamilies.value const base = fontFamilies.value
const extras = [ const extras = [
store.userSettings?.chosenHeadingFont, store.userSettings?.chosenTitleFont,
store.userSettings?.chosenBodyFont store.userSettings?.chosenBodyFont
].filter((f): f is string => !!f && !base.includes(f)) ].filter((f): f is string => !!f && !base.includes(f))
return [...base, ...extras].sort((a, b) => a.localeCompare(b)) return [...base, ...extras].sort((a, b) => a.localeCompare(b))
}) })
const fontSizes = Array.from({ length: 7 }, (_, i) => `${9 + i * 2}pt`) const fontSizes = Array.from({ length: 7 }, (_, i) => `${9 + i * 2}pt`)
const headingFont = computed({ const titleFont = computed({
get: () => store.userSettings?.chosenHeadingFont, get: () => store.userSettings?.chosenTitleFont,
set: (value) => store.setHeadingFont(value!) set: (value) => store.setTitleFont(value!)
}) })
const bodyFont = computed({ const bodyFont = computed({
get: () => store.userSettings?.chosenBodyFont, get: () => store.userSettings?.chosenBodyFont,
@@ -49,35 +49,13 @@ const fontSize = computed({
<template> <template>
<div class="font-change"> <div class="font-change">
<div> <div>
<label for="heading-font" class="font-label">h</label> <label for="title-font" class="font-label">t</label>
<select id="heading-font" class="select" v-model="headingFont"> <select id="title-font" class="select" v-model="titleFont">
<option v-for="font in sortedFontFamilies" :key="font" :value="font"> <option v-for="font in sortedFontFamilies" :key="font" :value="font">
{{ font }} {{ font }}
</option> </option>
</select> </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> <label for="body-font" class="font-label">p</label>
<select id="body-font" class="select" v-model="bodyFont"> <select id="body-font" class="select" v-model="bodyFont">
<option v-for="font in sortedFontFamilies" :key="font" :value="font"> <option v-for="font in sortedFontFamilies" :key="font" :value="font">

View File

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

View File

@@ -1,54 +0,0 @@
<script lang="ts" setup>
import { useEventListener } from "@vueuse/core"
import { ref, watch } from "vue"
import { useImageLightbox } from "@/hooks/useImageLightbox.hook"
import { svgToDataUrl } from "@/utils/svgDownload"
const { isOpen, src, alt, open, close } = useImageLightbox()
const dialogRef = ref<HTMLDialogElement | null>(null)
const onClick = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (target.closest(".svg-download-buttons")) return
const svg = target.closest<SVGSVGElement>(".tikz svg, .mermaid svg")
if (svg) {
event.preventDefault()
open(svgToDataUrl(svg), "diagram")
return
}
const img = target.closest("img")
if (img && img.closest(".note-content, .note-display")) {
event.preventDefault()
open(img.currentSrc || img.src, img.alt)
}
}
useEventListener(document, "click", onClick)
watch(isOpen, (value) => {
const el = dialogRef.value
if (!el) return
if (value && !el.open) el.showModal()
else if (!value && el.open) el.close()
})
</script>
<template>
<dialog
ref="dialogRef"
class="modal image-lightbox not-prose"
@close="close()"
@click="close()"
>
<img
v-if="src"
:src="src"
:alt="alt"
class="max-h-[96vh] max-w-[96vw] rounded-lg bg-white p-3 cursor-zoom-out"
/>
</dialog>
</template>

View File

@@ -1,29 +0,0 @@
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

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

View File

@@ -1,197 +0,0 @@
<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,5 +1,11 @@
<script lang="ts" setup> <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 { useEditionMode } from "@/hooks/useEditionMode"
import { useFile } from "@/hooks/useFile.hook" import { useFile } from "@/hooks/useFile.hook"
@@ -17,7 +23,6 @@ import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
import { getFileLanguage, isMarkdownPath } from "@/utils/fileLanguage" import { getFileLanguage, isMarkdownPath } from "@/utils/fileLanguage"
import { filenameToNoteTitle } from "@/utils/noteTitle" import { filenameToNoteTitle } from "@/utils/noteTitle"
import { errorMessage } from "@/utils/notif" import { errorMessage } from "@/utils/notif"
import { threeWayMerge } from "@/utils/threeWayMerge"
const LinkedNotes = defineAsyncComponent( const LinkedNotes = defineAsyncComponent(
() => import("@/components/LinkedNotes.vue") () => import("@/components/LinkedNotes.vue")
@@ -35,10 +40,6 @@ const EditNote = defineAsyncComponent(
() => import("@/modules/note/components/EditNote.vue") () => import("@/modules/note/components/EditNote.vue")
) )
const NoteState = defineAsyncComponent(
() => import("@/components/NoteState.vue")
)
const props = defineProps<{ const props = defineProps<{
user: string user: string
repo: string repo: string
@@ -52,28 +53,16 @@ const repo = computed(() => props.repo)
const sha = computed(() => props.sha) const sha = computed(() => props.sha)
const index = computed(() => props.index) const index = computed(() => props.index)
const { scrollToFocusedNote, replaceStackedNote } = useRouteQueryStackedNotes() const { scrollToFocusedNote } = 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 { const {
path, path,
newerSha,
content, content,
rawContent, rawContent,
getRawContent, getRawContent,
saveCacheNote, saveCacheNote,
getEditedSha getEditedSha
} = useFile(sha) } = 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 initialRawContent = ref<string | null>(null)
const isMarkdown = computed(() => const isMarkdown = computed(() =>
path.value ? isMarkdownPath(path.value) : true path.value ? isMarkdownPath(path.value) : true
@@ -155,8 +144,7 @@ const {
lastCheckedAt, lastCheckedAt,
latestSha, latestSha,
check: checkFreshness, check: checkFreshness,
pullLatest, pullLatest
resolveMergeSources
} = useNoteFreshness({ } = useNoteFreshness({
user: user.value, user: user.value,
repo: repo.value, repo: repo.value,
@@ -166,34 +154,10 @@ const {
}) })
const conflictOpen = ref(false) const conflictOpen = ref(false)
const loadStatus = ref<"loading" | "ready" | "failed">("loading")
const loadNote = async () => { onMounted(async () => {
loadStatus.value = "loading" initialRawContent.value = await getRawContent()
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( watch(
path, path,
@@ -208,10 +172,10 @@ const { mode, toggleMode } = useEditionMode()
useMarkdownPostRender(content, () => `.note-${sha.value}`, { useMarkdownPostRender(content, () => `.note-${sha.value}`, {
onReady: () => listenToClick(), onReady: () => listenToClick(),
tikz: true, tikz: true,
macroplan: true,
mermaid: () => rawContent.value.includes("```mermaid"), mermaid: () => rawContent.value.includes("```mermaid"),
shikiji: () => isMarkdown.value && rawContent.value.includes("```"), shikiji: () => isMarkdown.value && rawContent.value.includes("```"),
images: () => (/\!\[.*?\]\(.*?\)/.test(rawContent.value) ? props.sha : null), images: () =>
/\!\[.*?\]\(.*?\)/.test(rawContent.value) ? props.sha : null,
triggers: [mode] triggers: [mode]
}) })
@@ -221,13 +185,6 @@ const performSave = async (overrideSha?: string) => {
return 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 editedSha = overrideSha ?? (await getEditedSha()) ?? sha.value
const { sha: newSha, conflict } = await updateFile({ const { sha: newSha, conflict } = await updateFile({
content: rawContent.value, content: rawContent.value,
@@ -236,7 +193,9 @@ const performSave = async (overrideSha?: string) => {
}) })
if (conflict) { if (conflict) {
await handleConflict() await checkFreshness()
conflictOpen.value = true
if (mode.value === "read") toggleMode()
return return
} }
@@ -246,52 +205,9 @@ const performSave = async (overrideSha?: string) => {
} }
await saveCacheNote(encodeUTF8ToBase64(rawContent.value), { await saveCacheNote(encodeUTF8ToBase64(rawContent.value), {
editedSha: newSha, editedSha: newSha
path: path.value
}) })
initialRawContent.value = rawContent.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) => { watch(mode, async (newMode) => {
@@ -300,7 +216,8 @@ watch(mode, async (newMode) => {
return return
} }
const hasUserFinishedToEdit = newMode === "read" && isDirty.value const hasUserFinishedToEdit =
newMode === "read" && rawContent.value !== initialRawContent.value
if (!hasUserFinishedToEdit) { if (!hasUserFinishedToEdit) {
return return
@@ -312,7 +229,7 @@ watch(mode, async (newMode) => {
await checkFreshness() await checkFreshness()
if (freshnessStatus.value === "outdated") { if (freshnessStatus.value === "outdated") {
await handleConflict() conflictOpen.value = true
return return
} }
@@ -324,7 +241,6 @@ const onConflictDiscard = async () => {
if (raw !== null) { if (raw !== null) {
rawContent.value = raw rawContent.value = raw
initialRawContent.value = raw initialRawContent.value = raw
advanceStackTo(latestSha.value)
} }
} }
@@ -348,9 +264,9 @@ const onBadgeClick = async () => {
return return
} }
const hasUnsavedEdits = isDirty.value const hasUnsavedEdits = rawContent.value !== initialRawContent.value
if (hasUnsavedEdits) { if (hasUnsavedEdits) {
await handleConflict() conflictOpen.value = true
return return
} }
@@ -358,7 +274,6 @@ const onBadgeClick = async () => {
if (raw !== null) { if (raw !== null) {
rawContent.value = raw rawContent.value = raw
initialRawContent.value = raw initialRawContent.value = raw
advanceStackTo(latestSha.value)
return return
} }
if (failureStatus === "unauthorized") { if (failureStatus === "unauthorized") {
@@ -389,7 +304,7 @@ const onBadgeClick = async () => {
class="action" class="action"
/> />
<button <button
v-if="isMarkdown && canPush && loadStatus === 'ready'" v-if="isMarkdown && canPush"
class="action button is-text is-light" class="action button is-text is-light"
:class="{ 'is-link': mode === 'edit' }" :class="{ 'is-link': mode === 'edit' }"
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''" :style="mode === 'edit' ? 'color: var(--color-primary)' : ''"
@@ -496,28 +411,11 @@ const onBadgeClick = async () => {
<div v-if="mode === 'edit' && isMarkdown" class="edit"> <div v-if="mode === 'edit' && isMarkdown" class="edit">
<edit-note :key="editKey" v-model="rawContent" /> <edit-note :key="editKey" v-model="rawContent" />
</div> </div>
<template v-else-if="mode === 'read'"> <div
<div v-if="newerSha" class="snapshot-banner"> v-if="mode === 'read'"
<span>You're viewing an older shared version.</span> class="note-content"
<button v-html="displayedContent"
type="button" ></div>
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> </section>
<linked-notes v-if="hasBacklinks && content" :sha="sha" /> <linked-notes v-if="hasBacklinks && content" :sha="sha" />
<note-conflict-modal <note-conflict-modal
@@ -588,18 +486,6 @@ $border-color: rgba(18, 19, 58, 0.2);
display: none; 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 { .action {
margin: 0; margin: 0;

View File

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

View File

@@ -25,17 +25,15 @@ const segments = computed(() => segmentBody(props.task.body))
const PRIORITIES = ["A", "B", "C", "D"] as const const PRIORITIES = ["A", "B", "C", "D"] as const
const priorityBadgeClassFor = (priority?: string): string => { const priorityBadgeClassFor = (priority?: string): string => {
const preClass = "badge badge-soft"
switch (priority) { switch (priority) {
case "A": case "A":
return `${preClass} badge-error` return "badge badge-soft badge-error"
case "B": case "B":
return `${preClass} badge-warning` return "badge badge-soft badge-warning"
case "C": case "C":
return `${preClass} badge-info` return "badge badge-soft badge-info"
default: default:
return `${preClass} badge-ghost` return "badge badge-soft badge-ghost"
} }
} }
@@ -106,9 +104,7 @@ const MS_PER_DAY = 86_400_000
const formatDueDate = (value: string): string => { const formatDueDate = (value: string): string => {
const due = parseDueDate(value) const due = parseDueDate(value)
if (!due) return value if (!due) return value
const diff = Math.round( const diff = Math.round((due.getTime() - startOfToday().getTime()) / MS_PER_DAY)
(due.getTime() - startOfToday().getTime()) / MS_PER_DAY
)
if (diff === 0) return "Today" if (diff === 0) return "Today"
if (diff === 1) return "Tomorrow" if (diff === 1) return "Tomorrow"
if (diff === -1) return "Yesterday" if (diff === -1) return "Yesterday"
@@ -335,8 +331,12 @@ watch(
aria-hidden="true" aria-hidden="true"
> >
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0" /> <path
<path d="M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28" /> d="M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"
/>
<path
d="M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28"
/>
</svg> </svg>
{{ seg.value }} {{ seg.value }}
<button <button

View File

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

View File

@@ -2,7 +2,6 @@ import { computed, Ref, ref, toValue } from "vue"
import { markdownBuilder } from "@/hooks/useMarkdown.hook" import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache" import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
import { latestShaIfOlder } from "@/modules/note/snapshotStatus"
import { queryFileContent } from "@/modules/repo/services/repo" import { queryFileContent } from "@/modules/repo/services/repo"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store" import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
@@ -15,16 +14,6 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
return file?.path 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 { const {
render, render,
renderFromUTF8, renderFromUTF8,
@@ -58,8 +47,6 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
fromCache.value = !!cachedNote fromCache.value = !!cachedNote
if (cachedNote) { if (cachedNote) {
cachedNotePath.value = cachedNote.path ?? cachedNotePath.value
if (from === "path") { if (from === "path") {
queryFileContent(store.user, store.repo, shaValue).then( queryFileContent(store.user, store.repo, shaValue).then(
(fileContent) => { (fileContent) => {
@@ -117,7 +104,6 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
return { return {
path, path,
newerSha,
content, content,
rawContent, rawContent,
getRawContent, getRawContent,

View File

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

View File

@@ -1,19 +0,0 @@
import { ref } from "vue"
const isOpen = ref(false)
const src = ref("")
const alt = ref("")
export const useImageLightbox = () => {
const open = (imageSrc: string, imageAlt = "") => {
src.value = imageSrc
alt.value = imageAlt
isOpen.value = true
}
const close = () => {
isOpen.value = false
}
return { isOpen, src, alt, open, close }
}

View File

@@ -1,42 +0,0 @@
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,7 +25,6 @@ import {
} from "@/utils/decodeBase64ToUTF8" } from "@/utils/decodeBase64ToUTF8"
import { html5Media } from "@/utils/markdown/markdown-html5-media" import { html5Media } from "@/utils/markdown/markdown-html5-media"
import { markdownItTablerIcons } from "@/utils/markdown/markdown-it-tabler-icons" import { markdownItTablerIcons } from "@/utils/markdown/markdown-it-tabler-icons"
import { renderFallback } from "@/utils/markdown/renderFallback"
const TIKZ_BUNDLE_URL = const TIKZ_BUNDLE_URL =
"https://cdn.jsdelivr.net/gh/artisticat1/obsidian-tikzjax@0.5.2/tikzjax.js" "https://cdn.jsdelivr.net/gh/artisticat1/obsidian-tikzjax@0.5.2/tikzjax.js"
@@ -95,37 +94,6 @@ 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() const slugger = new GithubSlugger()
let tabGroupCounter = 0 let tabGroupCounter = 0
@@ -138,7 +106,6 @@ const md = new MarkdownIt({
}) })
.use(markdownItMermaidExtractor) .use(markdownItMermaidExtractor)
.use(markdownItTikzExtractor) .use(markdownItTikzExtractor)
.use(markdownItMacroplanExtractor)
.use(html5Media) .use(html5Media)
.use(blockEmbedPlugin, { .use(blockEmbedPlugin, {
youtube: { youtube: {
@@ -194,7 +161,6 @@ export const useShikiji = (): Promise<void> => {
"html", "html",
"css", "css",
"json", "json",
"rust",
{ {
...alloyGrammar, ...alloyGrammar,
name: "alloy", name: "alloy",
@@ -433,14 +399,7 @@ const renderMarkdown = (content: string, env?: Record<string, unknown>) => {
// unhighlighted because nothing else invalidates the computed. // unhighlighted because nothing else invalidates the computed.
if (content.includes("```")) void shikijiReady.value if (content.includes("```")) void shikijiReady.value
slugger.reset() slugger.reset()
try { return env ? md.render(content, env) : md.render(content)
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 ({ export const renderCodeFile = async ({
@@ -454,22 +413,10 @@ export const renderCodeFile = async ({
}): Promise<string> => { }): Promise<string> => {
await useShikiji() await useShikiji()
const heading = filename ? `# ${filename}\n\n` : "" 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) { if (lang !== null) {
const rendered = renderMarkdown( return renderMarkdown(`${heading}\`\`\`\`${lang}\n${rawContent}\n\`\`\`\``)
`${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>`
} }
// Mirror Shikiji's per-line spans so the line-number CSS applies uniformly. return `${renderMarkdown(heading)}<pre><code>${md.utils.escapeHtml(rawContent)}</code></pre>`
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) => { export const markdownBuilder = (defaultPrefix?: Ref<string> | string) => {

View File

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

View File

@@ -2,27 +2,18 @@ import { createPinia, setActivePinia } from "pinia"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { ref } from "vue" import { ref } from "vue"
import { DataType } from "@/data/DataType.enum"
const { const {
fetchLatestSha, fetchLatestSha,
addFile, addFile,
getRawContent, getRawContent,
saveCacheNote, saveCacheNote,
queryFileContent, queryFileContent
dataGet
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
fetchLatestSha: vi.fn(), fetchLatestSha: vi.fn(),
addFile: vi.fn(), addFile: vi.fn(),
getRawContent: vi.fn((s: string) => s), getRawContent: vi.fn((s: string) => s),
saveCacheNote: vi.fn(), 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", () => ({ vi.mock("@/hooks/useGitHubContent.hook", () => ({
@@ -241,87 +232,3 @@ describe("useNoteFreshness.pullLatest", () => {
expect(queryFileContent).toHaveBeenCalledWith("alice", "notes", "cached-sha") 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,11 +1,8 @@
import { Ref, ref } from "vue" import { Ref, ref } from "vue"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { useGitHubContent } from "@/hooks/useGitHubContent.hook" import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
import { markdownBuilder } from "@/hooks/useMarkdown.hook" import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache" import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
import { Note } from "@/modules/note/models/Note"
import { queryFileContent } from "@/modules/repo/services/repo" import { queryFileContent } from "@/modules/repo/services/repo"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store" import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
@@ -117,52 +114,11 @@ export const useNoteFreshness = ({
return { raw: getRawContent(fileContent), failureStatus: null } 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 { return {
status, status,
lastCheckedAt, lastCheckedAt,
latestSha, latestSha,
check, check,
pullLatest, pullLatest
resolveMergeSources
} }
} }

View File

@@ -1,50 +0,0 @@
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,16 +111,6 @@ 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 = ( const addStackedNote = (
currentSha: string, currentSha: string,
sha: string, sha: string,
@@ -159,26 +149,9 @@ export const useRouteQueryStackedNotes = () => {
scrollToFocusedNote({ noteId: selector ?? sha, hash, anchorTop }) 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 { return {
stackedNotes: readonly(stackedNotes), stackedNotes: readonly(stackedNotes),
addStackedNote, addStackedNote,
replaceStackedNote, scrollToFocusedNote
scrollToFocusedNote,
scrollToLastStackedNote
} }
} }

View File

@@ -1,436 +0,0 @@
<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

@@ -1,153 +0,0 @@
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

@@ -1,62 +0,0 @@
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

@@ -1,87 +0,0 @@
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

@@ -1,72 +0,0 @@
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

@@ -1,59 +0,0 @@
// 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

@@ -1,40 +0,0 @@
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

@@ -1,55 +0,0 @@
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

@@ -1,65 +0,0 @@
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,8 +21,7 @@ export const buildNoteDocs = (
_id: generateId(DataType.Note, sha), _id: generateId(DataType.Note, sha),
$type: DataType.Note, $type: DataType.Note,
content, content,
editedSha, editedSha
path
} }
return path return path
? [base, { ...base, _id: generateId(DataType.Note, path) }] ? [base, { ...base, _id: generateId(DataType.Note, path) }]
@@ -62,18 +61,11 @@ export const prepareNoteCache = (sha: string, path?: string) => {
content: string, content: string,
params?: { editedSha?: string; path?: 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 = { const newNote: Note = {
_id: contentId, _id: noteId,
$type: DataType.Note, $type: DataType.Note,
content, content,
editedSha: params?.editedSha, editedSha: params?.editedSha
path: params?.path ?? path
} }
if (params && params.path) { if (params && params.path) {

View File

@@ -1,62 +0,0 @@
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

@@ -1,38 +0,0 @@
// 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,9 +4,6 @@ import { Model } from "@/data/models/Model"
export interface Note extends Model<DataType.Note> { export interface Note extends Model<DataType.Note> {
content: string content: string
editedSha?: 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 { export interface PublicNoteListItem {

View File

@@ -1,23 +0,0 @@
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

@@ -1,25 +0,0 @@
/**
* 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 fontSize?: string
chosenFontSize?: string chosenFontSize?: string
backlink?: boolean backlink?: boolean
chosenHeadingFont?: string chosenTitleFont?: string
chosenBodyFont?: string chosenBodyFont?: string
pageWidth?: string pageWidth?: string
} }

View File

@@ -25,7 +25,6 @@ import { getOctokit, runWithAuthRetry } from "./octo"
import { import {
getFiles, getFiles,
getRepoPermission, getRepoPermission,
getUserSettingsContent,
queryFileContent queryFileContent
} from "./repo" } from "./repo"
@@ -166,58 +165,3 @@ describe("queryFileContent", () => {
expect(warn).toHaveBeenCalled() 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,14 +123,12 @@ export const getUserSettingsContent = async (
const raw = JSON.parse(atob(content)) as UserSettings & { const raw = JSON.parse(atob(content)) as UserSettings & {
t?: string t?: string
h?: string
p?: string p?: string
} }
const { t, h, p, ...rest } = raw const { t, p, ...rest } = raw
return { return {
...rest, ...rest,
// `h` (heading) is the current key; `t` (title) is kept for back-compat. chosenTitleFont: t,
chosenHeadingFont: h ?? t,
chosenBodyFont: p chosenBodyFont: p
} }
} }

View File

@@ -105,18 +105,6 @@ describe("userRepo store — synchronous mutations", () => {
expect(vi.mocked(data.update)).not.toHaveBeenCalled() 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", () => { it("setFontFamily initializes userSettings when absent and persists to localStorage", () => {
const store = useUserRepoStore() const store = useUserRepoStore()
store.user = "alice" store.user = "alice"
@@ -131,40 +119,22 @@ describe("userRepo store — synchronous mutations", () => {
expect(persisted.chosenFontFamily).toBe("Inter") expect(persisted.chosenFontFamily).toBe("Inter")
}) })
it("setFontSize, setHeadingFont, setBodyFont each persist their respective field", () => { it("setFontSize, setTitleFont, setBodyFont each persist their respective field", () => {
const store = useUserRepoStore() const store = useUserRepoStore()
store.user = "alice" store.user = "alice"
store.repo = "notes" store.repo = "notes"
store.setFontSize("18px") store.setFontSize("18px")
store.setHeadingFont("Serif") store.setTitleFont("Serif")
store.setBodyFont("Sans") store.setBodyFont("Sans")
const persisted = JSON.parse( const persisted = JSON.parse(
localStorage.getItem("remanso:layout:alice:notes") as string localStorage.getItem("remanso:layout:alice:notes") as string
) )
expect(persisted.chosenFontSize).toBe("18px") expect(persisted.chosenFontSize).toBe("18px")
expect(persisted.chosenHeadingFont).toBe("Serif") expect(persisted.chosenTitleFont).toBe("Serif")
expect(persisted.chosenBodyFont).toBe("Sans") 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", () => { describe("userRepo store — setUserRepo", () => {
@@ -191,25 +161,6 @@ describe("userRepo store — setUserRepo", () => {
expect(store.loadError).toBeNull() 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 () => { it("populates files from getFiles on success", async () => {
vi.mocked(getFiles).mockResolvedValue([ vi.mocked(getFiles).mockResolvedValue([
{ sha: "a", path: "x.md", type: "blob" } as never { 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 if (!this.userSettings) return
try { try {
const { const {
chosenHeadingFont, chosenTitleFont,
chosenBodyFont, chosenBodyFont,
chosenFontSize, chosenFontSize,
chosenFontFamily, chosenFontFamily,
@@ -65,7 +65,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
localStorage.setItem( localStorage.setItem(
`remanso:layout:${this.user}:${this.repo}`, `remanso:layout:${this.user}:${this.repo}`,
JSON.stringify({ JSON.stringify({
chosenHeadingFont, chosenTitleFont,
chosenBodyFont, chosenBodyFont,
chosenFontSize, chosenFontSize,
chosenFontFamily, chosenFontFamily,
@@ -86,17 +86,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
let lsLayout: Partial<UserSettings> = {} let lsLayout: Partial<UserSettings> = {}
try { try {
const lsRaw = localStorage.getItem(`remanso:layout:${user}:${repo}`) const lsRaw = localStorage.getItem(`remanso:layout:${user}:${repo}`)
if (lsRaw) { if (lsRaw) lsLayout = JSON.parse(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 { } catch {
// ignore // ignore
} }
@@ -167,9 +157,9 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
: userSettings?.fontFamily : userSettings?.fontFamily
const chosenFontSize = const chosenFontSize =
this.userSettings?.chosenFontSize ?? userSettings?.fontSize this.userSettings?.chosenFontSize ?? userSettings?.fontSize
const chosenHeadingFont = const chosenTitleFont =
this.userSettings?.chosenHeadingFont ?? this.userSettings?.chosenTitleFont ??
userSettings?.chosenHeadingFont ?? userSettings?.chosenTitleFont ??
chosenFontFamily chosenFontFamily
const chosenBodyFont = const chosenBodyFont =
this.userSettings?.chosenBodyFont ?? this.userSettings?.chosenBodyFont ??
@@ -185,14 +175,14 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
chosenFontFamily ?? this.userSettings.fontFamily chosenFontFamily ?? this.userSettings.fontFamily
this.userSettings.chosenFontSize = this.userSettings.chosenFontSize =
chosenFontSize ?? this.userSettings.fontSize chosenFontSize ?? this.userSettings.fontSize
this.userSettings.chosenHeadingFont = chosenHeadingFont this.userSettings.chosenTitleFont = chosenTitleFont
this.userSettings.chosenBodyFont = chosenBodyFont this.userSettings.chosenBodyFont = chosenBodyFont
this._persistLayout() this._persistLayout()
// Persist only repo config fields — chosen* are localStorage-only // Persist only repo config fields — chosen* are localStorage-only
const { const {
chosenHeadingFont: _h, chosenTitleFont: _t,
chosenBodyFont: _b, chosenBodyFont: _b,
chosenFontSize: _s, chosenFontSize: _s,
chosenFontFamily: _f, chosenFontFamily: _f,
@@ -243,9 +233,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
`${this.user}-${this.repo}` `${this.user}-${this.repo}`
) )
const newFiles = [ const newFiles = [
...toRaw(this.files).filter( ...toRaw(this.files).filter((f) => f.sha !== file.sha),
(f) => f.sha !== file.sha && f.path !== file.path
),
toRaw(file) toRaw(file)
] ]
data.update<DataType.SavedRepo, SavedRepo>({ data.update<DataType.SavedRepo, SavedRepo>({
@@ -304,11 +292,11 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
this.userSettings.chosenFontSize = fontSize this.userSettings.chosenFontSize = fontSize
this._persistLayout() this._persistLayout()
}, },
setHeadingFont(font: string) { setTitleFont(font: string) {
if (!this.userSettings) { if (!this.userSettings) {
this.userSettings = { $type: DataType.UserSettings } this.userSettings = { $type: DataType.UserSettings }
} }
this.userSettings.chosenHeadingFont = font this.userSettings.chosenTitleFont = font
this._persistLayout() this._persistLayout()
}, },
setBodyFont(font: string) { setBodyFont(font: string) {
@@ -317,13 +305,6 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
} }
this.userSettings.chosenBodyFont = font this.userSettings.chosenBodyFont = font
this._persistLayout() 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 fontSize = store.userSettings?.chosenFontSize
const bodyFont = store.userSettings?.chosenBodyFont const bodyFont = store.userSettings?.chosenBodyFont
const headingFont = store.userSettings?.chosenHeadingFont const titleFont = store.userSettings?.chosenTitleFont
downloadFont(bodyFont || DEFAULT_FONT_POLICY, "--font-family") downloadFont(bodyFont || DEFAULT_FONT_POLICY, "--font-family")
downloadFont( downloadFont(
headingFont || bodyFont || DEFAULT_FONT_POLICY, titleFont || bodyFont || DEFAULT_FONT_POLICY,
"--heading-font-family" "--title-font-family"
) )
root.style.setProperty("--font-size", fontSize || DEFAULT_FONT_SIZE) root.style.setProperty("--font-size", fontSize || DEFAULT_FONT_SIZE)

View File

@@ -6,7 +6,7 @@
:root { :root {
--primary-color: #ffa4c0; --primary-color: #ffa4c0;
--font-family: "Libertinus Serif", serif; --font-family: "Libertinus Serif", serif;
--heading-font-family: "Libertinus Serif", serif; --title-font-family: "Libertinus Serif", serif;
--font-size: 13pt; --font-size: 13pt;
--font-color: #4a4a4a; --font-color: #4a4a4a;
--link: #445fb9; --link: #445fb9;
@@ -186,46 +186,6 @@ pre {
overflow-wrap: anywhere; 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 { pre.mermaid {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -258,20 +218,11 @@ pre.tikz svg {
} }
} }
.tikz-loading, .tikz-loading {
.macroplan-loading {
opacity: 0.6; opacity: 0.6;
font-style: italic; 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 { .svg-download-host {
position: relative; position: relative;
display: inline-block; display: inline-block;
@@ -313,16 +264,14 @@ pre.tikz svg {
filter: none; filter: none;
} }
.tikz-error, .tikz-error {
.macroplan-error {
border-left: 3px solid var(--color-error); border-left: 3px solid var(--color-error);
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--color-error) 10%, transparent); background: color-mix(in srgb, var(--color-error) 10%, transparent);
text-align: left; text-align: left;
} }
.tikz-error pre, .tikz-error pre {
.macroplan-error pre {
white-space: pre-wrap; white-space: pre-wrap;
font-size: 0.85em; font-size: 0.85em;
} }
@@ -395,72 +344,3 @@ iframe {
padding-bottom: 0.2rem; padding-bottom: 0.2rem;
padding-inline-start: 0.2rem; padding-inline-start: 0.2rem;
} }
/* Image lightbox — desktop click-to-fullscreen */
/* Affordance: zoomable content images and diagrams (desktop only). */
@media (min-width: 769px) {
.note-content img,
.note-display img,
.tikz svg,
.mermaid svg {
cursor: zoom-in;
}
}
/* DaisyUI hides the native ::backdrop and uses the .modal element's own
background-color for the dim, so the blurred overlay lives on the element. */
.image-lightbox {
background-color: rgb(0 0 0 / 0);
-webkit-backdrop-filter: blur(0);
backdrop-filter: blur(0);
transition:
visibility 0.3s allow-discrete,
background-color 0.3s ease-out,
-webkit-backdrop-filter 0.3s ease-out,
backdrop-filter 0.3s ease-out;
}
.image-lightbox[open] {
background-color: rgb(0 0 0 / 0.7);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
@starting-style {
.image-lightbox[open] {
background-color: rgb(0 0 0 / 0);
-webkit-backdrop-filter: blur(0);
backdrop-filter: blur(0);
}
}
.image-lightbox img {
transition:
opacity 0.3s ease-out,
scale 0.3s ease-out;
}
.image-lightbox[open] img {
opacity: 1;
scale: 1;
}
.image-lightbox:not([open]) img {
opacity: 0;
scale: 0.95;
}
@starting-style {
.image-lightbox[open] img {
opacity: 0;
scale: 0.95;
}
}
@media (prefers-reduced-motion: reduce) {
.image-lightbox,
.image-lightbox img {
transition: none;
}
}

View File

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

View File

@@ -1,26 +0,0 @@
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

@@ -1,14 +0,0 @@
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

@@ -71,22 +71,17 @@ const buildExportableSvgString = (svg: SVGSVGElement): string => {
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink") clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink")
const { width, height } = getSvgPixelSize(svg) const { width, height } = getSvgPixelSize(svg)
const viewBox = svg.viewBox?.baseVal
const originX = viewBox && viewBox.width > 0 ? viewBox.x : 0
const originY = viewBox && viewBox.height > 0 ? viewBox.y : 0
if (!clone.getAttribute("viewBox")) { if (!clone.getAttribute("viewBox")) {
clone.setAttribute("viewBox", `${originX} ${originY} ${width} ${height}`) clone.setAttribute("viewBox", `0 0 ${width} ${height}`)
} }
clone.setAttribute("width", String(width)) clone.setAttribute("width", String(width))
clone.setAttribute("height", String(height)) clone.setAttribute("height", String(height))
// Cover the full viewBox (its origin can be negative — e.g. a diagram whose
// content extends above y=0), otherwise that content falls outside the fill.
const bg = document.createElementNS(SVG_NS, "rect") const bg = document.createElementNS(SVG_NS, "rect")
bg.setAttribute("x", String(originX)) bg.setAttribute("x", "0")
bg.setAttribute("y", String(originY)) bg.setAttribute("y", "0")
bg.setAttribute("width", String(width)) bg.setAttribute("width", "100%")
bg.setAttribute("height", String(height)) bg.setAttribute("height", "100%")
bg.setAttribute("fill", "#ffffff") bg.setAttribute("fill", "#ffffff")
clone.insertBefore(bg, clone.firstChild) clone.insertBefore(bg, clone.firstChild)
@@ -101,9 +96,6 @@ const buildExportableSvgString = (svg: SVGSVGElement): string => {
return new XMLSerializer().serializeToString(clone) return new XMLSerializer().serializeToString(clone)
} }
export const svgToDataUrl = (svg: SVGSVGElement): string =>
`data:image/svg+xml;charset=utf-8,${encodeURIComponent(buildExportableSvgString(svg))}`
const triggerBlobDownload = (blob: Blob, filename: string): void => { const triggerBlobDownload = (blob: Blob, filename: string): void => {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement("a") const a = document.createElement("a")

View File

@@ -1,94 +0,0 @@
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

@@ -1,40 +0,0 @@
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,7 +1,6 @@
import { describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { import {
applyRecurrence,
contextsOf, contextsOf,
isBlank, isBlank,
parseFile, parseFile,
@@ -217,79 +216,6 @@ 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", () => { describe("todotxt toggleCompleted", () => {
it("marks a task complete with today's date", () => { it("marks a task complete with today's date", () => {
// Construct via local-time fields so the test doesn't depend on the // Construct via local-time fields so the test doesn't depend on the

View File

@@ -210,74 +210,6 @@ const todayIso = (now: Date): string => {
return `${y}-${m}-${d}` 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. // Toggle the completed state of a task.
// On complete: stamp today's completion date. Priority stays in place per spec. // 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 — // On uncomplete: drop completion date but leave the rest of the line as-is —

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8" import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
import { errorMessage } from "@/utils/notif" import { errorMessage } from "@/utils/notif"
import { import {
applyRecurrence,
contextsOf, contextsOf,
FileLine, FileLine,
isBlank, isBlank,
@@ -24,7 +23,6 @@ type Prop = {
} }
const TODO_PATH = "todo.txt" const TODO_PATH = "todo.txt"
const DONE_PATH = "done.txt"
const FluxNote = defineAsyncComponent(() => import("@/components/FluxNote.vue")) const FluxNote = defineAsyncComponent(() => import("@/components/FluxNote.vue"))
@@ -60,34 +58,6 @@ watch(
{ immediate: true } { immediate: true }
) )
const doneFile = computed(() => store.files.find((f) => f.path === DONE_PATH))
const doneSha = computed(() => doneFile.value?.sha ?? "")
const donePath = computed(() => doneFile.value?.path ?? DONE_PATH)
// Reuse useTodoTxtCommit as a read-only loader. We never call its `mutate`,
// so nothing is ever written to done.txt from this view — the file is
// produced by an external tool.
const { items: doneItems, syncContent: syncDoneContent } = useTodoTxtCommit({
user: props.user,
repo: props.repo,
path: donePath,
initialContent: "",
initialSha: doneSha,
debounceMs: 1000
})
watch(
doneSha,
async (newSha) => {
if (!newSha) return
const base64 = await queryFileContent(props.user, props.repo, newSha)
if (base64) {
syncDoneContent(decodeBase64ToUTF8(base64), newSha)
}
},
{ immediate: true }
)
// Rank `A` = 65 .. `Z` = 90; absent priority is Infinity so it sorts last // Rank `A` = 65 .. `Z` = 90; absent priority is Infinity so it sorts last
// regardless of locale collation quirks. // regardless of locale collation quirks.
const priorityRank = (p?: string): number => const priorityRank = (p?: string): number =>
@@ -105,10 +75,6 @@ const taskEntries = computed(() => {
return out return out
}) })
const archivedTasks = computed(() =>
doneItems.value.filter((line): line is Task => !isBlank(line))
)
const allProjects = computed(() => { const allProjects = computed(() => {
const set = new Set<string>() const set = new Set<string>()
items.value.forEach((line) => { items.value.forEach((line) => {
@@ -145,7 +111,7 @@ const activePriorities = ref<Set<string | "none">>(new Set())
// XOR: either "open" only, "done" only, or "all" (no status filter). // XOR: either "open" only, "done" only, or "all" (no status filter).
type StatusFilter = "all" | "open" | "done" type StatusFilter = "all" | "open" | "done"
const statusFilter = ref<StatusFilter>("open") const statusFilter = ref<StatusFilter>("all")
const setStatusFilter = (next: Exclude<StatusFilter, "all">) => { const setStatusFilter = (next: Exclude<StatusFilter, "all">) => {
statusFilter.value = statusFilter.value === next ? "all" : next statusFilter.value = statusFilter.value === next ? "all" : next
} }
@@ -238,15 +204,7 @@ const filteredEntries = computed(() => {
}) })
const updateTask = (index: number, next: Task) => { const updateTask = (index: number, next: Task) => {
mutate((current) => { mutate((current) => current.map((line, i) => (i === index ? next : line)))
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) => { const deleteTask = (index: number) => {
@@ -269,22 +227,6 @@ const addTask = () => {
const { createFile } = useGitHubContent({ user: props.user, repo: props.repo }) const { createFile } = useGitHubContent({ user: props.user, repo: props.repo })
const isCreating = ref(false) const isCreating = ref(false)
// Pin a leaving row's box to its current coordinates before Vue makes it
// position: absolute. Without this, browsers (notably Firefox) compute the
// static position of an absolutely-positioned former-flex child to (0,0),
// so multiple leaving rows stack on top of each other and the remaining
// rows snap into place once the DOM nodes are removed.
const lockLeavingPosition = (el: Element) => {
const li = el as HTMLElement
const parent = li.parentElement
if (!parent) return
const liRect = li.getBoundingClientRect()
const parentRect = parent.getBoundingClientRect()
li.style.top = `${liRect.top - parentRect.top}px`
li.style.left = `${liRect.left - parentRect.left}px`
li.style.width = `${liRect.width}px`
}
const createTodoFile = async () => { const createTodoFile = async () => {
if (isCreating.value) return if (isCreating.value) return
isCreating.value = true isCreating.value = true
@@ -444,19 +386,16 @@ const createTodoFile = async () => {
</button> </button>
</div> </div>
<TransitionGroup <TransitionGroup name="todo" tag="ul" class="todo-list">
name="todo"
tag="ul"
class="todo-list"
@before-leave="lockLeavingPosition"
>
<li v-for="entry in filteredEntries" :key="entry.index"> <li v-for="entry in filteredEntries" :key="entry.index">
<todo-txt-item <div class="todo-li-inner">
:task="entry.line" <todo-txt-item
:can-edit="canPush" :task="entry.line"
@update="(t) => updateTask(entry.index, t)" :can-edit="canPush"
@delete="deleteTask(entry.index)" @update="(t) => updateTask(entry.index, t)"
/> @delete="deleteTask(entry.index)"
/>
</div>
</li> </li>
</TransitionGroup> </TransitionGroup>
@@ -473,15 +412,6 @@ const createTodoFile = async () => {
> >
Your todo list is empty. Add a task above. Your todo list is empty. Add a task above.
</p> </p>
<details v-if="doneFile" class="todo-archive">
<summary>Archived ({{ archivedTasks.length }})</summary>
<ul class="todo-archive-list">
<li v-for="(task, i) in archivedTasks" :key="i">
<todo-txt-item :task="task" :can-edit="false" />
</li>
</ul>
</details>
</template> </template>
</flux-note> </flux-note>
</div> </div>
@@ -502,14 +432,13 @@ const createTodoFile = async () => {
margin: 0; margin: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
// No `gap` gap can't transition, so it would snap closed when a row gap: 0.25rem;
// is removed at the end of the leave animation. Spacing lives on
// .todo-li-inner instead so it collapses with the row.
li { li {
list-style: none; list-style: none;
// Use grid-template-rows 1fr 0fr so the row's height collapses // Use grid-template-rows 1fr 0fr so the row's height (and therefore
// smoothly during leave instead of snapping when the DOM is removed. // the surrounding gap) collapses smoothly during leave instead of
// snapping closed when the DOM node is removed.
display: grid; display: grid;
grid-template-rows: 1fr; grid-template-rows: 1fr;
} }
@@ -518,7 +447,6 @@ const createTodoFile = async () => {
.todo-li-inner { .todo-li-inner {
overflow: hidden; overflow: hidden;
min-height: 0; min-height: 0;
padding-bottom: 0.25rem;
} }
.todo-enter-active, .todo-enter-active,
@@ -582,37 +510,5 @@ const createTodoFile = async () => {
.todo-filter-chip { .todo-filter-chip {
cursor: pointer; cursor: pointer;
} }
.todo-archive {
margin-top: 1rem;
> summary {
cursor: pointer;
opacity: 0.7;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
list-style: none;
}
> summary::-webkit-details-marker {
display: none;
}
}
.todo-archive-list {
list-style: none;
padding-left: 0;
margin: 0.5rem 0 0;
display: flex;
flex-direction: column;
li {
list-style: none;
opacity: 0.7;
padding-bottom: 0.25rem;
}
}
} }
</style> </style>

View File

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