Compare commits
14 Commits
5386fa66ea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f89c2fa776 | ||
|
|
c21619c53a | ||
|
|
b34b5851cd | ||
|
|
9581dc88aa | ||
|
|
40b0af6b01 | ||
|
|
2592b19c8f | ||
|
|
99edff828a | ||
|
|
b720759c89 | ||
|
|
a88d0dbac7 | ||
|
|
70fbe5156f | ||
|
|
049ce01ab5 | ||
|
|
f9906cba30 | ||
|
|
1b6c7542d7 | ||
|
|
70f9a6b718 |
@@ -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
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
"retrobus": "^1.9.4",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"shikiji-core": "0.10.2",
|
||||
"smol-toml": "^1.7.0",
|
||||
"valibot": "^1.4.2",
|
||||
"vue": "^3.5.18",
|
||||
"vue-i18n": "^11.1.11",
|
||||
"vue-router": "^4.5.1",
|
||||
|
||||
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
@@ -137,6 +137,12 @@ importers:
|
||||
shikiji-core:
|
||||
specifier: 0.10.2
|
||||
version: 0.10.2
|
||||
smol-toml:
|
||||
specifier: ^1.7.0
|
||||
version: 1.7.0
|
||||
valibot:
|
||||
specifier: ^1.4.2
|
||||
version: 1.4.2(typescript@5.9.3)
|
||||
vue:
|
||||
specifier: ^3.5.18
|
||||
version: 3.5.18(typescript@5.9.3)
|
||||
@@ -5933,6 +5939,10 @@ packages:
|
||||
resolution: {integrity: sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
smol-toml@1.7.0:
|
||||
resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
snapdragon-node@2.1.1:
|
||||
resolution: {integrity: sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -6449,6 +6459,14 @@ packages:
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
valibot@1.4.2:
|
||||
resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==}
|
||||
peerDependencies:
|
||||
typescript: '>=5'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
validate-npm-package-license@3.0.4:
|
||||
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
|
||||
|
||||
@@ -13005,6 +13023,8 @@ snapshots:
|
||||
|
||||
smob@1.6.1: {}
|
||||
|
||||
smol-toml@1.7.0: {}
|
||||
|
||||
snapdragon-node@2.1.1:
|
||||
dependencies:
|
||||
define-property: 1.0.0
|
||||
@@ -13533,6 +13553,10 @@ snapshots:
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
valibot@1.4.2(typescript@5.9.3):
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
validate-npm-package-license@3.0.4:
|
||||
dependencies:
|
||||
spdx-correct: 3.2.0
|
||||
|
||||
@@ -11,6 +11,7 @@ import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
|
||||
import { useNoteView } from "@/hooks/useNoteView.hook"
|
||||
import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
|
||||
import { useResolveLiveNotes } from "@/hooks/useResolveLiveNotes.hook"
|
||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||
import { useVisitRepo } from "@/modules/history/hooks/useVisitRepo.hook"
|
||||
import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue"
|
||||
@@ -43,7 +44,12 @@ useUserSettings()
|
||||
const { visitRepo } = useVisitRepo({ user: user, repo: repo })
|
||||
const { toHTML } = markdownBuilder(repo)
|
||||
const { listenToClick } = useLinks("note-display")
|
||||
const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
|
||||
const { stackedNotes, scrollToFocusedNote, scrollToLastStackedNote } =
|
||||
useRouteQueryStackedNotes()
|
||||
|
||||
// A living link arrives with paths in `?liveNotes`; resolve them to the latest
|
||||
// shas, then focus the deepest note just like an ordinary shared link does.
|
||||
useResolveLiveNotes(() => scrollToLastStackedNote())
|
||||
|
||||
const { titles } = useNoteView()
|
||||
const { isLogged } = useGitHubLogin()
|
||||
@@ -62,7 +68,8 @@ const hasContent = computed(() => !!renderedContent.value)
|
||||
|
||||
useMarkdownPostRender(renderedContent, () => ".note-display", {
|
||||
onReady: () => listenToClick(),
|
||||
tikz: true
|
||||
tikz: true,
|
||||
macroplan: true
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -77,7 +84,10 @@ const retryLoad = () => {
|
||||
store.setUserRepo(props.user, props.repo)
|
||||
}
|
||||
|
||||
onMounted(() => visitRepo())
|
||||
onMounted(() => {
|
||||
visitRepo()
|
||||
scrollToLastStackedNote()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
store.resetFiles()
|
||||
|
||||
@@ -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 } }"
|
||||
|
||||
197
src/components/ShareNote.vue
Normal file
197
src/components/ShareNote.vue
Normal 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>
|
||||
@@ -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) => {
|
||||
@@ -201,10 +208,10 @@ const { mode, toggleMode } = useEditionMode()
|
||||
useMarkdownPostRender(content, () => `.note-${sha.value}`, {
|
||||
onReady: () => listenToClick(),
|
||||
tikz: true,
|
||||
macroplan: true,
|
||||
mermaid: () => rawContent.value.includes("```mermaid"),
|
||||
shikiji: () => isMarkdown.value && rawContent.value.includes("```"),
|
||||
images: () =>
|
||||
/\!\[.*?\]\(.*?\)/.test(rawContent.value) ? props.sha : null,
|
||||
images: () => (/\!\[.*?\]\(.*?\)/.test(rawContent.value) ? props.sha : null),
|
||||
triggers: [mode]
|
||||
})
|
||||
|
||||
@@ -214,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,
|
||||
@@ -286,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
|
||||
@@ -335,7 +348,7 @@ const onBadgeClick = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const hasUnsavedEdits = rawContent.value !== initialRawContent.value
|
||||
const hasUnsavedEdits = isDirty.value
|
||||
if (hasUnsavedEdits) {
|
||||
await handleConflict()
|
||||
return
|
||||
@@ -376,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)' : ''"
|
||||
@@ -488,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
|
||||
@@ -587,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;
|
||||
|
||||
|
||||
@@ -79,7 +79,8 @@ const content = computed(() =>
|
||||
|
||||
useMarkdownPostRender(content, () => `.note-${classNameId.value}`, {
|
||||
onReady: () => listenToClick(),
|
||||
tikz: true
|
||||
tikz: true,
|
||||
macroplan: true
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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' }"
|
||||
|
||||
@@ -95,6 +95,37 @@ const markdownItTikzExtractor = (md: MarkdownIt) => {
|
||||
}
|
||||
}
|
||||
|
||||
const markdownItMacroplanExtractor = (md: MarkdownIt) => {
|
||||
const defaultFence =
|
||||
md.renderer.rules.fence ||
|
||||
function (
|
||||
tokens: Array<Token>,
|
||||
index: number,
|
||||
options: Options,
|
||||
_: unknown,
|
||||
self: Renderer
|
||||
) {
|
||||
return self.renderToken(tokens, index, options)
|
||||
}
|
||||
|
||||
md.renderer.rules.fence = function (
|
||||
tokens: Array<Token>,
|
||||
index: number,
|
||||
options: Options,
|
||||
env: unknown,
|
||||
self: Renderer
|
||||
) {
|
||||
const token = tokens[index]
|
||||
|
||||
if (token.info.trim() === "macroplan") {
|
||||
const encoded = encodeUTF8ToBase64(token.content)
|
||||
return `<div class="macroplan-block" data-macroplan-source="${encoded}"><span class="macroplan-loading">Rendering macroplan…</span></div>\n`
|
||||
}
|
||||
|
||||
return defaultFence(tokens, index, options, env, self)
|
||||
}
|
||||
}
|
||||
|
||||
const slugger = new GithubSlugger()
|
||||
|
||||
let tabGroupCounter = 0
|
||||
@@ -107,6 +138,7 @@ const md = new MarkdownIt({
|
||||
})
|
||||
.use(markdownItMermaidExtractor)
|
||||
.use(markdownItTikzExtractor)
|
||||
.use(markdownItMacroplanExtractor)
|
||||
.use(html5Media)
|
||||
.use(blockEmbedPlugin, {
|
||||
youtube: {
|
||||
@@ -162,6 +194,7 @@ export const useShikiji = (): Promise<void> => {
|
||||
"html",
|
||||
"css",
|
||||
"json",
|
||||
"rust",
|
||||
{
|
||||
...alloyGrammar,
|
||||
name: "alloy",
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
runTikz,
|
||||
useShikiji
|
||||
} from "@/hooks/useMarkdown.hook"
|
||||
import { runMacroplan } from "@/modules/macroplan/runMacroplan"
|
||||
import { attachSvgDownloads } from "@/utils/svgDownload"
|
||||
|
||||
interface MarkdownPostRenderOptions {
|
||||
onReady?: () => void
|
||||
tikz?: boolean
|
||||
macroplan?: boolean
|
||||
mermaid?: () => boolean
|
||||
shikiji?: () => boolean
|
||||
images?: () => string | null | undefined
|
||||
@@ -44,6 +46,10 @@ export const useMarkdownPostRender = (
|
||||
renderJobs.push(runMermaid(`${scope} .mermaid`))
|
||||
}
|
||||
|
||||
if (options.macroplan) {
|
||||
renderJobs.push(runMacroplan(`${scope} .macroplan-block`))
|
||||
}
|
||||
|
||||
if (options.shikiji?.()) {
|
||||
void useShikiji()
|
||||
}
|
||||
|
||||
50
src/hooks/useResolveLiveNotes.hook.ts
Normal file
50
src/hooks/useResolveLiveNotes.hook.ts
Normal 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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
436
src/modules/macroplan/components/MacroplanGrid.vue
Normal file
436
src/modules/macroplan/components/MacroplanGrid.vue
Normal file
@@ -0,0 +1,436 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
|
||||
import type { FeatureRow, MarkerKind,Plan } from "../model/types"
|
||||
import { type WeekId,weekLabel } from "../model/week"
|
||||
|
||||
const props = defineProps<{ plan: Plan }>()
|
||||
|
||||
type Tone = "success" | "warning" | "error" | "neutral"
|
||||
|
||||
const GLYPH: Record<MarkerKind, string> = {
|
||||
original: "◯",
|
||||
reestimate: "△",
|
||||
"delivered-on-time": "◉",
|
||||
"delivered-late": "▲",
|
||||
}
|
||||
const MARKER_CLASS: Record<MarkerKind, string> = {
|
||||
original: "text-base-content/50",
|
||||
reestimate: "text-warning",
|
||||
"delivered-on-time": "text-success",
|
||||
"delivered-late": "text-error",
|
||||
}
|
||||
// When two markers land on the same week, the higher rank owns the cell.
|
||||
const RANK: Record<MarkerKind, number> = {
|
||||
"delivered-late": 3,
|
||||
"delivered-on-time": 3,
|
||||
reestimate: 2,
|
||||
original: 1,
|
||||
}
|
||||
const TONE_TEXT: Record<Tone, string> = {
|
||||
success: "text-success",
|
||||
warning: "text-warning",
|
||||
error: "text-error",
|
||||
neutral: "text-primary",
|
||||
}
|
||||
const TONE_BORDER: Record<Tone, string> = {
|
||||
success: "border-success",
|
||||
warning: "border-warning",
|
||||
error: "border-error",
|
||||
neutral: "border-primary",
|
||||
}
|
||||
const TONE_DOT: Record<Tone, string> = {
|
||||
success: "bg-success",
|
||||
warning: "bg-warning",
|
||||
error: "bg-error",
|
||||
neutral: "bg-base-300",
|
||||
}
|
||||
|
||||
// Layout constants (must match the CSS vars --name-w / --wk) for stacking math.
|
||||
const NAME_W = 9 * 16
|
||||
const WK = 3.5 * 16
|
||||
const BAND_CHAR = 6.6 // ≈ Fira Code advance (px) at the band font size
|
||||
|
||||
const weeks = computed(() => props.plan.weeks)
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `var(--name-w) repeat(${weeks.value.length}, var(--wk)) minmax(9rem, 1fr)`,
|
||||
}))
|
||||
|
||||
function tone(row: FeatureRow): Tone {
|
||||
if (row.delivered) return row.onTime ? "success" : "error"
|
||||
if (row.status === "on-track") return "success"
|
||||
if (row.status === "at-risk") return "warning"
|
||||
if (row.status === "off-track") return "error"
|
||||
return "neutral"
|
||||
}
|
||||
|
||||
function statusWord(row: FeatureRow): string {
|
||||
return row.status ? row.status.replace("-", " ") : "in flight"
|
||||
}
|
||||
|
||||
function markerAt(row: FeatureRow, w: WeekId): MarkerKind | null {
|
||||
let best: MarkerKind | null = null
|
||||
for (const m of row.markers) {
|
||||
if (m.week === w && (best === null || RANK[m.kind] > RANK[best])) best = m.kind
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
interface Cell {
|
||||
// how far the bar line runs within this cell: none, center→right,
|
||||
// left→center, or full width
|
||||
line: "none" | "right" | "left" | "full"
|
||||
isStart: boolean
|
||||
glyph: string
|
||||
glyphCls: string
|
||||
}
|
||||
|
||||
// rows × weeks render matrix, computed once.
|
||||
const matrix = computed<Cell[][]>(() =>
|
||||
props.plan.rows.map((row) =>
|
||||
weeks.value.map((w) => {
|
||||
const m = markerAt(row, w)
|
||||
const inBar = w >= row.startWeek && w <= row.barEndWeek
|
||||
let line: Cell["line"] = "none"
|
||||
if (inBar) {
|
||||
const isStart = w === row.startWeek
|
||||
const isEnd = w === row.barEndWeek
|
||||
line = isStart && isEnd ? "none" : isStart ? "right" : isEnd ? "left" : "full"
|
||||
}
|
||||
return {
|
||||
line,
|
||||
isStart: inBar && w === row.startWeek,
|
||||
glyph: m ? GLYPH[m] : "",
|
||||
glyphCls: m ? MARKER_CLASS[m] : "",
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// Per-week column metadata for the header + the now / milestone column rules.
|
||||
const cols = computed(() =>
|
||||
weeks.value.map((w) => ({
|
||||
w,
|
||||
label: weekLabel(w),
|
||||
isNow: props.plan.nowInRange && w === props.plan.nowWeek,
|
||||
isMilestone: props.plan.milestones.some((m) => m.week === w),
|
||||
})),
|
||||
)
|
||||
|
||||
function colClass(i: number): string {
|
||||
const c = cols.value[i]
|
||||
if (c.isNow) return "col-now"
|
||||
if (c.isMilestone) return "col-ms"
|
||||
return ""
|
||||
}
|
||||
|
||||
// Milestone label flags above the axis, greedily stacked onto extra rows so
|
||||
// labels in nearby weeks never overlap. `col` is the 1-based grid column.
|
||||
const milestoneFlags = computed(() => {
|
||||
const items = props.plan.milestones
|
||||
.map((m) => ({ m, col: weeks.value.indexOf(m.week) + 2 }))
|
||||
.filter((x) => x.col >= 2)
|
||||
.sort((a, b) => a.col - b.col)
|
||||
const rowEnd: number[] = [] // px x-extent of the last flag placed in each row
|
||||
return items.map((x) => {
|
||||
const startX = NAME_W + (x.col - 2) * WK
|
||||
const text = `◆ ${x.m.name}` + (x.m.unmet.length ? ` · ${x.m.unmet.length} unmet` : "")
|
||||
const width = text.length * BAND_CHAR + 14
|
||||
let row = 0
|
||||
while (row < rowEnd.length && rowEnd[row] > startX - 6) row++
|
||||
rowEnd[row] = startX + width
|
||||
const title = x.m.unmet.length
|
||||
? `${x.m.name} — unmet: ${x.m.unmet.join(", ")}`
|
||||
: `${x.m.name} — all required features met`
|
||||
return { name: x.m.name, unmet: x.m.unmet, col: x.col, row: row + 1, title }
|
||||
})
|
||||
})
|
||||
const bandRows = computed(() => milestoneFlags.value.reduce((n, f) => Math.max(n, f.row), 0))
|
||||
const bandStyle = computed(() => ({
|
||||
gridTemplateColumns: `var(--name-w) repeat(${weeks.value.length}, var(--wk))`,
|
||||
gridTemplateRows: `repeat(${bandRows.value}, 1.15rem)`,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="macroplan overflow-auto rounded-box border border-base-300 bg-base-100">
|
||||
<!-- milestone band: stacked label flags with leader lines down to the axis -->
|
||||
<div v-if="milestoneFlags.length" class="ms-band" :style="bandStyle">
|
||||
<template v-for="f in milestoneFlags" :key="f.name + '-' + f.col">
|
||||
<i class="ms-lead" :style="{ gridColumn: f.col, gridRow: f.row + ' / -1' }" />
|
||||
<span class="ms-flag" :style="{ gridColumn: f.col, gridRow: f.row }" :title="f.title">
|
||||
<span class="ms-dia">◆</span> {{ f.name
|
||||
}}<span v-if="f.unmet.length" class="ms-unmet"> · {{ f.unmet.length }} unmet</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="plan-grid" :style="gridStyle">
|
||||
<!-- header row -->
|
||||
<div class="hcell corner">Feature</div>
|
||||
<div
|
||||
v-for="(c, ci) in cols"
|
||||
:key="'h-' + c.w"
|
||||
class="hcell wkhead"
|
||||
:class="[colClass(ci), { 'now-head': c.isNow }]"
|
||||
>
|
||||
<span class="wklabel">{{ c.label }}</span>
|
||||
<span v-if="c.isNow" class="badge-now">now</span>
|
||||
</div>
|
||||
<div class="hcell learnhead">Learning / Status</div>
|
||||
|
||||
<!-- feature rows -->
|
||||
<template v-for="(row, ri) in plan.rows" :key="row.name">
|
||||
<div class="namecell border-l-4" :class="TONE_BORDER[tone(row)]">
|
||||
<span class="truncate" :title="row.name">{{ row.name }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(cell, ci) in matrix[ri]"
|
||||
:key="row.name + '-' + ci"
|
||||
class="cell"
|
||||
:class="colClass(ci)"
|
||||
>
|
||||
<i v-if="cell.line !== 'none'" class="bar" :class="[cell.line, TONE_TEXT[tone(row)]]"></i>
|
||||
<i v-if="cell.isStart" class="riser" :class="TONE_TEXT[tone(row)]"></i>
|
||||
<span v-if="cell.glyph" class="glyph" :class="cell.glyphCls">{{ cell.glyph }}</span>
|
||||
</div>
|
||||
<div class="learncell">
|
||||
<template v-if="row.delivered">
|
||||
<span v-if="row.learning" class="note">{{ row.learning }}</span>
|
||||
<span v-else class="muted">{{
|
||||
row.onTime ? "delivered on time" : "delivered late"
|
||||
}}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="dot" :class="TONE_DOT[tone(row)]"></span>
|
||||
<span class="note" :title="row.note || ''">{{ row.note || statusWord(row) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- legend -->
|
||||
<div class="legend">
|
||||
<span><b class="text-base-content/50">◯</b> original estimate</span>
|
||||
<span><b class="text-warning">△</b> re-estimate</span>
|
||||
<span><b class="text-success">◉</b> on time</span>
|
||||
<span><b class="text-error">▲</b> late</span>
|
||||
<span><b>┣━</b> feature bar</span>
|
||||
<span><b class="ms-dia">◆</b> milestone</span>
|
||||
<span><b class="now-swatch"></b> today</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.macroplan {
|
||||
--name-w: 9rem;
|
||||
--wk: 3.5rem;
|
||||
}
|
||||
.plan-grid {
|
||||
display: grid;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
/* Never let a too-tall container (e.g. the mobile 100svh note height that
|
||||
cascades in) stretch the auto row tracks — pack rows to their content
|
||||
height at the top. */
|
||||
align-content: start;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
/* ── Milestone band ───────────────────────────────────────────────── */
|
||||
.ms-band {
|
||||
display: grid;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
padding-top: 0.35rem;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
.ms-flag {
|
||||
align-self: center;
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: var(--color-base-content);
|
||||
z-index: 1;
|
||||
}
|
||||
.ms-dia {
|
||||
color: color-mix(in oklab, var(--color-base-content) 55%, var(--color-base-100));
|
||||
}
|
||||
.ms-unmet {
|
||||
color: var(--color-error); /* unmet required features = a problem = red */
|
||||
font-weight: 700;
|
||||
}
|
||||
.ms-lead {
|
||||
justify-self: start;
|
||||
width: 0;
|
||||
border-left: 2px dashed color-mix(in oklab, var(--color-base-content) 30%, var(--color-base-100));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hcell {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: var(--color-base-200);
|
||||
border-bottom: 1px solid var(--color-base-300);
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.corner {
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wkhead {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
line-height: 1.15;
|
||||
padding-inline: 0.15rem;
|
||||
}
|
||||
.wklabel {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.now-head {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.badge-now {
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.learnhead {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.namecell {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
background: var(--color-base-100);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 2rem;
|
||||
padding: 0 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--color-base-200);
|
||||
}
|
||||
.namecell .truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2rem;
|
||||
border-bottom: 1px solid var(--color-base-200);
|
||||
}
|
||||
.bar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
height: 2px;
|
||||
transform: translateY(-50%);
|
||||
background: currentColor;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.bar.full {
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
.bar.left {
|
||||
left: 0;
|
||||
right: 50%;
|
||||
} /* enters from the left, stops at the glyph */
|
||||
.bar.right {
|
||||
left: 50%;
|
||||
right: 0;
|
||||
} /* starts at the first glyph, runs right */
|
||||
.riser {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 28%;
|
||||
bottom: 28%;
|
||||
width: 2px;
|
||||
transform: translateX(-50%);
|
||||
background: currentColor;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.glyph {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.18rem;
|
||||
/* halo so the bar reads as passing behind, not through, the symbol */
|
||||
background: var(--color-base-100);
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
|
||||
.learncell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 2rem;
|
||||
padding: 0 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
border-bottom: 1px solid var(--color-base-200);
|
||||
}
|
||||
.learncell .muted {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.learncell .note {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.dot {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 9999px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* "today" — a neutral grey column (red is reserved for problems) */
|
||||
.cell.col-now,
|
||||
.cell.col-now .glyph {
|
||||
background: color-mix(in oklab, var(--color-base-content) 7%, var(--color-base-100));
|
||||
}
|
||||
.col-ms {
|
||||
border-left: 2px dashed color-mix(in oklab, var(--color-base-content) 30%, var(--color-base-100));
|
||||
}
|
||||
|
||||
.legend {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 1.25rem;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.legend b {
|
||||
font-weight: 700;
|
||||
}
|
||||
.now-swatch {
|
||||
display: inline-block;
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
vertical-align: -0.1rem;
|
||||
border-radius: 0.15rem;
|
||||
background: color-mix(in oklab, var(--color-base-content) 7%, var(--color-base-100));
|
||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 22%, var(--color-base-100));
|
||||
}
|
||||
</style>
|
||||
153
src/modules/macroplan/model/parse.ts
Normal file
153
src/modules/macroplan/model/parse.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { parse as parseToml } from "smol-toml"
|
||||
import * as v from "valibot"
|
||||
|
||||
import type { RawPlan, StatusLevel } from "./types"
|
||||
import { toYmd } from "./week"
|
||||
|
||||
/** Thrown for any malformed source — message is safe to show the author. */
|
||||
export class PlanParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = "PlanParseError"
|
||||
}
|
||||
}
|
||||
|
||||
const STATUSES = ["on-track", "at-risk", "off-track"] as const satisfies readonly StatusLevel[]
|
||||
|
||||
// ── Field schemas ──────────────────────────────────────────────────────────
|
||||
// A TOML date (smol-toml returns a Date subclass) or a yyyy-mm-dd string,
|
||||
// normalized to yyyy-mm-dd via `toYmd`.
|
||||
const Ymd = v.pipe(
|
||||
v.union([v.date(), v.string()], "must be a date (e.g. 2026-06-01)"),
|
||||
v.transform((value: string | Date) => toYmd(value)),
|
||||
)
|
||||
|
||||
const Status = v.picklist(STATUSES, `must be one of ${STATUSES.join(", ")}`)
|
||||
|
||||
const Name = v.pipe(v.string("is required"), v.nonEmpty("is required"))
|
||||
|
||||
const FeatureSchema = v.object({
|
||||
name: Name,
|
||||
start: Ymd,
|
||||
original: Ymd,
|
||||
reestimates: v.optional(v.array(Ymd, "must be a list of dates"), []),
|
||||
delivered: v.optional(Ymd),
|
||||
learning: v.optional(v.string()),
|
||||
status: v.optional(Status),
|
||||
note: v.optional(v.string()),
|
||||
})
|
||||
|
||||
const MilestoneSchema = v.object({
|
||||
name: Name,
|
||||
week: Ymd,
|
||||
requires: v.optional(
|
||||
v.array(v.string("must be a feature name"), "must be a list of feature names"),
|
||||
[],
|
||||
),
|
||||
})
|
||||
|
||||
/** The Macroplan format version this build understands (see docs/format.md).
|
||||
* Files may declare `macroplan_version`; a newer version is rejected rather
|
||||
* than silently mis-rendered against a schema we don't know. */
|
||||
export const FORMAT_VERSION = 1
|
||||
|
||||
/** Parse + validate a Macroplan TOML source into the raw model. */
|
||||
export function parseMacroplan(source: string): RawPlan {
|
||||
let data: Record<string, unknown>
|
||||
try {
|
||||
data = parseToml(source) as Record<string, unknown>
|
||||
} catch (e) {
|
||||
throw new PlanParseError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
|
||||
checkVersion(data.macroplan_version)
|
||||
|
||||
const features = asBlocks(data.feature, "feature").map((f, i) =>
|
||||
check(FeatureSchema, f, blockWhere("feature", f, i)),
|
||||
)
|
||||
requireUniqueFeatureNames(features)
|
||||
|
||||
return {
|
||||
title: data.title != null ? String(data.title) : "Untitled Macroplan",
|
||||
start: data.start != null ? check(Ymd, data.start, "plan", "start") : undefined,
|
||||
end: data.end != null ? check(Ymd, data.end, "plan", "end") : undefined,
|
||||
features,
|
||||
milestones: asBlocks(data.milestone, "milestone").map((m, i) =>
|
||||
check(MilestoneSchema, m, blockWhere("milestone", m, i)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate the optional `macroplan_version` marker. Absent means the current
|
||||
* version; a future version we don't understand is rejected rather than
|
||||
* silently mis-rendered. */
|
||||
function checkVersion(value: unknown): void {
|
||||
if (value == null) return
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
||||
throw new PlanParseError("`macroplan_version` must be a positive integer")
|
||||
}
|
||||
if (value > FORMAT_VERSION) {
|
||||
throw new PlanParseError(
|
||||
`unsupported macroplan_version ${value} — this build understands up to ${FORMAT_VERSION}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Feature names are the join key for a Milestone's `requires`, so they must be
|
||||
* unique — otherwise a requirement resolves ambiguously to the first match. */
|
||||
function requireUniqueFeatureNames(features: readonly { name: string }[]): void {
|
||||
const seen = new Set<string>()
|
||||
for (const f of features) {
|
||||
if (seen.has(f.name)) {
|
||||
throw new PlanParseError(`duplicate feature name "${f.name}" — feature names must be unique`)
|
||||
}
|
||||
seen.add(f.name)
|
||||
}
|
||||
}
|
||||
|
||||
function asBlocks(value: unknown, key: string): unknown[] {
|
||||
if (value == null) return []
|
||||
if (!Array.isArray(value)) {
|
||||
throw new PlanParseError(`\`${key}\` must be written as [[${key}]] blocks`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** "feature \"Payments\"" when the block carries a name, else "feature #2". */
|
||||
function blockWhere(kind: string, block: unknown, i: number): string {
|
||||
const name =
|
||||
block != null && typeof block === "object" && "name" in block
|
||||
? (block as { name: unknown }).name
|
||||
: undefined
|
||||
return name != null && name !== "" ? `${kind} "${String(name)}"` : `${kind} #${i + 1}`
|
||||
}
|
||||
|
||||
/** Validate `value` against `schema`, raising a contextual PlanParseError. */
|
||||
function check<S extends v.GenericSchema>(
|
||||
schema: S,
|
||||
value: unknown,
|
||||
where: string,
|
||||
field?: string,
|
||||
): v.InferOutput<S> {
|
||||
const result = v.safeParse(schema, value)
|
||||
if (!result.success) {
|
||||
throw new PlanParseError(`${where}: ${friendly(result.issues[0], field)}`)
|
||||
}
|
||||
return result.output
|
||||
}
|
||||
|
||||
type Issue = {
|
||||
readonly message: string
|
||||
readonly received: string
|
||||
readonly path?: ReadonlyArray<{ readonly key: unknown }>
|
||||
}
|
||||
|
||||
/** Render an issue against the offending field, e.g. "`start` must be a date"
|
||||
* — or "missing `original`" when the key is absent (valibot reports its own
|
||||
* "Invalid key" wording for that, which isn't fit to show the author). */
|
||||
function friendly(issue: Issue, fallbackField?: string): string {
|
||||
const key = issue.path?.[0]?.key
|
||||
const field = typeof key === "string" ? key : fallbackField
|
||||
if (issue.received === "undefined") return field ? `missing \`${field}\`` : "missing value"
|
||||
return field ? `\`${field}\` ${issue.message}` : issue.message
|
||||
}
|
||||
62
src/modules/macroplan/model/plan.spec.ts
Normal file
62
src/modules/macroplan/model/plan.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { parseMacroplan, PlanParseError } from "./parse"
|
||||
import { buildPlan } from "./plan"
|
||||
|
||||
const TODAY = "2026-06-17" // a Wednesday → week of Mon 2026-06-15
|
||||
|
||||
const SOURCE = `
|
||||
title = "Q3 plan"
|
||||
|
||||
[[feature]]
|
||||
name = "Auth"
|
||||
start = 2026-06-01
|
||||
original = 2026-06-15
|
||||
delivered = 2026-06-15
|
||||
|
||||
[[feature]]
|
||||
name = "Payments"
|
||||
start = 2026-06-01
|
||||
original = 2026-06-08
|
||||
reestimates = [2026-06-22]
|
||||
status = "at-risk"
|
||||
|
||||
[[milestone]]
|
||||
name = "MVP"
|
||||
week = 2026-06-15
|
||||
requires = ["Auth", "Payments"]
|
||||
`
|
||||
|
||||
describe("macroplan model", () => {
|
||||
it("derives a render-ready plan from TOML", () => {
|
||||
const plan = buildPlan(parseMacroplan(SOURCE), TODAY)
|
||||
|
||||
expect(plan.title).toBe("Q3 plan")
|
||||
expect(plan.weeks).toEqual([
|
||||
"2026-06-01",
|
||||
"2026-06-08",
|
||||
"2026-06-15",
|
||||
"2026-06-22"
|
||||
])
|
||||
expect(plan.nowWeek).toBe("2026-06-15")
|
||||
|
||||
const auth = plan.rows.find((r) => r.name === "Auth")
|
||||
expect(auth?.onTime).toBe(true)
|
||||
expect(auth?.markers).toContainEqual({
|
||||
week: "2026-06-15",
|
||||
kind: "delivered-on-time"
|
||||
})
|
||||
|
||||
const payments = plan.rows.find((r) => r.name === "Payments")
|
||||
expect(payments?.delivered).toBe(false)
|
||||
expect(payments?.slipCount).toBe(1)
|
||||
|
||||
expect(plan.milestones[0].unmet).toEqual(["Payments"])
|
||||
})
|
||||
|
||||
it("rejects malformed sources with an author-safe error", () => {
|
||||
expect(() => parseMacroplan('[[feature]]\nname = "No dates"')).toThrow(
|
||||
PlanParseError
|
||||
)
|
||||
})
|
||||
})
|
||||
87
src/modules/macroplan/model/plan.ts
Normal file
87
src/modules/macroplan/model/plan.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { FeatureRow, Marker, MilestoneLine,Plan, RawFeature, RawPlan } from "./types"
|
||||
import { mondayOf, type WeekId,weekRange } from "./week"
|
||||
|
||||
/**
|
||||
* Derive the render-ready Plan from the raw model (component C2).
|
||||
*
|
||||
* On-time vs. late is judged ONLY against the Original Estimate, never a
|
||||
* Re-estimate (ADR-0001). The full slip history (`◯` + every `△`) is preserved.
|
||||
*/
|
||||
export function buildPlan(raw: RawPlan, today: Date | string = new Date()): Plan {
|
||||
const nowWeek = mondayOf(today)
|
||||
const rows = raw.features.map((f) => buildRow(f, nowWeek))
|
||||
|
||||
const milestones = raw.milestones.map((m): MilestoneLine => {
|
||||
const week = mondayOf(m.week)
|
||||
const unmet = m.requires.filter((name) => {
|
||||
const row = rows.find((r) => r.name === name)
|
||||
if (!row) return true // references an unknown Feature → can't be met
|
||||
const delivery = row.markers.find(
|
||||
(x) => x.kind === "delivered-on-time" || x.kind === "delivered-late",
|
||||
)
|
||||
return !delivery || delivery.week > week // undelivered, or delivered after the milestone
|
||||
})
|
||||
return { name: m.name, week, requires: m.requires, unmet }
|
||||
})
|
||||
|
||||
// Range: earliest start to last marker/milestone (CONTEXT.md). Empty weeks drawn.
|
||||
// Optional authored `start`/`end` widen this span with lead-in / trailing weeks;
|
||||
// they only ever extend it — a Feature or marker outside them is never clipped.
|
||||
const allWeeks: WeekId[] = []
|
||||
if (raw.start != null) allWeeks.push(mondayOf(raw.start))
|
||||
if (raw.end != null) allWeeks.push(mondayOf(raw.end))
|
||||
for (const r of rows) {
|
||||
allWeeks.push(r.startWeek, r.barEndWeek)
|
||||
for (const mk of r.markers) allWeeks.push(mk.week)
|
||||
}
|
||||
for (const m of milestones) allWeeks.push(m.week)
|
||||
|
||||
let weeks: WeekId[] = []
|
||||
if (allWeeks.length) {
|
||||
const start = allWeeks.reduce((a, b) => (a < b ? a : b))
|
||||
const end = allWeeks.reduce((a, b) => (a > b ? a : b))
|
||||
weeks = weekRange(start, end)
|
||||
}
|
||||
const nowInRange = weeks.length > 0 && nowWeek >= weeks[0] && nowWeek <= weeks[weeks.length - 1]
|
||||
|
||||
return { title: raw.title, weeks, rows, milestones, nowWeek, nowInRange }
|
||||
}
|
||||
|
||||
function buildRow(f: RawFeature, nowWeek: WeekId): FeatureRow {
|
||||
const startWeek = mondayOf(f.start)
|
||||
const originalWeek = mondayOf(f.original)
|
||||
const deliveredWeek = f.delivered ? mondayOf(f.delivered) : undefined
|
||||
const delivered = deliveredWeek != null
|
||||
// ADR-0001: compare the Delivery week to the Original Estimate week only.
|
||||
const onTime = delivered ? deliveredWeek! <= originalWeek : null
|
||||
|
||||
const markers: Marker[] = []
|
||||
for (const re of f.reestimates) markers.push({ week: mondayOf(re), kind: "reestimate" })
|
||||
if (delivered) {
|
||||
markers.push({ week: deliveredWeek!, kind: onTime ? "delivered-on-time" : "delivered-late" })
|
||||
}
|
||||
// The Original Estimate `◯` stands unless an on-time/early delivery already
|
||||
// occupies (or precedes) it — then the delivery marker speaks for it.
|
||||
if (!(delivered && onTime)) {
|
||||
markers.push({ week: originalWeek, kind: "original" })
|
||||
}
|
||||
|
||||
const intrinsicEnd = [startWeek, ...markers.map((m) => m.week)].reduce((a, b) => (a > b ? a : b))
|
||||
// A delivered Feature's bar ends at its delivery. An undelivered Feature that
|
||||
// is already past its furthest estimate keeps "running" up to now (overdue),
|
||||
// so its bar extends to the now week; otherwise it ends at the last marker.
|
||||
const barEndWeek = delivered || intrinsicEnd > nowWeek ? intrinsicEnd : nowWeek
|
||||
|
||||
return {
|
||||
name: f.name,
|
||||
startWeek,
|
||||
barEndWeek,
|
||||
markers,
|
||||
delivered,
|
||||
onTime,
|
||||
status: f.status,
|
||||
note: f.note,
|
||||
learning: f.learning,
|
||||
slipCount: f.reestimates.length,
|
||||
}
|
||||
}
|
||||
72
src/modules/macroplan/model/types.ts
Normal file
72
src/modules/macroplan/model/types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { WeekId } from "./week"
|
||||
|
||||
export type StatusLevel = "on-track" | "at-risk" | "off-track"
|
||||
|
||||
// ── Raw model: as authored, after TOML parse + validation, before derivation ──
|
||||
|
||||
export interface RawFeature {
|
||||
name: string
|
||||
start: string // yyyy-mm-dd
|
||||
original: string // yyyy-mm-dd — the Original Estimate
|
||||
reestimates: string[] // yyyy-mm-dd[]
|
||||
delivered?: string // yyyy-mm-dd
|
||||
learning?: string
|
||||
status?: StatusLevel
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface RawMilestone {
|
||||
name: string
|
||||
week: string // yyyy-mm-dd
|
||||
requires: string[] // Feature names
|
||||
}
|
||||
|
||||
export interface RawPlan {
|
||||
title: string
|
||||
start?: string // yyyy-mm-dd — optional authored left edge of the plan's span
|
||||
end?: string // yyyy-mm-dd — optional authored right edge of the plan's span
|
||||
features: RawFeature[]
|
||||
milestones: RawMilestone[]
|
||||
}
|
||||
|
||||
// ── Derived model: render-ready (C2 output) ──
|
||||
|
||||
export type MarkerKind =
|
||||
| "original" // ◯ Original Estimate, not yet delivered
|
||||
| "reestimate" // △ a slip to a later week
|
||||
| "delivered-on-time" // ◉ delivered on/before the Original Estimate
|
||||
| "delivered-late" // ▲ delivered after the Original Estimate
|
||||
|
||||
export interface Marker {
|
||||
week: WeekId
|
||||
kind: MarkerKind
|
||||
}
|
||||
|
||||
export interface FeatureRow {
|
||||
name: string
|
||||
startWeek: WeekId
|
||||
barEndWeek: WeekId
|
||||
markers: Marker[]
|
||||
delivered: boolean
|
||||
onTime: boolean | null // null while in-flight
|
||||
status?: StatusLevel
|
||||
note?: string
|
||||
learning?: string
|
||||
slipCount: number // number of Re-estimates
|
||||
}
|
||||
|
||||
export interface MilestoneLine {
|
||||
name: string
|
||||
week: WeekId
|
||||
requires: string[]
|
||||
unmet: string[] // required Features not delivered on/before this week
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
title: string
|
||||
weeks: WeekId[]
|
||||
rows: FeatureRow[]
|
||||
milestones: MilestoneLine[]
|
||||
nowWeek: WeekId
|
||||
nowInRange: boolean
|
||||
}
|
||||
59
src/modules/macroplan/model/week.ts
Normal file
59
src/modules/macroplan/model/week.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// Week math. A "Week" is identified by the ISO date (yyyy-mm-dd) of its Monday.
|
||||
// ISO yyyy-mm-dd strings sort lexicographically == chronologically, so plain
|
||||
// string comparison (`<`, `<=`) is valid week ordering — we lean on that.
|
||||
|
||||
export type WeekId = string // 'yyyy-mm-dd', always a Monday
|
||||
|
||||
/**
|
||||
* Extract a yyyy-mm-dd string from a TOML date (smol-toml's `TomlDate.toISOString()`
|
||||
* returns the authored local date, e.g. "2026-06-01") or a plain string.
|
||||
*/
|
||||
export function toYmd(value: unknown): string {
|
||||
if (value instanceof Date) return value.toISOString().slice(0, 10)
|
||||
if (typeof value === "string") return value.slice(0, 10)
|
||||
throw new Error(`expected a date, got ${JSON.stringify(value)}`)
|
||||
}
|
||||
|
||||
// Anchor at UTC noon so day-of-week / day arithmetic never crosses a DST or
|
||||
// timezone boundary.
|
||||
function utcNoon(ymd: string): Date {
|
||||
const [y, m, d] = ymd.split("-").map(Number)
|
||||
return new Date(Date.UTC(y, m - 1, d, 12))
|
||||
}
|
||||
|
||||
function fmt(dt: Date): string {
|
||||
return dt.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** The Monday (yyyy-mm-dd) of the ISO week containing the given date. */
|
||||
export function mondayOf(value: unknown): WeekId {
|
||||
const dt = utcNoon(toYmd(value))
|
||||
const dow = dt.getUTCDay() // 0=Sun .. 6=Sat
|
||||
const shift = dow === 0 ? -6 : 1 - dow
|
||||
dt.setUTCDate(dt.getUTCDate() + shift)
|
||||
return fmt(dt)
|
||||
}
|
||||
|
||||
/** Add n weeks to a Monday WeekId (n may be negative). */
|
||||
export function addWeeks(week: WeekId, n: number): WeekId {
|
||||
const dt = utcNoon(week)
|
||||
dt.setUTCDate(dt.getUTCDate() + n * 7)
|
||||
return fmt(dt)
|
||||
}
|
||||
|
||||
/** Inclusive list of Monday WeekIds from start..end (both must already be Mondays). */
|
||||
export function weekRange(start: WeekId, end: WeekId): WeekId[] {
|
||||
if (start > end) return [start]
|
||||
const weeks: WeekId[] = []
|
||||
for (let w = start; w <= end; w = addWeeks(w, 1)) weeks.push(w)
|
||||
return weeks
|
||||
}
|
||||
|
||||
/** Short column label for a week, e.g. "Jun 15". */
|
||||
export function weekLabel(week: WeekId): string {
|
||||
return utcNoon(week).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
}
|
||||
40
src/modules/macroplan/runMacroplan.spec.ts
Normal file
40
src/modules/macroplan/runMacroplan.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
|
||||
|
||||
import { runMacroplan } from "./runMacroplan"
|
||||
|
||||
const placeholder = (source: string): HTMLElement => {
|
||||
const el = document.createElement("div")
|
||||
el.className = "macroplan-block"
|
||||
el.dataset.macroplanSource = encodeUTF8ToBase64(source)
|
||||
document.body.appendChild(el)
|
||||
return el
|
||||
}
|
||||
|
||||
describe("runMacroplan", () => {
|
||||
it("mounts the grid onto a valid placeholder", async () => {
|
||||
const el = placeholder(
|
||||
'title = "Plan"\n\n[[feature]]\nname = "Auth"\nstart = 2026-06-01\noriginal = 2026-06-15\n'
|
||||
)
|
||||
|
||||
await runMacroplan(".macroplan-block")
|
||||
|
||||
expect(el.dataset.macroplanRendered).toBe("true")
|
||||
expect(el.querySelector(".macroplan")).not.toBeNull()
|
||||
expect(el.textContent).toContain("Auth")
|
||||
el.remove()
|
||||
})
|
||||
|
||||
it("renders the parse error inline for malformed sources", async () => {
|
||||
const el = placeholder('[[feature]]\nname = "No dates"')
|
||||
|
||||
await runMacroplan(".macroplan-block")
|
||||
|
||||
expect(el.dataset.macroplanRendered).toBe("error")
|
||||
expect(el.querySelector(".macroplan-error")?.textContent).toContain(
|
||||
"missing `start`"
|
||||
)
|
||||
el.remove()
|
||||
})
|
||||
})
|
||||
55
src/modules/macroplan/runMacroplan.ts
Normal file
55
src/modules/macroplan/runMacroplan.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { createApp } from "vue"
|
||||
|
||||
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
|
||||
|
||||
const renderMacroplanError = (el: HTMLElement, err: unknown): void => {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
el.textContent = ""
|
||||
const box = document.createElement("div")
|
||||
box.className = "macroplan-error"
|
||||
const title = document.createElement("strong")
|
||||
title.textContent = "Macroplan error"
|
||||
const detail = document.createElement("pre")
|
||||
detail.textContent = msg
|
||||
box.append(title, detail)
|
||||
el.appendChild(box)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a MacroplanGrid onto every ```macroplan placeholder in scope.
|
||||
* The parser (smol-toml + valibot) and the grid component only load
|
||||
* when a placeholder actually exists, so notes without a plan pay nothing.
|
||||
*/
|
||||
export const runMacroplan = async (querySelector: string): Promise<void> => {
|
||||
const elements = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(querySelector)
|
||||
).filter((el) => !el.dataset.macroplanRendered)
|
||||
if (elements.length === 0) return
|
||||
|
||||
const [{ parseMacroplan }, { buildPlan }, { default: MacroplanGrid }] =
|
||||
await Promise.all([
|
||||
import("./model/parse"),
|
||||
import("./model/plan"),
|
||||
import("./components/MacroplanGrid.vue")
|
||||
])
|
||||
|
||||
for (const el of elements) {
|
||||
el.dataset.macroplanRendered = "pending"
|
||||
|
||||
const encoded = el.dataset.macroplanSource
|
||||
if (!encoded) {
|
||||
el.dataset.macroplanRendered = "error"
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const plan = buildPlan(parseMacroplan(decodeBase64ToUTF8(encoded)))
|
||||
el.textContent = ""
|
||||
createApp(MacroplanGrid, { plan }).mount(el)
|
||||
el.dataset.macroplanRendered = "true"
|
||||
} catch (err) {
|
||||
renderMacroplanError(el, err)
|
||||
el.dataset.macroplanRendered = "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
62
src/modules/note/liveNotes.spec.ts
Normal file
62
src/modules/note/liveNotes.spec.ts
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
||||
38
src/modules/note/liveNotes.ts
Normal file
38
src/modules/note/liveNotes.ts
Normal 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)
|
||||
@@ -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;
|
||||
@@ -236,11 +258,20 @@ pre.tikz svg {
|
||||
}
|
||||
}
|
||||
|
||||
.tikz-loading {
|
||||
.tikz-loading,
|
||||
.macroplan-loading {
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* The grid's milestone-flag stacking math assumes a monospace advance
|
||||
(BAND_CHAR in MacroplanGrid.vue), and the macroplan app renders in
|
||||
Fira Code — don't let the note's serif leak in. */
|
||||
.macroplan-block {
|
||||
font-family: "Fira Code", ui-monospace, SFMono-Regular, Menlo, Consolas,
|
||||
monospace;
|
||||
}
|
||||
|
||||
.svg-download-host {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
@@ -282,14 +313,16 @@ pre.tikz svg {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.tikz-error {
|
||||
.tikz-error,
|
||||
.macroplan-error {
|
||||
border-left: 3px solid var(--color-error);
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tikz-error pre {
|
||||
.tikz-error pre,
|
||||
.macroplan-error pre {
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const EXT_TO_LANG: Record<string, string> = {
|
||||
scss: "css",
|
||||
json: "json",
|
||||
jsonc: "json",
|
||||
rs: "rust",
|
||||
als: "alloy"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computedAsync } from "@vueuse/core"
|
||||
import { useTitle } from "@vueuse/core"
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { computed, onMounted, ref, watch } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
|
||||
import HomeButton from "@/components/HomeButton.vue"
|
||||
@@ -112,13 +112,19 @@ const language = computed(() =>
|
||||
|
||||
const mainNoteId = computed(() => `${props.shortDid}-${props.rkey}`)
|
||||
|
||||
const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
|
||||
const { stackedNotes, scrollToFocusedNote, scrollToLastStackedNote } =
|
||||
useRouteQueryStackedNotes()
|
||||
const { listenToClick } = useATProtoLinks("note-display", { mainNoteId })
|
||||
useResizeContainer("note-container", stackedNotes)
|
||||
|
||||
onMounted(() => {
|
||||
scrollToLastStackedNote()
|
||||
})
|
||||
|
||||
useMarkdownPostRender(content, () => ".public-note-view .note-display", {
|
||||
onReady: () => listenToClick(),
|
||||
tikz: true
|
||||
tikz: true,
|
||||
macroplan: true
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user