Compare commits

12 Commits

Author SHA1 Message Date
Julien Calixte
f89c2fa776 style: just changed the view latest button
All checks were successful
CI / verify (push) Successful in 2m7s
2026-07-12 12:36:54 +02:00
Julien Calixte
c21619c53a feat(notes): drop the redundant Close button from share popup
All checks were successful
CI / verify (push) Successful in 1m6s
The backdrop already dismisses the dialog on outside click.
2026-07-11 12:57:44 +02:00
Julien Calixte
b34b5851cd fix(notes): color the share trigger icon with accent
Scoped styles in HeaderNote don't cross into the ShareNote child
component, so text-base-content painted the icon white/black instead
of matching the other header icons.
2026-07-11 12:57:41 +02:00
Julien Calixte
9581dc88aa fix(notes): don't save empty content over a note that hasn't loaded
All checks were successful
CI / verify (push) Successful in 1m9s
Entering and exiting edit mode while a note was still loading (or after a
failed fetch) triggered a save of the empty placeholder, clobbering the
real file on GitHub with an empty commit. Gate the dirty check, performSave,
and the edit button on a loaded baseline.
2026-07-11 12:51:27 +02:00
Julien Calixte
40b0af6b01 style(welcome): tighten landing copy and reduce em-dash use
All checks were successful
CI / verify (push) Successful in 1m7s
Rework the lede parenthetical and thin the calm/quiet/pool
metaphor where it doubled up on itself, to soften AI-writing tells.
2026-07-11 12:21:15 +02:00
Julien Calixte
2592b19c8f docs: require Tabler icons for all icons
All checks were successful
CI / verify (push) Successful in 1m7s
2026-07-11 12:12:33 +02:00
Julien Calixte
99edff828a feat(notes): use Tabler pin/repeat icons in the share popup
Replace the 📌/🔄 emoji with inline Tabler SVGs colored via --color-accent
to match the note-header icons, and reword the always-latest description so
the two options no longer share the same em-dash phrasing.
2026-07-11 12:12:30 +02:00
Julien Calixte
b720759c89 fix(macroplan): pack grid rows to content, don't stretch to fill
All checks were successful
CI / verify (push) Successful in 1m9s
The mobile 100svh note height cascades into the grid and stretched the
auto row tracks; align-content: start keeps rows at their content height.
2026-07-11 11:13:29 +02:00
Julien Calixte
a88d0dbac7 feat(notes): share an always-latest link, not just a snapshot
Some checks failed
CI / verify (push) Has been cancelled
The Share button now offers two links: the pinned snapshot (default) and
an "always latest" link that carries note paths in `?liveNotes` instead of
blob shas. On open they resolve to the current shas against the HEAD file
list and the URL is rewritten to the ordinary pinned form, so freshness,
editing and the snapshot banner keep working unchanged.
2026-07-11 11:11:51 +02:00
Julien Calixte
70fbe5156f fix(notes): apply shikiji dark palette in dark theme
All checks were successful
CI / verify (push) Successful in 1m4s
Shikiji emits the dark colors as --shiki-dark* inline vars but the
activation CSS was missing, so code blocks kept the vitesse-light
white background and the base-content line numbers were white-on-white.
2026-07-08 00:40:40 +02:00
Julien Calixte
049ce01ab5 feat(notes): scroll to the last stacked note on page load
All checks were successful
CI / verify (push) Successful in 1m5s
Shared links with stackedNotes landed on the readme, forcing readers to
scroll past every note to reach the one that matters — especially long
on mobile where notes stack vertically.
2026-07-08 00:30:56 +02:00
Julien Calixte
f9906cba30 feat(notes): highlight rust code blocks and .rs file views
All checks were successful
CI / verify (push) Successful in 1m7s
2026-07-08 00:12:17 +02:00
15 changed files with 442 additions and 34 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,5 @@
<script lang="ts" setup>
import {
computed,
defineAsyncComponent,
onMounted,
ref,
watch
} from "vue"
import { computed, defineAsyncComponent, onMounted, ref, watch } from "vue"
import { useEditionMode } from "@/hooks/useEditionMode"
import { useFile } from "@/hooks/useFile.hook"
@@ -188,6 +182,19 @@ const loadNote = async () => {
onMounted(loadNote)
// A note only has a trustworthy baseline once it has finished loading. While
// it is still loading (or after a failed load) rawContent holds the empty
// placeholder and initialRawContent is null, so a naive
// `rawContent !== initialRawContent` check reads as a huge edit — and saving
// that would clobber the real file on GitHub with an empty commit. Gate every
// "is this dirty?" decision on a loaded baseline.
const isDirty = computed(
() =>
loadStatus.value === "ready" &&
initialRawContent.value !== null &&
rawContent.value !== initialRawContent.value
)
watch(
path,
(p) => {
@@ -204,8 +211,7 @@ useMarkdownPostRender(content, () => `.note-${sha.value}`, {
macroplan: true,
mermaid: () => rawContent.value.includes("```mermaid"),
shikiji: () => isMarkdown.value && rawContent.value.includes("```"),
images: () =>
/\!\[.*?\]\(.*?\)/.test(rawContent.value) ? props.sha : null,
images: () => (/\!\[.*?\]\(.*?\)/.test(rawContent.value) ? props.sha : null),
triggers: [mode]
})
@@ -215,6 +221,13 @@ const performSave = async (overrideSha?: string) => {
return
}
// Defence in depth: never push content we didn't successfully load, or we'd
// overwrite the file on GitHub with the empty placeholder.
if (loadStatus.value !== "ready" || initialRawContent.value === null) {
console.warn("refusing to save a note that hasn't finished loading")
return
}
const editedSha = overrideSha ?? (await getEditedSha()) ?? sha.value
const { sha: newSha, conflict } = await updateFile({
content: rawContent.value,
@@ -287,8 +300,7 @@ watch(mode, async (newMode) => {
return
}
const hasUserFinishedToEdit =
newMode === "read" && rawContent.value !== initialRawContent.value
const hasUserFinishedToEdit = newMode === "read" && isDirty.value
if (!hasUserFinishedToEdit) {
return
@@ -336,7 +348,7 @@ const onBadgeClick = async () => {
return
}
const hasUnsavedEdits = rawContent.value !== initialRawContent.value
const hasUnsavedEdits = isDirty.value
if (hasUnsavedEdits) {
await handleConflict()
return
@@ -377,7 +389,7 @@ const onBadgeClick = async () => {
class="action"
/>
<button
v-if="isMarkdown && canPush"
v-if="isMarkdown && canPush && loadStatus === 'ready'"
class="action button is-text is-light"
:class="{ 'is-link': mode === 'edit' }"
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''"
@@ -489,7 +501,7 @@ const onBadgeClick = async () => {
<span>You're viewing an older shared version.</span>
<button
type="button"
class="snapshot-banner-action"
class="btn bt-sm btn-soft btn-info"
@click="viewLatest"
>
View latest
@@ -588,16 +600,6 @@ $border-color: rgba(18, 19, 58, 0.2);
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 {
margin: 0;

View File

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

View File

@@ -194,6 +194,7 @@ export const useShikiji = (): Promise<void> => {
"html",
"css",
"json",
"rust",
{
...alloyGrammar,
name: "alloy",

View File

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

View File

@@ -111,6 +111,16 @@ export const useRouteQueryStackedNotes = () => {
})
}
// Focus the deepest note when opening a shared link with stacked notes,
// instead of landing on the readme and forcing a manual scroll (especially
// long on mobile where notes stack vertically).
const scrollToLastStackedNote = () => {
const lastNote = stackedNotes.value.at(-1)
if (lastNote) {
scrollToFocusedNote({ noteId: lastNote })
}
}
const addStackedNote = (
currentSha: string,
sha: string,
@@ -168,6 +178,7 @@ export const useRouteQueryStackedNotes = () => {
stackedNotes: readonly(stackedNotes),
addStackedNote,
replaceStackedNote,
scrollToFocusedNote
scrollToFocusedNote,
scrollToLastStackedNote
}
}

View File

@@ -232,6 +232,10 @@ const bandStyle = computed(() => ({
display: grid;
width: max-content;
min-width: 100%;
/* Never let a too-tall container (e.g. the mobile 100svh note height that
cascades in) stretch the auto row tracks — pack rows to their content
height at the top. */
align-content: start;
font-variant-ligatures: none;
}

View File

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

View File

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

View File

@@ -204,6 +204,28 @@ pre {
user-select: none;
}
/* Shikiji renders dual-theme tokens but only inlines the light colors;
the dark palette lives in --shiki-dark* vars that we must apply ourselves.
Without this, dark theme keeps the white code background and the
base-content line numbers become white-on-white. */
[data-theme="dracula"] :is(.shiki, .shiki span) {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) :is(.shiki, .shiki span) {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
}
pre.mermaid {
display: flex;
justify-content: center;

View File

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

View File

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