M12 — the Android client, end to end #2

Merged
bvandeusen merged 86 commits from dev into main 2026-08-21 08:53:58 -04:00
6 changed files with 121 additions and 54 deletions
Showing only changes of commit 67b9ea2938 - Show all commits
+15 -1
View File
@@ -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" />
+56
View File
@@ -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>
+15
View File
@@ -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
+31 -45
View File
@@ -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>
+2 -4
View File
@@ -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>
+2 -4
View File
@@ -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>