frontend: one grid for every lens, and a cross-fade between surfaces (task 1913)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 59s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m33s
Android (Tauri) / Android APK (debug) (push) Successful in 4m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m32s
Desktop (Tauri) / Update manifest (push) Successful in 5s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 59s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m33s
Android (Tauri) / Android APK (debug) (push) Successful in 4m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m32s
Desktop (Tauri) / Update manifest (push) Successful in 5s
"The same board, re-filtered" has to be literally true to read as true. The column classes were copy-pasted into five places — the board's pinned and other sections, its non-board branch, search, and timeline — so a lens could drift from home by a single edit. One already had: the FLIP reflow from 1914 landed on the board's three grids and left search and timeline popping. NoteGrid is now the only file that knows how the masonry is laid out or how it moves, and search and timeline gained the motion by adopting it. It takes activeId rather than an index. The board splits its notes across two grids, so index-based focus made the call site do offset arithmetic (focusedIndex === pinnedNotes.length + i) against a list the grid didn't own. The lens cross-fade is deliberately UNKEYED, which is the whole trick. Board, archive, trash and label all render the same BoardView; keying the transition on the route would remount it, blanking the board and refetching — exactly the page-change feeling this is meant to remove. Unkeyed, Vue transitions only when the component TYPE changes (board to search to timeline to graph), and moving between the board's own lenses stays an in-place reflow that NoteGrid animates. The two behaviours fall out of one rule rather than needing to be special-cased. Out is quicker than in because mode="out-in" makes the durations additive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -428,7 +428,21 @@ async function signOut() {
|
||||
<!-- tabindex="-1" so the skip link above actually moves FOCUS here, not just
|
||||
the viewport — several browsers scroll to a plain anchor without focusing
|
||||
it, which leaves the next Tab back at the top of the page. -->
|
||||
<main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none"><RouterView /></main>
|
||||
<!-- Cross-fade between lenses so switching reads as the same space
|
||||
re-framing rather than a page swap.
|
||||
Deliberately UNKEYED: board / archive / trash / label all render the same
|
||||
BoardView, so keying on the route would remount it — blanking the board
|
||||
and refetching, which is precisely the page-change feeling this is meant
|
||||
to remove. Unkeyed, Vue only transitions when the component TYPE changes
|
||||
(board ↔ search ↔ timeline ↔ graph), and moving between the board's own
|
||||
lenses stays an in-place reflow that NoteGrid animates. -->
|
||||
<main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition name="lens" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<LabelsModal v-if="managing" @close="managing = false" />
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import NoteCard from "./NoteCard.vue";
|
||||
import type { Note } from "../stores/notes";
|
||||
|
||||
/**
|
||||
* The masonry of note cards — the one place the board's LAYOUT and its MOTION are
|
||||
* defined.
|
||||
*
|
||||
* It exists because "the same board, re-filtered" has to be literally true to read
|
||||
* as true. The column classes were copy-pasted into five places (the board's pinned
|
||||
* and other sections, the non-board views, search, timeline), so a lens could drift
|
||||
* from home by a single edit — and one already had: the FLIP reflow landed on the
|
||||
* board's three grids and left search and timeline popping.
|
||||
*
|
||||
* `activeId` rather than an index, because the board splits its notes across two
|
||||
* grids and index-based focus made every call site do offset arithmetic against a
|
||||
* list it didn't own.
|
||||
*/
|
||||
defineProps<{
|
||||
notes: Note[];
|
||||
/** The keyboard-focused note, if any. */
|
||||
activeId?: string | null;
|
||||
/** Board views only — reorder is meaningless in a search result or a date group. */
|
||||
reorderable?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: "open", note: Note): void;
|
||||
(e: "dragstart", note: Note): void;
|
||||
(e: "dragend", note: Note): void;
|
||||
(e: "drop", targetId: string): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- `board` is the transition name defined in style.css; `appear` covers first
|
||||
paint so a lens arrives eased rather than snapping in. -->
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
name="board"
|
||||
appear
|
||||
class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4"
|
||||
>
|
||||
<NoteCard
|
||||
v-for="n in notes"
|
||||
:key="n.id"
|
||||
:note="n"
|
||||
:active="activeId === n.id"
|
||||
:reorderable="reorderable"
|
||||
@open="$emit('open', $event)"
|
||||
@dragstart="$emit('dragstart', $event)"
|
||||
@dragend="$emit('dragend', $event)"
|
||||
@drop="$emit('drop', $event)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
@@ -127,6 +127,21 @@ body {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
/* Lens cross-fade (M7). Only fires between genuinely different surfaces; the
|
||||
* board's own lenses share a component and reflow in place instead. Out is quicker
|
||||
* than in so the gap between them stays short — `mode="out-in"` means the two
|
||||
* durations are additive, and anything slower starts to feel like waiting. */
|
||||
.lens-enter-active {
|
||||
transition: opacity 150ms ease-out;
|
||||
}
|
||||
.lens-leave-active {
|
||||
transition: opacity 100ms ease-in;
|
||||
}
|
||||
.lens-enter-from,
|
||||
.lens-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.icon-btn {
|
||||
@apply rounded-md p-1.5 text-neutral-500 transition hover:bg-black/5 focus:outline-none
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useNoteEditor } from "../composables/useNoteEditor";
|
||||
import AsyncState from "../components/AsyncState.vue";
|
||||
import EmptyState from "../components/EmptyState.vue";
|
||||
import FilterBar from "../components/FilterBar.vue";
|
||||
import NoteCard from "../components/NoteCard.vue";
|
||||
import NoteGrid from "../components/NoteGrid.vue";
|
||||
import NoteEditor from "../components/NoteEditor.vue";
|
||||
import { isDesktop, sync as syncBridge } from "../desktop/bridge";
|
||||
|
||||
@@ -76,6 +76,9 @@ const orderedNotes = computed<Note[]>(() =>
|
||||
isMainBoard.value ? [...pinnedNotes.value, ...otherNotes.value] : notes.items,
|
||||
);
|
||||
const focusedIndex = ref(-1);
|
||||
/** The focused note's id — what the grids compare against, so neither has to know
|
||||
* its own offset within the combined keyboard-navigation order. */
|
||||
const focusedId = computed<string | null>(() => orderedNotes.value[focusedIndex.value]?.id ?? null);
|
||||
const inTrash = computed(() => currentView.value === "trash");
|
||||
|
||||
function moveFocus(delta: number) {
|
||||
@@ -300,59 +303,42 @@ async function onDrop(targetId: string) {
|
||||
<template v-if="isMainBoard">
|
||||
<section v-if="pinnedNotes.length">
|
||||
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Pinned</h2>
|
||||
<TransitionGroup tag="div" name="board" appear class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard
|
||||
v-for="(n, i) in pinnedNotes"
|
||||
:key="n.id"
|
||||
:note="n"
|
||||
:active="focusedIndex === i"
|
||||
reorderable
|
||||
@open="openEditor"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<NoteGrid
|
||||
:notes="pinnedNotes"
|
||||
:active-id="focusedId"
|
||||
reorderable
|
||||
@open="openEditor"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
</section>
|
||||
<section v-if="otherNotes.length" :class="pinnedNotes.length ? 'mt-8' : ''">
|
||||
<h2 v-if="pinnedNotes.length" class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||
Others
|
||||
</h2>
|
||||
<TransitionGroup tag="div" name="board" appear class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard
|
||||
v-for="(n, i) in otherNotes"
|
||||
:key="n.id"
|
||||
:note="n"
|
||||
:active="focusedIndex === pinnedNotes.length + i"
|
||||
reorderable
|
||||
@open="openEditor"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<NoteGrid
|
||||
:notes="otherNotes"
|
||||
:active-id="focusedId"
|
||||
reorderable
|
||||
@open="openEditor"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<TransitionGroup
|
||||
<NoteGrid
|
||||
v-else
|
||||
tag="div"
|
||||
name="board"
|
||||
appear
|
||||
class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4"
|
||||
>
|
||||
<NoteCard
|
||||
v-for="(n, i) in notes.items"
|
||||
:key="n.id"
|
||||
:note="n"
|
||||
:active="focusedIndex === i"
|
||||
reorderable
|
||||
@open="openEditor"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
:notes="notes.items"
|
||||
:active-id="focusedId"
|
||||
reorderable
|
||||
@open="openEditor"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
</template>
|
||||
</AsyncState>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useNoteList } from "../composables/useNoteList";
|
||||
import { useNoteEditor } from "../composables/useNoteEditor";
|
||||
import AsyncState from "../components/AsyncState.vue";
|
||||
import EmptyState from "../components/EmptyState.vue";
|
||||
import NoteCard from "../components/NoteCard.vue";
|
||||
import NoteGrid from "../components/NoteGrid.vue";
|
||||
import NoteEditor from "../components/NoteEditor.vue";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -38,9 +38,7 @@ watch(query, run, { immediate: true });
|
||||
|
||||
<AsyncState :loading="loading" :error="error || undefined" error-title="Couldn't search" @retry="run">
|
||||
<EmptyState v-if="query && results.length === 0" title="No matches" :subtitle="noMatchSubtitle" />
|
||||
<div v-else-if="results.length" class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard v-for="n in results" :key="n.id" :note="n" @open="openEditor" />
|
||||
</div>
|
||||
<NoteGrid v-else-if="results.length" :notes="results" @open="openEditor" />
|
||||
</AsyncState>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useNoteList } from "../composables/useNoteList";
|
||||
import { useNoteEditor } from "../composables/useNoteEditor";
|
||||
import AsyncState from "../components/AsyncState.vue";
|
||||
import EmptyState from "../components/EmptyState.vue";
|
||||
import NoteCard from "../components/NoteCard.vue";
|
||||
import NoteGrid from "../components/NoteGrid.vue";
|
||||
import NoteEditor from "../components/NoteEditor.vue";
|
||||
|
||||
// The "find by WHEN" recall lens: your notes grouped by when you captured them,
|
||||
@@ -149,9 +149,7 @@ onMounted(load);
|
||||
<div v-else class="flex flex-col gap-8">
|
||||
<section v-for="group in groups" :key="group.key">
|
||||
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-neutral-400">{{ group.label }}</h2>
|
||||
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard v-for="n in group.notes" :key="n.id" :note="n" @open="openEditor" />
|
||||
</div>
|
||||
<NoteGrid :notes="group.notes" @open="openEditor" />
|
||||
</section>
|
||||
</div>
|
||||
</AsyncState>
|
||||
|
||||
Reference in New Issue
Block a user