Compare commits

..

236 Commits

Author SHA1 Message Date
Julien Calixte
b8c9d07930 feat(notes): auto-merge non-overlapping edits on conflict
All checks were successful
CI / verify (push) Successful in 1m13s
When a save hits a GitHub conflict, try a three-way merge first: if our
edits and the remote ones don't overlap, commit the result silently with
a tailored toast. Only genuinely overlapping edits open the conflict
modal. Adds an optional successMessage override to updateFile.
2026-06-30 00:27:07 +02:00
Julien Calixte
cb22c755df feat(notes): resolve base and remote sources for merge
resolveMergeSources reads the edited-from snapshot as the merge base
(its own immutable cache entry, never the path pointer) and the current
remote blob, returning the decoded text a three-way merge needs.
2026-06-30 00:27:03 +02:00
Julien Calixte
3a528c63e0 feat(notes): add line-level three-way merge util
Wraps node-diff3 to merge two edits derived from a common ancestor.
Conservative by design: edits to immediately-adjacent lines are
reported as conflicts so the caller can fall back to manual resolution
rather than risk a wrong auto-merge.
2026-06-30 00:26:59 +02:00
Julien Calixte
c2764deb49 chore(deps): add node-diff3 for three-way merge 2026-06-30 00:26:54 +02:00
Julien Calixte
57ef4a9e50 style(notes): constrain public note list to a centered column
All checks were successful
CI / verify (push) Successful in 2m4s
Full-bleed rows flung the author/date meta to the far edge on wide
screens. Cap the list and tabs to a 42rem centered column, stack the
meta tight under the title, and drop the button padding on titles.
2026-06-29 21:38:43 +02:00
Julien Calixte
9d27aa024f feat(notes): banner when viewing an older shared version
All checks were successful
CI / verify (push) Successful in 1m3s
Cache the note path on each snapshot so an old shared sha can find its
current version, and show a calm "older shared version — View latest"
banner. The exact snapshot is shown as-is; the banner only offers a jump
to the latest, never swapping content.
2026-06-29 01:04:51 +02:00
Julien Calixte
2f8d3c24a4 feat(notes): keep old shas as immutable snapshots
All checks were successful
CI / verify (push) Successful in 1m5s
Cache content under its own sha (write-once) with the path key as the
latest pointer, and advance the stack handle to the new sha after an
edit or pull. A shared link keeps showing exactly what was shared, while
the live view follows your changes.
2026-06-29 00:43:56 +02:00
Julien Calixte
08d2d804ff feat(notes): show loading and failed states for stacked notes
All checks were successful
CI / verify (push) Successful in 1m3s
A note that couldn't load rendered a silent blank div. Add a calm
NoteState placeholder (spinner while loading, message + retry on
failure) and gate the read view on it; content still wins when present.
2026-06-29 00:31:09 +02:00
Julien Calixte
8f50259efe docs: record two-key cache strategy for reference modes
Decide the C3/C4 cache shape: content keyed by its own SHA (write-once,
immutable snapshot store) plus a path key holding the latest content
(live pointer). Notes the current immutability violation to fix.
2026-06-29 00:31:04 +02:00
Julien Calixte
a04d169bd8 fix(notes): show raw-text fallback when markdown render fails
All checks were successful
CI / verify (push) Successful in 1m2s
A throwing markdown-it plugin propagated uncaught and left the note
silently blank. Wrap the central render in try/catch and degrade to the
note's escaped raw text with a gentle notice, so content stays readable.
2026-06-29 00:00:20 +02:00
Julien Calixte
1b47101340 fix(notes): persist new SHA and dedup files by path on edit
All checks were successful
CI / verify (push) Successful in 2m2s
Editing a note in a stacked note saved cache without the path, so
store.files never received the new SHA and lookups went stale. Pass the
path through, and make addFile dedup by path as well as SHA so an edit
(same path, new SHA) replaces the old entry instead of duplicating it.
2026-06-28 23:52:37 +02:00
Julien Calixte
a7d846aa33 docs: add language glossary, QFD design, and ADRs
Capture the walk-with-me + qfd session: CONTEXT.md ubiquitous-language
glossary, DESIGN.md goal-driven cascade (Beauty / Pride & Peace /
public voice), and ADRs for Note identity (Path) and the two reference
modes (Live vs Snapshot).
2026-06-28 23:52:27 +02:00
Julien Calixte
fac05af862 feat: keep faved repos in list
All checks were successful
CI / verify (push) Successful in 1m3s
2026-06-21 12:07:19 +02:00
Julien Calixte
ad0a388f57 Merge branch 'main' of ssh://git.apoena.dev:22222/julien/remanso
All checks were successful
CI / verify (push) Successful in 2m3s
2026-06-21 10:29:23 +02:00
Julien Calixte
d76d63a198 feat(todo): auto-schedule next occurrence on recurring task completion
When a task with rec:Nd/w/m/y is checked off, a new open task is
appended with the updated due date. Non-strict (rec:1m) bases the
next due on today; strict (rec:+1m) bases it on the existing due date.
2026-06-21 10:26:37 +02:00
Julien Calixte
7575e34f8b style: inset lightbox image with a small dim margin
All checks were successful
CI / verify (push) Successful in 1m5s
Cap the fullscreen image at 96vh/96vw so a thin dim margin frames the
white image instead of bleeding to the viewport edges.
2026-06-18 00:23:38 +02:00
Julien Calixte
0f19482cf2 feat: fullscreen image lightbox on image/diagram click
All checks were successful
CI / verify (push) Successful in 2m2s
Click a note image or a mermaid/tikz diagram to view it fullscreen over
a blurred dim backdrop, with a fade/scale animation and rounded white
frame. Works on desktop and mobile; diagram SVGs are serialized to a
data URL via svgToDataUrl. Closes on click or Escape.
2026-06-18 00:19:51 +02:00
Julien Calixte
826f7a4ec9 fix(svg): cover full viewBox with export background
The white background rect was anchored at 0,0, but a diagram's viewBox
origin can be negative (content drawn above y=0, e.g. a QFD house roof),
leaving that content outside the fill. Anchor the rect to the viewBox
origin so the whole drawing sits on white in PNG/SVG exports.
2026-06-18 00:19:38 +02:00
Julien Calixte
e24f75a1cb feat(todo): show archived tasks from done.txt in a collapsible section
All checks were successful
CI / verify (push) Successful in 1m8s
2026-06-15 00:02:24 +02:00
Julien Calixte
2c3ce18e4d feat: filter only open todo items
All checks were successful
CI / verify (push) Successful in 1m2s
2026-06-14 21:00:41 +02:00
Julien Calixte
cef5873c95 Merge branch 'main' of ssh://git.apoena.dev:22222/remanso-space/remanso
All checks were successful
CI / verify (push) Successful in 2m5s
2026-06-14 19:49:25 +02:00
Julien Calixte
c296921eaf chore: update pnpm on ci 2026-06-14 19:49:10 +02:00
Julien Calixte
8e9ca655a9 2026-06-13 23:31:43
Some checks failed
CI / verify (push) Failing after 6s
2026-06-13 23:31:43 +02:00
Julien Calixte
a5de08087e 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 23:06:55 +02:00
Julien Calixte
bff3db79f9 style(todo): add plus/at icons to project and context chips
Aligns the baseline of +project and @context chips with due:/rec: chips
which already render Tabler SVG prefixes. Without an icon the text-only
prefix sat a few pixels off vertically.
2026-06-13 22:21:29 +02:00
Julien Calixte
08ea81684e style(notes): show repo title and user inline with a separator
Some checks failed
CI / verify (push) Failing after 8s
2026-06-13 22:13:41 +02:00
Julien Calixte
af7c87a18c fix(todo): drop stale filter selections when source values disappear
Some checks failed
CI / verify (push) Failing after 7s
If a user filters by a project/context/priority and then edits tasks until
that value no longer exists, the chip is gone but the active set still
contains it — leaving the list filtered to empty with no chip to toggle
off. Watch each available-list and prune orphaned entries from the
matching active set. Also tightens the task-input placeholder and reflows
some template whitespace.
2026-06-13 22:11:31 +02:00
Julien Calixte
4c29151f7c feat(todo): add exclusive open/done status filter
Some checks failed
CI / verify (push) Failing after 7s
2026-06-13 21:48:44 +02:00
Julien Calixte
d455ae0000 style(todo): bump clear-filters button from xs to sm
Some checks failed
CI / verify (push) Failing after 8s
2026-06-13 21:45:25 +02:00
Julien Calixte
03e7185abe fix(todo): rebuild priority dropdown with controlled state
Some checks failed
CI / verify (push) Failing after 7s
The DaisyUI <details>/<summary> dropdown didn't toggle reliably in Firefox
(summary + inline-flex badge), and :focus-within left the wrong row open
when TransitionGroup reordered tasks on sort. Replace with a Vue-controlled
ref + onClickOutside, render options as colored badge buttons (no menu
hover underline), and lift the widget by z-index: 1 only while open so the
absolute menu paints above the next row.
2026-06-13 21:42:09 +02:00
Julien Calixte
5de84f135f feat(todo): add priority filter and stack filter rows inline
Adds a Priorities filter column with toggleable (A)/(B)/(C)/(D)/no-priority
chips. Reflows the filter section from a two-column grid into single-line
strips so each filter type (Priorities · Projects · Contexts) reads on one
row with a fixed-width label and wrap-only-on-overflow chips.
2026-06-13 21:42:04 +02:00
Julien Calixte
72dee337b7 feat(todo): switch /todo view to todo.txt format
Some checks failed
CI / verify (push) Failing after 1m1s
Replace the markdown _todo/todo.md view with a structured todo.txt UI at the
repo root: priority dropdown, inline body edit, project/context/due/rec chips
with Tabler icons, add input as a join-suffix, project/context filter columns
with TransitionGroup animation, priority-sorted list. Drops the now-unused
.todo-notes markdown checkbox styles from app.css.
2026-06-13 21:23:19 +02:00
Julien Calixte
70d9d8a890 feat(todotxt): add parser/serializer for todo.txt format
Pure utility with Task model, line/file parse/serialize, project/context/tag
extraction, due/rec segment splitting, and toggleCompleted. Round-trip tests
cover priorities, dates, tag tokens, mid-word @ guards, and blank lines.
2026-06-13 21:23:11 +02:00
Julien Calixte
9380eace92 chore: drop unused TanStack Vue Query and ts-rest client
Some checks failed
CI / verify (push) Failing after 1m2s
VueQueryPlugin was mounted but no useQuery/useMutation calls existed,
and the noteRouter ts-rest contract was never imported. The callers
of api.remanso.space all use plain fetch(), so the three packages
(@tanstack/vue-query, @ts-rest/core, @ts-rest/vue-query) and the
src/modules/post directory can go.
2026-06-12 13:05:42 +02:00
Julien Calixte
57bc63d43b fix(markdown): wrap long inline code in Safari
Some checks failed
CI / verify (push) Failing after 51s
Safari does not treat `/` as a soft break opportunity inside inline
`<code>`, so long paths overflow the fixed-width stacked note column.
Use `overflow-wrap: anywhere` so the column can shrink to its max-width
instead of being pushed wider by unbreakable inline code.
2026-06-09 16:22:03 +02:00
Julien Calixte
ac3a641b22 fix(markdown): re-render code blocks after Shikiji loads
Some checks failed
CI / verify (push) Failing after 7s
The singleton `md` instance is mutated by `useShikiji()` asynchronously,
but `content` computeds had no reactive dep on that loading, so the
initial render on page reload never picked up syntax highlighting.
Link-click navigation appeared to work only because Shikiji was already
installed in `md` by an earlier render.
2026-06-09 14:38:40 +02:00
Julien Calixte
cdfc90f7eb chore: update pnpm
Some checks failed
CI / verify (push) Failing after 28s
2026-06-09 14:35:00 +02:00
Julien Calixte
f8e0bbf9a7 Merge branch 'main' of ssh://git.apoena.dev:22222/remanso-space/remanso
Some checks failed
CI / verify (push) Has been cancelled
2026-06-09 14:34:31 +02:00
Julien Calixte
f2ba56e936 fix: add tsx as lang 2026-06-09 14:34:23 +02:00
Julien Calixte
bb3a4a4dad test: cover post-merge gaps in utils, services, and hooks
All checks were successful
CI / verify (push) Successful in 59s
Adds specs for the files added or modified in the recent merge that
weren't yet covered: oauthReturnPath, withATProtoImages, link,
displayLanguage, and the useGitHubContent / useImageUpload /
useNoteFreshness hooks. Locks in the new pullLatest contract
({raw, failureStatus}) and the fetchLatestSha branch semantics
(ok/unauthorized/offline).
2026-06-06 23:36:17 +02:00
Julien Calixte
af2ffc3949 chore(ci): re-enable pnpm cache
All checks were successful
CI / verify (push) Successful in 1m56s
Runner cache backend is now reachable from the job container, so
`cache: pnpm` on setup-node works again. Reverts b77c097.
2026-06-06 23:27:58 +02:00
Julien Calixte
b77c097032 chore(ci): disable pnpm cache to avoid 9-min runner timeout
All checks were successful
CI / verify (push) Successful in 47s
The Gitea cache server at 10.0.23.3:42951 is unreachable from the
job container. Each cache restore and save hangs ~4m40s on TCP
timeout before falling back to a cold run, costing ~9m per job.
Removing `cache: pnpm` brings total CI time down to ~45s.
2026-06-06 23:17:26 +02:00
Julien Calixte
9f21cb7882 chore: merge origin/main with local test additions
All checks were successful
CI / verify (push) Successful in 10m1s
Resolves conflict in repo.spec.ts by combining getRepoPermission
tests from main with the getFiles and queryFileContent tests from
the local test pass. Adds getRepoPermission to the userRepo.store
spec mocks so the new permission probe added upstream doesn't break
the store tests.
2026-06-06 22:17:10 +02:00
Julien Calixte
1a2d8f4ebf chore: add tests 2026-06-06 22:13:14 +02:00
Julien Calixte
d99672dbd8 fix(auth): return to origin path after GitHub login
Sign-in saves the current path in sessionStorage so the OAuth callback
can route back instead of always landing on Home.
2026-05-29 16:49:44 +02:00
Julien Calixte
a09e541fa8 feat(repo): hide write UI for users without push access
Fetch the viewer's push permission via GET /repos/{owner}/{repo} when
entering a repo and gate every write affordance behind it: edit and
image-upload buttons in StackedNote, new-fleeting-note and YouTube
buttons in FleetingNotes, and interactive checkboxes in TodoNotes
(rendered disabled when read-only). Anonymous viewers get the same
treatment because the permissions field is absent without auth, which
collapses to canPush = false.

Previously every write button was visible regardless of role, so
read-role collaborators and anonymous viewers could enter edit mode and
type before the save failed with 401/403.
2026-05-29 15:24:12 +02:00
Julien Calixte
455addf760 style(notes): move image upload button after edit/save button 2026-05-25 21:29:54 +02:00
Julien Calixte
b87836782b fix(notes): persist uploaded image to cache so it survives reload 2026-05-25 21:29:51 +02:00
Julien Calixte
299493854e fix(notes): type emit calls in NoteConflictModal
Vue's defineEmits generates per-event overloads, so emit(action) with
a union of literal types failed to type-check. Split the single
choose() helper into one handler per action.
2026-05-25 21:27:48 +02:00
Julien Calixte
54c52feeba refactor(notes): return failure status from pullLatest
Callers had to read back the side-effected status ref to know why a
pull failed, which also broke TS narrowing on the freshness badge
handler. Return { raw, failureStatus } so the reason flows through
the return value.
2026-05-25 21:25:54 +02:00
Julien Calixte
60e3849c20 fix(notes): always insert blank line before appended image link 2026-05-25 21:23:31 +02:00
Julien Calixte
4ab0be75e7 feat(notes): show loader on image upload button while uploading 2026-05-25 21:23:22 +02:00
Julien Calixte
6a0f0d08d2 feat(notes): add image upload button when editing a markdown note 2026-05-25 21:17:46 +02:00
Julien Calixte
0b3411626c feat(utils): add uniqueFilename helper with -2 collision suffix 2026-05-25 21:17:42 +02:00
Julien Calixte
01bb4b8c70 refactor(github): extract putRaw and add uploadBinaryFile 2026-05-25 21:17:38 +02:00
Julien Calixte
8a8509a0f3 feat(notes): surface GitHub auth-expired state on freshness badge
Show a cloud-lock badge with a re-sign-in tooltip when the freshness
check resolves to unauthorized, and toast on badge click so the user
gets explicit feedback instead of a silent retry-loop.
2026-05-17 21:10:26 +02:00
Julien Calixte
151a4d9137 fix(github): auto-retry GitHub calls on 401 and expose unauthorized state
fetchLatestSha and queryFileContent were silently catching every error
(including expired-token 401s) and returning null, so the freshness
badge could stay on "offline" with no UI hint that re-auth was needed.
Wrap both calls in runWithAuthRetry to recover from refreshable tokens
transparently, and switch fetchLatestSha to a tagged result so the
freshness hook can distinguish auth failure from network failure.
2026-05-17 21:10:20 +02:00
Julien Calixte
c412c75cfd feat(auth): add force option and runWithAuthRetry helper
GitHub refresh tokens are single-use, so concurrent refreshes race and
the loser ends up with a revoked token — dedupe in-flight calls.
2026-05-17 21:10:13 +02:00
Julien Calixte
e39ac32e43 feat(public-notes): place user pill in header instead of tabs row
Moves the user identity element out of the tabs bar (where it was
absolutely positioned over the tabs) into a proper header row next to
the home button, and switches it from SignInAtproto to the shared
UserPill so the same atproto-first identity shows everywhere.
2026-05-17 14:15:22 +02:00
Julien Calixte
a7ea2ce9cf refactor(profile): adopt UserPill and ProfileModal in home and repos
Replaces the duplicated profile-chip button and profile_modal dialog in
WelcomeWorld and RepoList with the shared components, and removes the
now-orphaned profile-chip, hw-modal, hw-ms-label, hw-btn-ghost, and
hw-rule styles. As a side effect the displayed identity now prefers the
ATProto handle over the GitHub username.
2026-05-17 14:15:18 +02:00
Julien Calixte
1bf09a6629 feat(profile): add shared UserPill and ProfileModal components
UserPill renders an avatar + display name button, preferring the ATProto
handle over the GitHub username, and emits @click so each view decides
what to open. ProfileModal extracts the previously duplicated
profile_modal dialog with self-contained DaisyUI-token styling.
2026-05-17 14:15:13 +02:00
Julien Calixte
7a2ccc1bd1 fix(github): silence contents endpoint deprecation warnings
Use RFC 6570 reserved expansion ({+path}) so slashes stay literal in
the request URL, and pin the contents call to API version 2026-03-10
to clear the Sunset: 2028-03-10 header on the deprecated 2022-11-28
version. The only breaking change in 2026-03-10 (submodule type in
directory listings) doesn't affect us — fetchLatestSha is per-file
and the array branch already short-circuits.
2026-05-17 00:34:03 +02:00
Julien Calixte
e11609990d fix(repo): derive starred list from saved favorites
Starred section used to intersect favorites with the paginated GitHub
repo list, so favorites past the first page only appeared after the user
scrolled. Derive RepoBase directly from PouchDB favorites and exclude
them from "all repos" by both id and name.
2026-05-17 00:24:47 +02:00
Julien Calixte
e121cc2d05 fix(repo): dedupe favorite repos by name
Stale FavoriteRepo docs with old GitHub IDs (e.g. from deleted or
recreated repos) surfaced as duplicates once the prefix scan started
returning every doc.
2026-05-17 00:24:43 +02:00
Julien Calixte
f9e1495fc3 fix(repo): load all favorites without filtering by loaded repos
Previously favorites were only fetched for repos already loaded via
useRepos, hiding favorites whose repo metadata hadn't been resolved
yet.
2026-05-17 00:17:34 +02:00
Julien Calixte
eeca9d071f chore(analytics): point openpanel to stats apoena endpoint 2026-05-17 00:17:31 +02:00
Julien Calixte
e5dcdac65a feat(offline): record structured failures during note caching
Expose a failures list alongside the existing failedNotes counter so each
failure carries kind/path/sha/message. Also catches throws from
buildNoteDocs and bulkUpdate that previously aborted the whole loop.
2026-05-17 00:07:20 +02:00
Julien Calixte
2994839ee5 feat(offline): track structured failure details during caching
Replace the failure counter with a list of typed failures (fetch,
build, save, readme, unknown) carrying path, sha, message, and
affected doc ids so callers can surface diagnostics instead of just
a count.
2026-05-17 00:07:13 +02:00
Julien Calixte
dd576ced90 style(repo): strip border and shadow from brand mark in nav 2026-05-17 00:07:09 +02:00
Julien Calixte
0a2445a06d fix(repo): paginate repo list alphabetically server-side
Switch from /search/repositories (best-match order) to /user/repos
with sort=full_name so infinite scroll appends in A–Z order instead
of injecting new pages into earlier letters.
2026-05-16 23:59:04 +02:00
Julien Calixte
f3ed5e063f perf(offline): parallelise note caching with batched writes
Fetch blobs with a concurrency of 8 instead of one at a time, batch
PouchDB writes via bulkUpdate, filter already-cached files up front,
and run the README fetch in parallel with the worker loop.
2026-05-16 23:48:06 +02:00
Julien Calixte
2002ac670a feat(data): expose bulkUpdate on PouchDB wrapper
Wraps PouchDB.bulkDocs so callers can flush many docs in one worker
round trip, avoiding the read-before-write that data.update does per
document.
2026-05-16 23:47:59 +02:00
Julien Calixte
0ad747b69f fix(offline): pre-cache home README under its synthetic key
The home page reads README via getCachedMainReadme, which uses
Note-{owner}-{repo}-README, not the blob SHA. Cache-all only wrote
SHA-keyed entries, so the homepage README was empty offline.
2026-05-16 23:31:58 +02:00
Julien Calixte
c5236b2587 fix(offline): don't abort cache loop on a single fetch failure
A single null from queryFileContent silently exited cacheAllNotes,
leaving every later note uncached while the success toast still fired.
Skip the failing file, count failures, and surface them via errorMessage.
2026-05-16 23:31:42 +02:00
Julien Calixte
fc4ed188d7 feat(repo): add Octokit request timeouts and retry UI
Before, every GitHub call awaited indefinitely with no AbortSignal,
so a "lie-fi" connection (technically online, effectively dead) left
FluxNote stuck on the skeleton loader and the freshness badge spinning
forever. Inject an 8s AbortSignal.timeout via octokit.hook.before
(20s override on the recursive tree fetch), classify failures in the
userRepo store as auth vs network, and surface a "Couldn't reach
GitHub" retry button when no cached content is available.
2026-05-16 23:28:11 +02:00
Julien Calixte
de7ac8d096 fix(freshness): hold checking state for 400ms minimum
Cached GitHub responses resolved so fast that the spinner state was
overwritten before Vue could render it, so clicking the badge from
"Not checked" looked like nothing happened.
2026-05-16 18:38:59 +02:00
Julien Calixte
0ef339d42e fix(markdown): scope SVG download buttons to rendered diagrams
Only target SVGs inside .tikz / .mermaid containers so action-bar
icons (edit, save, freshness badge) no longer get download buttons.
2026-05-16 12:57:16 +02:00
Julien Calixte
0e70d808ce fix(markdown): raise PNG export scale to 3× 2026-05-16 12:31:18 +02:00
Julien Calixte
952417621e fix(markdown): embed used @font-face rules in exported SVGs
The exported SVG carries font-family="cmr7"/"cmsy7"/… attributes but
no @font-face, so standalone viewers fall back to system fonts. That
scatters TikZ glyphs (precomputed x positions assume Computer Modern
metrics) and turns cmsy7 minus signs into ¡ (custom symbol encoding).
Inline only the rules for font-families the SVG actually uses, and
await document.fonts.ready before rasterizing to PNG.
2026-05-16 12:29:10 +02:00
Julien Calixte
9d1b6552cb feat(markdown): add hover download buttons on rendered SVGs
Attach SVG and PNG (×2) download buttons to every rendered SVG (TikZ,
Mermaid, …) once post-render finishes. Exports always use the original
colors with a white background baked in, regardless of theme.
2026-05-16 12:17:04 +02:00
Julien Calixte
f3708b2f9d fix(markdown): invert TikZ SVGs on dark themes for readability 2026-05-16 11:47:31 +02:00
Julien Calixte
a4d9774191 chore(deps): declare shikiji-core as direct dependency
useMarkdown.hook.ts imports types from shikiji-core, but it was only
present transitively via markdown-it-shikiji. pnpm's strict resolution
blocks importing undeclared transitives, breaking type-check.

