feat(6839): the decision log and the knowledge feed load as you scroll, through one ScrollSentinel
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / integration (push) Successful in 2m9s
CI & Build / Build & push image (push) Successful in 36s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / integration (push) Successful in 2m9s
CI & Build / Build & push image (push) Successful in 36s
ScrollSentinel sits at the foot of a server-paged list and asks for the next page a screen before the reader reaches it. It asks once per cursor, forgets the cursor when its element goes, and shows Try again on a failed page instead of retrying in a loop. FamilyView's decision log loses its "Older decisions" button. A failed page shows in place rather than as a toast, and rows already shown are skipped when a new decision shifts the offset. KnowledgeView drops its inline IntersectionObserver for the component. The cursor is the ids consumed, not the items rendered, so a batch that returns fewer records than it asked for still moves the feed on. A failed batch puts its ids back and offers Try again, where it used to fail silently and stop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* The end of a server-paged list: asks for the next page as the reader nears
|
||||||
|
* it, in place of a "Load more" button. The parent keeps the paging — what the
|
||||||
|
* next page is keyed on, whether one is in flight, whether the last one
|
||||||
|
* failed — and this owns the observing.
|
||||||
|
*
|
||||||
|
* <ScrollSentinel :cursor="more ? String(rows.length) : ''"
|
||||||
|
* :loading="loadingMore" :failed="moreFailed" @more="loadMore" />
|
||||||
|
*
|
||||||
|
* An empty cursor means the list is complete, and nothing renders.
|
||||||
|
*/
|
||||||
|
import { onBeforeUnmount, ref, watch } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{ cursor: string; loading: boolean; failed?: boolean }>();
|
||||||
|
const emit = defineEmits<{ more: [] }>();
|
||||||
|
|
||||||
|
const sentinel = ref<HTMLElement | null>(null);
|
||||||
|
const visible = ref(false);
|
||||||
|
|
||||||
|
// One request per page. The observer reports every crossing, and a short last
|
||||||
|
// page leaves the sentinel on screen for good, so the cursor last asked for is
|
||||||
|
// remembered: a page is asked for once, and a failed one is not retried on
|
||||||
|
// every scroll, because a failure leaves the cursor where it was. Only
|
||||||
|
// Try again asks for it again.
|
||||||
|
let asked = "";
|
||||||
|
|
||||||
|
function pump() {
|
||||||
|
if (!visible.value || props.loading || props.failed || !props.cursor) return;
|
||||||
|
if (props.cursor === asked) return;
|
||||||
|
asked = props.cursor;
|
||||||
|
emit("more");
|
||||||
|
}
|
||||||
|
|
||||||
|
function retry() {
|
||||||
|
asked = props.cursor;
|
||||||
|
emit("more");
|
||||||
|
}
|
||||||
|
|
||||||
|
let observer: IntersectionObserver | undefined;
|
||||||
|
|
||||||
|
function observe(el: HTMLElement | null) {
|
||||||
|
observer?.disconnect();
|
||||||
|
observer = undefined;
|
||||||
|
if (!el) {
|
||||||
|
// Visibility and the cursor asked for go with the element. A list that
|
||||||
|
// starts over must not inherit "the end is on screen" from the one it
|
||||||
|
// replaced, and an offset cursor repeats from list to list, so "50" asked
|
||||||
|
// of the old list must not stop the new one asking for its own "50".
|
||||||
|
visible.value = false;
|
||||||
|
asked = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
visible.value = entries.some((e) => e.isIntersecting);
|
||||||
|
pump();
|
||||||
|
},
|
||||||
|
// Extends the watched area a screen below the viewport, so the next page is
|
||||||
|
// asked for before the reader reaches the end rather than when they do.
|
||||||
|
{ rootMargin: "0px 0px 100% 0px" },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
// flush: "post": the template ref is only set once the DOM is patched.
|
||||||
|
watch(sentinel, observe, { flush: "post" });
|
||||||
|
|
||||||
|
// A page that lands without pushing the end off screen gives the observer
|
||||||
|
// nothing new to report, so carry on from the props, or the list stops early.
|
||||||
|
watch([() => props.cursor, () => props.loading, () => props.failed], pump);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => observer?.disconnect());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="cursor" class="sentinel">
|
||||||
|
<!-- The one case with a button: an observer re-firing on a failure would be
|
||||||
|
a request loop. -->
|
||||||
|
<p v-if="failed" class="sentinel-note sentinel-failed" role="alert">
|
||||||
|
Couldn’t load more.
|
||||||
|
<button type="button" class="btn-secondary btn-sm" @click="retry">Try again</button>
|
||||||
|
</p>
|
||||||
|
<template v-else>
|
||||||
|
<div ref="sentinel" class="sentinel-mark" aria-hidden="true"></div>
|
||||||
|
<p v-if="loading" class="sentinel-note" role="status">Loading more…</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sentinel { grid-column: 1 / -1; padding: 0.75rem 0; text-align: center; }
|
||||||
|
.sentinel-mark { height: 1px; }
|
||||||
|
.sentinel-note { margin: 0; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
||||||
|
.sentinel-failed { display: flex; align-items: center; justify-content: center; gap: 0.75rem; }
|
||||||
|
</style>
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
type FamilyIdea, type IdeaSnapshot, type IdeaStatus,
|
type FamilyIdea, type IdeaSnapshot, type IdeaStatus,
|
||||||
} from "@/api/family";
|
} from "@/api/family";
|
||||||
import FamilyAdoptionMatrix from "@/components/FamilyAdoptionMatrix.vue";
|
import FamilyAdoptionMatrix from "@/components/FamilyAdoptionMatrix.vue";
|
||||||
|
import ScrollSentinel from "@/components/ScrollSentinel.vue";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import { fmtStamp } from "@/utils/dateFormat";
|
import { fmtStamp } from "@/utils/dateFormat";
|
||||||
import { recordHref } from "@/utils/recordHref";
|
import { recordHref } from "@/utils/recordHref";
|
||||||
@@ -36,6 +37,8 @@ const loading = ref(true);
|
|||||||
const failed = ref(false);
|
const failed = ref(false);
|
||||||
const moreDecisions = ref(false);
|
const moreDecisions = ref(false);
|
||||||
const loadingMore = ref(false);
|
const loadingMore = ref(false);
|
||||||
|
const moreFailed = ref(false);
|
||||||
|
const decisionOffset = ref(0);
|
||||||
|
|
||||||
// One pending reversal at a time: which row, which act, and its reason.
|
// One pending reversal at a time: which row, which act, and its reason.
|
||||||
const pending = ref<{ kind: "undo" | "retire"; id: number } | null>(null);
|
const pending = ref<{ kind: "undo" | "retire"; id: number } | null>(null);
|
||||||
@@ -63,7 +66,9 @@ async function load() {
|
|||||||
criteria.value = ideaData.criteria;
|
criteria.value = ideaData.criteria;
|
||||||
conflictOrder.value = ideaData.conflict_order ?? [];
|
conflictOrder.value = ideaData.conflict_order ?? [];
|
||||||
decisions.value = log;
|
decisions.value = log;
|
||||||
|
decisionOffset.value = log.length;
|
||||||
moreDecisions.value = log.length === PAGE;
|
moreDecisions.value = log.length === PAGE;
|
||||||
|
moreFailed.value = false;
|
||||||
} catch {
|
} catch {
|
||||||
// Said out loud: a log that failed to load must not read as "nothing
|
// Said out loud: a log that failed to load must not read as "nothing
|
||||||
// has been decided".
|
// has been decided".
|
||||||
@@ -73,14 +78,20 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Older decisions load as the log is scrolled. The log is offset-paged, so a
|
||||||
|
// decision recorded since the first page shifts the rest down by one; a row
|
||||||
|
// already shown is skipped, and the offset still counts what the server sent.
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
loadingMore.value = true;
|
loadingMore.value = true;
|
||||||
|
moreFailed.value = false;
|
||||||
try {
|
try {
|
||||||
const next = await listFamilyDecisions(PAGE, decisions.value.length);
|
const next = await listFamilyDecisions(PAGE, decisionOffset.value);
|
||||||
decisions.value.push(...next);
|
const shown = new Set(decisions.value.map((d) => d.id));
|
||||||
|
decisions.value.push(...next.filter((d) => !shown.has(d.id)));
|
||||||
|
decisionOffset.value += next.length;
|
||||||
moreDecisions.value = next.length === PAGE;
|
moreDecisions.value = next.length === PAGE;
|
||||||
} catch (e: unknown) {
|
} catch {
|
||||||
toast.show(apiErrorMessage(e, "Could not load more decisions"), "error");
|
moreFailed.value = true;
|
||||||
} finally {
|
} finally {
|
||||||
loadingMore.value = false;
|
loadingMore.value = false;
|
||||||
}
|
}
|
||||||
@@ -343,9 +354,12 @@ onMounted(load);
|
|||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<button v-if="moreDecisions" class="btn-secondary btn-sm" :disabled="loadingMore" @click="loadMore">
|
<ScrollSentinel
|
||||||
{{ loadingMore ? "Loading…" : "Older decisions" }}
|
:cursor="moreDecisions ? String(decisionOffset) : ''"
|
||||||
</button>
|
:loading="loadingMore"
|
||||||
|
:failed="moreFailed"
|
||||||
|
@more="loadMore"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { apiGet } from "@/api/client";
|
import { apiGet } from "@/api/client";
|
||||||
import type { TaskKind, TaskStatus, TaskPriority } from "@/types/note";
|
import type { TaskKind, TaskStatus, TaskPriority } from "@/types/note";
|
||||||
@@ -10,6 +10,7 @@ import UsageBadge from "@/components/UsageBadge.vue";
|
|||||||
import NoteSweepPane from "@/components/NoteSweepPane.vue";
|
import NoteSweepPane from "@/components/NoteSweepPane.vue";
|
||||||
import StatusBadge from "@/components/StatusBadge.vue";
|
import StatusBadge from "@/components/StatusBadge.vue";
|
||||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||||
|
import ScrollSentinel from "@/components/ScrollSentinel.vue";
|
||||||
import GraphView from "@/views/GraphView.vue";
|
import GraphView from "@/views/GraphView.vue";
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
@@ -193,7 +194,7 @@ function onClickOutsideNewNote(e: MouseEvent) {
|
|||||||
|
|
||||||
const ID_BATCH = 100; // IDs fetched per server round-trip
|
const ID_BATCH = 100; // IDs fetched per server round-trip
|
||||||
const INITIAL_CONTENT = 50; // items loaded on first render
|
const INITIAL_CONTENT = 50; // items loaded on first render
|
||||||
const CONTENT_PAGE = 24; // items loaded per sentinel trigger
|
const CONTENT_PAGE = 24; // items loaded each time the end of the feed nears
|
||||||
const REFILL_THRESHOLD = 48; // fetch more IDs when queue drops below this
|
const REFILL_THRESHOLD = 48; // fetch more IDs when queue drops below this
|
||||||
|
|
||||||
const items = ref<KnowledgeItem[]>([]);
|
const items = ref<KnowledgeItem[]>([]);
|
||||||
@@ -214,11 +215,21 @@ const totalKnowledge = ref(0); // total matching items on server
|
|||||||
const allIdsFetched = ref(false); // no more ID pages to fetch
|
const allIdsFetched = ref(false); // no more ID pages to fetch
|
||||||
const idsFetching = ref(false); // ID batch request in-flight
|
const idsFetching = ref(false); // ID batch request in-flight
|
||||||
const contentFetching = ref(false); // content batch request in-flight
|
const contentFetching = ref(false); // content batch request in-flight
|
||||||
const sentinelVisible = ref(false); // updated by IntersectionObserver
|
const moreFailed = ref(false); // the last page asked for did not load
|
||||||
|
|
||||||
const loading = computed(() => idsFetching.value || contentFetching.value);
|
const loading = computed(() => idsFetching.value || contentFetching.value);
|
||||||
|
|
||||||
let fetchGen = 0; // incremented on each reset to invalidate stale responses
|
let fetchGen = 0; // incremented on each reset to invalidate stale responses
|
||||||
|
const feedGen = ref(0); // fetchGen, readable by the template
|
||||||
|
|
||||||
|
// What the next page is keyed on: the feed it belongs to and how many of its ids
|
||||||
|
// have been loaded. Ids, not items: a batch can return fewer records than it
|
||||||
|
// asked for, and the feed must still move on. Empty once every id is loaded.
|
||||||
|
const nextCursor = computed(() =>
|
||||||
|
idQueue.value.length > 0 || !allIdsFetched.value
|
||||||
|
? `${feedGen.value}:${idOffset.value - idQueue.value.length}`
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
|
||||||
function buildFilterParams(): URLSearchParams {
|
function buildFilterParams(): URLSearchParams {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
@@ -244,7 +255,9 @@ async function fetchIdBatch(gen: number) {
|
|||||||
idOffset.value += data.ids.length;
|
idOffset.value += data.ids.length;
|
||||||
totalKnowledge.value = data.total;
|
totalKnowledge.value = data.total;
|
||||||
if (!data.has_more) allIdsFetched.value = true;
|
if (!data.has_more) allIdsFetched.value = true;
|
||||||
} catch { /* silent */ }
|
} catch {
|
||||||
|
if (gen === fetchGen) moreFailed.value = true;
|
||||||
|
}
|
||||||
finally { if (gen === fetchGen) idsFetching.value = false; }
|
finally { if (gen === fetchGen) idsFetching.value = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,26 +275,38 @@ async function loadNextContent(gen: number, count: number) {
|
|||||||
if (idQueue.value.length < REFILL_THRESHOLD && !allIdsFetched.value && !idsFetching.value) {
|
if (idQueue.value.length < REFILL_THRESHOLD && !allIdsFetched.value && !idsFetching.value) {
|
||||||
fetchIdBatch(gen);
|
fetchIdBatch(gen);
|
||||||
}
|
}
|
||||||
} catch { /* silent */ }
|
} catch {
|
||||||
|
// The ids go back to the front of the queue, so Try again asks for the
|
||||||
|
// same records rather than skipping past them.
|
||||||
|
if (gen === fetchGen) {
|
||||||
|
idQueue.value.unshift(...toLoad);
|
||||||
|
moreFailed.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
finally {
|
finally {
|
||||||
|
if (gen === fetchGen) contentFetching.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next page of the feed: more ids first when the queue has run dry.
|
||||||
|
async function loadMore() {
|
||||||
|
const gen = fetchGen;
|
||||||
|
moreFailed.value = false;
|
||||||
|
if (idQueue.value.length === 0) await fetchIdBatch(gen);
|
||||||
if (gen !== fetchGen) return;
|
if (gen !== fetchGen) return;
|
||||||
contentFetching.value = false;
|
await loadNextContent(gen, CONTENT_PAGE);
|
||||||
// Sentinel may still be visible if new items didn't push it off screen
|
|
||||||
await nextTick();
|
|
||||||
if (sentinelVisible.value && idQueue.value.length > 0 && gen === fetchGen) {
|
|
||||||
loadNextContent(gen, CONTENT_PAGE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reset() {
|
async function reset() {
|
||||||
fetchGen++;
|
fetchGen++;
|
||||||
|
feedGen.value = fetchGen;
|
||||||
const gen = fetchGen;
|
const gen = fetchGen;
|
||||||
items.value = [];
|
items.value = [];
|
||||||
idQueue.value = [];
|
idQueue.value = [];
|
||||||
idOffset.value = 0;
|
idOffset.value = 0;
|
||||||
totalKnowledge.value = 0;
|
totalKnowledge.value = 0;
|
||||||
allIdsFetched.value = false;
|
allIdsFetched.value = false;
|
||||||
|
moreFailed.value = false;
|
||||||
await fetchIdBatch(gen);
|
await fetchIdBatch(gen);
|
||||||
if (gen !== fetchGen) return;
|
if (gen !== fetchGen) return;
|
||||||
await loadNextContent(gen, INITIAL_CONTENT);
|
await loadNextContent(gen, INITIAL_CONTENT);
|
||||||
@@ -294,23 +319,16 @@ async function fetchTags() {
|
|||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetAndReobserve() {
|
|
||||||
await reset();
|
|
||||||
await nextTick();
|
|
||||||
setupObserver();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSearchInput() {
|
function onSearchInput() {
|
||||||
if (searchDebounce) clearTimeout(searchDebounce);
|
if (searchDebounce) clearTimeout(searchDebounce);
|
||||||
searchDebounce = setTimeout(() => resetAndReobserve(), 380);
|
searchDebounce = setTimeout(() => reset(), 380);
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([activeType, sortMode], () => resetAndReobserve());
|
watch([activeType, sortMode], () => reset());
|
||||||
// Closing the sweep remounts the feed, and with it the scroll sentinel — a
|
// Closing the sweep starts the feed over, so a note confirmed in the sweep is
|
||||||
// fresh element the old observer is not watching. Without this the list loads
|
// shown as it now stands.
|
||||||
// its first page and then never loads another.
|
watch(sweepActive, (open) => { if (!open) reset(); });
|
||||||
watch(sweepActive, (open) => { if (!open) resetAndReobserve(); });
|
watch(activeTag, () => { fetchCounts(); reset(); });
|
||||||
watch(activeTag, () => { fetchCounts(); resetAndReobserve(); });
|
|
||||||
|
|
||||||
// ─── Today bar ────────────────────────────────────────────────────────────────
|
// ─── Today bar ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -377,24 +395,6 @@ function formatDate(iso: string): string {
|
|||||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Sentinel observer ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const sentinelEl = ref<HTMLElement | null>(null);
|
|
||||||
let observer: IntersectionObserver | null = null;
|
|
||||||
|
|
||||||
function setupObserver() {
|
|
||||||
observer?.disconnect();
|
|
||||||
if (!sentinelEl.value) return;
|
|
||||||
observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
sentinelVisible.value = entry.isIntersecting;
|
|
||||||
if (entry.isIntersecting) loadNextContent(fetchGen, CONTENT_PAGE);
|
|
||||||
},
|
|
||||||
{ root: null, rootMargin: "200px" }
|
|
||||||
);
|
|
||||||
observer.observe(sentinelEl.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -403,14 +403,11 @@ onMounted(async () => {
|
|||||||
fetchTags();
|
fetchTags();
|
||||||
fetchCounts();
|
fetchCounts();
|
||||||
fetchTodayBar();
|
fetchTodayBar();
|
||||||
await nextTick();
|
|
||||||
setupObserver();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('click', onClickOutsideNewNote);
|
document.removeEventListener('click', onClickOutsideNewNote);
|
||||||
if (searchDebounce) clearTimeout(searchDebounce);
|
if (searchDebounce) clearTimeout(searchDebounce);
|
||||||
observer?.disconnect();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -583,10 +580,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- The whole feed stands down while the sweep is open: two answers
|
<!-- The whole feed stands down while the sweep is open: two answers
|
||||||
to two different questions on one screen is neither. Wrapped
|
to two different questions on one screen is neither. -->
|
||||||
rather than given an extra v-if branch, because the scroll
|
|
||||||
sentinel lives inside the grid and the observer must not be left
|
|
||||||
holding a ref to something that never renders. -->
|
|
||||||
<template v-if="!sweepActive">
|
<template v-if="!sweepActive">
|
||||||
<!-- Loading / empty -->
|
<!-- Loading / empty -->
|
||||||
<div v-if="loading && items.length === 0" class="knowledge-empty">Loading…</div>
|
<div v-if="loading && items.length === 0" class="knowledge-empty">Loading…</div>
|
||||||
@@ -675,10 +669,12 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sentinel — IntersectionObserver triggers next content batch -->
|
<ScrollSentinel
|
||||||
<div ref="sentinelEl" class="scroll-sentinel">
|
:cursor="nextCursor"
|
||||||
<span v-if="contentFetching" class="sentinel-loading">Loading…</span>
|
:loading="loading"
|
||||||
</div>
|
:failed="moreFailed"
|
||||||
|
@more="loadMore"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -1115,19 +1111,6 @@ onUnmounted(() => {
|
|||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Sentinel ────────────────────────────────────────────── */
|
|
||||||
.scroll-sentinel {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
height: 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.sentinel-loading {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Graph panel ─────────────────────────────────────────── */
|
/* ── Graph panel ─────────────────────────────────────────── */
|
||||||
.graph-panel {
|
.graph-panel {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
|
|||||||
Reference in New Issue
Block a user