fix: stabilize live session UI and skill publishing
Keep message navigation and progress state synchronized, and preserve sequential split-flap count updates with a bounded queue. Publish the Obelisk skill under skills/obelisk for npx skills, sharing the same staging layout between CI and local releases with regression coverage.
This commit is contained in:
@@ -2,6 +2,52 @@ function normalizeFlapValue(value) {
|
||||
return String(value ?? '');
|
||||
}
|
||||
|
||||
const MAX_QUEUED_FLAPS = 4;
|
||||
const MAX_CONSECUTIVE_STEPS = 4;
|
||||
|
||||
function normalizeQueue(queued) {
|
||||
if (Array.isArray(queued)) return queued;
|
||||
return queued === null || queued === undefined ? [] : [normalizeFlapValue(queued)];
|
||||
}
|
||||
|
||||
function numericDirection(from, to) {
|
||||
if (!/^(0|[1-9]\d*)$/.test(from) || !/^(0|[1-9]\d*)$/.test(to)) return null;
|
||||
const fromNumber = Number(from);
|
||||
const toNumber = Number(to);
|
||||
if (!Number.isSafeInteger(fromNumber) || !Number.isSafeInteger(toNumber)) return null;
|
||||
return Math.sign(toNumber - fromNumber);
|
||||
}
|
||||
|
||||
function targetsBetween(from, to) {
|
||||
const direction = numericDirection(from, to);
|
||||
if (direction === null) return [to];
|
||||
if (direction === 0) return [];
|
||||
const distance = Math.abs(Number(to) - Number(from));
|
||||
if (distance > MAX_CONSECUTIVE_STEPS) return [to];
|
||||
return Array.from(
|
||||
{ length: distance },
|
||||
(_, index) => String(Number(from) + direction * (index + 1)),
|
||||
);
|
||||
}
|
||||
|
||||
function boundedQueue(targets) {
|
||||
if (targets.length <= MAX_QUEUED_FLAPS) return targets;
|
||||
return [...targets.slice(0, MAX_QUEUED_FLAPS - 1), targets.at(-1)];
|
||||
}
|
||||
|
||||
function startFlap(state, targets) {
|
||||
const [to, ...queued] = targets;
|
||||
if (to === undefined) return state;
|
||||
return {
|
||||
...state,
|
||||
from: state.settled,
|
||||
to,
|
||||
animating: true,
|
||||
queued: boundedQueue(queued),
|
||||
version: state.version + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFlapState(value) {
|
||||
const settled = normalizeFlapValue(value);
|
||||
return {
|
||||
@@ -9,7 +55,7 @@ export function createFlapState(value) {
|
||||
from: settled,
|
||||
to: settled,
|
||||
animating: false,
|
||||
queued: null,
|
||||
queued: [],
|
||||
version: 0,
|
||||
};
|
||||
}
|
||||
@@ -18,33 +64,38 @@ export function requestFlap(state, value, { reducedMotion = false } = {}) {
|
||||
const next = normalizeFlapValue(value);
|
||||
if (reducedMotion) return createFlapState(next);
|
||||
if (state.animating) {
|
||||
if (next === state.to) return { ...state, queued: null };
|
||||
return { ...state, queued: next };
|
||||
const queued = normalizeQueue(state.queued);
|
||||
if (next === state.to) return { ...state, queued: [] };
|
||||
const queuedIndex = queued.indexOf(next);
|
||||
if (queuedIndex >= 0) return { ...state, queued: queued.slice(0, queuedIndex + 1) };
|
||||
|
||||
const tail = queued.at(-1) ?? state.to;
|
||||
const activeDirection = numericDirection(state.from, tail);
|
||||
const incomingDirection = numericDirection(tail, next);
|
||||
const additions = targetsBetween(tail, next);
|
||||
if (activeDirection !== null && incomingDirection !== null
|
||||
&& (activeDirection === 0 || incomingDirection === activeDirection)) {
|
||||
return { ...state, queued: boundedQueue([...queued, ...additions]) };
|
||||
}
|
||||
return { ...state, queued: boundedQueue(targetsBetween(state.to, next)) };
|
||||
}
|
||||
if (next === state.settled) return state;
|
||||
return {
|
||||
...state,
|
||||
from: state.settled,
|
||||
to: next,
|
||||
animating: true,
|
||||
queued: null,
|
||||
version: state.version + 1,
|
||||
};
|
||||
return startFlap(state, targetsBetween(state.settled, next));
|
||||
}
|
||||
|
||||
export function finishFlap(state) {
|
||||
if (!state.animating) return state;
|
||||
const settled = state.to;
|
||||
const queued = state.queued;
|
||||
const queued = normalizeQueue(state.queued);
|
||||
const stable = {
|
||||
...state,
|
||||
settled,
|
||||
from: settled,
|
||||
to: settled,
|
||||
animating: false,
|
||||
queued: null,
|
||||
queued: [],
|
||||
};
|
||||
return queued !== null && queued !== settled ? requestFlap(stable, queued) : stable;
|
||||
return queued.length ? startFlap(stable, queued) : stable;
|
||||
}
|
||||
|
||||
export function flapSlots(fromValue, toValue) {
|
||||
|
||||
@@ -222,6 +222,11 @@ function onScroll(event) {
|
||||
});
|
||||
}
|
||||
|
||||
function setMessagePosition(index, total) {
|
||||
currentMsgIdx.value = index;
|
||||
progressPct.value = total <= 1 ? 100 : Math.round((index / (total - 1)) * 100);
|
||||
}
|
||||
|
||||
function updateScrollProgress() {
|
||||
if (!wrapRef.value || !detailRef.value) return;
|
||||
const msgs = detailRef.value.querySelectorAll('.msg, .wf-card, .skill-card');
|
||||
@@ -235,9 +240,7 @@ function updateScrollProgress() {
|
||||
const navHeight = 52;
|
||||
const bottomLine = el.getBoundingClientRect().bottom - navHeight;
|
||||
const bottomMsgIdx = findLastMessageAtOrAbove(msgs, bottomLine);
|
||||
currentMsgIdx.value = bottomMsgIdx;
|
||||
const pct = msgs.length <= 1 ? 100 : Math.round((bottomMsgIdx / (msgs.length - 1)) * 100);
|
||||
progressPct.value = pct;
|
||||
setMessagePosition(bottomMsgIdx, msgs.length);
|
||||
}
|
||||
|
||||
function navTo(target) {
|
||||
@@ -250,7 +253,7 @@ function navTo(target) {
|
||||
else if (target === 'prev') idx = Math.max(0, currentMsgIdx.value - 1);
|
||||
else if (target === 'next') idx = Math.min(msgs.length - 1, currentMsgIdx.value + 1);
|
||||
else return;
|
||||
currentMsgIdx.value = idx;
|
||||
setMessagePosition(idx, msgs.length);
|
||||
navLock = true;
|
||||
const navHeight = 52;
|
||||
const el = wrapRef.value;
|
||||
|
||||
Reference in New Issue
Block a user