Pinned to 0.10.2 to match the version markdown-it-shikiji@0.10.2 pulls
in. The shikiji line is deprecated upstream in favor of shiki; a real
migration is out of scope here.
2026-05-16 10:04:01 +02:00
Julien Calixte
37c65142b7 chore: remove stale skills-lock.json 2026-05-16 10:01:47 +02:00
Julien Calixte
6bb5c8a860 fix(tsconfig): drop preserveSymlinks so vue types resolve under pnpm
pnpm resolves every package via a symlink into .pnpm/; preserveSymlinks
pinned resolution to those symlink paths, blocking the vue ->
@vue/runtime-dom -> @vue/runtime-core re-export chain and surfacing
"has no exported member" errors for Ref, toValue, computed, etc.
2026-05-16 10:01:47 +02:00
Julien Calixte
572a05962c fix(markdown): load TikZ font stylesheet to keep text aligned
The obsidian-tikzjax bundle emits SVG referencing Computer Modern font
families like cmbx7 but ships the @font-face declarations in a separate
styles.css. Without it the browser substitutes a default font whose
glyph widths don't match TeX's, leaving visible gaps between each
kerned text run. Lazy-load the stylesheet alongside the engine on
first TikZ render so cache hits and misses both see correct fonts.
2026-05-15 16:25:55 +02:00
Julien Calixte
33285d723a refactor(markdown): extract post-render effects into composable
Four components duplicated the watch -> nextTick -> listenToClick -> runTikz
shape. Collapse to useMarkdownPostRender, which also accepts optional flags
for Mermaid, Shikiji and image hydration so StackedNote no longer needs its
own inline conditional chain.
2026-05-15 15:48:12 +02:00
Julien Calixte
813f16655c Merge branch 'main' of ssh://git.apoena.dev:22222/remanso-space/remanso 2026-05-15 15:40:21 +02:00
Julien Calixte
bf19e982fe feat(markdown): render TikZ fenced blocks to SVG
Adds a markdown-it extractor for ```tikz blocks that emits a sanitized
placeholder, plus a runTikz() that lazy-loads the obsidian-tikzjax 0.5.2
bundle from jsdelivr on first encounter, sanitizes the produced SVG with
DOMPurify, and caches it in PouchDB keyed by sha256(source) so repeat
views are instant and offline-capable.
2026-05-15 15:38:48 +02:00
Julien Calixte
248dea6ade style(repos): use tabler lock icon for private repos 2026-05-14 16:18:11 +02:00
Julien Calixte
4fd72226ff refactor(repos): redesign RepoList in editorial style
Align the repo manager with WelcomeWorld and PublicNoteView: editorial
top nav, serif hero, pastel favorite tiles, A-Z grouped list, skeleton
and credential-error states, and a name filter.
2026-05-14 16:13:41 +02:00
Julien Calixte
816c3687d8 fix(auth): clear stale credential error after github re-auth
The 401 flag and cached repo list were module-level and only reset
after a 20-min stale window, so re-authenticating left the
"credentials are invalid or expired" message pinned on. Watch the
access token: reset state and refetch on change. Also await
saveCredentials before redirecting so refs are settled.
2026-05-14 13:04:44 +02:00
Julien Calixte
f2f2a3114b style: no more underline for header tag 2026-05-14 01:22:46 +02:00
Julien Calixte
2f71566083 style(modal): keep conflict modal actions stacked on all sizes 2026-05-14 01:10:40 +02:00
Julien Calixte
80ae544a28 style(notes): drop hover cursor on rotated stacked-note header 2026-05-14 01:10:38 +02:00
Julien Calixte
bfd981de13 fix(ci): restore allowBuilds map in pnpm-workspace.yaml
pnpm 11.x reads the per-package allowBuilds boolean map, not the
pnpm 10 onlyBuiltDependencies arrays. The array form is silently
ignored, so every build script falls through as 'ignored' and
strict CI mode fails. Confirmed by reproducing locally and by
inspecting what 'pnpm approve-builds --all' writes back.
2026-05-13 18:54:38 +02:00
Julien Calixte
453332513a fix(docker): copy pnpm-workspace.yaml into deps stage
The build-allow config lives in pnpm-workspace.yaml, but the deps
stage only copied package.json and pnpm-lock.yaml — so the
container saw no allowlist and pnpm install failed on ignored
build scripts.
2026-05-13 18:49:23 +02:00
Julien Calixte
abc0113c8e chore(docker): defer pnpm version to packageManager field
Drop the explicit pnpm@latest prepare step and let corepack pick
up the pinned version from package.json on first invocation, so
the Docker build can't drift away from the local toolchain.
2026-05-13 18:48:09 +02:00
Julien Calixte
52deb5feb4 fix(ci): use portable pnpm build-allow config
The allowBuilds map syntax only works in pnpm 11.x, but the
Dockerfile resolves pnpm@latest to a 10.x that doesn't recognize
it, so install fails on unapproved build scripts. Switch to the
onlyBuiltDependencies/ignoredBuiltDependencies arrays and pin
packageManager so CI and local stay in sync.
2026-05-13 18:46:06 +02:00
Julien Calixte
9e07204430 design: change light theme to light 2026-05-13 18:38:54 +02:00
Julien Calixte
cd60429145 chore: pnpm to latest version 2026-05-09 15:00:43 +02:00
Julien Calixte
aad07184fd fix(freshness): surface silent failures when pulling latest
queryFileContent threw on octokit errors (stale SHA 404, expired token,
network blip) and the rejection bubbled up unhandled through pullLatest
and onBadgeClick, leaving the badge stuck on "Outdated" with no log or
toast. Wrap the octokit call, log on failure, clear the cached SHA so
the next click re-resolves it, and show an error toast.

Also fix a dead `if (!user || !repo) { null }` that did nothing.
2026-05-06 22:02:50 +02:00
Julien Calixte
76829afba2 design: change light theme to caramellatte 2026-05-06 20:37:55 +02:00
Julien Calixte
05f59a568d design: change light theme to lemonade 2026-05-06 20:31:57 +02:00
Julien Calixte
559bfccd08 design: change dark theme to dracula 2026-05-06 20:26:01 +02:00
Julien Calixte
f8ae4351d6 design: change light theme to cupcake 2026-05-06 20:25:13 +02:00
Julien Calixte
30f200df30 fix(flux-note): stop showing sign-in prompt while readme is loading
Cache miss wrote null into store.readme before getMainReadme finished,
collapsing isLoading and surfacing the not-accessible UI mid-fetch.
Also branch that UI on auth state so signed-in users aren't told to
sign in when access fails.
2026-05-06 09:54:25 +02:00
Julien Calixte
58568e2245 fix(pwa): use alpha mask for monochrome icon
Per W3C spec, purpose: "monochrome" icons use only the alpha channel
as the silhouette; RGB is ignored and replaced with the platform
theme color. The previous monochrome-icon.png was a black-on-white
RGB image with no alpha, so Safari (macOS PWAs) and Chrome (Android
themed icons) treated every pixel as opaque and painted the whole
1024x1024 canvas with theme_color (#ffa4c0) - a solid pink tile.

Regenerate as RGBA with the silhouette in alpha (derived from the
favicon's alpha channel via a sharp-based helper script). Rename to
monochromeicon.png to bust Safari's stuck PWA icon cache from prior
broken installs.
2026-05-05 17:40:40 +02:00
Julien Calixte
fd7d06ce69 design: change light theme to retro 2026-05-05 16:11:45 +02:00
Julien Calixte
5a9c0a3704 lint 2026-05-04 23:54:26 +02:00
Julien Calixte
e425be5c96 refactor(freshness): drop time-based stale-known status
The 2-minute timer + tick ref decayed verified to stale-known and rendered
a clock icon, but the user can always click the badge to re-check. Removing
the timer simplifies the hook and the badge has one fewer visual state.
2026-05-04 23:53:48 +02:00
Julien Calixte
84803c45dd refactor(scroll): clean up debug overlay and pass anchor by param
Removes the temporary on-screen scroll diagnosis panel and the global
window.__scrollAtClick stash. The anchor scrollTop is now captured
synchronously at addStackedNote entry and threaded through
scrollToFocusedNote and scrollToNoteElement to scrollToElement, so no
state survives across calls — nothing to reset on repo or page change.
2026-05-04 23:02:12 +02:00
Julien Calixte
a526a9f6af fix(scroll): snap to click-time scrollTop before smooth scroll
Capture mainApp.scrollTop synchronously when addStackedNote runs and
snap the scroll back to that value before scrollIntoView fires, so
the smooth scroll begins from where the user actually tapped rather
than from a position drifted by momentum or async work.
2026-05-04 19:57:00 +02:00
Julien Calixte
08e01d8484 revert: restore mobile body scroll for pull-to-reload
Reverts 550b3cf — removing the override broke pull-to-reload, and
single-scroll-container did not fix the offset glitch anyway.
2026-05-04 19:04:46 +02:00
Julien Calixte
c88340d5f1 chore(debug): add temporary scroll overlay for mobile diagnosis 2026-05-04 19:02:35 +02:00
Julien Calixte
550b3cf019 fix(layout): remove mobile body scroll to keep one scroll container
Both html/body and #main-app being scrollable on mobile made
scrollIntoView animate two ancestors at once, shifting the start
frame of the smooth scroll. With body locked, #main-app is the only
scroller and the animation matches the user's actual position.
2026-05-04 18:58:04 +02:00
Julien Calixte
2f05b93f51 fix(stacked-note): size mobile notes with svh to stabilize scroll target
Dynamic viewport units rescale every note when the mobile address bar
grows or shrinks, shifting the scroll target by the address-bar height
mid-flight. Small viewport units stay constant across address-bar
transitions so the smooth scroll lands where it was aimed.
2026-05-04 18:45:45 +02:00
Julien Calixte
cc266eac7c refactor(scroll): delegate note scroll to scrollIntoView
Native scrollIntoView reads the element position at scroll time and
picks the right scrollable ancestor itself, sidestepping iOS Safari
quirks with scrollTo on overflow containers and visual-viewport shifts.
2026-05-04 18:29:05 +02:00
Julien Calixte
be006f08b4 fix(stacked-note): align mobile scroll target to element rect
Replace the (index + 1) * clientHeight math and 80ms setTimeout with a
scrollToElement helper that reads getBoundingClientRect inside rAF, so
the smooth scroll starts from the user's actual position even when the
note is freshly mounted.
2026-05-04 18:15:10 +02:00
Julien Calixte
55ee3bddeb fix(router): skip view transition on query-only navigation
The root fade overlapped smooth scrolls triggered when stackedNotes
mutated, making the scroll appear to start from the snapshot's frame
instead of the user's actual position.
2026-05-04 18:15:04 +02:00
Julien Calixte
1f324208d2 design(stacked-notes): action buttons in vertical bar 2026-05-04 10:54:50 +02:00
Julien Calixte
002cf9a4b1 fix(stacked-note): act on outdated badge clicks
Clicking the badge while it shows outdated now pulls the latest version
from GitHub when there are no unsaved edits, or opens the conflict
modal when edits are in flight. Previously the click only re-ran the
same freshness check, so the badge appeared dead.
2026-05-03 23:37:28 +02:00
Julien Calixte
efe9c01e63 chore(github-content): pin api version on fetchLatestSha request
Silences the @octokit/request deprecation warning that prints whenever
the unversioned /repos/{owner}/{repo}/contents/{path} call fires.
2026-05-03 23:37:24 +02:00
Julien Calixte
d31c774ace feat(stacked-note): surface note freshness and guard saves on conflict
Adds a Tabler-icon badge in the stacked-note action bar showing whether
the loaded copy still matches GitHub HEAD (verified / outdated / offline
/ checking / unknown / stale-known). The save flow now re-checks before
the PUT and opens a conflict modal when GitHub has moved on, with three
explicit choices: discard local edits and pull, overwrite anyway, or
cancel. Race-condition 409s from the PUT itself are routed through the
same modal.
2026-05-03 23:32:54 +02:00
Julien Calixte
d8a59467a0 refactor(github-content): expose conflict info and add latest-sha lookup
updateFile/createFile now return { sha, conflict } so 409/422 from GitHub
can drive a UI flow instead of being swallowed as a generic save error.
Also adds fetchLatestSha(path) for cheap freshness checks against HEAD.
2026-05-03 23:32:37 +02:00
Julien Calixte
dffee40776 2026-05-02 22:48:10 2026-05-02 22:48:10 +02:00
Julien Calixte
4328411d88 chore(zed): disable deno LSP for TS/JS in this project 2026-05-02 22:43:49 +02:00
Julien Calixte
3339e28d41 style(notes): distinguish scrollable column from canvas
Tint the surrounding viewport and add a soft right-edge shadow on
the leftmost note so users can see where scrolling actually applies.
2026-05-02 09:51:24 +02:00
Julien Calixte
c8e5fd26a0 chore: drop disabled husky pre-push hook and dependency
The .husky/_pre-push script was renamed from pre-push, which
disables it under husky v9. With no remaining active hooks, husky
is dead weight, so remove the dependency and prepare script too.
2026-05-02 09:26:52 +02:00
Julien Calixte
f562ca48b1 docs(welcome): link footer atproto entry to atproto.com 2026-05-02 09:16:14 +02:00
Julien Calixte
7c40feeae0 style(welcome): drop network card hover left border 2026-05-02 09:08:09 +02:00
Julien Calixte
4d7b7d01f6 fix(welcome): keep network strip from widening the hero grid
Grid items default to min-width: auto, so the 5×220px scroll strip
forced the 1.2fr column to its intrinsic width and pushed the right
column out. min-width: 0 lets the track shrink and overflow-x scroll.
2026-05-02 08:31:04 +02:00
Julien Calixte
c78ce38845 docs(welcome): rephrase public-notes label to fit pool metaphor 2026-05-01 23:54:24 +02:00
Julien Calixte
b572380c37 feat(welcome): show live public-notes preview inline
Replaces the static "From the open network" CTA and sidebar button with a
horizontal strip and compact list of recent public notes fetched from the
public api.remanso.space/notes endpoint, so visitors can taste the network
before clicking through. Includes shimmer skeletons and a quiet fallback
when the endpoint is unreachable.
2026-05-01 23:27:55 +02:00
Julien Calixte
43c5e65077 docs(welcome): rephrase zettelkasten closing line for clarity 2026-05-01 12:40:56 +02:00
Julien Calixte
7b5af57941 fix(welcome): make zettelkasten link example read grammatically 2026-05-01 12:18:17 +02:00
Julien Calixte
abda5264a8 feat(welcome): use tabler svg icons for feature row 2026-05-01 12:16:36 +02:00
Julien Calixte
e715fb02d3 fix(markdown): cache Shikiji init promise to avoid race on parallel callers
The boolean guard flipped synchronously before the async plugin load
resolved, so concurrent callers (e.g. multiple stacked non-markdown
notes mounting on reload) returned early and rendered before
markdown-it-shikiji was attached to the shared md instance. Cache the
in-flight promise instead so all callers await the same resolution.
2026-04-30 11:03:29 +02:00
Julien Calixte
4c7c688688 2026-04-30 10:34:00 2026-04-30 10:34:00 +02:00
Julien Calixte
7b4c7947aa fix: remove bottom padding 2026-04-29 22:06:58 +02:00
Julien Calixte
68022971cd refactor(notes): restore fixed mobile heights for scroll math
Re-pin .note and .stacked-note to 100dvh on mobile and bring back the
container height in useResizeContainer so (index + 1) * height has a
reachable scroll target. Switch the polled scroll helper to that same
formula instead of offsetTop.
2026-04-29 11:32:23 +02:00
Julien Calixte
f529832eee refactor(notes): scope stacked-note sticky to desktop
Move position: sticky from the global .note rule into the desktop
@media block of the scoped stacked-note components, so mobile no longer
inherits sticky positioning (and no top is set there).
2026-04-29 11:32:13 +02:00
Julien Calixte
3e9418285f refactor(notes): let mobile notes size to content 2026-04-29 11:09:31 +02:00
Julien Calixte
17f015b686 fix(notes): wait for stacked note element before scrolling on mobile
A single nextTick is not enough for a freshly added stacked note to be
in the DOM, so the mobile scroll target was computed against a null
element. Poll with requestAnimationFrame (mirroring scrollToHashInNote)
and use offsetTop, with an (index + 1) * height fallback.
2026-04-29 10:52:07 +02:00
Julien Calixte
adb1bd5945 fix: fix height on mobile 2026-04-29 10:34:46 +02:00
Julien Calixte
86866e7d77 feat(welcome): wire demo note links with stack reveal and flash 2026-04-27 23:10:29 +02:00
Julien Calixte
cf5567de7c refactor(notes): use options object for renderCodeFile params 2026-04-27 20:36:46 +02:00
Julien Calixte
9d6f70546e feat(notes): render code files with Shikiji syntax highlighting
Non-markdown files opened as stacked notes are now highlighted using
the existing markdown-it-shikiji pipeline (4-backtick fence wrapping)
with a h1 filename heading. Edit controls are hidden for code files.
Adds alloy language grammar and a fileLanguage utility mapping
extensions to Shikiji language IDs.
2026-04-27 19:57:15 +02:00
Julien Calixte
812f393283 design: reduce padding for pre in tabs 2026-04-27 18:22:48 +02:00
Julien Calixte
37b39a6d96 design: box for tabs instead 2026-04-27 15:34:12 +02:00
Julien Calixte
df8bda0130 feat(markdown): render tabs as DaisyUI radio input pattern
Use @mdit/plugin-tab custom renderers to emit DaisyUI tabs-lift
structure (radio inputs + tab-content divs) instead of unstyled
default output. CSS-driven, no JS required.
2026-04-27 15:28:10 +02:00
Julien Calixte
74491a45a9 fix(repoList): prevent duplicate entries from concurrent loadMore calls
Add isLoading guard so concurrent fetches are rejected, and include
isLoading in canLoadMore so vInfiniteScroll waits before firing again.
2026-04-27 10:33:31 +02:00
Julien Calixte
da4fada8a1 fix(repoList): handle Bad credentials error from GitHub API
Catch 401 responses in useRepos loadMore and expose hasCredentialError,
then show a sign-in prompt in RepoList instead of an unhandled rejection.
2026-04-27 10:32:37 +02:00
Julien Calixte
df3e217d01 fix(userRepo): unwrap reactive proxies before postMessage to worker
Vue reactive Proxies cannot be serialized by the Structured Clone
Algorithm used by postMessage/Comlink. Use toRaw() on this.files and
this.userSettings before passing them to data.update() to avoid the
DataCloneError.
2026-04-27 10:22:20 +02:00
Julien Calixte
d50adc72e9 refactor(downloadFont): handle generic families and multi-family strings
Strips generic CSS families (serif, monospace, etc.) before building
the font API URL, and correctly parses comma-separated font stacks.
2026-04-27 10:12:12 +02:00
Julien Calixte
78de5e280f feat: show GitHub sign-in when repo is not accessible
Adds a message + sign-in button in FluxNote when the readme resolves
to null (private/unauthorized repo), and on the SpaceCowboy 404 page.
2026-04-27 10:12:09 +02:00
Julien Calixte
28ca9a17a9 fix(FluxNote): stop skeleton showing when repo is inaccessible
The skeleton was conditioned on `isLoading || !hasContent`, so it
persisted forever when readme resolved to null (e.g. private repo
visited while logged out). Skeleton now only shows while loading.
2026-04-27 10:07:08 +02:00
Julien Calixte
836b480ea6 fix(navigation): resolve clicked anchor when target is a nested element
A click on a child of an <a> (e.g. nested <strong>, <em>, <code>, icon)
made event.target a non-anchor, so getAttribute('href') returned null
and the handler bailed without preventDefault. The browser then
performed the native navigation, which for relative links like
'../note.md' resolved against the current /:user/:repo URL and the SPA
re-routed treating the destination as a new repo.
2026-04-26 13:58:48 +02:00
Julien Calixte
9f75e7971d fix(layout): cache pageWidth in localStorage to avoid render glitch
The page width from .remanso.json was only applied after an async
PouchDB + network fetch, so notes briefly rendered at the default
500px before snapping to the configured value. Persist pageWidth
alongside the existing font cache (key renamed to remanso:layout:*),
so it is read synchronously during setUserRepo and applied before
the first render. Also always reset --note-width with a default
fallback to prevent stale values leaking across repo navigation.
2026-04-26 13:44:10 +02:00
Julien Calixte
181ffd1e5c feat(navigation): smooth scroll for in-note anchor links
Pure-fragment links (#heading) used to fall through to the browser's
default jump. Handle them in the click listener and scope the lookup
to the same stacked note so identical heading ids in other notes
don't win, with smooth scroll behavior to match cross-note anchors
into already-stacked notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:59:12 +02:00
Julien Calixte
c00065ce4a refactor(navigation): scrollToFocusedNote takes an options object
Smooth-scroll for the anchor jump when the target note is already
stacked, instant otherwise. While threading the new flag, the four
positional params got hard to read, so collapse them into
{ noteId, notes, hash, smoothHash } and update all call sites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:56:32 +02:00
Julien Calixte
4ce8c30649 fix(navigation): support anchor fragments in note links
Links like `path/to/note.md#heading` previously errored with "Note not
found" because the full href (including `#hash`) was matched against
file paths. Split the fragment off in the link handler, plumb it through
the event bus, and scroll the matching heading into view once the
target note is in place. Headings now get GitHub-style ids via
markdown-it-anchor + github-slugger so the anchors actually exist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:40:30 +02:00
Julien Calixte
d098b3b404 fix: no more clip overflow y 2026-04-25 00:02:12 +02:00
Julien Calixte
e03ff49764 fix(mobile): restore overflow-y and unstick readme on vertical scroll
- Restore explicit overflow-y:auto on #main-app for mobile (removed in
  63f5d64) — implicit coercion from overflow-x:auto is not reliable in
  all Safari/WebKit versions.
- Override position:sticky on .readme to position:relative on mobile.
  The desktop sticky (left:0) is correct for horizontal scroll, but on
  mobile vertical scroll it pinned the 100dvh-tall readme across the
  entire viewport, hiding all stacked notes behind it.
2026-04-24 23:42:22 +02:00
Julien Calixte
19495ddf0c feat(scroll): use smooth scrollTo instead of direct property assignment 2026-04-23 18:07:44 +02:00
Julien Calixte
63f5d644eb fix: remove overflow y 2026-04-23 18:03:51 +02:00
Julien Calixte
63bc3f4d5d fix(mobile): scroll #main-app instead of body on mobile
body/html have overflow:hidden so scrollTop is a no-op on them.
#main-app is the actual scroll container; use overflow-y:auto on
mobile and target it directly in scrollToNote and the scroll listener.
2026-04-23 18:01:30 +02:00
Julien Calixte
ded770aff1 fix(mobile): restore body scroll and prevent spurious section scroll
Three layered fixes for mobile note scrolling:

1. app.css / App.vue: on mobile, override overflow:hidden on html/body
   and overflow:visible on #main-app so content from useResizeContainer
   (which sets the note-container height to (n+1)*100vh) propagates to
   the document and document.body.scrollTop works again.

2. FluxNote.vue: give each .note an explicit height:100dvh on mobile so
   the percentage-based height:100% does not resolve against the
   inflated container height set by useResizeContainer.

3. StackedNote / StackedPublicNote: replace overflow-y:hidden with
   overflow-y:clip on the section. Unlike hidden, clip does not create a
   scroll container, so touch events fall through to the page scroll and
   the section never feels "draggable" when content fits within the note.
2026-04-23 17:58:33 +02:00
Julien Calixte
d12d7b660b Revert "fix(layout): make stacked notes stick horizontally when scrolling"
This reverts commit 86c9feaf55.
2026-04-22 23:59:36 +02:00
Julien Calixte
86c9feaf55 fix(layout): make stacked notes stick horizontally when scrolling
Add left offset to each stacked note so position: sticky activates
during horizontal scroll, pinning notes progressively to the right
of the readme column at calc((index + 1) * var(--note-width)).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:56:43 +02:00
Julien Calixte
449a16f791 fix(layout): prevent document-level scroll-y when stacked notes overflow
Contain horizontal overflow within #main-app instead of leaking to the
document, which caused a horizontal scrollbar to consume viewport height
and trigger an unwanted vertical scrollbar. Also fix note pane height
to use 100% instead of 100vh, and switch useResizeContainer to minWidth
so the flex container can grow when the window is wider than the notes.
Add a window resize listener to keep the value accurate on resize.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:52:03 +02:00
Julien Calixte
ee8bbd4a37 feat(config): add pageWidth setting to .remanso.json
Allows repo owners to configure note column width via `"pageWidth": "700px"` in .remanso.json. Applies the value to the --note-width CSS variable and invalidates the cached width so resize/overlay hooks pick it up.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:34:26 +02:00
Julien Calixte
1f272bc3e2 fix: no more after for repos 2026-04-22 23:01:40 +02:00
Julien Calixte
29c22a9b0f copy(home): reframe manifesto sentence without negative framing 2026-04-20 18:23:47 +02:00
Julien Calixte
5c76170645 style(home): adjust CTA card width from w-2xl to w-xl 2026-04-20 18:19:42 +02:00
Julien Calixte
ceb800b6ac refactor(home): replace gh-form pill with DaisyUI input + joined button
Wrap inputs in <label class="input"> (DaisyUI v5 compound input pattern).
Form uses flex fill so inputs auto-size and the button stays on the right
on a single line regardless of container width.
2026-04-20 18:15:12 +02:00
Julien Calixte
f809a1f5f8 style(home): enlarge GitHub repo and open network CTA cards 2026-04-20 18:00:42 +02:00
Julien Calixte
5cda110a98 style(home): sharpen hero headline and lede copy
"settles into a pool" → "comes to rest" (clearer double meaning)
"margin enough to think" → "where your thinking finally runs clear"
2026-04-20 17:58:45 +02:00
Julien Calixte
ce690b6767 fix(home): remove z-index from footer/main so PWA toast is not obscured 2026-04-20 17:17:24 +02:00
Julien Calixte
73253c9ad2 style(home): replace "Link, don't file" with "Link, don't nest" 2026-04-20 16:44:17 +02:00
Julien Calixte
369d730f70 feat: highlight on link clicked 2026-04-20 16:41:09 +02:00
Julien Calixte
668f73b546 feat(home): highlight linked note card on "durable enough" click 2026-04-20 16:38:00 +02:00
Julien Calixte
b1be42b5bf feat(home): redesign homepage with editorial and launchpad layouts
Replace the minimal centered layout with a full literary/academic
homepage: logged-out users see an editorial hero, manifesto, demo
notes, and ZK primer; logged-in users see a personal launchpad
(greeting, repo tiles, last visited, review queue) followed by the
same editorial content below.

Uses DaisyUI CSS variables throughout (color-mix) so it adapts to
any theme change without hardcoded overrides.
2026-04-20 14:32:48 +02:00
Julien Calixte
70b679b204 Merge branch 'main' of ssh://git.apoena.dev:22222/remanso-space/remanso 2026-04-20 11:10:48 +02:00
Julien Calixte
36dc1293f9 docs: fix ATProto session storage split between SecureStore and SQLite 2026-04-20 10:56:03 +02:00
Julien Calixte
801b7cb94a docs: add React Native migration design spec 2026-04-20 10:55:44 +02:00
Julien Calixte
1fa66d8594 fix: prevent spurious y-scrollbar when section has overflow-x: auto on mobile
Setting overflow-x: auto forces overflow-y off 'visible' per CSS spec,
which caused an unwanted vertical scrollbar in stacked note sections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 14:28:25 +02:00
Julien Calixte
b827f31cf0 fix: current color for svg in buttons 2026-04-19 10:49:37 +02:00
Julien Calixte
cf02569c75 design: change light theme to emerald 2026-04-19 10:39:49 +02:00
Julien Calixte
0a4f8dbf41 fix: make BackButton and LinkedNotes keyboard accessible
Replace <a> (no href) with <button> so both elements receive tab focus.
BackButton gets text-base-content to preserve icon color; LinkedNotes
uses btn class="link" to keep the inline text-link appearance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:08:34 +02:00
Julien Calixte
b6f6759af5 fix: restore icon color on button elements in header
<button> gets color:ButtonText from the browser UA stylesheet, making
SVG stroke="currentColor" render black. Add text-base-content to
inherit the DaisyUI theme color like the <a>-based router-links do.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:07:25 +02:00
Julien Calixte
c42c26a407 fix: restore icon color on FontChange trigger button
<button> defaults to color: ButtonText (black) in browsers, unlike <a>
which inherits. Adding color: inherit restores the theme color for the
SVG stroke (which uses currentColor).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:06:25 +02:00
Julien Calixte
cfe5ef8fcd fix: make HomeButton keyboard accessible
Replace <a> with <button> so the home logo receives tab focus.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:04:50 +02:00
Julien Calixte
4c5116bc89 fix: make FontChange modal trigger keyboard accessible
Replace <a> with <button> for the typography icon in HeaderNote so it
receives tab focus — <a> without href is excluded from the tab order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:02:12 +02:00
Julien Calixte
8581baafb7 design: change dark theme to forest 2026-04-08 19:07:03 +02:00
Julien Calixte
29c092e0a0 design: change dark theme to abyss 2026-04-08 19:04:28 +02:00
Julien Calixte
410c0cec7c design: change dark theme to sunset 2026-04-08 19:03:07 +02:00
Julien Calixte
66a1bcbaa9 design: change dark theme to black 2026-04-08 19:01:48 +02:00
Julien Calixte
541e058d12 fix: restore dark theme and fix theme script regex
Dark theme was set to "dim" in theme.config.ts while app.css registered
"sunset" as the prefersdark theme. The script's regex required a trailing
comma that didn't exist on the last property, causing silent failures.
2026-04-08 19:01:29 +02:00
Julien Calixte
a05ff9f238 design: change dark theme to sunset 2026-04-08 18:57:36 +02:00
Julien Calixte
6558de8df5 design: change dark theme to black 2026-04-08 18:51:30 +02:00
Julien Calixte
b48c1bd0d5 prune: remove obsolete agent 2026-04-06 23:41:19 +02:00
Julien Calixte
e369541dc0 refactor: scope PouchDB writes to repo config, not user font prefs
chosen* fields are per-browser preferences — localStorage is the correct
and sufficient store for them. Removing data.update from font setters and
stripping chosen* from the GitHub fetch PouchDB write prevents stale PouchDB
data from conflicting with localStorage on reload.
2026-04-06 23:26:50 +02:00
Julien Calixte
73a6014750 fix: persist font selections across navigation and page reloads
- Use v-model with writable computeds instead of :value+@change so selects
  re-sync when the options list changes asynchronously
- Always include currently chosen fonts in sortedFontFamilies so a selected
  font not present in .remanso.json fontFamilies still shows in the select
- Initialize userSettings instead of returning early in font setters so
  changes made before async GitHub fetch completes are not silently dropped
- Back font choices with localStorage so they survive hard reloads even when
  PouchDB/IndexedDB fails silently in the web worker
2026-04-06 18:51:27 +02:00
Julien Calixte
c197b80095 feat: smaller modal 2026-04-06 17:44:43 +02:00
Julien Calixte
f3e74aed34 fix: resolve all TypeScript type errors
- Install missing comlink (was in lockfile but not node_modules)
- Add @ts-rest/core and @ts-rest/vue-query (imported but not declared as deps)
- Add declare module '*.vue' shim to shims-vue.d.ts
- Replace arktype validators in ts-rest contract with contract.type<T>() since @ts-rest expects Zod schemas
2026-04-06 15:05:57 +02:00
Julien Calixte
8d9134a062 perf: cache repo list with 20-minute stale time
Hoist useRepos state to module scope so all callers share one instance, and skip re-fetching until data is older than 20 minutes.
2026-04-06 14:59:12 +02:00
Julien Calixte
006cd63388 feat: paginate repo list with infinite scroll
Load 30 repos at a time instead of 100 at once, showing data sooner.
Adds v-infinite-scroll to RepoList.vue to fetch subsequent pages on scroll.
2026-04-05 11:56:36 +02:00
Julien Calixte
3de9eb35f6 feat: show font family selectors with default fonts when no .remanso.json 2026-04-05 10:49:01 +02:00
Julien Calixte
99c349f6df fix: preserve font settings when repo has no .remanso.json
When no config file exists, userSettings was set to null which destroyed
cached user preferences and silently blocked all setFont* actions.
2026-04-04 14:39:34 +02:00
Julien Calixte
64b29bcdef fix: remove favicon.png from PWA manifest icons to fix dock icon on macOS 2026-04-04 14:22:14 +02:00
Julien Calixte
9e26e231cb fix: show theme and font size controls before font families load
Move the v-if guard from the outer FontChange wrapper to only the font-family
selects, so ThemeSwap and the font-size select are always visible in the modal
even before userSettings.fontFamilies resolves asynchronously.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:35:05 +02:00
Julien Calixte
b003a3e008 perf: move PouchDB/IndexedDB operations to a Web Worker
All database reads and writes now run off the main thread via a
dedicated worker, eliminating IndexedDB overhead from the frame budget.

- Create data.worker.ts exposing the Data class via Comlink
- Refactor data.ts to export a Comlink-wrapped proxy and a standalone
  generateId() pure function (workers can't expose sync methods cleanly)
- Update all 10 call sites to import generateId directly instead of
  calling data.generateId()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:27:45 +02:00
Julien Calixte
1b5e23e3d4 fix: keep font settings visible during repo navigation
- resetFiles() no longer clears userSettings so FontChange stays visible
  while navigating between repos (old fonts show until new ones load)
- Add _requestId counter to setUserRepo() to discard stale async callbacks
  from previous navigations, preventing state corruption on quick nav
- Load savedRepo and userSettings caches in parallel with Promise.all,
  reducing yield points so cache hits apply before first render

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:21:56 +02:00
Julien Calixte
52d7c84bd0 perf: prevent FPS drops during navigation in FluxNoteView
- Narrow backlinks watcher from entire store to store.files only,
  reducing trigger count from ~8 to 2 per navigation
- Defer computation start by 300ms so it runs after the 250ms view
  transition animation completes
- Yield to the browser between each file iteration using
  scheduler.yield() (with setTimeout fallback) to avoid blocking frames

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:14:02 +02:00
Julien Calixte
d76182b2c2 Merge branch 'main' of ssh://git.apoena.dev:22222/remanso-space/remanso 2026-04-03 15:02:46 +02:00
Julien Calixte
ed1a6b7fba fix: add the right margin to the right components 2026-03-29 22:09:01 +02:00
Julien Calixte
d5b251c4a0 fix: remove overflow because it's causing too much trouble 2026-03-29 22:00:22 +02:00
Julien Calixte
19b77810ec chore: remove healthcheck in docker to be faster 2026-03-29 21:55:32 +02:00
Julien Calixte
c8b0a78973 fix: add nginx SPA fallback to serve index.html for all routes
Prevents 404 errors when navigating directly to client-side routes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:50:50 +02:00
Julien Calixte
087d1a355e revert: remove justify-content center from welcome content
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:37:19 +01:00
Julien Calixte
5d90da8ab5 feat: center welcome content vertically
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:36:40 +01:00
Julien Calixte
72d065975d fix: lock html/body to 100dvh overflow hidden on all screen sizes
All views that need scroll use their own overflow-y: auto containers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:35:11 +01:00
Julien Calixte
8b3df48791 fix: clip app at 100dvh to prevent body scroll on mobile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:29:50 +01:00
Julien Calixte
cd8e173e05 fix: use 100dvh for body and #app to match dynamic viewport
Prevents white space below the app on Android Chrome where the
system nav bar makes 100vh > 100dvh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:25:19 +01:00
Julien Calixte
8767f7c430 fix: give .home explicit height so flex children resolve correctly
On Chrome Android, cross-axis stretch doesn't always produce a
definite height for inner flex items. Adding height: 100dvh to
.home ensures flex: 1 on .welcome-world resolves to full viewport.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:22:54 +01:00
Julien Calixte
369a200a42 fix: wrap content in flex:1 div so footer doesn't overflow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:07:08 +01:00
Julien Calixte
06eaa3c9a7 fix: ensure footer stays at bottom with align-self stretch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:56:09 +01:00
Julien Calixte
4cbcf42e3d feat: replace BackButton and logo with HomeButton in PublicNoteView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:32:19 +01:00
Julien Calixte
a0be25c0dd fix: prevent layout shift on first load in PWA mode
Replace space-between with flex-start + margin-top:auto on footer and
add gap to avoid wide spacing while async components are loading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:28:47 +01:00
Julien Calixte
dcee26100f fix: use 100dvh to prevent scroll on mobile first load
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:05:34 +01:00
Julien Calixte
ac68c68f8a feat: reorganize FontChange layout and resize header icons
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:46:44 +01:00
Julien Calixte
982f3070a1 fix: use <a> for font modal trigger to match icon color
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:35:52 +01:00
Julien Calixte
20e9538983 feat: mv profile to footer 2026-03-28 20:24:08 +01:00
Julien Calixte
10c3e1ca60 feat: replace back button with HomeButton and fix view transition
- Use HomeButton component in HeaderNote for logo, hover, and view-transition-name
- Eagerly import HeaderNote in FluxNote so the logo exists in the DOM when the transition snapshot is taken
- Wait for afterEach + nextTick in the view transition hook to handle lazy-loaded routes
- Add cursor: pointer to font change button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:19:59 +01:00
6dc98c80ca Merge pull request 'chore/migrate-to-oxc' (#1) from chore/migrate-to-oxc into main
Reviewed-on: #1
2026-03-28 09:00:30 +00:00
Julien Calixte
1aef212a36 docs: rolldown and oxc 2026-03-22 01:23:13 +01:00
147 changed files with 13446 additions and 1667 deletions

View File

@@ -1,8 +0,0 @@
{
"name": "remanso-skills",
"version": "1.0.0",
"description": "Local skills for the Remanso project",
"author": {
"name": "julien"
}
}

View File

@@ -1,196 +0,0 @@
---
name: migrate-oxlint
description: Guide for migrating a project from ESLint to Oxlint. Use when asked to migrate, convert, or switch a JavaScript/TypeScript project's linter from ESLint to Oxlint.
---
This skill guides you through migrating a JavaScript/TypeScript project from ESLint to [Oxlint](https://oxc.rs/docs/guide/usage/linter/).
## Overview
Oxlint is a high-performance linter that implements many popular ESLint rules natively in Rust. It can be used alongside ESLint or as a full replacement.
An official migration tool is available, and will be used by this skill: [`@oxlint/migrate`](https://github.com/oxc-project/oxlint-migrate)
## Step 1: Run Automated Migration
Run the migration tool in the project root:
```bash
npx @oxlint/migrate
```
This reads your ESLint flat config (`eslint.config.js` for example) and generates a `.oxlintrc.json` file from it. It will find your ESLint config file automatically in most cases.
See options below for more info.
### Key Options
| Option | Description |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `--type-aware` | Include type-aware rules from `@typescript-eslint` (will require the `oxlint-tsgolint` package to be installed after migrating) |
| `--with-nursery` | Include experimental rules still under development, may not be fully stable or consistent with ESLint equivalents |
| `--js-plugins [bool]` | Enable/disable ESLint plugin migration via `jsPlugins` (default: enabled) |
| `--details` | List rules that could not be migrated |
| `--replace-eslint-comments` | Convert all `// eslint-disable` comments to `// oxlint-disable` |
| `--output-file <file>` | Specify a different output path (default: `.oxlintrc.json`) |
If your ESLint config is not at the default location, pass the path explicitly:
```bash
npx @oxlint/migrate ./path/to/eslint.config.js
```
## Step 2: Review Generated Config
After migration, review the generated `.oxlintrc.json`.
### Plugin Mapping
The migration tool automatically maps ESLint plugins to oxlint's built-in equivalents. The following table is for reference when reviewing the generated config:
| ESLint Plugin | Oxlint Plugin Name |
| --------------------------------------------------- | ------------------ |
| `@typescript-eslint/eslint-plugin` | `typescript` |
| `eslint-plugin-react` / `eslint-plugin-react-hooks` | `react` |
| `eslint-plugin-import` / `eslint-plugin-import-x` | `import` |
| `eslint-plugin-unicorn` | `unicorn` |
| `eslint-plugin-jsx-a11y` | `jsx-a11y` |
| `eslint-plugin-react-perf` | `react-perf` |
| `eslint-plugin-promise` | `promise` |
| `eslint-plugin-jest` | `jest` |
| `@vitest/eslint-plugin` | `vitest` |
| `eslint-plugin-jsdoc` | `jsdoc` |
| `eslint-plugin-next` | `nextjs` |
| `eslint-plugin-node` | `node` |
| `eslint-plugin-vue` | `vue` |
Default plugins (enabled when `plugins` field is omitted): `unicorn`, `typescript`, `oxc`.
Setting the `plugins` array explicitly overrides these defaults.
ESLint core rules are usable in oxlint without needing to configure a plugin in the config file.
### Rule Categories
Oxlint groups rules into categories for bulk configuration, though only `correctness` is enabled by default:
```json
{
"categories": {
"correctness": "error",
"suspicious": "warn"
}
}
```
Available categories: `correctness` (default: enabled), `suspicious`, `pedantic`, `perf`, `style`, `restriction`, `nursery`.
Individual rule settings in `rules` override category settings.
`@oxlint/migrate` will turn `correctness` off to avoid enabling additional rules that weren't enabled by your ESLint config. You can choose to enable additional categories after migration if desired.
### Check Unmigrated Rules
Run with `--details` to see which ESLint rules could not be migrated:
```bash
npx @oxlint/migrate --details
```
Review the output and decide whether to keep ESLint for those rules or not. Some rules may be mentioned in the output from `--details` as having equivalents in oxlint that were not automatically mapped by the migration tool. In those cases, consider enabling the equivalent oxlint rule manually after migration.
## Step 3: Install Oxlint
Install the core oxlint package (use `yarn install`, `pnpm install`, `vp install`, `bun install`, etc. depending on your package manager):
```bash
npm install -D oxlint
```
If you want to add the `oxlint-tsgolint` package, if you intend to use type-aware rules that require TypeScript type information:
```bash
npm install -D oxlint-tsgolint
```
No other packages besides the above are needed by default, though you will need to keep/install any additional ESLint plugins that were migrated into `jsPlugins`. Do not add `@oxlint/migrate` to the package.json, it is meant for one-off usage.
## Step 4: Handle Unsupported Features
Some features require manual attention:
- Local plugins (relative path imports): Must be migrated manually to `jsPlugins`
- `eslint-plugin-prettier`: Supported, but very slow. It is recommended to use [oxfmt](https://oxc.rs/docs/guide/usage/formatter) instead, or switch to `prettier --check` as a separate step alongside oxlint.
- `settings` in override configs: Oxlint does not support `settings` inside `overrides` blocks.
- ESLint v9+ plugins: Not all work with oxlint's JS Plugins API, but the majority will.
### Local Plugins
If you have any custom ESLint rules in the project repo itself, you can migrate them manually after running the migration tool by adding them to the `jsPlugins` field in `.oxlintrc.json`:
```json
{
"jsPlugins": ["./path/to/my-plugin.js"],
"rules": {
"local-plugin/rule-name": "error"
}
}
```
### External ESLint Plugins
For ESLint plugins without a built-in oxlint equivalent, use the `jsPlugins` field to load them:
```json
{
"jsPlugins": ["eslint-plugin-custom"],
"rules": {
"custom/my-rule": "warn"
}
}
```
## Step 5: Update CI and Scripts
Replace ESLint commands with oxlint. Path arguments are optional; oxlint defaults to the current working directory.
```bash
# Before
npx eslint src/
npx eslint --fix src/
# After
npx oxlint src/
npx oxlint --fix src/
```
### Common CLI Options
| ESLint | oxlint equivalent |
| ------------------------- | ---------------------------------------------- |
| `eslint .` | `oxlint` (default: lints the cwd) |
| `eslint src/` | `oxlint src/` |
| `eslint --fix` | `oxlint --fix` |
| `eslint --max-warnings 0` | `oxlint --deny-warnings` or `--max-warnings 0` |
| `eslint --format json` | `oxlint --format json` |
Additional oxlint options:
- `--tsconfig <path>`: Specify tsconfig.json path, likely unnecessary unless you have a non-standard name for `tsconfig.json`.
## Tips
- You can run alongside ESLint if necessary: Oxlint is designed to complement ESLint during migration, but with JS Plugins many projects can switch over fully without losing many rules.
- Disable comments work: `// eslint-disable` and `// eslint-disable-next-line` comments are supported by oxlint. Use `--replace-eslint-comments` when running @oxlint/migrate to convert them to `// oxlint-disable` equivalents if desired.
- List available rules: Run `npx oxlint --rules` to see all supported rules, or refer to the [rule documentation](https://oxc.rs/docs/guide/usage/linter/rules.html).
- Schema support: Add `"$schema": "./node_modules/oxlint/configuration_schema.json"` to `.oxlintrc.json` for editor autocompletion if the migration tool didn't do it automatically.
- Output formats: `default`, `stylish`, `json`, `github`, `gitlab`, `junit`, `checkstyle`, `unix`
- Ignore files: `.eslintignore` is supported by oxlint if you have it, but it's recommended to move any ignore patterns into the `ignorePatterns` field in `.oxlintrc.json` for consistency and simplicity. All files and paths ignored via a `.gitignore` file will be ignored by oxlint by default as well.
- If you ran the migration tool multiple times, remove the `.oxlintrc.json.bak` backup file created by the migration tool once you've finished migrating.
- If you are not using any JS Plugins and have replaced your ESLint configuration, you can remove all ESLint packages from your project dependencies.
- Ensure your editor is configured to use oxlint instead of ESLint for linting and error reporting. You may want to install the Oxc extension for your preferred editor. See https://oxc.rs/docs/guide/usage/linter/editors.html for more details.
## References
- [CLI Reference](https://oxc.rs/docs/guide/usage/linter/cli.html)
- [Config File Reference](https://oxc.rs/docs/guide/usage/linter/config-file-reference.html)
- [Complete Oxlint rule list and docs](https://oxc.rs/docs/guide/usage/linter/rules.html)

View File

@@ -1,14 +0,0 @@
{
"name": "remanso-local",
"description": "Local plugins for the Remanso project",
"owner": {
"name": "julien"
},
"plugins": [
{
"name": "remanso-skills",
"description": "Local skills for the Remanso project (migrate-oxlint, etc.)",
"source": "./.agents"
}
]
}

View File

@@ -5,10 +5,6 @@
"label": "Vue i18n",
"uri": "https://vue-i18n.intlify.dev/guide/introduction.html"
}
],
"vite.config.ts": {
"label": "Remanso GitHub app",
"uri": "https://github.com/organizations/remanso-spance/settings/apps/lite-note"
}
]
}
}

40
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,40 @@
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ gitea.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 11.5.2
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type-check
run: pnpm types
- name: Lint
run: pnpm lint
- name: Test
run: pnpm test --run

1
.gitignore vendored
View File

@@ -2,7 +2,6 @@
node_modules
/dist
# local env files
.env.local
.env.*.local

View File

@@ -1,37 +0,0 @@
# Define the checksum file
CHECKSUM_FILE=".env.checksum"
# Calculate the current checksum of the .env file
CURRENT_CHECKSUM=$(shasum -a 256 .env | awk '{ print $1 }')
# Check if checksum file exists
if [ -f "$CHECKSUM_FILE" ]; then
# Read the previous checksum
PREVIOUS_CHECKSUM=$(cat "$CHECKSUM_FILE")
# Compare the current checksum with the previous checksum
if [ "$CURRENT_CHECKSUM" = "$PREVIOUS_CHECKSUM" ]; then
echo ".env file has not changed. Skipping Netlify environment import."
exit 0
fi
fi
# If the checksum is different or the file doesn't exist, import the variables
echo "Importing environment variables to Netlify..."
netlify env:import .env
if [ $? -ne 0 ]; then
echo "Failed to import environment variables to Netlify. Aborting push."
exit 1
fi
# Save the new checksum
echo "$CURRENT_CHECKSUM" > "$CHECKSUM_FILE"
# Stage the checksum file
git add "$CHECKSUM_FILE"
# Amend the last commit with the updated checksum
git commit -m "Update .env checksum"
echo "Environment variables imported successfully."

23
.zed/settings.json Normal file
View File

@@ -0,0 +1,23 @@
{
"format_on_save": "on",
"formatter": {
"external": {
"command": "node_modules/.bin/oxfmt",
"arguments": ["--stdin-filepath", "{buffer_path}"]
}
},
"languages": {
"TypeScript": {
"language_servers": ["!deno", "..."]
},
"TSX": {
"language_servers": ["!deno", "..."]
},
"JavaScript": {
"language_servers": ["!deno", "..."]
},
"JSX": {
"language_servers": ["!deno", "..."]
}
}
}

View File

@@ -25,7 +25,7 @@ Run a single test file: `pnpm test src/modules/repo/services/resolvePath.spec.ts
- **Vue 3** (3.5) with Composition API, **Pinia** for state, **Vue Router**
- **Vite 7** build tool, **TypeScript** strict mode
- **Tailwind CSS v4** + **DaisyUI v5** for styling (PostCSS plugin approach, not tailwind.config.js)
- **TanStack Vue Query** + **ts-rest** + **arktype** for typed API contracts
- **arktype** for runtime type validation (used by the ATProto module)
- **PouchDB** (IndexedDB) for local persistence
- **Octokit** for GitHub API integration
- **markdown-it** with plugins (KaTeX, Shikiji, Mermaid, checkboxes, GitHub alerts)
@@ -50,8 +50,7 @@ src/
│ ├── user/ # Authentication, user settings
│ ├── card/ # Spaced repetition
│ ├── history/ # Edit history tracking
── atproto/ # ATProto/Bluesky integration (DID resolution, blob URLs)
│ └── post/ # ts-rest API client for public note publishing (api.remanso.space)
── atproto/ # ATProto/Bluesky integration (DID resolution, blob URLs)
├── hooks/ # Composition hooks (useMarkdown, useBacklinks, useGitHubContent, etc.)
├── data/ # PouchDB wrapper and data models
├── utils/ # Utilities including custom markdown-it plugins
@@ -73,7 +72,6 @@ src/
- `src/modules/repo/store/userRepo.store.ts` - Central Pinia store for repo/file state
- `src/modules/repo/services/octo.ts` - Octokit wrapper for GitHub API
- `src/hooks/useMarkdown.hook.ts` - Markdown rendering with all plugins
- `src/modules/post/data/client.ts` - ts-rest API contract definitions with arktype validation
- `src/data/data.ts` - PouchDB database wrapper
### Patterns
@@ -81,5 +79,4 @@ src/
- All components use Composition API (`<script setup lang="ts">`)
- Custom hooks encapsulate feature logic (`use*.hook.ts` or `use*.ts`)
- Event buses (`noteEventBus`, `backlinkEventBus`) for cross-component communication
- ts-rest contracts use arktype schemas for request/response validation
- Path alias: `@` maps to `src/`

125
CONTEXT.md Normal file
View File

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

420
DESIGN.md Normal file
View File

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

View File

@@ -1,11 +1,11 @@
# ---- Stage 1: deps (only invalidated when lockfile changes) ----
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
@@ -13,7 +13,7 @@ RUN pnpm install --frozen-lockfile
# ---- Stage 2: build (invalidated on any source change) ----
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable
WORKDIR /app
@@ -28,8 +28,6 @@ RUN pnpm run build
FROM nginx:alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO- http://localhost:80/ || exit 1

View File

@@ -0,0 +1,40 @@
import path from "path"
import sharp from "sharp"
// PWA spec: `purpose: "monochrome"` icons are *masks*. The user agent ignores
// RGB and uses only the alpha channel as the silhouette, then paints it with
// the platform theme color. So the source PNG must be RGBA with the silhouette
// in alpha, NOT a black-on-white RGB image.
const SRC = path.resolve(__dirname, "../public/favicon.png")
const OUT = path.resolve(__dirname, "../public/monochromeicon.png")
const SIZE = 1024
async function main() {
const { data, info } = await sharp(SRC)
.resize(SIZE, SIZE, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true })
if (info.channels !== 4) throw new Error(`expected RGBA, got ${info.channels} channels`)
const out = Buffer.alloc(data.length)
for (let i = 0; i < data.length; i += 4) {
out[i] = 0
out[i + 1] = 0
out[i + 2] = 0
out[i + 3] = data[i + 3]
}
await sharp(out, { raw: { width: SIZE, height: SIZE, channels: 4 } })
.png({ compressionLevel: 9 })
.toFile(OUT)
console.log(`Wrote ${OUT} (${SIZE}x${SIZE} RGBA)`)
}
main().catch((e) => {
console.error(e)
process.exit(1)
})

View File

@@ -28,8 +28,8 @@ let themeConfigContent = readFileSync(themeConfigPath, "utf8")
// Remplacer la valeur du thème sombre
themeConfigContent = themeConfigContent.replace(
/dark:\s*['"][^'"]*['"],/,
`dark: '${newTheme}',`
/dark:\s*['"][^'"]*['"](,?)/,
`dark: '${newTheme}'$1`
)
// Écrire le contenu mis à jour dans le fichier

View File

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

View File

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

View File

@@ -0,0 +1,236 @@
# Remanso React Native Migration — Design Spec
**Date:** 2026-04-20
**Status:** Approved
## Overview
Migrate Remanso from a Vue 3 web app to a fully native iOS + Android app built with Expo (React Native). The primary motivation is native feel: fluid stack animations, swipe-back gestures, and native navigation chrome. The scope is a full replacement of the web app.
## Tech Stack
| Concern | Current (Vue) | React Native |
| ---------------- | ---------------------- | ---------------------------------------------------------- |
| Framework | Vue 3 + Vite | Expo SDK (managed workflow) |
| Routing | Vue Router | Expo Router (file-system routing over React Navigation v7) |
| State | Pinia | Zustand |
| Server state | TanStack Vue Query | TanStack Query (same library) |
| Styling | DaisyUI + Tailwind CSS | NativeWind v4 |
| Local DB | PouchDB (IndexedDB) | Expo SQLite |
| Simple KV store | localStorage | MMKV |
| Auth | OAuth redirects | expo-auth-session |
| GitHub API | @octokit/rest | @octokit/rest (unchanged) |
| i18n | vue-i18n | react-i18next |
| Date utils | date-fns | date-fns (unchanged) |
| Markdown content | markdown-it (DOM) | react-native-webview |
| Fonts | CSS custom properties | expo-font |
## Navigation Structure
Expo Router generates a React Navigation v7 tree from the file system. The stacked note pattern — the core native feel win — maps to a nested Stack navigator where each backlink push adds a note with a native slide animation and swipe-left pops it.
```
Root Stack
├── Auth screens (unauthenticated, no tab bar)
│ ├── Home / Welcome
│ ├── GitHub OAuth callback
│ └── ATProto OAuth callback
└── Authenticated App — Bottom Tabs
├── Tab: Feed (Stack)
│ ├── Repo picker
│ └── Note Stack (nested Stack)
│ ├── Note (root)
│ ├── Note (pushed via backlink) ← swipe-back to pop
│ └── Note (pushed via backlink) ← swipe-back to pop
├── Tab: Inbox / Drafts / Todos (Stack)
│ └── Note list → Note detail
├── Tab: Public Notes (Stack)
│ ├── Public note list
│ └── Public note detail
└── Tab: Settings (Stack)
├── Settings root
└── Font picker (presented as modal)
```
History and Spaced Repetition are accessible as sections within the Feed tab or as additional tabs — to be decided during implementation.
## Data Layer
PouchDB is replaced by **Expo SQLite** for structured data and **MMKV** for simple key-value preferences.
### Expo SQLite schema
```sql
CREATE TABLE IF NOT EXISTS github_tokens (
id TEXT PRIMARY KEY,
access_token TEXT NOT NULL,
refresh_token TEXT,
expires_at INTEGER
);
CREATE TABLE IF NOT EXISTS atproto_sessions (
id TEXT PRIMARY KEY,
did TEXT NOT NULL
-- full session_json stored in Expo SecureStore keyed by id
);
CREATE TABLE IF NOT EXISTS saved_repos (
id TEXT PRIMARY KEY,
user TEXT NOT NULL,
repo TEXT NOT NULL,
files_json TEXT NOT NULL,
cached_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS history (
id TEXT PRIMARY KEY,
user TEXT NOT NULL,
repo TEXT NOT NULL,
note_path TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);
```
The existing `DataApi` interface (`add`, `update`, `remove`, `get`, `getAll`) is re-implemented as a thin wrapper over Expo SQLite. No ORM — plain async SQL with `CREATE TABLE IF NOT EXISTS` on first open handles schema initialization at this scale.
Sensitive data (access tokens, refresh tokens, ATProto sessions) is stored in **Expo SecureStore** rather than plain SQLite. SQLite holds only metadata and content cache.
### MMKV
Replaces localStorage for user settings: font family, font size, theme preference. Synchronous reads, no async overhead.
### No Web Worker
Database calls move to the main thread via Expo SQLite's async API. The performance concern that justified the Web Worker on web does not apply on mobile.
## Auth Flows
### GitHub OAuth
The existing auth server (`api.remanso.space/auth/github`) handles code exchange — the client flow is unchanged:
1. `expo-auth-session` opens an in-app browser tab with the GitHub authorize URL
2. OAuth redirect captured by Expo's deep link handler
3. Code sent to `api.remanso.space/auth/github?code=...` for token exchange
4. Access token + refresh token stored in Expo SecureStore
5. Token refresh logic (15-minute pre-expiry check) stays the same; HTTP calls use `fetch`
### ATProto / Bluesky OAuth
`@atproto/oauth-client-browser` is browser-only (IndexedDB, `window.crypto`, browser redirects). There is no official React Native client. A custom client (~200300 lines) is implemented using:
- `expo-auth-session` for the OAuth redirect flow (PKCE)
- `expo-crypto` for PKCE code verifier/challenge generation
- Expo SecureStore for session persistence
- The same Bluesky API endpoints as the browser client
This keeps ATProto auth fully client-side, consistent with the app's current architecture.
## State Management
Zustand replaces Pinia. The store shape is identical to `userRepo.store.ts`:
```ts
const useRepoStore = create<RepoState>((set, get) => ({
user: "",
repo: "",
files: [],
userSettings: null,
needToLogin: false,
setRepo: (user, repo) => set({ user, repo }),
loadFiles: async () => {
/* Octokit call */
},
loadSettings: async () => {
/* MMKV read */
}
}))
```
TanStack Query handles all GitHub API server state (file fetching, README, repo listing) — same library, same patterns as today.
## Styling
NativeWind v4 provides Tailwind utility classes in React Native. DaisyUI is web-only and has no React Native equivalent — all component styling (buttons, cards, modals) is hand-written using NativeWind utilities.
The two DaisyUI themes (`retro` light, `coffee` dark) are translated into a custom NativeWind theme in `tailwind.config.ts` with the same color tokens. System appearance (`useColorScheme`) drives theme selection.
Font customization uses `expo-font` for loading custom fonts and React Native's `fontFamily` style prop, replacing the CSS custom property approach.
## Markdown Rendering
The markdown-it pipeline (KaTeX, Mermaid, shiki, tabler icons, html5-media, GitHub alerts, checkboxes) runs in the React Native JS context unchanged — same code, same output. The resulting HTML string is passed to a `NoteWebView` component built on `react-native-webview`.
`NoteWebView` is a native UIView/View wrapper around a WebView engine. It is a React Native component — not a web app. The surrounding app (navigation chrome, tab bar, headers, settings, auth screens) is 100% native. Only the note content pane renders HTML. This is the standard pattern for rich content in React Native (used by GitHub Mobile, Linear, and others).
The WebView communicates back to the native layer via `postMessage` for:
- Internal note link taps (trigger React Navigation push)
- External URL taps (open in system browser)
- Backlink detection events
## Project Structure
```
src/
├── app/ # Expo Router — file-system screens
│ ├── _layout.tsx # Root Stack navigator
│ ├── index.tsx # Home / Welcome
│ └── (tabs)/ # Authenticated tab navigator
│ ├── _layout.tsx
│ ├── feed/ # Feed + Note Stack screens
│ ├── inbox/
│ ├── public/
│ └── settings/
├── modules/ # Feature domains (mirrors current structure)
│ ├── note/ # Note models, hooks, caching
│ ├── repo/ # Zustand store, Octokit service
│ ├── user/
│ │ ├── auth/ # GitHub + ATProto OAuth hooks
│ │ └── fonts.ts # Font downloading (was utils/downloadFont.ts)
│ ├── card/ # Spaced repetition
│ ├── history/ # Edit history
│ ├── atproto/ # Custom ATProto OAuth client, DID resolution
│ └── post/ # ts-rest API client (unchanged)
├── components/ # Shared UI components
├── rendering/ # Markdown pipeline — first-class module
│ ├── pipeline.ts # markdown-it setup + plugin registration
│ ├── plugins/ # Custom plugins
│ │ ├── html5-media.ts
│ │ ├── regexp.ts
│ │ └── tabler-icons.ts
│ └── NoteWebView.tsx # react-native-webview wrapper
├── lib/ # Shared low-level, stateless helpers
│ ├── text.ts # slugify, noteTitle, displayLanguage
│ ├── links.ts # link.ts + youtube.ts
│ ├── encoding.ts # decodeBase64ToUTF8
│ └── notifications.ts # notif.ts
├── hooks/ # React hooks
├── data/ # Expo SQLite wrapper (same DataApi interface)
├── locales/ # i18n strings (unchanged)
└── constants/ # Theme tokens, note width constants
```
## Key Risks
1. **ATProto custom OAuth client** — no official SDK; requires careful PKCE implementation and session lifecycle management.
2. **DaisyUI → NativeWind component styling** — no 1:1 mapping; all themed components need to be rebuilt. Most labor-intensive non-feature work.
3. **NoteWebView ↔ native bridge** — link tap handling and scroll coordination between the WebView and the native Stack navigator require careful implementation to feel seamless.
4. **Mermaid in WebView** — Mermaid is JS-heavy; initial render may be slow on lower-end Android. May need lazy rendering or a timeout fallback.
## Out of Scope
- PWA / service worker (not applicable to native)
- Web Worker (replaced by async SQLite)
- Comlink (not applicable)
- Server-side rendering

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en" data-theme="garden">
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />

9
nginx.conf Normal file
View File

@@ -0,0 +1,9 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -2,6 +2,7 @@
"name": "remanso",
"version": "1.0.0",
"private": true,
"packageManager": "pnpm@11.5.2",
"scripts": {
"dev": "vite",
"build": "vite build",
@@ -12,33 +13,35 @@
"lint:fix": "oxlint --fix",
"fmt": "oxfmt",
"fmt:check": "oxfmt --check",
"prepare": "husky",
"theme:light": "esno _scripts/change-theme-light.ts",
"theme:dark": "esno _scripts/change-theme-dark.ts",
"generate-pwa-assets": "pwa-assets-generator"
"theme:dark": "esno _scripts/change-theme-dark.ts"
},
"dependencies": {
"@atproto/oauth-client-browser": "^0.3.41",
"@better-fetch/fetch": "^1.1.21",
"@better-fetch/logger": "^1.1.21",
"@intlify/unplugin-vue-i18n": "^6.0.8",
"@mdit/plugin-tab": "^0.24.2",
"@octokit/core": "^7.0.6",
"@octokit/rest": "^22.0.1",
"@openpanel/web": "^1.3.0",
"@tailwindcss/postcss": "^4.1.16",
"@tanstack/vue-query": "^5.92.9",
"@toycode/markdown-it-class": "^1.2.4",
"@vscode/markdown-it-katex": "^1.1.2",
"@vueuse/components": "^14.2.1",
"@vueuse/core": "^13.6.0",
"@vueuse/router": "^13.6.0",
"arktype": "^2.1.29",
"comlink": "^4.4.2",
"date-fns": "^4.1.0",
"dompurify": "^3.4.3",
"events": "^3.3.0",
"font-color-contrast": "^11.1.0",
"fontfaceobserver": "^2.3.0",
"github-slugger": "^2.0.0",
"isomorphic-fetch": "^3.0.0",
"markdown-it": "^14.1.0",
"markdown-it-anchor": "^9.2.0",
"markdown-it-block-embed": "^0.0.3",
"markdown-it-checkbox": "^1.1.0",
"markdown-it-github-alerts": "^1.0.0",
@@ -46,6 +49,7 @@
"markdown-it-shikiji": "^0.10.2",
"mermaid": "^11.12.1",
"nanoid": "^5.1.6",
"node-diff3": "^3.2.1",
"notyf": "^3.10.0",
"pastel-color": "^1.0.3",
"pinia": "^2.2.6",
@@ -55,6 +59,7 @@
"register-service-worker": "^1.7.2",
"retrobus": "^1.9.4",
"sanitize-html": "^2.17.0",
"shikiji-core": "0.10.2",
"vue": "^3.5.18",
"vue-i18n": "^11.1.11",
"vue-router": "^4.5.1",
@@ -63,6 +68,7 @@
},
"devDependencies": {
"@babel/core": "^7.28.5",
"@pinia/testing": "^1.0.3",
"@tailwindcss/typography": "^0.5.19",
"@types/fontfaceobserver": "^2.1.3",
"@types/markdown-it": "^14.1.2",
@@ -72,6 +78,7 @@
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/compiler-sfc": "^3.5.28",
"@vue/test-utils": "^2.4.11",
"autoprefixer": "^10.4.24",
"daisyui": "^5.5.18",
"dotenv": "^17.2.3",
@@ -79,7 +86,7 @@
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.4.1",
"esno": "^4.8.0",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"oxfmt": "^0.42.0",
"oxlint": "^1.57.0",
"prettier": "^3.8.1",

934
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,7 @@
allowBuilds:
'@parcel/watcher': true
core-js: true
esbuild: true
fsevents: true
sharp: true
vue-demi: true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

BIN
public/monochromeicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -1,10 +0,0 @@
{
"version": 1,
"skills": {
"migrate-oxlint": {
"source": "oxc-project/oxc",
"sourceType": "github",
"computedHash": "80ce5201b1ef52d6cabe553a4cacfd6e1db97bad99618216b9cf9318d11d7e64"
}
}
}

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup>
import ImageLightbox from "@/components/ImageLightbox.vue"
import NewVersion from "@/components/NewVersion.vue"
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
@@ -12,15 +13,24 @@ const { isATProtoReady } = useATProtoLogin()
<router-view v-if="isReady && isATProtoReady" />
<new-version />
<image-lightbox />
</div>
</template>
<style lang="scss">
#main-app {
height: 100vh;
height: 100dvh;
width: 100%;
max-width: none;
display: flex;
flex: 1;
overflow-x: auto;
}
@media screen and (max-width: 768px) {
#main-app {
overflow-y: auto;
}
}
::view-transition-old(root),

View File

@@ -1,7 +1,7 @@
import { OpenPanel } from "@openpanel/web"
export const op = new OpenPanel({
apiUrl: "https://api.panel.apoena.dev",
apiUrl: "https://api.stats.apoena.dev",
clientId: "038a6aac-19bb-4a7f-9aae-2d0201fead5b",
trackScreenViews: true,
trackOutgoingLinks: true,

View File

@@ -4,6 +4,7 @@ interface EventBusParams {
user: string
repo: string
path: string
hash?: string
currentNoteSHA?: string
}

View File

@@ -3,6 +3,7 @@ import { onBeforeMount, ref } from "vue"
import { useRoute, useRouter } from "vue-router"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
import { consumeGithubOAuthReturnPath } from "@/modules/user/service/oauthReturnPath"
import { signIn } from "@/modules/user/service/signIn"
const route = useRoute()
@@ -19,12 +20,17 @@ onBeforeMount(async () => {
if ("error" in token) {
hasError.value = true
} else {
token.access_token
saveCredentials(token)
await saveCredentials(token)
}
const returnPath = consumeGithubOAuthReturnPath()
if (!hasError.value && returnPath) {
router.replace(returnPath)
} else {
router.replace({ name: "Home" })
}
}
})
</script>

View File

@@ -24,7 +24,7 @@ const goBack = () => {
</script>
<template>
<a class="btn btn-sm back-button" @click="goBack">
<button class="btn btn-sm back-button text-base-content" @click="goBack">
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-arrow-narrow-left"
@@ -41,5 +41,5 @@ const goBack = () => {
<line x1="5" y1="12" x2="9" y2="16" />
<line x1="5" y1="12" x2="9" y2="8" />
</svg>
</a>
</button>
</template>

View File

@@ -1,18 +1,14 @@
<script lang="ts" setup>
import {
computed,
defineAsyncComponent,
nextTick,
onMounted,
onUnmounted,
toRefs,
watch
} from "vue"
import { computed, onMounted, onUnmounted, toRefs, watch } from "vue"
import HeaderNote from "@/components/HeaderNote.vue"
import SignInGithub from "@/components/SignInGithub.vue"
import SkeletonLoader from "@/components/SkeletonLoader.vue"
import StackedNote from "@/components/StackedNote.vue"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
import { useLinks } from "@/hooks/useLinks.hook"
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
import { useNoteView } from "@/hooks/useNoteView.hook"
import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
@@ -21,10 +17,6 @@ import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { useUserSettings } from "@/modules/user/hooks/useUserSettings.hook"
const HeaderNote = defineAsyncComponent(
() => import("@/components/HeaderNote.vue")
)
const props = withDefaults(
defineProps<{
user: string
@@ -54,6 +46,7 @@ const { listenToClick } = useLinks("note-display")
const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
const { titles } = useNoteView()
const { isLogged } = useGitHubLogin()
useResizeContainer("note-container", stackedNotes)
const renderedContent = computed(() =>
@@ -67,14 +60,10 @@ const renderedContent = computed(() =>
const isLoading = computed(() => renderedContent.value === undefined)
const hasContent = computed(() => !!renderedContent.value)
watch(
renderedContent,
async () => {
await nextTick()
listenToClick()
},
{ immediate: true }
)
useMarkdownPostRender(renderedContent, () => ".note-display", {
onReady: () => listenToClick(),
tikz: true
})
watch(
[refProps.user, refProps.repo],
@@ -84,6 +73,10 @@ watch(
{ immediate: true }
)
const retryLoad = () => {
store.setUserRepo(props.user, props.repo)
}
onMounted(() => visitRepo())
onUnmounted(() => {
@@ -107,14 +100,30 @@ onUnmounted(() => {
<h1 class="heading-1">
{{ repo }}
</h1>
{{ user }}
· {{ user }}
</div>
<cache-all-notes />
</div>
<slot />
<skeleton-loader v-if="isLoading || !hasContent" />
<skeleton-loader v-if="isLoading" />
<div
v-else-if="withContent && !hasContent && store.loadError === 'network'"
class="repo-network-error"
>
<p>Couldn't reach GitHub. Check your connection.</p>
<button class="btn btn-primary" @click="retryLoad">Retry</button>
</div>
<div v-else-if="withContent && !hasContent" class="repo-not-found">
<template v-if="isLogged">
<p>This repository is not accessible.</p>
</template>
<template v-else>
<p>This repository is private. Sign in to view it.</p>
<sign-in-github />
</template>
</div>
<p
v-else-if="withContent"
v-else-if="withContent && hasContent"
class="note-display"
v-html="renderedContent"
/>
@@ -139,6 +148,10 @@ $header-height: 40px;
display: flex;
flex: 1;
.heading-1 {
display: inline-block;
}
&.content {
.title,
h1,
@@ -200,6 +213,16 @@ $header-height: 40px;
}
}
.repo-not-found,
.repo-network-error {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 2rem 0;
color: var(--color-base-content);
}
.note-display {
padding-bottom: 2rem;
}
@@ -208,12 +231,6 @@ $header-height: 40px;
display: flex;
flex-direction: column;
overflow-y: auto;
height: 100vh;
position: sticky;
&:not(:first-child) {
border-top: 1px solid rgba(18, 19, 58, 0.2);
}
.title {
text-align: left;
@@ -221,6 +238,8 @@ $header-height: 40px;
}
@media screen and (min-width: 769px) {
background-color: var(--note-canvas-bg);
.repo-title-breadcrumb {
padding: 0.5rem 1rem 0;
transform-origin: 0 0;
@@ -237,6 +256,11 @@ $header-height: 40px;
.note {
min-width: var(--note-width);
max-width: var(--note-width);
background-color: var(--color-base-100);
}
.readme {
box-shadow: var(--note-sheet-shadow);
}
}
}
@@ -245,6 +269,7 @@ $header-height: 40px;
.flux-note {
.readme {
padding: 0 0.75rem;
position: relative;
}
}
@@ -254,6 +279,7 @@ $header-height: 40px;
.note {
width: 100vw;
height: 100svh;
overflow-y: visible;
}

View File

@@ -7,50 +7,94 @@ import { useUserRepoStore } from "../modules/repo/store/userRepo.store"
const store = useUserRepoStore()
const fontFamilies = computed(() => store.userSettings?.fontFamilies ?? [])
const sortedFontFamilies = computed(() =>
[...fontFamilies.value].sort((a, b) => a.localeCompare(b))
const DEFAULT_FONT_FAMILIES = [
"EB Garamond",
"Inter",
"Lato",
"Libertinus Serif",
"Lora",
"Merriweather",
"Playfair Display",
"Roboto",
"Source Serif 4"
]
const fontFamilies = computed(
() => store.userSettings?.fontFamilies ?? DEFAULT_FONT_FAMILIES
)
const sortedFontFamilies = computed(() => {
const base = fontFamilies.value
const extras = [
store.userSettings?.chosenTitleFont,
store.userSettings?.chosenBodyFont
].filter((f): f is string => !!f && !base.includes(f))
return [...base, ...extras].sort((a, b) => a.localeCompare(b))
})
const fontSizes = Array.from({ length: 7 }, (_, i) => `${9 + i * 2}pt`)
const titleFont = computed({
get: () => store.userSettings?.chosenTitleFont,
set: (value) => store.setTitleFont(value!)
})
const bodyFont = computed({
get: () => store.userSettings?.chosenBodyFont,
set: (value) => store.setBodyFont(value!)
})
const fontSize = computed({
get: () => store.userSettings?.chosenFontSize,
set: (value) => store.setFontSize(value!)
})
</script>
<template>
<div class="font-change" v-if="sortedFontFamilies.length > 0">
<theme-swap />
<select
class="select"
:value="store.userSettings?.chosenFontFamily"
@change="store.setFontFamily(($event.target as HTMLSelectElement).value)"
>
<div class="font-change">
<div>
<label for="title-font" class="font-label">t</label>
<select id="title-font" class="select" v-model="titleFont">
<option v-for="font in sortedFontFamilies" :key="font" :value="font">
{{ font }}
</option>
</select>
<select
class="select"
:value="store.userSettings?.chosenFontSize"
@change="store.setFontSize(($event.target as HTMLSelectElement).value)"
>
<label for="body-font" class="font-label">p</label>
<select id="body-font" class="select" v-model="bodyFont">
<option v-for="font in sortedFontFamilies" :key="font" :value="font">
{{ font }}
</option>
</select>
</div>
<div>
<theme-swap />
<label for="font-size" class="font-label">s</label>
<select id="font-size" class="select" v-model="fontSize">
<option v-for="size in fontSizes" :key="size" :value="size">
{{ size }}
</option>
</select>
</div>
</div>
</template>
<style lang="scss" scoped>
.font-change {
flex: 1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
select {
flex: 1;
display: flex;
}
div {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
margin: 1rem;
}
}
.font-label {
font-weight: bold;
font-size: 0.75rem;
opacity: 0.6;
}
</style>

View File

@@ -1,32 +1,13 @@
<script lang="ts" setup>
import FontChange from "@/components/FontChange.vue"
import HomeButton from "@/components/HomeButton.vue"
defineProps<{ user: string; repo: string }>()
</script>
<template>
<header class="header-note">
<router-link
:to="{ name: 'Home' }"
class="button is-small is-white back-button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-arrow-narrow-left"
width="28"
height="28"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="5" y1="12" x2="19" y2="12" />
<line x1="5" y1="12" x2="9" y2="16" />
<line x1="5" y1="12" x2="9" y2="8" />
</svg>
</router-link>
<home-button />
<!-- <router-link
:to="{ name: 'SpacedRepetitionCard', params: { user, repo } }"
>
@@ -51,12 +32,15 @@ defineProps<{ user: string; repo: string }>()
</svg>
</router-link> -->
<button onclick="font_modal.showModal()">
<button
class="btn btn-ghost btn-circle text-base-content"
onclick="font_modal.showModal()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icons-tabler-outline icon-tabler-typography"
width="36"
height="36"
width="30"
height="30"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
@@ -71,11 +55,14 @@ defineProps<{ user: string; repo: string }>()
<path d="M5 20l6 -16l2 0l7 16" />
</svg>
</button>
<router-link :to="{ name: 'FluxNoteView', params: { user, repo } }">
<router-link
class="btn btn-ghost btn-circle"
:to="{ name: 'FluxNoteView', params: { user, repo } }"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="36"
height="36"
width="30"
height="30"
viewBox="0 0 24 24"
stroke="currentColor"
fill="none"
@@ -88,12 +75,15 @@ defineProps<{ user: string; repo: string }>()
<path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6" />
</svg>
</router-link>
<router-link :to="{ name: 'DraftNotes', params: { user, repo } }">
<router-link
class="btn btn-ghost btn-circle"
:to="{ name: 'DraftNotes', params: { user, repo } }"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-notes"
width="36"
height="36"
width="30"
height="30"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
@@ -107,11 +97,14 @@ defineProps<{ user: string; repo: string }>()
<line x1="9" y1="15" x2="13" y2="15" />
</svg>
</router-link>
<router-link :to="{ name: 'TodoNotes', params: { user, repo } }">
<router-link
class="btn btn-ghost btn-circle"
:to="{ name: 'TodoNotes', params: { user, repo } }"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="36"
height="36"
width="30"
height="30"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -129,12 +122,15 @@ defineProps<{ user: string; repo: string }>()
<path d="M11 18l9 0" />
</svg>
</router-link>
<router-link :to="{ name: 'FleetingNotes', params: { user, repo } }">
<router-link
class="btn btn-ghost btn-circle"
:to="{ name: 'FleetingNotes', params: { user, repo } }"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-mailbox"
width="36"
height="36"
width="30"
height="30"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
@@ -150,7 +146,7 @@ defineProps<{ user: string; repo: string }>()
</svg>
</router-link>
<dialog id="font_modal" class="modal">
<div class="modal-box">
<div class="modal-box w-10/12 max-w-2xl">
<h3 class="text-lg font-bold">Style settings</h3>
<font-change />
</div>
@@ -168,12 +164,6 @@ defineProps<{ user: string; repo: string }>()
justify-content: space-between;
margin-top: 10px;
img {
&:hover {
cursor: pointer;
}
}
button {
color: var(--color-accent);
}

View File

@@ -6,9 +6,12 @@ const goHome = () => router.push({ name: "Home" })
</script>
<template>
<a class="btn btn-ghost btn-circle btn-lg" @click="goHome">
<button
class="btn btn-ghost btn-circle btn-lg text-base-content"
@click="goHome"
>
<img src="/favicon.png" alt="Remanso icon" class="remanso-logo" />
</a>
</button>
</template>
<style>

View File

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

@@ -24,9 +24,9 @@ const emitNote = (sha: string) => {
<h5 class="subtitle is-5">🔗</h5>
<ul class="links">
<li v-for="link in backlink?.links" :key="link.sha">
<a @click.prevent="emitNote(link.sha)">
<button class="link" @click="emitNote(link.sha)">
{{ link.title }}
</a>
</button>
</li>
</ul>
</div>

View File

@@ -0,0 +1,90 @@
<script lang="ts" setup>
import { onMounted, ref, watch } from "vue"
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{
discard: []
overwrite: []
cancel: []
"update:open": [value: boolean]
}>()
const dialogRef = ref<HTMLDialogElement | null>(null)
const close = () => {
if (dialogRef.value?.open) dialogRef.value.close()
emit("update:open", false)
}
const onDiscard = () => {
emit("discard")
close()
}
const onOverwrite = () => {
emit("overwrite")
close()
}
const onCancel = () => {
emit("cancel")
close()
}
watch(
() => props.open,
(open) => {
const el = dialogRef.value
if (!el) return
if (open && !el.open) el.showModal()
else if (!open && el.open) el.close()
}
)
onMounted(() => {
if (props.open) dialogRef.value?.showModal()
})
</script>
<template>
<dialog
ref="dialogRef"
class="modal"
@close="emit('update:open', false)"
@cancel.prevent="onCancel()"
>
<div class="modal-box">
<h3 class="text-lg font-bold">GitHub has a newer version of this note</h3>
<p class="py-3 text-sm">
Someone (or another device) updated this note on GitHub since you
started editing. If you save now, their changes will be overwritten.
</p>
<div class="modal-action flex-col gap-2">
<button
type="button"
class="btn btn-ghost"
@click="onCancel()"
>
Cancel
</button>
<button
type="button"
class="btn btn-warning"
@click="onOverwrite()"
>
Save anyway (overwrite)
</button>
<button
type="button"
class="btn btn-primary"
@click="onDiscard()"
>
Discard my edits, pull latest
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="submit" @click="onCancel()">close</button>
</form>
</dialog>
</template>

View File

@@ -0,0 +1,74 @@
import { mount } from "@vue/test-utils"
import { describe, expect, it } from "vitest"
import NoteFreshnessBadge from "./NoteFreshnessBadge.vue"
const factory = (props: { status: string; lastCheckedAt?: Date | null }) =>
mount(NoteFreshnessBadge, {
props: { lastCheckedAt: null, ...props } as never
})
describe("NoteFreshnessBadge", () => {
it("renders the verified state with the cloud-check icon", () => {
const wrapper = factory({ status: "verified" })
expect(wrapper.classes()).toContain("state-verified")
expect(wrapper.find(".icon-tabler-cloud-check").exists()).toBe(true)
})
it("renders the outdated state with the download icon", () => {
const wrapper = factory({ status: "outdated" })
expect(wrapper.classes()).toContain("state-outdated")
expect(wrapper.find(".icon-tabler-cloud-download").exists()).toBe(true)
})
it("renders the offline state with the cloud-off icon", () => {
const wrapper = factory({ status: "offline" })
expect(wrapper.classes()).toContain("state-offline")
expect(wrapper.find(".icon-tabler-cloud-off").exists()).toBe(true)
})
it("renders the unauthorized state with the lock icon", () => {
const wrapper = factory({ status: "unauthorized" })
expect(wrapper.classes()).toContain("state-unauthorized")
expect(wrapper.find(".icon-tabler-cloud-lock").exists()).toBe(true)
})
it("disables the button while checking", () => {
const wrapper = factory({ status: "checking" })
expect(wrapper.attributes("disabled")).toBeDefined()
expect(wrapper.find(".icon-tabler-loader-2").exists()).toBe(true)
})
it("emits click when not busy", async () => {
const wrapper = factory({ status: "verified" })
await wrapper.trigger("click")
expect(wrapper.emitted("click")).toHaveLength(1)
})
it("includes the last-checked time in the verified tooltip", () => {
const wrapper = factory({
status: "verified",
lastCheckedAt: new Date("2026-01-01T10:30:00")
})
const tooltip = wrapper.attributes("title") as string
expect(tooltip).toMatch(/Verified at/)
})
it("renders the unknown state as default", () => {
const wrapper = factory({ status: "unknown" })
expect(wrapper.classes()).toContain("state-unknown")
expect(wrapper.find(".icon-tabler-cloud-question").exists()).toBe(true)
})
it("offline tooltip prompts a retry", () => {
const wrapper = factory({ status: "offline" })
expect(wrapper.attributes("title")).toMatch(/retry/i)
})
it("unauthorized tooltip prompts a sign-in", () => {
const wrapper = factory({ status: "unauthorized" })
expect(wrapper.attributes("title")).toMatch(/[Ss]ign in/)
})
})

View File

@@ -0,0 +1,242 @@
<script lang="ts" setup>
import { computed } from "vue"
import type { FreshnessStatus } from "@/hooks/useNoteFreshness.hook"
const props = defineProps<{
status: FreshnessStatus
lastCheckedAt: Date | null
}>()
defineEmits<{ (e: "click"): void }>()
const formatTime = (d: Date) =>
d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
const label = computed(() => {
switch (props.status) {
case "verified":
return "Up to date"
case "checking":
return "Checking…"
case "outdated":
return "Outdated"
case "offline":
return "Cant reach GitHub"
case "unauthorized":
return "Sign in to GitHub"
case "unknown":
default:
return "Not checked"
}
})
const tooltip = computed(() => {
switch (props.status) {
case "verified":
return props.lastCheckedAt
? `Verified at ${formatTime(props.lastCheckedAt)}. Click to re-check.`
: "Click to re-check."
case "outdated":
return "GitHub has a newer version. Click to pull latest."
case "offline":
return "Could not reach GitHub. Click to retry."
case "unauthorized":
return "GitHub auth expired. Sign in again, then click to retry."
case "checking":
return "Checking against GitHub…"
case "unknown":
default:
return "Click to check against GitHub."
}
})
const stateClass = computed(() => `state-${props.status}`)
const isBusy = computed(() => props.status === "checking")
</script>
<template>
<button
class="freshness button is-text is-light"
:class="stateClass"
:title="tooltip"
:aria-label="tooltip"
:disabled="isBusy"
@click="$emit('click')"
>
<svg
v-if="status === 'verified'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-cloud-check"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M11 18.004h-4.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.393 -1.762 1.794 -3.2 3.675 -3.773c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.388 0 2.585 .82 3.138 2.007"
/>
<path d="M15 19l2 2l4 -4" />
</svg>
<svg
v-else-if="status === 'unknown'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-cloud-question"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M14.5 18.004h-7.843c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.393 -1.762 1.794 -3.2 3.675 -3.773c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99"
/>
<path d="M19 22v.01" />
<path
d="M19 19a2.003 2.003 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483"
/>
</svg>
<svg
v-else-if="status === 'outdated'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-cloud-download"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M19 18a3.5 3.5 0 0 0 0 -7h-1a5 4.5 0 0 0 -11 -2a4.6 4.4 0 0 0 -2.1 8.4"
/>
<path d="M12 13l0 9" />
<path d="M9 19l3 3l3 -3" />
</svg>
<svg
v-else-if="status === 'checking'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-loader-2 spin"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 3a9 9 0 1 0 9 9" />
</svg>
<svg
v-else-if="status === 'unauthorized'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-cloud-lock"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M11 18.004h-4.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.393 -1.762 1.794 -3.2 3.675 -3.773c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.18 0 2.227 .593 2.85 1.5"
/>
<path d="M15 16m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" />
<path d="M18 14a2 2 0 0 1 2 2v0h-4v0a2 2 0 0 1 2 -2z" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-cloud-off"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M9.58 5.548c.24 -.11 .492 -.207 .752 -.286c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.913 0 3.464 1.56 3.464 3.486c0 .957 -.383 1.824 -1.003 2.454m-2.997 1.033h-11.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.13 -.582 .37 -1.128 .7 -1.62"
/>
<path d="M3 3l18 18" />
</svg>
</button>
</template>
<style lang="scss" scoped>
.freshness {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
background: transparent;
border: 0;
cursor: pointer;
vertical-align: middle;
&[disabled] {
cursor: progress;
}
.icon {
flex-shrink: 0;
}
.freshness-label {
line-height: 1;
}
}
.state-verified {
color: var(--color-success, hsl(140, 60%, 35%));
}
.state-outdated {
color: var(--color-warning, hsl(35, 90%, 45%));
}
.state-offline {
color: var(--color-error, hsl(0, 70%, 45%));
}
.state-unauthorized {
color: var(--color-warning, hsl(35, 90%, 45%));
}
.state-unknown,
.state-checking {
color: var(--color-base-content);
opacity: 0.6;
}
.spin {
animation: freshness-spin 1s linear infinite;
}
@keyframes freshness-spin {
to {
transform: rotate(360deg);
}
}
@media screen and (max-width: 768px) {
.freshness-label {
display: none;
}
}
</style>

View File

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

View File

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

View File

@@ -0,0 +1,153 @@
<script setup lang="ts">
import { computed } from "vue"
import SignInAtproto from "@/components/SignInAtproto.vue"
import SignInGithub from "@/components/SignInGithub.vue"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
const { accessToken } = useGitHubLogin()
const isGitHubLoggedIn = computed(() => !!accessToken.value)
</script>
<template>
<dialog id="profile_modal" class="modal profile-modal">
<div class="modal-box profile-modal-box">
<div class="profile-modal-head">
<h3>Profile</h3>
<form method="dialog">
<button class="profile-modal-x" aria-label="close">×</button>
</form>
</div>
<div class="profile-modal-section">
<div class="profile-modal-label">Bluesky / ATProto</div>
<sign-in-atproto :with-sign-out="true" />
</div>
<hr class="profile-modal-rule" />
<div class="profile-modal-section">
<div class="profile-modal-label">GitHub</div>
<sign-in-github />
<router-link
v-if="isGitHubLoggedIn"
:to="{ name: 'RepoList' }"
class="profile-modal-link"
>Manage your repos</router-link
>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button></button>
</form>
</dialog>
</template>
<style scoped lang="scss">
.profile-modal {
--pm-rule: color-mix(
in oklch,
var(--color-base-content) 12%,
var(--color-base-200)
);
--pm-ink: var(--color-base-content);
--pm-ink-soft: color-mix(
in oklch,
var(--color-base-content) 65%,
var(--color-base-200)
);
--pm-ink-faint: color-mix(
in oklch,
var(--color-base-content) 38%,
var(--color-base-200)
);
--pm-accent: #e36598;
--pm-accent-wash: color-mix(in oklch, #e36598 12%, var(--color-base-200));
--pm-accent-deep: color-mix(
in oklch,
#e36598 75%,
var(--color-base-content)
);
}
.profile-modal-box {
background: var(--color-base-200);
border: 1px solid var(--pm-rule);
border-radius: 6px;
padding: 1.5rem;
box-shadow: 0 30px 60px -20px rgba(0, 0, 0, 0.3);
color: var(--pm-ink);
}
.profile-modal-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
h3 {
font-size: 1.3rem;
margin: 0;
font-weight: 600;
}
}
.profile-modal-x {
border: 0;
background: transparent;
font-size: 1.5rem;
cursor: pointer;
color: var(--pm-ink-soft);
line-height: 1;
padding: 0.25rem 0.5rem;
&:hover {
color: var(--pm-accent-deep);
}
}
.profile-modal-section {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
margin-bottom: 0.5rem;
}
.profile-modal-label {
display: block;
width: 100%;
font-size: 0.72rem;
letter-spacing: 0.14em;
color: var(--pm-ink-faint);
margin-bottom: 0.25rem;
text-transform: uppercase;
}
.profile-modal-rule {
border: 0;
border-top: 1px solid var(--pm-rule);
margin: 1.25rem 0;
}
.profile-modal-link {
font-size: 16px;
padding: 0.55rem 1rem;
border-radius: 2px;
border: 1px solid var(--pm-rule);
background: transparent;
color: var(--pm-ink-soft);
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
&:hover {
background: var(--pm-accent-wash);
border-color: var(--pm-accent);
color: var(--pm-accent-deep);
}
}
</style>

View File

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

View File

@@ -40,6 +40,7 @@ const getStyle = (seed: string) => {
<style scoped lang="scss">
.repo-list {
display: flex;
justify-content: space-evenly;
gap: 1rem;
flex-wrap: wrap;

View File

@@ -1,20 +1,34 @@
<script lang="ts" setup>
import { useRoute } from "vue-router"
import { GITHUB_OAUTH_RETURN_PATH_KEY } from "@/modules/user/service/oauthReturnPath"
const GITHUB_URL = "https://github.com/login/oauth/authorize"
const CLIENT_ID = "Iv1.12dc43d013ce3623"
const SCOPE = "repo%20workflow"
const REDIRECT_URI = window.location.origin
const route = useRoute()
const url = new URL(GITHUB_URL)
url.searchParams.set("client_id", CLIENT_ID)
url.searchParams.set("scope", SCOPE)
url.searchParams.set("redirect_uri", REDIRECT_URI)
const href = url.toString()
const saveReturnPath = () => {
sessionStorage.setItem(GITHUB_OAUTH_RETURN_PATH_KEY, route.fullPath)
}
</script>
<template>
<a :href="href" class="sign-in-github btn btn-sm btn-primary">
<a
:href="href"
class="sign-in-github btn btn-sm btn-primary"
@click="saveReturnPath"
>
Sign in with
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -2,7 +2,6 @@
import {
computed,
defineAsyncComponent,
nextTick,
onMounted,
ref,
watch
@@ -11,24 +10,41 @@ import {
import { useEditionMode } from "@/hooks/useEditionMode"
import { useFile } from "@/hooks/useFile.hook"
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
import { useImages } from "@/hooks/useImages.hook"
import { useImageUpload } from "@/hooks/useImageUpload.hook"
import { useLinks } from "@/hooks/useLinks.hook"
import { runMermaid, useShikiji } from "@/hooks/useMarkdown.hook"
import { renderCodeFile } from "@/hooks/useMarkdown.hook"
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
import { useNoteFreshness } from "@/hooks/useNoteFreshness.hook"
import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook"
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
import { useTitleNotes } from "@/hooks/useTitleNotes.hook"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
import { getFileLanguage, isMarkdownPath } from "@/utils/fileLanguage"
import { filenameToNoteTitle } from "@/utils/noteTitle"
import { errorMessage } from "@/utils/notif"
import { threeWayMerge } from "@/utils/threeWayMerge"
const LinkedNotes = defineAsyncComponent(
() => import("@/components/LinkedNotes.vue")
)
const NoteFreshnessBadge = defineAsyncComponent(
() => import("@/components/NoteFreshnessBadge.vue")
)
const NoteConflictModal = defineAsyncComponent(
() => import("@/components/NoteConflictModal.vue")
)
const EditNote = defineAsyncComponent(
() => import("@/modules/note/components/EditNote.vue")
)
const NoteState = defineAsyncComponent(
() => import("@/components/NoteState.vue")
)
const props = defineProps<{
user: string
repo: string
@@ -42,17 +58,58 @@ const repo = computed(() => props.repo)
const sha = computed(() => props.sha)
const index = computed(() => props.index)
const { scrollToFocusedNote } = useRouteQueryStackedNotes()
const { scrollToFocusedNote, replaceStackedNote } = useRouteQueryStackedNotes()
// When this note's content changes (edit / pull) its sha changes too; advance
// the stack handle so the live view follows it, leaving the old sha as an
// immutable snapshot for any link already shared.
const advanceStackTo = (newSha: string | null) => {
if (newSha) replaceStackedNote(sha.value, newSha)
}
const {
path,
newerSha,
content,
rawContent,
getRawContent,
saveCacheNote,
getEditedSha
} = useFile(sha)
// When this is an older snapshot of a still-existing note, jump the stack to
// its current version (the exact snapshot is never swapped underneath you).
const viewLatest = () => advanceStackTo(newerSha.value)
const initialRawContent = ref<string | null>(null)
const isMarkdown = computed(() =>
path.value ? isMarkdownPath(path.value) : true
)
const displayedContent = ref("")
watch(
[rawContent, isMarkdown, path],
async ([raw, isMd, p]) => {
if (!raw) {
displayedContent.value = ""
return
}
if (isMd) {
displayedContent.value = content.value
return
}
const lang = p ? getFileLanguage(p) : null
const filename = p?.split("/").pop()
const result = await renderCodeFile({ rawContent: raw, lang, filename })
if (rawContent.value === raw) {
displayedContent.value = result
}
},
{ immediate: true }
)
watch(content, (c) => {
if (isMarkdown.value) displayedContent.value = c
})
const className = computed(() => `stacked-note-${props.index}`)
const { listenToClick } = useLinks(className.value, sha)
const titleClassName = computed(() => `title-${className.value}`)
@@ -60,6 +117,7 @@ useTitleNotes(repo)
const store = useUserRepoStore()
const hasBacklinks = computed(() => store.userSettings?.backlink)
const canPush = computed(() => store.canPush)
const { displayNoteOverlay } = useNoteOverlay(className.value, index)
const displayedTitle = computed(() => filenameToNoteTitle(props.title ?? ""))
@@ -70,35 +128,164 @@ const { updateFile } = useGitHubContent({
repo: repo.value
})
onMounted(async () => {
initialRawContent.value = await getRawContent()
const { uploadImage } = useImageUpload({
user: user.value,
repo: repo.value,
notePath: path
})
const fileInput = ref<HTMLInputElement | null>(null)
const editKey = ref(0)
const isUploading = ref(false)
const onImagePicked = async (e: Event) => {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ""
if (!file || !path.value) return
isUploading.value = true
try {
const result = await uploadImage(file)
if (!result) return
const trimmed = rawContent.value.replace(/\n+$/, "")
const prefix = trimmed ? `${trimmed}\n\n` : ""
rawContent.value = `${prefix}![](${result.filename})\n`
editKey.value++
} finally {
isUploading.value = false
}
}
const {
status: freshnessStatus,
lastCheckedAt,
latestSha,
check: checkFreshness,
pullLatest,
resolveMergeSources
} = useNoteFreshness({
user: user.value,
repo: repo.value,
sha,
path,
getEditedSha
})
const conflictOpen = ref(false)
const loadStatus = ref<"loading" | "ready" | "failed">("loading")
const loadNote = async () => {
loadStatus.value = "loading"
const raw = await getRawContent()
if (raw === null) {
loadStatus.value = "failed"
return
}
rawContent.value = raw
initialRawContent.value = raw
loadStatus.value = "ready"
}
onMounted(loadNote)
watch(
path,
(p) => {
if (p) void checkFreshness()
},
{ immediate: true }
)
const { mode, toggleMode } = useEditionMode()
watch([content, mode], () => {
if (!content.value) {
useMarkdownPostRender(content, () => `.note-${sha.value}`, {
onReady: () => listenToClick(),
tikz: true,
mermaid: () => rawContent.value.includes("```mermaid"),
shikiji: () => isMarkdown.value && rawContent.value.includes("```"),
images: () =>
/\!\[.*?\]\(.*?\)/.test(rawContent.value) ? props.sha : null,
triggers: [mode]
})
const performSave = async (overrideSha?: string) => {
if (!path.value) {
console.warn("no path found for this file")
return
}
nextTick(() => {
listenToClick()
if (/\!\[.*?\]\(.*?\)/.test(rawContent.value)) {
useImages(props.sha)
}
if (rawContent.value.includes("```mermaid")) {
runMermaid(`.note-${sha.value} .mermaid`)
}
if (rawContent.value.includes("```")) {
useShikiji()
}
const editedSha = overrideSha ?? (await getEditedSha()) ?? sha.value
const { sha: newSha, conflict } = await updateFile({
content: rawContent.value,
path: path.value,
sha: editedSha
})
})
if (conflict) {
await handleConflict()
return
}
if (!newSha) {
console.warn("no new SHA found for this file")
return
}
await saveCacheNote(encodeUTF8ToBase64(rawContent.value), {
editedSha: newSha,
path: path.value
})
initialRawContent.value = rawContent.value
advanceStackTo(newSha)
}
// On a save/freshness conflict, try a 3-way merge first: if our edits and the
// remote ones don't overlap, commit the merge silently (just a toast) so the
// user is never interrupted. Only genuinely overlapping edits open the modal.
const handleConflict = async () => {
if (!path.value) return
const sources = await resolveMergeSources()
if (sources) {
const { clean, merged } = threeWayMerge(
sources.base,
rawContent.value,
sources.theirs
)
if (clean) {
const { sha: newSha, conflict } = await updateFile({
content: merged,
path: path.value,
sha: sources.remoteSha,
successMessage: "✅ Merged remote changes & saved"
})
if (!conflict && newSha) {
rawContent.value = merged
await saveCacheNote(encodeUTF8ToBase64(merged), {
editedSha: newSha,
path: path.value
})
initialRawContent.value = merged
advanceStackTo(newSha)
return
}
// Remote moved again between fetch and commit — fall back to the modal.
}
}
// Overlapping edits, or merge sources unavailable — let the user decide.
// Ensure latestSha is set so the modal's "overwrite" has a target.
if (!latestSha.value) await checkFreshness()
conflictOpen.value = true
if (mode.value === "read") toggleMode()
}
watch(mode, async (newMode) => {
if (newMode === "edit") {
void checkFreshness()
return
}
const hasUserFinishedToEdit =
newMode === "read" && rawContent.value !== initialRawContent.value
@@ -107,28 +294,68 @@ watch(mode, async (newMode) => {
}
if (!path.value) {
console.warn("no path found for this file")
return
}
const editedSha = (await getEditedSha()) ?? sha.value
const newSha = await updateFile({
content: rawContent.value,
path: path.value,
sha: editedSha
})
if (!newSha) {
console.warn("no new SHA found for this file")
await checkFreshness()
if (freshnessStatus.value === "outdated") {
await handleConflict()
return
}
await saveCacheNote(encodeUTF8ToBase64(rawContent.value), {
editedSha: newSha
})
initialRawContent.value = rawContent.value
await performSave()
})
const onConflictDiscard = async () => {
const { raw } = await pullLatest()
if (raw !== null) {
rawContent.value = raw
initialRawContent.value = raw
advanceStackTo(latestSha.value)
}
}
const onConflictOverwrite = async () => {
if (latestSha.value) {
await performSave(latestSha.value)
}
}
const onConflictCancel = () => {
if (mode.value === "read") toggleMode()
}
const onBadgeClick = async () => {
try {
if (freshnessStatus.value !== "outdated") {
await checkFreshness()
if (freshnessStatus.value === "unauthorized") {
errorMessage("🔐 GitHub auth expired — please sign in again")
}
return
}
const hasUnsavedEdits = rawContent.value !== initialRawContent.value
if (hasUnsavedEdits) {
await handleConflict()
return
}
const { raw, failureStatus } = await pullLatest()
if (raw !== null) {
rawContent.value = raw
initialRawContent.value = raw
advanceStackTo(latestSha.value)
return
}
if (failureStatus === "unauthorized") {
errorMessage("🔐 GitHub auth expired — please sign in again")
}
} catch (error) {
console.error("freshness badge click failed", error)
errorMessage("❌ Couldn't pull latest from GitHub")
}
}
</script>
<template>
@@ -140,23 +367,16 @@ watch(mode, async (newMode) => {
[`note-${sha}`]: true
}"
>
<a
class="title-stacked-note-link"
@click.prevent="scrollToFocusedNote(props.sha)"
>
<div
class="title-stacked-note breadcrumbs text-sm"
:class="titleClassName"
>
<ul>
<li v-for="(part, i) in breadcrumbs" :key="i">
{{ part }}
</li>
</ul>
</div>
</a>
<section class="text-content">
<div class="title-stacked-note breadcrumbs text-sm" :class="titleClassName">
<div class="action-bar">
<note-freshness-badge
:status="freshnessStatus"
:last-checked-at="lastCheckedAt"
@click="onBadgeClick"
class="action"
/>
<button
v-if="isMarkdown && canPush"
class="action button is-text is-light"
:class="{ 'is-link': mode === 'edit' }"
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''"
@@ -205,12 +425,94 @@ watch(mode, async (newMode) => {
<path d="M14 4l0 4l-6 0l0 -4" />
</svg>
</button>
<div v-if="mode === 'edit'" class="edit">
<edit-note v-model="rawContent" />
<button
v-if="isMarkdown && mode === 'edit' && canPush"
class="action button is-text is-light"
:title="isUploading ? 'Uploading…' : 'Upload image'"
:disabled="isUploading"
@click="fileInput?.click()"
>
<span
v-if="isUploading"
class="loading loading-spinner loading-sm"
></span>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-photo-plus"
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 8h.01" />
<path
d="M12.5 21h-6.5a3 3 0 0 1 -3 -3v-12a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v6.5"
/>
<path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l4 4" />
<path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l2 2" />
<path d="M16 19h6" />
<path d="M19 16v6" />
</svg>
</button>
<input
ref="fileInput"
type="file"
accept="image/*"
class="hidden-input"
@change="onImagePicked"
/>
</div>
<div v-if="mode === 'read'" class="note-content" v-html="content"></div>
<a
class="title-stacked-note-link"
@click.prevent="scrollToFocusedNote({ noteId: props.sha })"
>
<ul>
<li v-for="(part, i) in breadcrumbs" :key="i">
{{ part }}
</li>
</ul>
</a>
</div>
<section class="text-content">
<div v-if="mode === 'edit' && isMarkdown" class="edit">
<edit-note :key="editKey" v-model="rawContent" />
</div>
<template v-else-if="mode === 'read'">
<div v-if="newerSha" class="snapshot-banner">
<span>You're viewing an older shared version.</span>
<button
type="button"
class="snapshot-banner-action"
@click="viewLatest"
>
View latest
</button>
</div>
<div
v-if="displayedContent"
class="note-content"
v-html="displayedContent"
></div>
<note-state
v-else
:status="loadStatus === 'failed' ? 'failed' : 'loading'"
@retry="loadNote"
/>
</template>
</section>
<linked-notes v-if="hasBacklinks && content" :sha="sha" />
<note-conflict-modal
v-model:open="conflictOpen"
@discard="onConflictDiscard"
@overwrite="onConflictOverwrite"
@cancel="onConflictCancel"
/>
</div>
</template>
@@ -228,7 +530,7 @@ $border-color: rgba(18, 19, 58, 0.2);
}
section {
padding: 0 0.5rem 2rem;
padding: 0 0.5rem;
}
}
@@ -242,7 +544,6 @@ $border-color: rgba(18, 19, 58, 0.2);
background-color: var(--color-base-100);
color: var(--color-base-content);
font-size: 0.8em;
overflow: hidden;
ul,
li {
@@ -257,14 +558,51 @@ $border-color: rgba(18, 19, 58, 0.2);
flex: 1;
scrollbar-width: none;
div {
> .edit,
> .note-content {
height: 100%;
}
}
.action-bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.25rem;
}
.hidden-input {
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;
}
.snapshot-banner-action {
flex-shrink: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--color-primary);
cursor: pointer;
text-decoration: underline;
}
.action {
float: right;
margin: 0.2rem;
margin: 0;
&:hover {
cursor: pointer;
}
img {
vertical-align: bottom;
@@ -274,9 +612,10 @@ $border-color: rgba(18, 19, 58, 0.2);
@media screen and (max-width: 768px) {
.stacked-note {
padding: 0 0.75rem 1rem;
height: 100svh;
section {
padding: 1rem 0 2rem;
padding: 1rem 0;
overflow-x: auto;
}
@@ -291,10 +630,12 @@ $border-color: rgba(18, 19, 58, 0.2);
.stacked-note {
border-top: 0;
border-left: 1px solid $border-color;
position: sticky;
top: 0;
}
.title-stacked-note {
padding: 0 1rem;
padding: 0;
transform-origin: 0 0;
transform: rotate(90deg);
}
@@ -302,6 +643,12 @@ $border-color: rgba(18, 19, 58, 0.2);
a {
white-space: nowrap;
}
.action-bar {
.action {
transform: rotate(-90deg);
}
}
}
@media print {

View File

@@ -1,11 +1,12 @@
<script lang="ts" setup>
import { computedAsync } from "@vueuse/core"
import { computed, nextTick, ref, watch } from "vue"
import { computed, ref, watch } from "vue"
import { useRoute } from "vue-router"
import SkeletonLoader from "@/components/SkeletonLoader.vue"
import { useATProtoLinks } from "@/hooks/useATProtoLinks.hook"
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook"
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
import { getAuthor } from "@/modules/atproto/getAuthor"
@@ -76,14 +77,10 @@ const content = computed(() =>
: ""
)
watch(
content,
async () => {
await nextTick()
listenToClick()
},
{ immediate: true }
)
useMarkdownPostRender(content, () => `.note-${classNameId.value}`, {
onReady: () => listenToClick(),
tikz: true
})
</script>
<template>
@@ -97,7 +94,7 @@ watch(
>
<a
class="title-stacked-note-link"
@click.prevent="scrollToFocusedNote(didrkey)"
@click.prevent="scrollToFocusedNote({ noteId: didrkey })"
>
<div
class="title-stacked-note breadcrumbs text-sm"
@@ -130,7 +127,7 @@ $border-color: rgba(18, 19, 58, 0.2);
}
section {
padding: 0 0.5rem 2rem;
padding: 0 0.5rem;
}
}
@@ -144,7 +141,6 @@ $border-color: rgba(18, 19, 58, 0.2);
background-color: var(--color-base-100);
color: var(--color-base-content);
font-size: 0.8em;
overflow: hidden;
ul,
li {
@@ -178,7 +174,7 @@ $border-color: rgba(18, 19, 58, 0.2);
padding: 0 0.75rem 1rem;
section {
padding: 1rem 0 2rem;
padding: 1rem 0;
overflow-x: auto;
}
@@ -193,6 +189,8 @@ $border-color: rgba(18, 19, 58, 0.2);
.stacked-note {
border-top: 0;
border-left: 1px solid $border-color;
position: sticky;
top: 0;
}
.title-stacked-note {

View File

@@ -0,0 +1,582 @@
<script setup lang="ts">
import { onClickOutside } from "@vueuse/core"
import { computed, nextTick, ref, useTemplateRef, watch } from "vue"
import {
removeTagFromBody,
removeTokenFromBody,
segmentBody,
Task,
toggleCompleted
} from "@/utils/todotxt"
const props = defineProps<{
task: Task
canEdit: boolean
}>()
const emit = defineEmits<{
update: [task: Task]
delete: []
}>()
const segments = computed(() => segmentBody(props.task.body))
const PRIORITIES = ["A", "B", "C", "D"] as const
const priorityBadgeClassFor = (priority?: string): string => {
const preClass = "badge badge-soft"
switch (priority) {
case "A":
return `${preClass} badge-error`
case "B":
return `${preClass} badge-warning`
case "C":
return `${preClass} badge-info`
default:
return `${preClass} badge-ghost`
}
}
const priorityBadgeClass = computed(() =>
priorityBadgeClassFor(props.task.priority)
)
const toggle = () => {
if (!props.canEdit) return
emit("update", toggleCompleted(props.task))
}
const priorityOpen = ref(false)
const priorityDropdown = useTemplateRef<HTMLDivElement>("priorityDropdown")
onClickOutside(priorityDropdown, () => {
priorityOpen.value = false
})
const togglePriorityMenu = () => {
if (!props.canEdit) return
priorityOpen.value = !priorityOpen.value
}
const setPriority = (priority: string | undefined) => {
if (!props.canEdit) return
emit("update", { ...props.task, priority })
priorityOpen.value = false
}
const removeToken = (kind: "project" | "context", value: string) => {
if (!props.canEdit) return
const newBody = removeTokenFromBody(
props.task.body,
kind === "project" ? "+" : "@",
value
)
emit("update", { ...props.task, body: newBody })
}
const removeTag = (key: "due" | "rec", value: string) => {
if (!props.canEdit) return
const newBody = removeTagFromBody(props.task.body, key, value)
emit("update", { ...props.task, body: newBody })
}
const startOfToday = (): Date => {
const now = new Date()
return new Date(now.getFullYear(), now.getMonth(), now.getDate())
}
const parseDueDate = (value: string): Date | null => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null
const [y, m, d] = value.split("-").map(Number)
return new Date(y, m - 1, d)
}
const dueClass = (value: string): string => {
const due = parseDueDate(value)
if (!due) return "badge badge-soft badge-accent"
const today = startOfToday().getTime()
if (due.getTime() < today) return "badge badge-soft badge-error"
if (due.getTime() === today) return "badge badge-soft badge-warning"
return "badge badge-soft badge-accent"
}
const MS_PER_DAY = 86_400_000
const formatDueDate = (value: string): string => {
const due = parseDueDate(value)
if (!due) return value
const diff = Math.round(
(due.getTime() - startOfToday().getTime()) / MS_PER_DAY
)
if (diff === 0) return "Today"
if (diff === 1) return "Tomorrow"
if (diff === -1) return "Yesterday"
return due.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric"
})
}
const dueTitle = (value: string): string => {
const due = parseDueDate(value)
if (!due) return `Due ${value}`
return `Due ${due.toLocaleDateString(undefined, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric"
})}`
}
const REC_UNITS: Record<string, [string, string]> = {
d: ["day", "days"],
w: ["week", "weeks"],
m: ["month", "months"],
y: ["year", "years"]
}
const REC_ADVERBS: Record<string, string> = {
d: "Daily",
w: "Weekly",
m: "Monthly",
y: "Yearly"
}
const formatRecurrence = (value: string): string => {
const m = value.match(/^\+?(\d+)([dwmyDWMY])$/)
if (!m) return value
const n = parseInt(m[1], 10)
const unit = m[2].toLowerCase()
if (n === 1) return REC_ADVERBS[unit] ?? value
const [, plural] = REC_UNITS[unit]
return `Every ${n} ${plural}`
}
const recTitle = (value: string): string => {
const isStrict = value.startsWith("+")
const formatted = formatRecurrence(value)
return isStrict
? `${formatted} (from due date)`
: `${formatted} (from completion)`
}
const editing = ref(false)
const bodyDraft = ref("")
const inputRef = useTemplateRef<HTMLInputElement>("bodyInput")
const startEdit = () => {
if (!props.canEdit || props.task.completed) return
bodyDraft.value = props.task.body
editing.value = true
nextTick(() => {
inputRef.value?.focus()
inputRef.value?.select()
})
}
const commitEdit = () => {
if (!editing.value) return
const next = bodyDraft.value.trim()
editing.value = false
if (next !== props.task.body) {
emit("update", { ...props.task, body: next })
}
}
const cancelEdit = () => {
editing.value = false
}
watch(
() => props.task.body,
() => {
if (editing.value) editing.value = false
}
)
</script>
<template>
<div
class="todo-row group flex items-start gap-2 py-1 px-2 rounded hover:bg-base-200"
:class="{ 'opacity-60': task.completed }"
>
<input
type="checkbox"
class="checkbox checkbox-success checkbox-sm mt-1"
:checked="task.completed"
:disabled="!canEdit"
@change="toggle"
/>
<div
ref="priorityDropdown"
class="todo-priority"
:class="{ 'todo-priority--open': priorityOpen }"
>
<button
type="button"
:class="priorityBadgeClass"
class="todo-priority-trigger cursor-pointer w-7 justify-center mt-0.5"
:disabled="!canEdit"
@click="togglePriorityMenu"
>
{{ task.priority ?? "—" }}
</button>
<div
v-if="canEdit && priorityOpen"
class="todo-priority-menu bg-base-200 rounded-box shadow p-2"
>
<button
v-for="p in PRIORITIES"
:key="p"
type="button"
:class="priorityBadgeClassFor(p)"
class="todo-priority-option w-10 justify-center cursor-pointer"
@click="setPriority(p)"
>
({{ p }})
</button>
<button
type="button"
:class="priorityBadgeClassFor(undefined)"
class="todo-priority-option w-10 justify-center cursor-pointer"
@click="setPriority(undefined)"
>
</button>
</div>
</div>
<div class="flex-1 min-w-0">
<input
v-if="editing"
ref="bodyInput"
v-model="bodyDraft"
type="text"
class="input input-ghost input-sm w-full"
@blur="commitEdit"
@keydown.enter.prevent="commitEdit"
@keydown.escape.prevent="cancelEdit"
/>
<div
v-else
class="body-display cursor-text"
:class="{ 'line-through': task.completed }"
@click="startEdit"
>
<template v-for="(seg, i) in segments" :key="i">
<span v-if="seg.kind === 'text'">{{ seg.value }}</span>
<span
v-else-if="seg.kind === 'project'"
class="todo-chip badge badge-soft badge-primary mx-0.5"
@click.stop
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-plus"
width="14"
height="14"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 5l0 14" />
<path d="M5 12l14 0" />
</svg>
{{ seg.value }}
<button
v-if="canEdit"
class="todo-chip-close"
type="button"
:aria-label="`Remove project ${seg.value}`"
@click.stop="removeToken('project', seg.value)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-x"
width="14"
height="14"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M18 6l-12 12" />
<path d="M6 6l12 12" />
</svg>
</button>
</span>
<span
v-else-if="seg.kind === 'context'"
class="todo-chip badge badge-soft badge-secondary mx-0.5"
@click.stop
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-at"
width="14"
height="14"
viewBox="0 0 24 24"
stroke-width="1.75"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<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 d="M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28" />
</svg>
{{ seg.value }}
<button
v-if="canEdit"
class="todo-chip-close"
type="button"
:aria-label="`Remove context ${seg.value}`"
@click.stop="removeToken('context', seg.value)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-x"
width="14"
height="14"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M18 6l-12 12" />
<path d="M6 6l12 12" />
</svg>
</button>
</span>
<span
v-else-if="seg.kind === 'due'"
:class="dueClass(seg.value) + ' mx-0.5 todo-chip'"
:title="dueTitle(seg.value)"
@click.stop
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-calendar"
width="16"
height="16"
viewBox="0 0 24 24"
stroke-width="1.75"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"
/>
<path d="M16 3v4" />
<path d="M8 3v4" />
<path d="M4 11h16" />
</svg>
{{ formatDueDate(seg.value) }}
<button
v-if="canEdit"
class="todo-chip-close"
type="button"
:aria-label="`Remove due date ${seg.value}`"
@click.stop="removeTag('due', seg.value)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-x"
width="14"
height="14"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M18 6l-12 12" />
<path d="M6 6l12 12" />
</svg>
</button>
</span>
<span
v-else
class="todo-chip badge badge-soft badge-info mx-0.5"
:title="recTitle(seg.value)"
@click.stop
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-repeat"
width="16"
height="16"
viewBox="0 0 24 24"
stroke-width="1.75"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<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>
{{ formatRecurrence(seg.value) }}
<button
v-if="canEdit"
class="todo-chip-close"
type="button"
:aria-label="`Remove recurrence ${seg.value}`"
@click.stop="removeTag('rec', seg.value)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-x"
width="14"
height="14"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M18 6l-12 12" />
<path d="M6 6l12 12" />
</svg>
</button>
</span>
</template>
<span v-if="task.body.length === 0" class="opacity-50 italic">
(empty task)
</span>
</div>
</div>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 transition"
aria-label="Delete task"
@click="emit('delete')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-trash"
width="16"
height="16"
viewBox="0 0 24 24"
stroke-width="1.75"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 7h16" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12" />
<path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3" />
</svg>
</button>
</div>
</template>
<style scoped lang="scss">
.body-display {
word-break: break-word;
}
.todo-priority {
position: relative;
display: inline-flex;
}
// Lift the whole priority widget above sibling rows while its menu is open,
// otherwise the absolutely-positioned menu paints behind row N+1's content.
// 1 is enough: no other element in this view sets a non-auto z-index.
.todo-priority--open {
z-index: 1;
}
.todo-priority-trigger {
border: 0;
}
.todo-priority-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: auto;
}
.todo-priority-option {
border: 0;
font: inherit;
transition:
transform 120ms ease,
filter 120ms ease;
&:hover {
filter: brightness(1.1);
transform: scale(1.05);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
.todo-chip {
display: inline-flex;
align-items: center;
gap: 0.2rem;
vertical-align: baseline;
}
.todo-chip-close {
display: inline-flex;
align-items: center;
cursor: pointer;
margin-left: 0.1rem;
padding: 0;
background: transparent;
border: 0;
color: inherit;
opacity: 0.7;
&:hover {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,98 @@
import { mount } from "@vue/test-utils"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { ref } from "vue"
const ghAccessToken = ref<string | null>(null)
const ghUsername = ref<string | null>(null)
const atIsLoggedIn = ref(false)
const atHandle = ref<string | null>(null)
const atAvatarUrl = ref<string | null>(null)
vi.mock("@/hooks/useGitHubLogin.hook", () => ({
useGitHubLogin: () => ({
username: ghUsername,
accessToken: ghAccessToken
})
}))
vi.mock("@/hooks/useATProtoLogin.hook", () => ({
useATProtoLogin: () => ({
isLoggedIn: atIsLoggedIn,
handle: atHandle,
avatarUrl: atAvatarUrl
})
}))
import UserPill from "./UserPill.vue"
describe("UserPill", () => {
beforeEach(() => {
ghAccessToken.value = null
ghUsername.value = null
atIsLoggedIn.value = false
atHandle.value = null
atAvatarUrl.value = null
})
afterEach(() => {
vi.clearAllMocks()
})
it("shows the ghost 'Sign in' pill when nobody is logged in", () => {
const wrapper = mount(UserPill)
expect(wrapper.text()).toContain("Sign in")
expect(wrapper.classes()).toContain("profile-chip--ghost")
})
it("shows the GitHub username and initial when logged in via GitHub only", () => {
ghAccessToken.value = "tok"
ghUsername.value = "alice"
const wrapper = mount(UserPill)
expect(wrapper.text()).toContain("alice")
expect(wrapper.find(".profile-avatar-initial").text()).toBe("A")
expect(wrapper.find("img").exists()).toBe(false)
expect(wrapper.classes()).not.toContain("profile-chip--ghost")
})
it("shows the ATProto handle and avatar when logged in via ATProto", () => {
atIsLoggedIn.value = true
atHandle.value = "alice.bsky.social"
atAvatarUrl.value = "https://cdn.example.com/avatar.jpg"
const wrapper = mount(UserPill)
expect(wrapper.text()).toContain("alice.bsky.social")
const img = wrapper.find("img")
expect(img.exists()).toBe(true)
expect(img.attributes("src")).toBe("https://cdn.example.com/avatar.jpg")
})
it("prefers the ATProto handle over the GitHub username when both are present", () => {
ghAccessToken.value = "tok"
ghUsername.value = "alice-gh"
atIsLoggedIn.value = true
atHandle.value = "alice.bsky.social"
const wrapper = mount(UserPill)
expect(wrapper.text()).toContain("alice.bsky.social")
expect(wrapper.text()).not.toContain("alice-gh")
})
it("falls back to '?' initial when no name is available but a user is logged in", () => {
atIsLoggedIn.value = true
atHandle.value = ""
const wrapper = mount(UserPill)
expect(wrapper.find(".profile-avatar-initial").text()).toBe("?")
})
it("emits click when clicked", async () => {
const wrapper = mount(UserPill)
await wrapper.trigger("click")
expect(wrapper.emitted("click")).toHaveLength(1)
})
})

111
src/components/UserPill.vue Normal file
View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed } from "vue"
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
const { username, accessToken } = useGitHubLogin()
const { isLoggedIn: isATProtoLoggedIn, handle, avatarUrl } = useATProtoLogin()
defineEmits<{
click: []
}>()
const isGitHubLoggedIn = computed(() => !!accessToken.value)
const isAnyUserLoggedIn = computed(
() => isGitHubLoggedIn.value || isATProtoLoggedIn.value
)
const displayUsername = computed(() => handle.value || username.value || "")
const displayInitial = computed(() =>
(displayUsername.value[0] || "?").toUpperCase()
)
</script>
<template>
<button
v-if="isAnyUserLoggedIn"
class="profile-chip"
type="button"
@click="$emit('click')"
>
<img
v-if="isATProtoLoggedIn && avatarUrl"
:src="avatarUrl"
class="profile-avatar-small"
alt="Profile"
/>
<span v-else class="profile-avatar-small profile-avatar-initial">
{{ displayInitial }}
</span>
<span class="profile-name">{{ displayUsername }}</span>
</button>
<button
v-else
class="profile-chip profile-chip--ghost"
type="button"
@click="$emit('click')"
>
Sign in
</button>
</template>
<style scoped lang="scss">
.profile-chip {
--pill-rule: color-mix(
in oklch,
var(--color-base-content) 12%,
var(--color-base-200)
);
--pill-accent: #e36598;
--pill-accent-wash: color-mix(
in oklch,
#e36598 12%,
var(--color-base-200)
);
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem 0.25rem 0.25rem;
border-radius: 999px;
border: 1px solid var(--pill-rule);
background: transparent;
cursor: pointer;
color: var(--color-base-content);
transition:
border-color 0.15s,
background 0.15s;
&:hover {
border-color: var(--pill-accent);
background: var(--pill-accent-wash);
}
&.profile-chip--ghost {
padding: 0.35rem 0.9rem;
font-size: 0.95rem;
}
}
.profile-avatar-small {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
display: block;
}
.profile-avatar-initial {
background: var(--pill-accent, #e36598);
color: white;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.9rem;
}
.profile-name {
font-size: 0.95rem;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -10,3 +10,7 @@ export const getNoteWidth = () => {
}
return cached
}
export const resetNoteWidthCache = () => {
cached = undefined
}

View File

@@ -7,5 +7,6 @@ export enum DataType {
RepetitionCard = "RepetitionCard",
History = "History",
UserSettings = "UserSettings",
AtprotoSession = "AtprotoSession"
AtprotoSession = "AtprotoSession",
TikzCache = "TikzCache"
}

View File

@@ -1,166 +1,34 @@
import { wrap } from "comlink"
import { nanoid } from "nanoid"
import indexedDb from "pouchdb-adapter-indexeddb"
import PouchDb from "pouchdb-browser"
import { DataType } from "./DataType.enum"
import { Model } from "./models/Model"
PouchDb.plugin(indexedDb)
interface GetAllParams {
export interface DataApi {
add<DT extends DataType>(model: Model<DT>): Promise<boolean>
update<DT extends DataType, T extends Model<DT>>(model: T): Promise<boolean>
bulkUpdate<DT extends DataType, T extends Model<DT>>(
models: T[]
): Promise<boolean>
remove(id: string): Promise<boolean>
get<DT extends DataType, T extends Model<DT>>(id: string): Promise<T | null>
getOrCreate<DT extends DataType, T extends Model<DT>>(
id: string,
initialValue: T
): Promise<T>
getAll<DT extends DataType, T extends Model<DT>>(params: {
prefix?: string
includeDocs?: boolean
includeAttachments?: boolean
keys?: string[]
}): Promise<T[]>
}
class Data {
// oxlint-disable-next-line typescript/ban-types
private readonly locale: PouchDB.Database<{}> | null = null
constructor() {
try {
this.locale = new PouchDb("remanso", {
adapter: "indexeddb"
})
} catch (error) {
console.warn("data error", error)
}
}
public async add<DT extends DataType>(model: Model<DT>): Promise<boolean> {
try {
const result = await this.locale?.put(model)
return result?.ok ?? false
} catch (error) {
console.warn(error)
return false
}
}
public async update<DT extends DataType, T extends Model<DT>>(
model: T
): Promise<boolean> {
try {
if (!model._id) {
const result = await this.locale?.put(model)
return result?.ok ?? false
}
const oldModel = await this.get(model._id)
if (oldModel) {
const result = await this.locale?.put({ ...oldModel, ...model })
return result?.ok ?? false
}
const result = await this.locale?.put(model)
return result?.ok ?? false
} catch (error) {
console.warn(error)
return false
}
}
public async remove(id: string): Promise<boolean> {
try {
const doc = await this.get(id)
if (!doc) {
return false
}
const result = await this.locale?.put({
...doc,
_deleted: true
})
return result?.ok ?? false
} catch {
return false
}
}
public async get<DT extends DataType, T extends Model<DT>>(
id: string
): Promise<T | null> {
try {
return ((await this.locale?.get(id)) as T) || null
} catch {
return null
}
}
public async getOrCreate<DT extends DataType, T extends Model<DT>>(
id: string,
initialValue: T
): Promise<T> {
const element = await this.get<DT, T>(id)
if (element) {
return element
}
await data.add<DT>({ ...initialValue, _id: id })
return this.getOrCreate(id, initialValue)
}
public async getAll<DT extends DataType, T extends Model<DT>>({
prefix,
includeDocs = true,
includeAttachments = false,
keys = []
}: GetAllParams): Promise<T[]> {
if (!this.locale) {
return []
}
if (keys.length) {
const response = await this.locale.allDocs({
include_docs: includeDocs,
attachments: includeAttachments,
keys: keys.map((key) => this.generateId(prefix, key))
})
if (includeDocs) {
return response.rows
.map((row) => {
if ("error" in row) {
return null
}
return row.doc
})
.filter(Boolean) as T[]
} else {
return response.rows
.map((row) => {
if ("error" in row) {
return null
}
return { _id: row.id }
})
.filter(Boolean) as T[]
}
}
const response = await this.locale.allDocs({
include_docs: includeDocs,
attachments: includeAttachments,
startkey: prefix ? prefix : undefined,
endkey: prefix ? `${prefix}\ufff0` : undefined
})
return response.rows.map((row) => row.doc) as T[]
}
public generateId(type?: DataType | string, id?: string) {
if (!type) {
return id || nanoid()
}
export const generateId = (type?: DataType | string, id?: string): string => {
if (!type) return id || nanoid()
return `${type}-${id || nanoid()}`
}
}
export const data = new Data()
import DataWorker from "./data.worker?worker"
export const data = wrap(new DataWorker()) as unknown as DataApi

169
src/data/data.worker.ts Normal file
View File

@@ -0,0 +1,169 @@
import { expose } from "comlink"
import { nanoid } from "nanoid"
import indexedDb from "pouchdb-adapter-indexeddb"
import PouchDb from "pouchdb-browser"
import { DataType } from "./DataType.enum"
import { Model } from "./models/Model"
PouchDb.plugin(indexedDb)
interface GetAllParams {
prefix?: string
includeDocs?: boolean
includeAttachments?: boolean
keys?: string[]
}
class Data {
// oxlint-disable-next-line typescript/ban-types
private readonly locale: PouchDB.Database<{}> | null = null
constructor() {
try {
this.locale = new PouchDb("remanso", {
adapter: "indexeddb"
})
} catch (error) {
console.warn("data error", error)
}
}
private buildId(type?: DataType | string, id?: string): string {
if (!type) return id || nanoid()
return `${type}-${id || nanoid()}`
}
public async add<DT extends DataType>(model: Model<DT>): Promise<boolean> {
try {
const result = await this.locale?.put(model)
return result?.ok ?? false
} catch (error) {
console.warn(error)
return false
}
}
public async update<DT extends DataType, T extends Model<DT>>(
model: T
): Promise<boolean> {
try {
if (!model._id) {
const result = await this.locale?.put(model)
return result?.ok ?? false
}
const oldModel = await this.get(model._id)
if (oldModel) {
const result = await this.locale?.put({ ...oldModel, ...model })
return result?.ok ?? false
}
const result = await this.locale?.put(model)
return result?.ok ?? false
} catch (error) {
console.warn(error)
return false
}
}
public async bulkUpdate<DT extends DataType, T extends Model<DT>>(
models: T[]
): Promise<boolean> {
if (!this.locale || !models.length) return false
try {
const result = await this.locale.bulkDocs(models)
return result.every((r) => "ok" in r && r.ok)
} catch (error) {
console.warn(error)
return false
}
}
public async remove(id: string): Promise<boolean> {
try {
const doc = await this.get(id)
if (!doc) {
return false
}
const result = await this.locale?.put({
...doc,
_deleted: true
})
return result?.ok ?? false
} catch {
return false
}
}
public async get<DT extends DataType, T extends Model<DT>>(
id: string
): Promise<T | null> {
try {
return ((await this.locale?.get(id)) as T) || null
} catch {
return null
}
}
public async getOrCreate<DT extends DataType, T extends Model<DT>>(
id: string,
initialValue: T
): Promise<T> {
const element = await this.get<DT, T>(id)
if (element) {
return element
}
await this.add<DT>({ ...initialValue, _id: id })
return this.getOrCreate(id, initialValue)
}
public async getAll<DT extends DataType, T extends Model<DT>>({
prefix,
includeDocs = true,
includeAttachments = false,
keys = []
}: GetAllParams): Promise<T[]> {
if (!this.locale) {
return []
}
if (keys.length) {
const response = await this.locale.allDocs({
include_docs: includeDocs,
attachments: includeAttachments,
keys: keys.map((key) => this.buildId(prefix, key))
})
if (includeDocs) {
return response.rows
.map((row) => {
if ("error" in row) return null
return row.doc
})
.filter(Boolean) as T[]
} else {
return response.rows
.map((row) => {
if ("error" in row) return null
return { _id: row.id }
})
.filter(Boolean) as T[]
}
}
const response = await this.locale.allDocs({
include_docs: includeDocs,
attachments: includeAttachments,
startkey: prefix ? prefix : undefined,
endkey: prefix ? `${prefix}\ufff0` : undefined
})
return response.rows.map((row) => row.doc) as T[]
}
}
expose(new Data())

View File

@@ -0,0 +1,6 @@
import { DataType } from "@/data/DataType.enum"
import { Model } from "@/data/models/Model"
export interface TikzCache extends Model<DataType.TikzCache> {
svg: string
}

View File

@@ -17,8 +17,8 @@ export const useATProtoLinks = (
const { currentAtUri, mainNoteId } = options
const linkNote = (event: Event) => {
const target = event.target as HTMLElement
const href = target.getAttribute("href")
const anchor = (event.target as HTMLElement).closest("a")
const href = anchor?.getAttribute("href")
if (!href) {
return
@@ -50,7 +50,7 @@ export const useATProtoLinks = (
: `${params.shortDid}-${params.rkey}`
if (noteId === toValue(mainNoteId)) {
scrollToFocusedNote(null)
scrollToFocusedNote()
return
}
@@ -67,7 +67,7 @@ export const useATProtoLinks = (
const noteId = `${toShortDid(did)}-${rkey}`
if (noteId === toValue(mainNoteId)) {
scrollToFocusedNote(null)
scrollToFocusedNote()
return
}

View File

@@ -14,14 +14,32 @@ import {
const did = ref<string | null>(null)
const handle = ref<string | null>(null)
const avatarUrl = ref<string | null>(null)
let init = true
const fetchAvatar = async (actorDid: string) => {
try {
const res = await fetch(
`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actorDid)}`
)
if (res.ok) {
const data = await res.json()
avatarUrl.value = data.avatar ?? null
}
} catch {
avatarUrl.value = null
}
}
const initializeAuth = async () => {
// Load cached session from IndexedDB first (fast, local) so the UI can render immediately
const stored = await loadSession()
did.value = stored?.did ?? ""
handle.value = stored?.handle ?? ""
if (stored?.did) {
fetchAvatar(stored.did)
}
// Then restore OAuth session in the background (may involve network)
const session = await restoreSession()
@@ -32,6 +50,7 @@ const initializeAuth = async () => {
did.value = session.did
handle.value = resolvedHandle
await saveSession(session.did, resolvedHandle)
fetchAvatar(session.did)
window.history.replaceState(
null,
@@ -61,11 +80,13 @@ export const useATProtoLogin = () => {
await clearSession()
did.value = ""
handle.value = ""
avatarUrl.value = null
}
return {
did,
handle,
avatarUrl,
isLoggedIn,
isATProtoReady,
signIn,

View File

@@ -2,7 +2,7 @@ import { useAsyncState } from "@vueuse/core"
import { ComputedRef, onUnmounted, toValue } from "vue"
import { backlinkEventBus } from "@/bus/backlinkEventBus"
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { BacklinkNote } from "@/modules/note/models/BacklinkNote"
@@ -11,7 +11,7 @@ export const useBacklinks = (sha: string | ComputedRef<string>) => {
const { state: backlink, execute } = useAsyncState(
data.get<DataType.BacklinkNote, BacklinkNote>(
data.generateId(DataType.BacklinkNote, sha)
generateId(DataType.BacklinkNote, sha)
),
null,
{

View File

@@ -35,7 +35,8 @@ export const useCheckboxCommit = ({
initialContent,
initialSha,
containerSelector,
debounceMs = 1000
debounceMs = 1000,
enabled = true
}: {
user: string
repo: string
@@ -44,6 +45,7 @@ export const useCheckboxCommit = ({
initialSha: Ref<string> | string
containerSelector: string
debounceMs?: number
enabled?: Ref<boolean> | boolean
}) => {
const { updateFile } = useGitHubContent({ user, repo })
@@ -74,7 +76,7 @@ export const useCheckboxCommit = ({
isCommitting.value = true
const newSha = await updateFile({
const { sha: newSha } = await updateFile({
content: pendingContent.value,
path: pathValue,
sha: currentSha.value
@@ -91,6 +93,8 @@ export const useCheckboxCommit = ({
const debouncedCommit = useDebounceFn(commitChanges, debounceMs)
const handleCheckboxChange = (event: Event) => {
if (!toValue(enabled)) return
const target = event.target as HTMLInputElement
if (target.tagName !== "INPUT" || target.type !== "checkbox") {
@@ -128,8 +132,16 @@ export const useCheckboxCommit = ({
const listenToCheckboxes = () => {
removeListeners()
const container = document.querySelector(containerSelector)
if (container) {
if (!container) return
if (toValue(enabled)) {
container.addEventListener("change", handleCheckboxChange)
} else {
container
.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')
.forEach((checkbox) => {
checkbox.disabled = true
})
}
}

View File

@@ -1,7 +1,7 @@
import { watch } from "vue"
import { backlinkEventBus } from "@/bus/backlinkEventBus"
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { useFile } from "@/hooks/useFile.hook"
import { Backlink } from "@/modules/note/models/Backlink"
@@ -14,10 +14,21 @@ import { confirmMessage } from "@/utils/notif"
const isMarkdown = (filename?: string) => filename?.endsWith(".md") ?? false
const yieldToMain = () =>
"scheduler" in globalThis
? (
globalThis as unknown as { scheduler: { yield: () => Promise<void> } }
).scheduler.yield()
: new Promise<void>((r) => setTimeout(r, 0))
export const useComputeBacklinks = () => {
const store = useUserRepoStore()
watch(store, async () => {
watch(
() => store.files,
async () => {
await new Promise<void>((r) => setTimeout(r, 300))
if (!store.userSettings?.backlink) {
return
}
@@ -27,14 +38,17 @@ export const useComputeBacklinks = () => {
const backlinks: Map<string, Backlink[]> = new Map()
for (const file of store.files) {
await yieldToMain()
if (!isMarkdown(file.path) || !file.sha) {
continue
}
const fileBacklinkId = data.generateId(DataType.BacklinkNote, file.sha)
const fileBacklink = await data.get<DataType.BacklinkNote, BacklinkNote>(
fileBacklinkId
)
const fileBacklinkId = generateId(DataType.BacklinkNote, file.sha)
const fileBacklink = await data.get<
DataType.BacklinkNote,
BacklinkNote
>(fileBacklinkId)
if (fileBacklink) {
continue
}
@@ -91,7 +105,7 @@ export const useComputeBacklinks = () => {
}
for (const [sha, fileBacklinks] of backlinks) {
const fileBacklinkId = data.generateId(DataType.BacklinkNote, sha)
const fileBacklinkId = generateId(DataType.BacklinkNote, sha)
const backlinkNote: BacklinkNote = {
_id: fileBacklinkId,
$type: DataType.BacklinkNote,
@@ -102,5 +116,6 @@ export const useComputeBacklinks = () => {
await data.update(backlinkNote)
backlinkEventBus.emit({ fileSha: sha })
}
})
}
)
}

View File

@@ -0,0 +1,76 @@
import { mount } from "@vue/test-utils"
import { afterEach, describe, expect, it, vi } from "vitest"
import { defineComponent, ref } from "vue"
const escape = ref(false)
vi.mock("@vueuse/core", () => ({
useMagicKeys: () => ({ escape })
}))
import { useEditionMode } from "./useEditionMode"
const host = (slot: (api: ReturnType<typeof useEditionMode>) => void) =>
mount(
defineComponent({
template: "<div/>",
setup() {
const api = useEditionMode()
slot(api)
return api
}
})
)
describe("useEditionMode", () => {
afterEach(() => {
escape.value = false
})
it("starts in read mode", () => {
let mode: unknown
host((api) => {
mode = api.mode.value
})
expect(mode).toBe("read")
})
it("toggleMode flips read ↔ edit", () => {
let api: ReturnType<typeof useEditionMode> | undefined
host((a) => {
api = a
})
api!.toggleMode()
expect(api!.mode.value).toBe("edit")
api!.toggleMode()
expect(api!.mode.value).toBe("read")
})
it("escape key exits edit mode", async () => {
let api: ReturnType<typeof useEditionMode> | undefined
host((a) => {
a.toggleMode()
api = a
})
expect(api!.mode.value).toBe("edit")
escape.value = true
await new Promise((r) => setTimeout(r, 0))
expect(api!.mode.value).toBe("read")
})
it("escape key is a no-op when already in read mode", async () => {
let api: ReturnType<typeof useEditionMode> | undefined
host((a) => {
api = a
})
escape.value = true
await new Promise((r) => setTimeout(r, 0))
expect(api!.mode.value).toBe("read")
})
})

View File

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

View File

@@ -0,0 +1,67 @@
import { mount } from "@vue/test-utils"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { defineComponent } from "vue"
const push = vi.fn()
vi.mock("vue-router", () => ({
useRouter: () => ({ push })
}))
import { useForm } from "./useForm.hook"
const host = () => {
let api!: ReturnType<typeof useForm>
mount(
defineComponent({
template: "<div/>",
setup() {
api = useForm()
return api
}
})
)
return api
}
describe("useForm", () => {
beforeEach(() => {
push.mockReset()
})
it("starts with empty user and repo inputs", () => {
const api = host()
expect(api.userInput.value).toBe("")
expect(api.repoInput.value).toBe("")
})
it("submit is a no-op when userInput is empty", () => {
const api = host()
api.repoInput.value = "notes"
api.submit()
expect(push).not.toHaveBeenCalled()
})
it("submit is a no-op when repoInput is empty", () => {
const api = host()
api.userInput.value = "alice"
api.submit()
expect(push).not.toHaveBeenCalled()
})
it("submit pushes the FluxNoteView route with user/repo params", () => {
const api = host()
api.userInput.value = "alice"
api.repoInput.value = "notes"
api.submit()
expect(push).toHaveBeenCalledWith({
name: "FluxNoteView",
params: { user: "alice", repo: "notes" }
})
})
})

View File

@@ -0,0 +1,197 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
vi.mock("@/modules/repo/services/octo", () => ({
getOctokit: vi.fn(),
runWithAuthRetry: vi.fn()
}))
vi.mock("@/utils/notif", () => ({
confirmMessage: vi.fn(),
errorMessage: vi.fn()
}))
import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo"
import { confirmMessage, errorMessage } from "@/utils/notif"
import { useGitHubContent } from "./useGitHubContent.hook"
const make = () => useGitHubContent({ user: "alice", repo: "notes" })
describe("useGitHubContent.fetchLatestSha", () => {
beforeEach(() => {
vi.mocked(runWithAuthRetry).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("returns the sha from a file response", async () => {
vi.mocked(runWithAuthRetry).mockResolvedValue({
data: { sha: "abc123" }
} as never)
expect(await make().fetchLatestSha("a.md")).toEqual({
kind: "ok",
sha: "abc123"
})
})
it("returns sha=null when the path resolves to a directory (array response)", async () => {
vi.mocked(runWithAuthRetry).mockResolvedValue({ data: [] } as never)
expect(await make().fetchLatestSha("dir/")).toEqual({
kind: "ok",
sha: null
})
})
it("returns sha=null when the data object has no sha field", async () => {
vi.mocked(runWithAuthRetry).mockResolvedValue({
data: { type: "submodule" }
} as never)
expect(await make().fetchLatestSha("path")).toEqual({
kind: "ok",
sha: null
})
})
it("returns kind=unauthorized on 401", async () => {
vi.mocked(runWithAuthRetry).mockRejectedValue(
Object.assign(new Error("Unauthorized"), { status: 401 })
)
expect(await make().fetchLatestSha("path")).toEqual({
kind: "unauthorized"
})
})
it("returns kind=offline on non-401 errors (404, network, etc.)", async () => {
vi.mocked(runWithAuthRetry).mockRejectedValue(
Object.assign(new Error("Not found"), { status: 404 })
)
expect(await make().fetchLatestSha("path")).toEqual({ kind: "offline" })
vi.mocked(runWithAuthRetry).mockRejectedValue(new Error("network"))
expect(await make().fetchLatestSha("path")).toEqual({ kind: "offline" })
})
})
describe("useGitHubContent.updateFile / createFile (putFile)", () => {
const mockOctokitRequest = vi.fn()
beforeEach(() => {
mockOctokitRequest.mockReset()
vi.mocked(getOctokit).mockResolvedValue({
request: mockOctokitRequest
} as never)
vi.mocked(confirmMessage).mockReset()
vi.mocked(errorMessage).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("returns the new sha and calls confirmMessage on success", async () => {
mockOctokitRequest.mockResolvedValue({
data: { content: { sha: "new-sha" } }
})
const result = await make().updateFile({
content: "# hello",
path: "a.md",
sha: "old-sha"
})
expect(result).toEqual({ sha: "new-sha", conflict: false })
expect(confirmMessage).toHaveBeenCalledWith("✅ Note saved")
})
it("flags conflict=true on 409 and 422 responses", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {})
for (const status of [409, 422]) {
mockOctokitRequest.mockRejectedValueOnce(
Object.assign(new Error("Conflict"), { status })
)
const result = await make().createFile({ content: "x", path: "a.md" })
expect(result).toEqual({ sha: null, conflict: true })
}
expect(errorMessage).toHaveBeenCalledWith(
"⚠ Conflict: this note changed on GitHub"
)
})
it("returns conflict=false and calls errorMessage on non-conflict errors", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {})
mockOctokitRequest.mockRejectedValue(
Object.assign(new Error("Server error"), { status: 500 })
)
const result = await make().updateFile({
content: "x",
path: "a.md",
sha: "old"
})
expect(result).toEqual({ sha: null, conflict: false })
expect(errorMessage).toHaveBeenCalledWith("❌ Note could not be saved")
})
})
describe("useGitHubContent.uploadBinaryFile", () => {
const mockOctokitRequest = vi.fn()
beforeEach(() => {
mockOctokitRequest.mockReset()
vi.mocked(getOctokit).mockResolvedValue({
request: mockOctokitRequest
} as never)
vi.mocked(errorMessage).mockReset()
vi.mocked(confirmMessage).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("uploads pre-base64-encoded content without re-encoding", async () => {
mockOctokitRequest.mockResolvedValue({
data: { content: { sha: "img-sha" } }
})
const result = await make().uploadBinaryFile({
base64: "ZGF0YQ==",
path: "img/cover.png"
})
expect(result).toEqual({ sha: "img-sha", conflict: false })
expect(mockOctokitRequest).toHaveBeenCalledWith(
"PUT /repos/{owner}/{repo}/contents/{+path}",
expect.objectContaining({
path: "img/cover.png",
content: "ZGF0YQ=="
})
)
expect(confirmMessage).toHaveBeenCalledWith("✅ Image uploaded")
})
it("surfaces a conflict on existing file (422)", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {})
mockOctokitRequest.mockRejectedValue(
Object.assign(new Error("Exists"), { status: 422 })
)
const result = await make().uploadBinaryFile({
base64: "Zm9v",
path: "img.png"
})
expect(result).toEqual({ sha: null, conflict: true })
expect(errorMessage).toHaveBeenCalledWith(
"⚠ A file already exists at this path on GitHub"
)
})
})

View File

@@ -1,7 +1,15 @@
import { getOctokit } from "@/modules/repo/services/octo"
import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo"
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
import { confirmMessage, errorMessage } from "@/utils/notif"
const isConflictStatus = (status: number) => status === 409 || status === 422
const isUnauthorizedStatus = (status: number | undefined) => status === 401
export type FetchShaResult =
| { kind: "ok"; sha: string | null }
| { kind: "unauthorized" }
| { kind: "offline" }
export const useGitHubContent = ({
user,
repo
@@ -9,45 +17,121 @@ export const useGitHubContent = ({
user: string
repo: string
}) => {
const putFile = async ({
content,
const fetchLatestSha = async (path: string): Promise<FetchShaResult> => {
try {
const response = await runWithAuthRetry((octokit) =>
octokit.request("GET /repos/{owner}/{repo}/contents/{+path}", {
owner: user,
repo,
path,
sha
headers: { "X-GitHub-Api-Version": "2026-03-10" }
})
)
const data = response?.data
if (Array.isArray(data) || !data) return { kind: "ok", sha: null }
return { kind: "ok", sha: "sha" in data ? data.sha : null }
} catch (error) {
const status = (error as { status?: number })?.status
if (isUnauthorizedStatus(status)) return { kind: "unauthorized" }
return { kind: "offline" }
}
}
const putRaw = async ({
contentBase64,
path,
sha,
message,
successMessage,
conflictMessage,
failureMessage
}: {
content: string
contentBase64: string
path: string
sha?: string
}) => {
message: string
successMessage: string
conflictMessage: string
failureMessage: string
}): Promise<{ sha: string | null; conflict: boolean }> => {
try {
const octokit = await getOctokit()
const response = await octokit.request(
`PUT /repos/{owner}/{repo}/contents/{path}`,
"PUT /repos/{owner}/{repo}/contents/{+path}",
{
owner: user,
repo,
path,
message: `Updating ${path} from Remanso`,
content: encodeUTF8ToBase64(content),
message,
content: contentBase64,
sha
}
)
confirmMessage("✅ Note saved")
confirmMessage(successMessage)
return response?.data.content?.sha ?? null
return { sha: response?.data.content?.sha ?? null, conflict: false }
} catch (error) {
errorMessage("❌ Note could not be saved")
const status = (error as { status?: number })?.status
if (status && isConflictStatus(status)) {
errorMessage(conflictMessage)
console.warn(error)
return { sha: null, conflict: true }
}
errorMessage(failureMessage)
console.warn(error)
return { sha: null, conflict: false }
}
}
return null
}
const putFile = async ({
content,
path,
sha,
successMessage = "✅ Note saved"
}: {
content: string
path: string
sha?: string
successMessage?: string
}): Promise<{ sha: string | null; conflict: boolean }> =>
putRaw({
contentBase64: encodeUTF8ToBase64(content),
path,
sha,
message: `Updating ${path} from Remanso`,
successMessage,
conflictMessage: "⚠ Conflict: this note changed on GitHub",
failureMessage: "❌ Note could not be saved"
})
const uploadBinaryFile = async ({
base64,
path
}: {
base64: string
path: string
}): Promise<{ sha: string | null; conflict: boolean }> =>
putRaw({
contentBase64: base64,
path,
message: `Uploading ${path} from Remanso`,
successMessage: "✅ Image uploaded",
conflictMessage: "⚠ A file already exists at this path on GitHub",
failureMessage: "❌ Image could not be uploaded"
})
return {
updateFile: async (props: { content: string; path: string; sha: string }) =>
putFile(props),
fetchLatestSha,
updateFile: async (props: {
content: string
path: string
sha: string
successMessage?: string
}) => putFile(props),
createFile: async (props: { content: string; path: string }) =>
putFile(props)
putFile(props),
uploadBinaryFile
}
}

View File

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

@@ -0,0 +1,164 @@
import { createPinia, setActivePinia } from "pinia"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
const uploadBinaryFile = vi.fn()
const registerUploadedFile = vi.fn()
vi.mock("@/hooks/useGitHubContent.hook", () => ({
useGitHubContent: () => ({ uploadBinaryFile })
}))
vi.mock("@/modules/repo/store/userRepo.store", () => ({
useUserRepoStore: () => ({
files: [{ path: "alice/notes/already-here.png", sha: "x" }],
registerUploadedFile
})
}))
vi.mock("@/utils/notif", () => ({
errorMessage: vi.fn()
}))
import { errorMessage } from "@/utils/notif"
import { useImageUpload } from "./useImageUpload.hook"
const makeFile = (name: string, body = "fake-image-bytes") =>
new File([body], name, { type: "image/png" })
describe("useImageUpload", () => {
beforeEach(() => {
setActivePinia(createPinia())
uploadBinaryFile.mockReset()
registerUploadedFile.mockReset()
vi.mocked(errorMessage).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("returns null and shows an error when notePath is missing", async () => {
const { uploadImage } = useImageUpload({
user: "alice",
repo: "notes",
notePath: undefined
})
expect(await uploadImage(makeFile("img.png"))).toBeNull()
expect(errorMessage).toHaveBeenCalledWith("❌ Image upload failed")
expect(uploadBinaryFile).not.toHaveBeenCalled()
})
it("uploads to the note's directory using the note basename and the file extension", async () => {
uploadBinaryFile.mockResolvedValue({ sha: "new-sha", conflict: false })
const { uploadImage } = useImageUpload({
user: "alice",
repo: "notes",
notePath: "alice/notes/my-note.md"
})
const result = await uploadImage(makeFile("photo.PNG"))
expect(result).toEqual({ filename: "my-note.png" })
expect(uploadBinaryFile).toHaveBeenCalledWith(
expect.objectContaining({
path: "alice/notes/my-note.png"
})
)
})
it("deduplicates filenames against existing files in the store", async () => {
uploadBinaryFile.mockResolvedValue({ sha: "new-sha", conflict: false })
const { uploadImage } = useImageUpload({
user: "alice",
repo: "notes",
notePath: "alice/notes/already-here.md"
})
const result = await uploadImage(makeFile("photo.png"))
expect(result?.filename).toBe("already-here-2.png")
})
it("returns null on conflict and does NOT register the file", async () => {
uploadBinaryFile.mockResolvedValue({ sha: null, conflict: true })
const { uploadImage } = useImageUpload({
user: "alice",
repo: "notes",
notePath: "x/note.md"
})
expect(await uploadImage(makeFile("img.png"))).toBeNull()
expect(registerUploadedFile).not.toHaveBeenCalled()
})
it("registers the uploaded file in the store on success", async () => {
uploadBinaryFile.mockResolvedValue({ sha: "abc", conflict: false })
const file = makeFile("img.png", "data")
const { uploadImage } = useImageUpload({
user: "alice",
repo: "notes",
notePath: "x/note.md"
})
await uploadImage(file)
expect(registerUploadedFile).toHaveBeenCalledWith({
path: "x/note.png",
sha: "abc",
type: "blob",
size: file.size
})
})
it("falls back to .png when the file name has no extension", async () => {
uploadBinaryFile.mockResolvedValue({ sha: "abc", conflict: false })
const { uploadImage } = useImageUpload({
user: "alice",
repo: "notes",
notePath: "x/note.md"
})
await uploadImage(makeFile("noextension"))
expect(uploadBinaryFile).toHaveBeenCalledWith(
expect.objectContaining({ path: "x/note.png" })
)
})
it("uses the root path when the note is at the repo root", async () => {
uploadBinaryFile.mockResolvedValue({ sha: "abc", conflict: false })
const { uploadImage } = useImageUpload({
user: "alice",
repo: "notes",
notePath: "root.md"
})
await uploadImage(makeFile("img.png"))
expect(uploadBinaryFile).toHaveBeenCalledWith(
expect.objectContaining({ path: "root.png" })
)
})
it("returns null and notifies on unexpected errors", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {})
uploadBinaryFile.mockRejectedValue(new Error("boom"))
const { uploadImage } = useImageUpload({
user: "alice",
repo: "notes",
notePath: "x/note.md"
})
expect(await uploadImage(makeFile("img.png"))).toBeNull()
expect(errorMessage).toHaveBeenCalledWith("❌ Image upload failed")
})
})

View File

@@ -0,0 +1,104 @@
import { Ref, toValue } from "vue"
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { errorMessage } from "@/utils/notif"
import { uniqueFilename } from "@/utils/uniqueFilename"
const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
const bytes = new Uint8Array(buffer)
const chunkSize = 0x8000
let binary = ""
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize)
binary += String.fromCharCode(...chunk)
}
return btoa(binary)
}
const splitPath = (fullPath: string): { directory: string; filename: string } => {
const lastSlash = fullPath.lastIndexOf("/")
if (lastSlash === -1) return { directory: "", filename: fullPath }
return {
directory: fullPath.slice(0, lastSlash),
filename: fullPath.slice(lastSlash + 1)
}
}
const stripMarkdownExtension = (filename: string): string =>
filename.replace(/\.(md|markdown|mdx)$/i, "")
const extractExtension = (filename: string): string => {
const dot = filename.lastIndexOf(".")
if (dot <= 0) return ".png"
return filename.slice(dot).toLowerCase()
}
export const useImageUpload = ({
user,
repo,
notePath
}: {
user: string
repo: string
notePath: Ref<string | undefined> | string | undefined
}) => {
const store = useUserRepoStore()
const { uploadBinaryFile } = useGitHubContent({ user, repo })
const uploadImage = async (
file: File
): Promise<{ filename: string } | null> => {
const currentNotePath = toValue(notePath)
if (!currentNotePath) {
errorMessage("❌ Image upload failed")
return null
}
try {
const { directory, filename: noteFilename } = splitPath(currentNotePath)
const basename = stripMarkdownExtension(noteFilename)
const extension = extractExtension(file.name)
const existingPaths = store.files
.map((f) => f.path)
.filter((p): p is string => typeof p === "string")
const filename = uniqueFilename({
basename,
extension,
existingPaths,
directory
})
const targetPath = directory ? `${directory}/${filename}` : filename
const buffer = await file.arrayBuffer()
const base64 = arrayBufferToBase64(buffer)
const { sha, conflict } = await uploadBinaryFile({
base64,
path: targetPath
})
if (conflict || !sha) {
return null
}
store.registerUploadedFile({
path: targetPath,
sha,
type: "blob",
size: file.size
})
return { filename }
} catch (error) {
console.warn("image upload failed", error)
errorMessage("❌ Image upload failed")
return null
}
}
return { uploadImage }
}

View File

@@ -11,14 +11,23 @@ export const useLinks = (
const store = useUserRepoStore()
const linkNote: EventListener = (event) => {
const target = event.target as HTMLElement
const href = target.getAttribute("href")
const anchor = (event.target as HTMLElement).closest("a")
const href = anchor?.getAttribute("href")
if (!href) {
return
}
if (href.startsWith("#")) {
event.preventDefault()
const id = href.slice(1)
const container = document.querySelector(`.${toValue(className)}`)
const heading = container?.querySelector(`#${CSS.escape(id)}`)
heading?.scrollIntoView({
block: "start",
inline: "nearest",
behavior: "smooth"
})
return
}
@@ -30,8 +39,13 @@ export const useLinks = (
return
}
const hashIndex = href.indexOf("#")
const path = hashIndex === -1 ? href : href.slice(0, hashIndex)
const hash = hashIndex === -1 ? undefined : href.slice(hashIndex + 1)
noteEventBus.emit({
path: href,
path,
hash,
currentNoteSHA: toValue(sha),
user: store.user,
repo: store.repo

View File

@@ -1,18 +1,37 @@
import type { MarkdownItTabData, MarkdownItTabInfo } from "@mdit/plugin-tab"
import { tab } from "@mdit/plugin-tab"
import markdownItKatex from "@vscode/markdown-it-katex"
import GithubSlugger from "github-slugger"
import MarkdownIt, { Options } from "markdown-it"
import Renderer, { type RenderRuleRecord } from "markdown-it/lib/renderer.mjs"
import type Token from "markdown-it/lib/token.mjs"
import markdownItAnchor from "markdown-it-anchor"
import blockEmbedPlugin from "markdown-it-block-embed"
import markdownItCheckbox from "markdown-it-checkbox"
import MarkdownItGitHubAlerts from "markdown-it-github-alerts"
import markdownItIframe from "markdown-it-iframe"
import Shikiji from "markdown-it-shikiji"
import mermaid from "mermaid"
import { Ref, toValue } from "vue"
import type { LanguageRegistration } from "shikiji-core"
import { Ref, ref, toValue } from "vue"
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
import { data } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import type { TikzCache } from "@/data/models/TikzCache"
import alloyGrammar from "@/utils/alloy.tmLanguage.json"
import {
decodeBase64ToUTF8,
encodeUTF8ToBase64
} from "@/utils/decodeBase64ToUTF8"
import { html5Media } from "@/utils/markdown/markdown-html5-media"
import { markdownItTablerIcons } from "@/utils/markdown/markdown-it-tabler-icons"
import { renderFallback } from "@/utils/markdown/renderFallback"
const TIKZ_BUNDLE_URL =
"https://cdn.jsdelivr.net/gh/artisticat1/obsidian-tikzjax@0.5.2/tikzjax.js"
const TIKZ_STYLES_URL =
"https://cdn.jsdelivr.net/gh/artisticat1/obsidian-tikzjax@0.5.2/styles.css"
const TIKZ_RENDER_TIMEOUT_MS = 30000
const markdownItMermaidExtractor = (md: MarkdownIt) => {
const defaultFence =
@@ -45,11 +64,49 @@ const markdownItMermaidExtractor = (md: MarkdownIt) => {
}
}
const markdownItTikzExtractor = (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() === "tikz") {
const encoded = encodeUTF8ToBase64(token.content)
return `<pre class="tikz" data-tikz-source="${encoded}"><span class="tikz-loading">Rendering TikZ…</span></pre>\n`
}
return defaultFence(tokens, index, options, env, self)
}
}
const slugger = new GithubSlugger()
let tabGroupCounter = 0
let currentTabGroup = 0
let currentTabActiveSet = false
const md = new MarkdownIt({
typographer: true,
quotes: ["«\xA0", "\xA0»", "\xA0", "\xA0"]
})
.use(markdownItMermaidExtractor)
.use(markdownItTikzExtractor)
.use(html5Media)
.use(blockEmbedPlugin, {
youtube: {
@@ -64,17 +121,33 @@ const md = new MarkdownIt({
})
.use(MarkdownItGitHubAlerts)
.use(markdownItTablerIcons)
.use(tab, {
name: "tabs",
openRender: (info: MarkdownItTabInfo) => {
currentTabGroup = ++tabGroupCounter
currentTabActiveSet = info.active >= 0
return '<div class="tabs tabs-box">\n'
},
closeRender: () => "</div>\n",
tabOpenRender: (data: MarkdownItTabData) => {
const isChecked =
data.isActive || (!currentTabActiveSet && data.index === 0)
const checked = isChecked ? " checked" : ""
const title = data.title.replace(/"/g, "&quot;")
return `<input type="radio" name="md-tabs-${currentTabGroup}" class="tab" aria-label="${title}"${checked}>\n<div class="tab-content bg-base-100 border-base-300 rounded-box p-2">\n`
},
tabCloseRender: () => "</div>\n"
})
.use(markdownItAnchor, {
slugify: (s: string) => slugger.slug(s)
})
let shikijiInitialized = false
let shikijiPromise: Promise<void> | null = null
const shikijiReady = ref(false)
export const useShikiji = async () => {
if (shikijiInitialized) {
return
}
shikijiInitialized = true
md.use(
await Shikiji({
export const useShikiji = (): Promise<void> => {
if (!shikijiPromise) {
shikijiPromise = Shikiji({
themes: {
light: "vitesse-light",
dark: "vitesse-black"
@@ -83,19 +156,29 @@ export const useShikiji = async () => {
"bash",
"javascript",
"typescript",
"tsx",
"markdown",
"mermaid",
"html",
"css",
"json"
"json",
{
...alloyGrammar,
name: "alloy",
aliases: ["als"]
} as unknown as LanguageRegistration
]
}).then((plugin) => {
md.use(plugin)
shikijiReady.value = true
})
)
}
return shikijiPromise
}
let mermaidInitialized = false
export const runMermaid = (querySelector: string) => {
export const runMermaid = (querySelector: string): Promise<void> => {
if (!mermaidInitialized) {
mermaidInitialized = true
mermaid.initialize({
@@ -105,11 +188,198 @@ export const runMermaid = (querySelector: string) => {
})
}
mermaid.run({
return mermaid.run({
querySelector
})
}
let tikzBundlePromise: Promise<void> | null = null
let tikzStylesPromise: Promise<void> | null = null
let domPurifyPromise: Promise<typeof import("dompurify")> | null = null
const ensureTikzBundle = (): Promise<void> => {
if (tikzBundlePromise) return tikzBundlePromise
tikzBundlePromise = new Promise<void>((resolve, reject) => {
const existing = document.getElementById("tikzjax-bundle")
if (existing) {
resolve()
return
}
const script = document.createElement("script")
script.id = "tikzjax-bundle"
script.src = TIKZ_BUNDLE_URL
script.async = true
script.crossOrigin = "anonymous"
script.addEventListener("load", () => resolve())
script.addEventListener("error", () => {
tikzBundlePromise = null
reject(new Error("Failed to load TikZ engine"))
})
document.head.appendChild(script)
})
return tikzBundlePromise
}
const ensureTikzStyles = (): Promise<void> => {
if (tikzStylesPromise) return tikzStylesPromise
tikzStylesPromise = new Promise<void>((resolve, reject) => {
const existing = document.getElementById("tikzjax-styles")
if (existing) {
resolve()
return
}
const link = document.createElement("link")
link.id = "tikzjax-styles"
link.rel = "stylesheet"
link.href = TIKZ_STYLES_URL
link.crossOrigin = "anonymous"
link.addEventListener("load", () => resolve())
link.addEventListener("error", () => {
tikzStylesPromise = null
reject(new Error("Failed to load TikZ font styles"))
})
document.head.appendChild(link)
})
return tikzStylesPromise
}
const ensureDomPurify = (): Promise<typeof import("dompurify")> => {
if (!domPurifyPromise) domPurifyPromise = import("dompurify")
return domPurifyPromise
}
const sha256Hex = async (text: string): Promise<string> => {
const buf = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(text)
)
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
}
const sanitizeSvg = async (svg: string): Promise<string> => {
const DOMPurify = (await ensureDomPurify()).default
return DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true }
})
}
const tidyTikzSource = (s: string): string =>
s
.replaceAll("&nbsp;", "")
.split("\n")
.map((line) => line.trim())
.filter((line) => line)
.join("\n")
const renderTikzError = (err: unknown, source: string): string => {
const msg = err instanceof Error ? err.message : String(err)
return (
`<div class="tikz-error"><strong>TikZ error</strong>` +
`<pre>${md.utils.escapeHtml(msg)}</pre>` +
`<details><summary>source</summary>` +
`<pre>${md.utils.escapeHtml(source)}</pre></details></div>`
)
}
const renderOneTikzBlock = (
el: HTMLElement,
source: string
): Promise<string> => {
return new Promise((resolve, reject) => {
let settled = false
const timer = window.setTimeout(() => {
if (settled) return
settled = true
el.removeEventListener("tikzjax-load-finished", handler)
reject(new Error("TikZ render timed out"))
}, TIKZ_RENDER_TIMEOUT_MS)
const handler = (e: Event) => {
if (settled) return
settled = true
window.clearTimeout(timer)
el.removeEventListener("tikzjax-load-finished", handler)
const target = e.target as Element | null
if (!target) {
reject(new Error("TikZ produced no output"))
return
}
resolve(target.outerHTML)
}
el.addEventListener("tikzjax-load-finished", handler)
while (el.firstChild) el.removeChild(el.firstChild)
const script = document.createElement("script")
script.type = "text/tikz"
script.textContent = source
el.appendChild(script)
})
}
export const runTikz = async (querySelector: string): Promise<void> => {
const elements = Array.from(
document.querySelectorAll<HTMLElement>(querySelector)
)
if (elements.length === 0) return
void ensureTikzStyles().catch(() => undefined)
await Promise.all(
elements.map(async (el) => {
if (el.dataset.tikzRendered) return
el.dataset.tikzRendered = "pending"
const encoded = el.dataset.tikzSource
if (!encoded) {
el.dataset.tikzRendered = "error"
return
}
const source = tidyTikzSource(decodeBase64ToUTF8(encoded))
let hash: string
try {
hash = await sha256Hex(source)
} catch (err) {
el.innerHTML = renderTikzError(err, source)
el.dataset.tikzRendered = "error"
return
}
const cacheId = `${DataType.TikzCache}-${hash}`
const cached = await data.get<DataType.TikzCache, TikzCache>(cacheId)
if (cached?.svg) {
el.innerHTML = cached.svg
el.dataset.tikzRendered = "true"
return
}
try {
await ensureTikzBundle()
const rawSvg = await renderOneTikzBlock(el, source)
const sanitized = await sanitizeSvg(rawSvg)
el.innerHTML = sanitized
el.dataset.tikzRendered = "true"
void data.add<DataType.TikzCache>({
_id: cacheId,
$type: DataType.TikzCache,
svg: sanitized
} as TikzCache)
} catch (err) {
el.innerHTML = renderTikzError(err, source)
el.dataset.tikzRendered = "error"
}
})
)
}
const rules: RenderRuleRecord = {
table_open: () =>
'<div class="overflow-x-auto"><table class="table table-zebra">',
@@ -123,11 +393,45 @@ const stripFrontmatter = (content: string): string => {
return match ? content.slice(match[0].length) : content
}
const renderMarkdown = (content: string, env?: Record<string, unknown>) => {
// Track Shikiji readiness so reactive consumers (e.g. the `content`
// computed in useFile) re-render once `useShikiji()` mutates the
// singleton `md` — otherwise the first render on page load stays
// unhighlighted because nothing else invalidates the computed.
if (content.includes("```")) void shikijiReady.value
slugger.reset()
try {
return env ? md.render(content, env) : md.render(content)
} catch (error) {
// Never let a plugin failure blank the note or dump a raw error —
// degrade to the note's raw text.
console.error("markdown render failed", error)
return renderFallback(content)
}
}
export const renderCodeFile = async ({
rawContent,
lang,
filename
}: {
rawContent: string
lang: string | null
filename?: string
}): Promise<string> => {
await useShikiji()
const heading = filename ? `# ${filename}\n\n` : ""
if (lang !== null) {
return renderMarkdown(`${heading}\`\`\`\`${lang}\n${rawContent}\n\`\`\`\``)
}
return `${renderMarkdown(heading)}<pre><code>${md.utils.escapeHtml(rawContent)}</code></pre>`
}
export const markdownBuilder = (defaultPrefix?: Ref<string> | string) => {
const getRawContent = (content: string) => decodeBase64ToUTF8(content)
const renderFromUTF8 = (content: string, prefix?: string) => {
return content
? md.render(stripFrontmatter(content), {
? renderMarkdown(stripFrontmatter(content), {
docId: defaultPrefix ? toValue(defaultPrefix) : (prefix ?? "")
})
: ""
@@ -135,7 +439,7 @@ export const markdownBuilder = (defaultPrefix?: Ref<string> | string) => {
return {
toHTML: (content: string) =>
content ? md.render(stripFrontmatter(content)) : "",
content ? renderMarkdown(stripFrontmatter(content)) : "",
render: (content: string, prefix?: string) =>
renderFromUTF8(decodeBase64ToUTF8(content), prefix),
renderFromUTF8,

View File

@@ -0,0 +1,64 @@
import { nextTick, type Ref, toValue, watch } from "vue"
import { useImages } from "@/hooks/useImages.hook"
import {
runMermaid,
runTikz,
useShikiji
} from "@/hooks/useMarkdown.hook"
import { attachSvgDownloads } from "@/utils/svgDownload"
interface MarkdownPostRenderOptions {
onReady?: () => void
tikz?: boolean
mermaid?: () => boolean
shikiji?: () => boolean
images?: () => string | null | undefined
triggers?: Ref<unknown>[]
}
export const useMarkdownPostRender = (
contentRef: Ref<unknown>,
scopeSelector: () => string,
options: MarkdownPostRenderOptions = {}
) => {
const sources = [contentRef, ...(options.triggers ?? [])]
watch(
sources,
async () => {
if (!toValue(contentRef)) return
await nextTick()
options.onReady?.()
const scope = scopeSelector()
const wantsTikz = !!options.tikz
const wantsMermaid = !!options.mermaid?.()
const renderJobs: Promise<unknown>[] = []
if (wantsTikz) {
renderJobs.push(runTikz(`${scope} .tikz`))
}
if (wantsMermaid) {
renderJobs.push(runMermaid(`${scope} .mermaid`))
}
if (options.shikiji?.()) {
void useShikiji()
}
const imagesSha = options.images?.()
if (imagesSha) {
useImages(imagesSha)
}
if (wantsTikz || wantsMermaid) {
await Promise.allSettled(renderJobs)
await nextTick()
attachSvgDownloads(document.querySelector(scope))
}
},
{ immediate: true }
)
}

View File

@@ -0,0 +1,327 @@
import { createPinia, setActivePinia } from "pinia"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { ref } from "vue"
import { DataType } from "@/data/DataType.enum"
const {
fetchLatestSha,
addFile,
getRawContent,
saveCacheNote,
queryFileContent,
dataGet
} = vi.hoisted(() => ({
fetchLatestSha: vi.fn(),
addFile: vi.fn(),
getRawContent: vi.fn((s: string) => s),
saveCacheNote: 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", () => ({
useGitHubContent: () => ({ fetchLatestSha })
}))
vi.mock("@/hooks/useMarkdown.hook", () => ({
markdownBuilder: () => ({ getRawContent, render: (s: string) => s })
}))
vi.mock("@/modules/note/cache/prepareNoteCache", () => ({
prepareNoteCache: () => ({
getCachedNote: async () => ({ note: null }),
saveCacheNote
})
}))
vi.mock("@/modules/repo/services/repo", () => ({
queryFileContent
}))
vi.mock("@/modules/repo/store/userRepo.store", () => ({
useUserRepoStore: () => ({ addFile })
}))
import { useNoteFreshness } from "./useNoteFreshness.hook"
const setup = (overrides: { sha?: string; path?: string; edited?: string | null } = {}) => {
setActivePinia(createPinia())
const sha = ref(overrides.sha ?? "local-sha")
const path = ref(overrides.path ?? "note.md")
const getEditedSha = vi.fn().mockResolvedValue(overrides.edited ?? null)
return useNoteFreshness({
user: "alice",
repo: "notes",
sha,
path,
getEditedSha
})
}
// Make performance.now() return values where elapsed > MIN_SPINNER_MS so the
// 400ms spinner-min-time wait is skipped.
const skipSpinnerWait = () => {
let call = 0
vi.spyOn(performance, "now").mockImplementation(() =>
call++ === 0 ? 0 : 10_000
)
}
describe("useNoteFreshness.check", () => {
beforeEach(() => {
fetchLatestSha.mockReset()
addFile.mockReset()
saveCacheNote.mockReset()
queryFileContent.mockReset()
skipSpinnerWait()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("does nothing when path is empty", async () => {
const { check, status } = setup({ path: "" })
await check()
expect(status.value).toBe("unknown")
expect(fetchLatestSha).not.toHaveBeenCalled()
})
it("sets status to 'verified' when remote sha matches local sha", async () => {
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "local-sha" })
const { check, status, latestSha, lastCheckedAt } = setup()
await check()
expect(status.value).toBe("verified")
expect(latestSha.value).toBe("local-sha")
expect(lastCheckedAt.value).toBeInstanceOf(Date)
})
it("sets status to 'outdated' when remote sha differs from local sha", async () => {
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
const { check, status } = setup()
await check()
expect(status.value).toBe("outdated")
})
it("prefers the edited sha over the live sha when comparing", async () => {
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "edited-sha" })
const { check, status } = setup({ sha: "live-sha", edited: "edited-sha" })
await check()
expect(status.value).toBe("verified")
})
it("sets status to 'unauthorized' on auth failure", async () => {
fetchLatestSha.mockResolvedValue({ kind: "unauthorized" })
const { check, status } = setup()
await check()
expect(status.value).toBe("unauthorized")
})
it("sets status to 'offline' on network failure", async () => {
fetchLatestSha.mockResolvedValue({ kind: "offline" })
const { check, status } = setup()
await check()
expect(status.value).toBe("offline")
})
it("sets status to 'offline' when remote returns sha=null (e.g. directory)", async () => {
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: null })
const { check, status } = setup()
await check()
expect(status.value).toBe("offline")
})
})
describe("useNoteFreshness.pullLatest", () => {
beforeEach(() => {
fetchLatestSha.mockReset()
addFile.mockReset()
saveCacheNote.mockReset()
queryFileContent.mockReset()
getRawContent.mockClear()
skipSpinnerWait()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("returns raw=null with no failureStatus when path is empty", async () => {
const { pullLatest } = setup({ path: "" })
expect(await pullLatest()).toEqual({ raw: null, failureStatus: null })
expect(fetchLatestSha).not.toHaveBeenCalled()
})
it("returns the fetched content and updates state on success", async () => {
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
queryFileContent.mockResolvedValue("BASE64")
getRawContent.mockReturnValue("# raw")
const { pullLatest, status, latestSha, lastCheckedAt } = setup()
const result = await pullLatest()
expect(result).toEqual({ raw: "# raw", failureStatus: null })
expect(status.value).toBe("verified")
expect(latestSha.value).toBe("remote-sha")
expect(lastCheckedAt.value).toBeInstanceOf(Date)
expect(addFile).toHaveBeenCalledWith({ path: "note.md", sha: "remote-sha" })
expect(saveCacheNote).toHaveBeenCalled()
})
it("surfaces 'unauthorized' failureStatus when remote resolve is unauthorized", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {})
fetchLatestSha.mockResolvedValue({ kind: "unauthorized" })
const { pullLatest, status } = setup()
expect(await pullLatest()).toEqual({
raw: null,
failureStatus: "unauthorized"
})
expect(status.value).toBe("unauthorized")
})
it("surfaces 'offline' failureStatus when remote resolve is offline", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {})
fetchLatestSha.mockResolvedValue({ kind: "offline" })
const { pullLatest, status } = setup()
expect(await pullLatest()).toEqual({
raw: null,
failureStatus: "offline"
})
expect(status.value).toBe("offline")
})
it("surfaces 'offline' failureStatus when blob content fetch fails", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {})
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
queryFileContent.mockResolvedValue(null)
const { pullLatest, status } = setup()
expect(await pullLatest()).toEqual({
raw: null,
failureStatus: "offline"
})
expect(status.value).toBe("offline")
})
it("uses the cached latestSha to skip a redundant fetchLatestSha call", async () => {
fetchLatestSha.mockResolvedValueOnce({ kind: "ok", sha: "cached-sha" })
queryFileContent.mockResolvedValue("BASE64")
const api = setup()
await api.check() // populates latestSha
expect(fetchLatestSha).toHaveBeenCalledTimes(1)
fetchLatestSha.mockClear()
await api.pullLatest()
expect(fetchLatestSha).not.toHaveBeenCalled()
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

@@ -0,0 +1,168 @@
import { Ref, ref } from "vue"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
import { Note } from "@/modules/note/models/Note"
import { queryFileContent } from "@/modules/repo/services/repo"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
export type FreshnessStatus =
| "unknown"
| "checking"
| "verified"
| "outdated"
| "offline"
| "unauthorized"
const MIN_SPINNER_MS = 400
export const useNoteFreshness = ({
user,
repo,
sha,
path,
getEditedSha
}: {
user: string
repo: string
sha: Ref<string>
path: Ref<string | undefined>
getEditedSha: () => Promise<string | null>
}) => {
const store = useUserRepoStore()
const { fetchLatestSha } = useGitHubContent({ user, repo })
const status = ref<FreshnessStatus>("unknown")
const lastCheckedAt = ref<Date | null>(null)
const latestSha = ref<string | null>(null)
const expectedSha = async () => (await getEditedSha()) ?? sha.value
const check = async () => {
if (!path.value) return
status.value = "checking"
const startedAt = performance.now()
let next: FreshnessStatus
const result = await fetchLatestSha(path.value)
if (result.kind === "unauthorized") {
next = "unauthorized"
} else if (result.kind === "offline" || result.sha === null) {
next = "offline"
} else {
latestSha.value = result.sha
lastCheckedAt.value = new Date()
const local = await expectedSha()
next = result.sha === local ? "verified" : "outdated"
}
const elapsed = performance.now() - startedAt
if (elapsed < MIN_SPINNER_MS) {
await new Promise((r) => setTimeout(r, MIN_SPINNER_MS - elapsed))
}
status.value = next
}
const resolveRemoteSha = async (
path: string
): Promise<{ sha: string | null; failureStatus: FreshnessStatus | null }> => {
if (latestSha.value) return { sha: latestSha.value, failureStatus: null }
const result = await fetchLatestSha(path)
if (result.kind === "unauthorized") {
return { sha: null, failureStatus: "unauthorized" }
}
if (result.kind === "offline" || result.sha === null) {
return { sha: null, failureStatus: "offline" }
}
return { sha: result.sha, failureStatus: null }
}
const pullLatest = async (): Promise<{
raw: string | null
failureStatus: FreshnessStatus | null
}> => {
if (!path.value) return { raw: null, failureStatus: null }
const usedCachedSha = latestSha.value !== null
const { sha: remoteSha, failureStatus } = await resolveRemoteSha(path.value)
if (!remoteSha) {
console.warn("pullLatest: could not resolve remote sha", { path: path.value })
if (failureStatus) status.value = failureStatus
return { raw: null, failureStatus }
}
const fileContent = await queryFileContent(user, repo, remoteSha)
if (!fileContent) {
console.warn("pullLatest: failed to fetch blob content", {
path: path.value,
remoteSha,
usedCachedSha
})
// Cached SHA may be stale — clear so the next click re-resolves it.
if (usedCachedSha) latestSha.value = null
status.value = "offline"
return { raw: null, failureStatus: "offline" }
}
const { saveCacheNote } = prepareNoteCache(sha.value, path.value)
await saveCacheNote(fileContent, {
editedSha: remoteSha,
path: path.value
})
store.addFile({ path: path.value, sha: remoteSha })
latestSha.value = remoteSha
lastCheckedAt.value = new Date()
status.value = "verified"
const { getRawContent } = markdownBuilder(sha.value)
return { raw: getRawContent(fileContent), failureStatus: null }
}
// The base blob (common ancestor) must come from its own immutable snapshot,
// never from the path pointer — that holds the latest, which is "theirs".
const getCachedBlob = async (blobSha: string): Promise<string | null> => {
const note = await data.get<DataType.Note, Note>(
generateId(DataType.Note, blobSha)
)
return note?.content ?? null
}
// Resolves the decoded raw text needed for a 3-way merge: `base` is the
// version we edited from (the rejected sha), `theirs` is the current remote.
// Returns null when any piece can't be resolved (offline, missing base).
const resolveMergeSources = async (): Promise<{
base: string
theirs: string
remoteSha: string
} | null> => {
if (!path.value) return null
const { sha: remoteSha } = await resolveRemoteSha(path.value)
if (!remoteSha) return null
// Surface the resolved sha so a modal fallback's Overwrite can use it.
latestSha.value = remoteSha
const theirsBlob = await queryFileContent(user, repo, remoteSha)
if (!theirsBlob) return null
const baseSha = (await getEditedSha()) ?? sha.value
const baseBlob =
(await getCachedBlob(baseSha)) ??
(await queryFileContent(user, repo, baseSha))
if (!baseBlob) return null
const { getRawContent } = markdownBuilder(sha.value)
return {
base: getRawContent(baseBlob),
theirs: getRawContent(theirsBlob),
remoteSha
}
}
return {
status,
lastCheckedAt,
latestSha,
check,
pullLatest,
resolveMergeSources
}
}

View File

@@ -25,7 +25,7 @@ export const useNoteView = () => {
)
const unsubscribeLink = noteEventBus.addEventBusListener(
({ path, currentNoteSHA }) => {
({ path, hash, currentNoteSHA }) => {
const currentFile = store.files.find(
(file) => file.sha === currentNoteSHA
)
@@ -38,7 +38,7 @@ export const useNoteView = () => {
return
}
addStackedNote(currentNoteSHA ?? "", file.sha)
addStackedNote(currentNoteSHA ?? "", file.sha, undefined, hash)
}
)

View File

@@ -1,18 +1,65 @@
import { useAsyncState } from "@vueuse/core"
import { computed, ref } from "vue"
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
import { buildNoteDocs } from "@/modules/note/cache/prepareNoteCache"
import { Note } from "@/modules/note/models/Note"
import { queryFileContent } from "@/modules/repo/services/repo"
import { getMainReadme, queryFileContent } from "@/modules/repo/services/repo"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
const CONCURRENCY = 8
const BULK_FLUSH_SIZE = 50
export type CacheFailureKind =
| "fetch"
| "build"
| "save"
| "readme"
| "unknown"
export type CacheFailure = {
kind: CacheFailureKind
path?: string
sha?: string
message?: string
docIds?: string[]
}
const describeError = (error: unknown): string => {
if (error instanceof Error) return error.message
return String(error)
}
const runWithConcurrency = async <T>(
items: T[],
limit: number,
worker: (item: T) => Promise<void>
) => {
let cursor = 0
const next = async (): Promise<void> => {
const i = cursor++
if (i >= items.length) return
await worker(items[i])
return next()
}
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, next)
)
}
export const useOfflineNotes = () => {
const store = useUserRepoStore()
const totalOfNotes = computed(() => store.files.length)
const noteCompleted = ref(0)
const failures = ref<CacheFailure[]>([])
const failedNotes = computed(() => failures.value.length)
const recordFailure = (failure: CacheFailure) => {
failures.value.push(failure)
console.warn("[offline-cache] failure", failure)
}
const cacheAllNotes = async () => {
const isInitialized = store.user && store.repo && totalOfNotes.value > 0
@@ -29,32 +76,84 @@ export const useOfflineNotes = () => {
const cachedNotesSet = new Set(cachedNotesFromSha.map((note) => note._id))
noteCompleted.value = 0
for (const file of store.files) {
noteCompleted.value++
if (
!file.sha ||
cachedNotesSet.has(data.generateId(DataType.Note, file.sha))
) {
continue
}
const { saveCacheNote } = prepareNoteCache(file.sha, file.path)
const contentFile = await queryFileContent(
store.user,
store.repo,
file.sha
const filesToFetch = store.files.filter(
(file) =>
file.sha && !cachedNotesSet.has(generateId(DataType.Note, file.sha))
)
if (!contentFile) {
return null
noteCompleted.value = store.files.length - filesToFetch.length
failures.value = []
const pendingDocs: Note[] = []
const flush = async () => {
if (!pendingDocs.length) return
const batch = pendingDocs.splice(0, pendingDocs.length)
try {
await data.bulkUpdate(batch)
} catch (error) {
recordFailure({
kind: "save",
message: describeError(error),
docIds: batch
.map((doc) => doc._id)
.filter((id): id is string => Boolean(id))
})
}
}
saveCacheNote(contentFile)
const fetchWork = runWithConcurrency(
filesToFetch,
CONCURRENCY,
async (file) => {
try {
const content = await queryFileContent(
store.user,
store.repo,
file.sha!
)
if (content === null) {
recordFailure({
kind: "fetch",
path: file.path,
sha: file.sha!
})
return
}
let docs: Note[]
try {
docs = buildNoteDocs(file.sha!, file.path, content)
} catch (error) {
recordFailure({
kind: "build",
path: file.path,
sha: file.sha!,
message: describeError(error)
})
return
}
pendingDocs.push(...docs)
if (pendingDocs.length >= BULK_FLUSH_SIZE) {
await flush()
}
} catch (error) {
recordFailure({
kind: "unknown",
path: file.path,
sha: file.sha,
message: describeError(error)
})
} finally {
noteCompleted.value++
}
}
)
const readmeWork = getMainReadme(store.user, store.repo).catch((error) => {
recordFailure({ kind: "readme", message: describeError(error) })
})
await Promise.all([fetchWork, readmeWork])
await flush()
}
const { execute, isLoading } = useAsyncState(cacheAllNotes, null, {
immediate: false
@@ -64,6 +163,8 @@ export const useOfflineNotes = () => {
cacheAllNotes: execute,
isLoading,
totalOfNotes,
noteCompleted
noteCompleted,
failedNotes,
failures
}
}

View File

@@ -10,31 +10,28 @@ export const useOverlay = (listen = true) => {
const isMobile = computed(() => width.value <= MOBILE_BREAKPOINT)
if (listen) {
// In Firefox/Chrome, body is the horizontal scroll container (body has
// computed overflow-x: auto from overflow-y: hidden). In Safari, the
// viewport (documentElement) is used instead. Listen on both.
const updateScroll = () => {
x.value = document.body.scrollLeft || window.scrollX
y.value = document.body.scrollTop || window.scrollY
const mainApp = document.getElementById("main-app")
x.value = mainApp?.scrollLeft ?? 0
y.value = mainApp?.scrollTop ?? 0
}
useEventListener(window, "scroll", updateScroll, {
passive: true,
capture: false
})
useEventListener(document.body, "scroll", updateScroll, {
passive: true,
capture: false
})
useEventListener(
() => document.getElementById("main-app"),
"scroll",
updateScroll,
{ passive: true }
)
}
const scrollToNote = (to: number) => {
const go = () => {
const mainApp = document.getElementById("main-app")
if (!mainApp) return
if (isMobile.value) {
document.body.scrollTop = to
document.documentElement.scrollTop = to
mainApp.scrollTo({ top: to, behavior: "smooth" })
} else {
document.body.scrollLeft = to
document.documentElement.scrollLeft = to
mainApp.scrollTo({ left: to, behavior: "smooth" })
}
}
@@ -43,10 +40,22 @@ export const useOverlay = (listen = true) => {
}, 80)
}
const scrollToElement = (element: HTMLElement, anchorTop?: number) => {
const mainApp = document.getElementById("main-app")
if (mainApp && anchorTop !== undefined) {
mainApp.scrollTop = anchorTop
}
requestAnimationFrame(() => {
element.scrollIntoView({ behavior: "smooth", block: "start" })
})
}
return {
x,
y,
isMobile,
scrollToNote
scrollToNote,
scrollToElement
}
}

View File

@@ -1,34 +1,94 @@
import { useAsyncState } from "@vueuse/core"
import { computed, ref, watch } from "vue"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
import { RepoBase } from "@/modules/repo/interfaces/RepoBase"
import { getOctokit } from "@/modules/repo/services/octo"
export const useRepos = () => {
const { username, accessToken } = useGitHubLogin()
const repos = useAsyncState<RepoBase[]>(async () => {
const PER_PAGE = 30
const STALE_TIME_MS = 20 * 60 * 1000
const repos = ref<RepoBase[]>([])
const isReady = ref(false)
const isLoading = ref(false)
const hasCredentialError = ref(false)
const currentPage = ref(0)
const hasMore = ref(true)
let lastFetchedAt = 0
const { username, accessToken } = useGitHubLogin()
const resetState = () => {
repos.value = []
currentPage.value = 0
hasMore.value = true
isReady.value = false
isLoading.value = false
hasCredentialError.value = false
lastFetchedAt = 0
}
const loadMore = async () => {
if (!accessToken.value || !username.value) {
return []
isReady.value = true
return
}
if (isLoading.value || !hasMore.value) return
isLoading.value = true
try {
const octokit = await getOctokit()
const repoList = await octokit.request("GET /search/repositories", {
q: `user:${username.value}`,
per_page: 100
const nextPage = currentPage.value + 1
const repoList = await octokit.request("GET /user/repos", {
sort: "full_name",
direction: "asc",
affiliation: "owner",
per_page: PER_PAGE,
page: nextPage
})
return repoList.data.items
.map((item) => ({
currentPage.value = nextPage
hasMore.value = repoList.data.length === PER_PAGE
const newItems = repoList.data.map((item) => ({
id: `${item.id}`,
name: item.name,
isPrivate: item.private
isPrivate: item.private ?? false
}))
.sort((a, b) => (a.name < b.name ? -1 : 1))
}, [])
return {
repos: repos.state,
isReady: repos.isReady
repos.value = [...repos.value, ...newItems]
} catch (err: unknown) {
if (
typeof err === "object" &&
err !== null &&
"status" in err &&
(err as { status: number }).status === 401
) {
hasCredentialError.value = true
} else {
throw err
}
} finally {
isReady.value = true
isLoading.value = false
}
}
watch(accessToken, (next, prev) => {
if (next === prev) return
resetState()
if (next && username.value) {
lastFetchedAt = Date.now()
loadMore()
}
})
export const useRepos = () => {
const canLoadMore = computed(() => !isLoading.value && hasMore.value)
const isStale = Date.now() - lastFetchedAt > STALE_TIME_MS
if (!isReady.value || isStale) {
if (isStale && isReady.value) {
resetState()
}
lastFetchedAt = Date.now()
loadMore()
}
return { repos, isReady, hasCredentialError, canLoadMore, loadMore }
}

View File

@@ -1,4 +1,4 @@
import { onMounted, type Ref, watch } from "vue"
import { onMounted, onUnmounted, type Ref, watch } from "vue"
import { getNoteWidth } from "@/constants/note-width"
import { useOverlay } from "@/hooks/useOverlay.hook"
@@ -19,9 +19,9 @@ export const useResizeContainer = (
}
if (isMobile.value) {
container.style.height = `${(stackedNotes.value.length + 1) * 100}vh`
container.style.height = `${(stackedNotes.value.length + 1) * 100}svh`
} else {
container.style.width = `${
container.style.minWidth = `${
getNoteWidth() * (stackedNotes.value.length + 1)
}px`
}
@@ -29,6 +29,11 @@ export const useResizeContainer = (
onMounted(() => {
resizeContainer()
window.addEventListener("resize", resizeContainer)
})
onUnmounted(() => {
window.removeEventListener("resize", resizeContainer)
})
watch(stackedNotes, resizeContainer, {

View File

@@ -18,24 +18,81 @@ export const useRouteQueryStackedNotes = () => {
})
const { height } = useWindowSize()
const { scrollToNote, isMobile } = useOverlay(false)
const { scrollToNote, scrollToElement, isMobile } = useOverlay(false)
const scrollToFocusedNote = (
noteId: string | null = null,
notes: string[] = stackedNotes.value
const scrollToHashInNote = (
cleanSha: string,
hash: string,
smooth: boolean,
attempts = 30
) => {
if (attempts <= 0) {
return
}
const heading = document.querySelector(
`.note-${cleanSha} #${CSS.escape(hash)}`
)
if (heading) {
heading.scrollIntoView({
block: "start",
inline: "nearest",
behavior: smooth ? "smooth" : "auto"
})
return
}
requestAnimationFrame(() => {
scrollToHashInNote(cleanSha, hash, smooth, attempts - 1)
})
}
const scrollToNoteElement = (
cleanNoteId: string,
index: number,
anchorTop?: number,
attempts = 30
) => {
const element = document.querySelector(
`.note-${cleanNoteId}`
) as HTMLElement | null
if (element) {
scrollToElement(element, anchorTop)
return
}
if (attempts <= 0) {
scrollToNote((index + 1) * height.value)
return
}
requestAnimationFrame(() => {
scrollToNoteElement(cleanNoteId, index, anchorTop, attempts - 1)
})
}
type ScrollToFocusedNoteOptions = {
noteId?: string | null
notes?: string[]
hash?: string
smoothHash?: boolean
anchorTop?: number
}
const scrollToFocusedNote = ({
noteId = null,
notes = stackedNotes.value,
hash,
smoothHash = false,
anchorTop
}: ScrollToFocusedNoteOptions = {}) => {
nextTick(() => {
const index = noteId ? notes.findIndex((nid) => nid === noteId) : 0
if (isMobile.value) {
if (noteId) {
const cleanNoteId = noteId.replaceAll(":", "-")
const element = document.querySelector(
`.note-${cleanNoteId}`
) as HTMLElement
const top = (index + 1) * (element?.clientHeight ?? height.value)
scrollToNote(top)
scrollToNoteElement(noteId.replaceAll(":", "-"), index, anchorTop)
} else {
scrollToNote(0)
}
@@ -47,16 +104,29 @@ export const useRouteQueryStackedNotes = () => {
scrollToNote(0)
}
}
if (hash && noteId) {
scrollToHashInNote(noteId.replaceAll(":", "-"), hash, smoothHash)
}
})
}
const addStackedNote = (
currentSha: string,
sha: string,
selector?: string
selector?: string,
hash?: string
) => {
const anchorTop =
document.getElementById("main-app")?.scrollTop ?? undefined
if (stackedNotes.value.includes(sha)) {
scrollToFocusedNote(selector ?? sha)
scrollToFocusedNote({
noteId: selector ?? sha,
hash,
smoothHash: true,
anchorTop
})
return
}
@@ -76,12 +146,28 @@ export const useRouteQueryStackedNotes = () => {
stackedNotes.value = newStackedNotes
}
scrollToFocusedNote(selector ?? sha, stackedNotes.value)
scrollToFocusedNote({ noteId: selector ?? sha, hash, anchorTop })
}
// Advance a note's handle in place when its content changes (edit / pull):
// the live view follows to the new sha, while any previously-shared link
// keeps pointing at the old, now-immutable snapshot.
const replaceStackedNote = (oldSha: string, newSha: string) => {
if (!oldSha || !newSha || oldSha === newSha) {
return
}
if (!stackedNotes.value.includes(oldSha)) {
return
}
stackedNotes.value = stackedNotes.value.map((note) =>
note === oldSha ? newSha : note
)
}
return {
stackedNotes: readonly(stackedNotes),
addStackedNote,
replaceStackedNote,
scrollToFocusedNote
}
}

View File

@@ -0,0 +1,87 @@
import { useDebounceFn } from "@vueuse/core"
import { onUnmounted, Ref, ref, toValue } from "vue"
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
import { FileLine, parseFile, serializeFile } from "@/utils/todotxt"
export const useTodoTxtCommit = ({
user,
repo,
path,
initialContent,
initialSha,
debounceMs = 1000
}: {
user: string
repo: string
path: Ref<string | undefined> | string | undefined
initialContent: Ref<string> | string
initialSha: Ref<string> | string
debounceMs?: number
}) => {
const { updateFile } = useGitHubContent({ user, repo })
const items = ref<FileLine[]>(parseFile(toValue(initialContent)))
const currentSha = ref(toValue(initialSha))
const isCommitting = ref(false)
const hasPendingChanges = ref(false)
// Re-seed from a freshly fetched file. We must not blow away in-flight edits.
const syncContent = (content: string, sha: string) => {
if (!hasPendingChanges.value) {
items.value = parseFile(content)
currentSha.value = sha
}
}
const commitChanges = async () => {
const pathValue = toValue(path)
if (!pathValue || !hasPendingChanges.value) {
return
}
if (isCommitting.value) {
debouncedCommit()
return
}
isCommitting.value = true
const { sha: newSha } = await updateFile({
content: serializeFile(items.value),
path: pathValue,
sha: currentSha.value
})
if (newSha) {
currentSha.value = newSha
hasPendingChanges.value = false
}
isCommitting.value = false
}
const debouncedCommit = useDebounceFn(commitChanges, debounceMs)
const mutate = (mutator: (current: FileLine[]) => FileLine[]) => {
items.value = mutator(items.value)
hasPendingChanges.value = true
debouncedCommit()
}
onUnmounted(() => {
// Flush any pending edit on unmount so navigation doesn't lose work.
if (hasPendingChanges.value) {
commitChanges()
}
})
return {
items,
currentSha,
isCommitting,
hasPendingChanges,
syncContent,
mutate
}
}

View File

@@ -2,7 +2,6 @@ import "notyf/notyf.min.css"
import "./styles/app.css"
import "@/analytics/openpanel"
import { VueQueryPlugin } from "@tanstack/vue-query"
import { createPinia } from "pinia"
import { createApp } from "vue"
import { createI18n } from "vue-i18n"
@@ -19,7 +18,6 @@ const i18n = createI18n({
createApp(App)
.use(router)
.use(VueQueryPlugin)
.use(i18n)
.use(createPinia())
.mount("#app")

View File

@@ -0,0 +1,53 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
vi.mock("@/modules/atproto/getAuthor", () => ({
getAuthor: vi.fn()
}))
import { getAuthor } from "@/modules/atproto/getAuthor"
import { getUrl } from "./getUrl"
describe("getUrl", () => {
beforeEach(() => {
vi.mocked(getAuthor).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("returns null when the author cannot be resolved", async () => {
vi.mocked(getAuthor).mockResolvedValue(null)
expect(
await getUrl({ did: "did:plc:abc", rkey: "r1" })
).toBeNull()
})
it("builds a getRecord URL with the right query params on the author's PDS", async () => {
vi.mocked(getAuthor).mockResolvedValue({
handle: "alice.bsky.social",
pds: "https://pds.example.com"
})
const url = await getUrl({ did: "did:plc:abc", rkey: "rkey1" })
const parsed = new URL(url as string)
expect(parsed.origin).toBe("https://pds.example.com")
expect(parsed.pathname).toBe("/xrpc/com.atproto.repo.getRecord")
expect(parsed.searchParams.get("repo")).toBe("did:plc:abc")
expect(parsed.searchParams.get("collection")).toBe("space.remanso.note")
expect(parsed.searchParams.get("rkey")).toBe("rkey1")
})
it("passes the did to getAuthor", async () => {
vi.mocked(getAuthor).mockResolvedValue({
handle: "h",
pds: "https://pds.example.com"
})
await getUrl({ did: "did:web:example.com", rkey: "r2" })
expect(getAuthor).toHaveBeenCalledWith("did:web:example.com")
})
})

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest"
import { parseAtUri } from "./parseAtUri"
describe("parseAtUri", () => {
it("parses a did:plc AT URI", () => {
expect(
parseAtUri("at://did:plc:abc123/app.bsky.feed.post/rkey-xyz")
).toEqual({
did: "did:plc:abc123",
rkey: "rkey-xyz"
})
})
it("parses a did:web AT URI", () => {
expect(
parseAtUri("at://did:web:example.com/space.remanso.note/note-1")
).toEqual({
did: "did:web:example.com",
rkey: "note-1"
})
})
it("treats rkeys with slashes as a single trailing segment", () => {
expect(
parseAtUri("at://did:plc:abc/space.remanso.note/multi/segment")
).toEqual({
did: "did:plc:abc",
rkey: "multi/segment"
})
})
it("throws when the URI does not start with at://", () => {
expect(() =>
parseAtUri("https://did:plc:abc/collection/rkey")
).toThrow(/Invalid AT URI/)
})
it("throws when the DID prefix is missing", () => {
expect(() => parseAtUri("at://abc/collection/rkey")).toThrow(
/Invalid AT URI/
)
})
it("throws when the collection or rkey is missing", () => {
expect(() => parseAtUri("at://did:plc:abc/onlycollection")).toThrow(
/Invalid AT URI/
)
})
it("throws on empty input", () => {
expect(() => parseAtUri("")).toThrow(/Invalid AT URI/)
})
})

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest"
import { fromShortDid, toShortDid } from "./shortDid"
describe("toShortDid", () => {
it("strips did:plc: prefix", () => {
expect(toShortDid("did:plc:abc123")).toBe("abc123")
})
it("strips did: prefix but keeps the method when non-plc", () => {
expect(toShortDid("did:web:example.com")).toBe("web:example.com")
})
it("returns input unchanged when there is no did: prefix", () => {
expect(toShortDid("abc123")).toBe("abc123")
})
})
describe("fromShortDid", () => {
it("adds did:plc: prefix to bare identifiers", () => {
expect(fromShortDid("abc123")).toBe("did:plc:abc123")
})
it("adds did: prefix when method is already present", () => {
expect(fromShortDid("web:example.com")).toBe("did:web:example.com")
})
it("passes through fully-qualified DIDs unchanged", () => {
expect(fromShortDid("did:plc:abc123")).toBe("did:plc:abc123")
expect(fromShortDid("did:web:example.com")).toBe("did:web:example.com")
})
})
describe("round-trip toShortDid → fromShortDid", () => {
it.each(["did:plc:abc123", "did:web:example.com", "did:key:zXyZ"])(
"is identity for %s",
(did) => {
expect(fromShortDid(toShortDid(did))).toBe(did)
}
)
})

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest"
import { withATProtoImages } from "./withATProtoImages"
const PDS = "https://pds.example.com"
const DID = "did:plc:abc123"
describe("withATProtoImages", () => {
it("rewrites a bafkrei CID image to a getBlob URL", () => {
const out = withATProtoImages(
"![cover](bafkreigh2akiscaildc7r4apx2t6q4t6n6kxjpw3xhqxkfvvbprdaezz4i)",
{ pds: PDS, did: DID }
)
expect(out).toContain(
`${PDS}/xrpc/com.atproto.sync.getBlob`
)
expect(out).toContain("did=did%3Aplc%3Aabc123")
expect(out).toContain(
"cid=bafkreigh2akiscaildc7r4apx2t6q4t6n6kxjpw3xhqxkfvvbprdaezz4i"
)
})
it("preserves the alt text", () => {
const out = withATProtoImages("![my cover](bafkreiabc)", {
pds: PDS,
did: DID
})
expect(out).toMatch(/!\[my cover\]/)
})
it("rewrites multiple images in one pass", () => {
const md = "![a](bafkreiaaa) and ![b](bafkreibbb)"
const out = withATProtoImages(md, { pds: PDS, did: DID })
expect(out.match(/com\.atproto\.sync\.getBlob/g)).toHaveLength(2)
expect(out).toContain("cid=bafkreiaaa")
expect(out).toContain("cid=bafkreibbb")
})
it("leaves regular image URLs untouched", () => {
const md = "![pic](https://example.com/a.png)"
expect(withATProtoImages(md, { pds: PDS, did: DID })).toBe(md)
})
it("leaves CIDs that don't match the bafkrei prefix untouched", () => {
const md = "![x](sha256-abc123)"
expect(withATProtoImages(md, { pds: PDS, did: DID })).toBe(md)
})
it("preserves an empty alt text", () => {
const out = withATProtoImages("![](bafkreiabc)", { pds: PDS, did: DID })
expect(out).toMatch(/^!\[\]/)
})
})

View File

@@ -4,7 +4,7 @@ import { useAsyncState } from "@vueuse/core"
import { addDays, isAfter } from "date-fns"
import { computed, nextTick, watch } from "vue"
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { useFile } from "@/hooks/useFile.hook"
import { useLinks } from "@/hooks/useLinks.hook"
@@ -51,7 +51,7 @@ export const useSpacedRepetitionCards = () => {
const repetition = await data.getOrCreate<
DataType.RepetitionCard,
RepetitionCard
>(data.generateId(DataType.RepetitionCard, cardFile.path), {
>(generateId(DataType.RepetitionCard, cardFile.path), {
$type: DataType.RepetitionCard,
level: 1,
repeatDate: new Date(),

View File

@@ -1,17 +1,17 @@
import { useAsyncState } from "@vueuse/core"
import { computed } from "vue"
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { History } from "@/data/models/History"
const HISTORY_ID = data.generateId(DataType.History, "history")
const HISTORY_ID = generateId(DataType.History, "history")
export const useLastVisitedRepos = () => {
const history = useAsyncState(
() =>
data.get<DataType.History, History>(
data.generateId(DataType.History, "history")
generateId(DataType.History, "history")
),
null
)

View File

@@ -1,10 +1,10 @@
import { Ref, toValue } from "vue"
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { History } from "@/data/models/History"
const HISTORY_ID = data.generateId(DataType.History, "history")
const HISTORY_ID = generateId(DataType.History, "history")
const MAX_REPO_HISTORY = 10
export const useVisitRepo = (newRepo: {

View File

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

View File

@@ -1,4 +1,4 @@
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { Note } from "@/modules/note/models/Note"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
@@ -11,11 +11,29 @@ type NoteCacheResult =
| { note: Note; from: "path" }
| { note: null; from: null }
export const buildNoteDocs = (
sha: string,
path: string | undefined,
content: string,
editedSha?: string
): Note[] => {
const base: Note = {
_id: generateId(DataType.Note, sha),
$type: DataType.Note,
content,
editedSha,
path
}
return path
? [base, { ...base, _id: generateId(DataType.Note, path) }]
: [base]
}
export const prepareNoteCache = (sha: string, path?: string) => {
const store = useUserRepoStore()
const noteId = data.generateId(DataType.Note, sha)
const notePath = path ? data.generateId(DataType.Note, path) : null
const noteId = generateId(DataType.Note, sha)
const notePath = path ? generateId(DataType.Note, path) : null
const getCachedNote = async (): Promise<NoteCacheResult> => {
const note = await data.get<DataType.Note, Note>(noteId)
@@ -44,11 +62,18 @@ export const prepareNoteCache = (sha: string, path?: string) => {
content: string,
params?: { editedSha?: string; path?: string }
) => {
// Content is addressed by its OWN sha so snapshots stay immutable: an edit
// writes under the new sha and never overwrites the previously-viewed one.
// The path key (notePath) always holds the latest content (live pointer).
const contentId = params?.editedSha
? generateId(DataType.Note, params.editedSha)
: noteId
const newNote: Note = {
_id: noteId,
_id: contentId,
$type: DataType.Note,
content,
editedSha: params?.editedSha
editedSha: params?.editedSha,
path: params?.path ?? path
}
if (params && params.path) {

View File

@@ -1,14 +1,27 @@
<script setup lang="ts">
import { useOfflineNotes } from "@/hooks/useOfflineNotes.hook"
import { confirmMessage } from "@/utils/notif"
import { confirmMessage, errorMessage } from "@/utils/notif"
const { cacheAllNotes, isLoading, totalOfNotes, noteCompleted } =
useOfflineNotes()
const {
cacheAllNotes,
isLoading,
totalOfNotes,
noteCompleted,
failedNotes,
failures
} = useOfflineNotes()
const confirmBeforeCachingAllNotes = async () => {
confirm("Do you want to cache all notes?")
await cacheAllNotes()
if (failedNotes.value > 0) {
console.table(failures.value)
errorMessage(
`${failedNotes.value} of ${totalOfNotes.value} note(s) could not be cached — try again`
)
} else {
confirmMessage("✅ All notes have been locally saved")
}
}
</script>

View File

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

View File

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

View File

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

View File

@@ -1,64 +0,0 @@
import { initContract } from "@ts-rest/core"
import { initQueryClient } from "@ts-rest/vue-query"
import { type } from "arktype"
const PublicNoteListItem = type({
did: "string",
rkey: "string",
title: "string",
publishedAt: "string",
createdAt: "string"
})
export type PublicNoteListItem = typeof PublicNoteListItem.infer
const PublicNote = type({
did: "string",
rkey: "string",
title: "string",
content: "string",
publishedAt: "string",
createdAt: "string"
})
export type PublicNote = typeof PublicNote.infer
const contract = initContract()
export const noteRouter = contract.router({
noteLists: {
method: "GET",
path: "/notes",
query: type({
cursor: "string | undefined",
limit: "number | undefined"
}),
responses: {
200: type({
notes: PublicNoteListItem.array()
})
},
summary: "List all notes"
},
noteListsByDid: {
method: "GET",
path: "/:did/notes",
pathParams: type({
did: "string"
}),
query: type({
cursor: "string | undefined",
limit: "number | undefined"
}),
responses: {
200: type({
notes: PublicNoteListItem.array()
})
},
summary: "List all notes"
}
})
export const client = initQueryClient(noteRouter, {
baseUrl: "https://api.remanso.space"
})

View File

@@ -1,25 +1,28 @@
import { computed, onMounted, ref } from "vue"
import { data } from "@/data/data"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { useRepos } from "@/hooks/useRepos.hook"
import { RepoBase } from "@/modules/repo/interfaces/RepoBase"
import { FavoriteRepo } from "@/modules/repo/models/FavoriteRepo"
export const useFavoriteRepos = () => {
const { repos } = useRepos()
const savedRepos = ref<FavoriteRepo[]>([])
const getFavorites = async () => {
savedRepos.value = await data.getAll<DataType.FavoriteRepo, FavoriteRepo>({
prefix: DataType.FavoriteRepo,
keys: repos.value.map((repo) => repo.id)
prefix: DataType.FavoriteRepo
})
}
const savedFavoriteRepos = computed(() =>
savedRepos.value.filter((repo) => repo.isFavorite)
)
const savedFavoriteRepos = computed(() => {
const seen = new Map<string, FavoriteRepo>()
for (const repo of savedRepos.value) {
if (repo.isFavorite && !seen.has(repo.name)) {
seen.set(repo.name, repo)
}
}
return [...seen.values()]
})
onMounted(() => {
getFavorites()
@@ -27,7 +30,7 @@ export const useFavoriteRepos = () => {
const toggleFavorite = async (repo: RepoBase, isFavorite: boolean) => {
const favorite: FavoriteRepo = {
_id: data.generateId(DataType.FavoriteRepo, repo.id),
_id: generateId(DataType.FavoriteRepo, repo.id),
$type: DataType.FavoriteRepo,
isFavorite,
name: repo.name,

View File

@@ -1,31 +1,38 @@
import { computed } from "vue"
import { DataType } from "@/data/DataType.enum"
import { useRepos } from "@/hooks/useRepos.hook"
import { useFavoriteRepos } from "@/modules/repo/hooks/useFavoriteRepos.hook"
import { RepoBase } from "@/modules/repo/interfaces/RepoBase"
const FAVORITE_ID_PREFIX = `${DataType.FavoriteRepo}-`
export const useRepoList = () => {
const { savedFavoriteRepos, addFavorite, removeFavorite } = useFavoriteRepos()
const { repos } = useRepos()
const { repos, canLoadMore, loadMore } = useRepos()
const favoriteRepos = computed(() => {
return repos.value.filter((repo) =>
savedFavoriteRepos.value.find(
(fav) => fav._id?.includes(repo.id) ?? false
const favoriteRepos = computed<RepoBase[]>(() =>
savedFavoriteRepos.value.map((fav) => ({
id: fav._id?.startsWith(FAVORITE_ID_PREFIX)
? fav._id.slice(FAVORITE_ID_PREFIX.length)
: (fav._id ?? ""),
name: fav.name,
isPrivate: fav.isPrivate
}))
)
)
})
const otherRepos = computed(() => {
return repos.value.filter(
(repo) => !favoriteRepos.value.find((favorite) => favorite.id === repo.id)
)
})
const favoriteCheckboxes = computed(() =>
favoriteRepos.value.map((favorite) => favorite.id)
)
const otherRepos = computed(() => {
const starredIds = new Set(favoriteCheckboxes.value)
const starredNames = new Set(favoriteRepos.value.map((r) => r.name))
return repos.value.filter(
(repo) => !starredIds.has(repo.id) && !starredNames.has(repo.name)
)
})
const toggleCheckbox = async (repo: RepoBase) => {
if (favoriteCheckboxes.value.includes(repo.id)) {
await removeFavorite(repo)
@@ -38,6 +45,8 @@ export const useRepoList = () => {
favoriteRepos,
otherRepos,
favoriteCheckboxes,
toggleCheckbox
toggleCheckbox,
canLoadMore,
loadMore
}
}

View File

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

View File

@@ -0,0 +1,62 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
vi.mock("@/modules/user/service/signIn", () => ({
getAccessToken: vi.fn().mockResolvedValue({ token: "test-token" })
}))
import { DEFAULT_OCTOKIT_TIMEOUT_MS, getOctokit } from "./octo"
const okResponse = () =>
new Response("{}", {
status: 200,
headers: { "content-type": "application/json" }
})
describe("getOctokit timeout hook", () => {
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it("injects an AbortSignal.timeout into requests by default", async () => {
const timeoutSpy = vi.spyOn(AbortSignal, "timeout")
let receivedSignal: AbortSignal | undefined
const mockFetch = vi.fn(async (_url: string, init: RequestInit) => {
receivedSignal = init.signal ?? undefined
return okResponse()
})
const octokit = await getOctokit()
await octokit.request("GET /user", {
request: { fetch: mockFetch as unknown as typeof fetch }
})
expect(timeoutSpy).toHaveBeenCalledWith(DEFAULT_OCTOKIT_TIMEOUT_MS)
expect(receivedSignal).toBeInstanceOf(AbortSignal)
})
it("respects a caller-provided signal and does not overwrite it", async () => {
const timeoutSpy = vi.spyOn(AbortSignal, "timeout")
const controller = new AbortController()
let receivedSignal: AbortSignal | undefined
const mockFetch = vi.fn(async (_url: string, init: RequestInit) => {
receivedSignal = init.signal ?? undefined
return okResponse()
})
const octokit = await getOctokit()
await octokit.request("GET /user", {
request: {
fetch: mockFetch as unknown as typeof fetch,
signal: controller.signal
}
})
expect(timeoutSpy).not.toHaveBeenCalled()
expect(receivedSignal).toBe(controller.signal)
})
})

Some files were not shown because too many files have changed in this diff Show More