feat(annotate): minimal-by-default HTML sessions with stale-preference decay (#1260)

* feat(annotate): minimal-by-default HTML sessions with stale-preference decay

Raw-HTML annotate sessions now open minimal by default: Pinpoint input, tools
hidden, left sidebar closed, and the right annotations drawer closed (the
drawer joins the persisted HTML chrome state, previously it always opened on
desktop). Explicit user choices still persist between HTML sessions, but the
records now carry a timestamp and expire after 7 days without a refresh, so
users who have not changed anything or annotated HTML in a while come back to
the product defaults. Explicit changes and annotation activity both re-stamp
the records, so active users keep their setup. Legacy untimestamped cookies
are treated as expired (a one-time reset to the new defaults). The markdown
surface keeps its own preference with no TTL, unchanged.

Mutation-verified: disabling the TTL fails 4 tests.

* test(annotate): stamp seeded chrome cookies and compare semantic fields

The App-level chrome suite seeded legacy untimestamped cookies, which the
stale-preference decay now treats as expired by design, and one assertion
compared cookie bytes that re-stamping legitimately changes. Seeds carry a
fresh savedAt and the write-integrity assertion compares the chrome fields.
This commit is contained in:
Michael Ramos
2026-08-10 16:22:41 -07:00
committed by GitHub
parent c4fc79f0aa
commit 7d7654fcda
7 changed files with 208 additions and 73 deletions
+24 -7
View File
@@ -158,8 +158,12 @@ describe.if(hasDom)("HTML annotate chrome (minimal-first render + persistence)",
test("hiding tools removes the collapsed sidebar tab flags, showing tools restores them", async () => {
setStorageBackend(memoryBackend);
seedAnnouncementsSeen();
// Simulate a returning user who previously showed tools.
memory.set("plannotator-html-chrome", JSON.stringify({ toolsHidden: false, sidebarOpen: false }));
// Simulate a returning user who previously showed tools (fresh stamp —
// untimestamped records are expired by design, see preferenceTtl.ts).
memory.set(
"plannotator-html-chrome",
JSON.stringify({ toolsHidden: false, sidebarOpen: false, panelOpen: false, savedAt: Date.now() }),
);
await mountHtmlAnnotate("Hide tools");
const strip = sidebarTabs();
@@ -198,15 +202,25 @@ describe.if(hasDom)("HTML annotate chrome (minimal-first render + persistence)",
removeItem: (key) => void memory.delete(key),
});
seedAnnouncementsSeen();
const remembered = JSON.stringify({ toolsHidden: false, sidebarOpen: true });
memory.set("plannotator-html-chrome", remembered);
const rememberedState = { toolsHidden: false, sidebarOpen: true, panelOpen: false };
memory.set(
"plannotator-html-chrome",
JSON.stringify({ ...rememberedState, savedAt: Date.now() }),
);
await mountHtmlAnnotate("Hide tools");
await settle();
// Writes re-stamp savedAt, so compare the semantic fields, not bytes: no
// write may ever carry chrome values other than the remembered ones,
// because this session never changes any chrome.
const semantic = (raw: string) => {
const { toolsHidden, sidebarOpen, panelOpen } = JSON.parse(raw);
return { toolsHidden, sidebarOpen, panelOpen };
};
for (const write of chromeWrites) {
expect(JSON.parse(write)).toEqual(JSON.parse(remembered));
expect(semantic(write)).toEqual(rememberedState);
}
expect(memory.get("plannotator-html-chrome")).toBe(remembered);
expect(semantic(memory.get("plannotator-html-chrome")!)).toEqual(rememberedState);
});
test("a 'user showed tools' state persists across a fresh mount", async () => {
@@ -229,7 +243,10 @@ describe.if(hasDom)("HTML annotate chrome (minimal-first render + persistence)",
test("a 'user re-hid everything' state persists across a fresh mount", async () => {
setStorageBackend(memoryBackend);
seedAnnouncementsSeen();
memory.set("plannotator-html-chrome", JSON.stringify({ toolsHidden: false, sidebarOpen: false }));
memory.set(
"plannotator-html-chrome",
JSON.stringify({ toolsHidden: false, sidebarOpen: false, panelOpen: false, savedAt: Date.now() }),
);
await mountHtmlAnnotate("Hide tools");
const hide = findButton("Hide tools");
+13 -3
View File
@@ -48,7 +48,7 @@ import { markVimModeAnnouncementSeen, needsVimModeAnnouncement } from '@plannota
import { buildDefaultPrompt, useAIChat } from '@plannotator/ui/hooks/useAIChat';
import { getUIPreferences, type UIPreferences, type PlanWidth } from '@plannotator/ui/utils/uiPreferences';
import { getEditorMode, saveEditorMode } from '@plannotator/ui/utils/editorMode';
import { getInputMethod, saveInputMethod } from '@plannotator/ui/utils/inputMethod';
import { getInputMethod, refreshInputMethodStamp, saveInputMethod } from '@plannotator/ui/utils/inputMethod';
import { getHtmlChromeState, saveHtmlChromeState } from '@plannotator/ui/utils/htmlChrome';
import { useInputMethodSwitch } from '@plannotator/ui/hooks/useInputMethodSwitch';
import { usePrintMode } from '@plannotator/ui/hooks/usePrintMode';
@@ -1633,6 +1633,7 @@ const App: React.FC = () => {
setHtmlToolsHidden(chrome.toolsHidden);
if (chrome.sidebarOpen) sidebar.open();
else sidebar.close();
setIsPanelOpen(chrome.panelOpen);
htmlChromeRestoredRef.current = true;
}, [
annotateSource,
@@ -1664,8 +1665,8 @@ const App: React.FC = () => {
skipNextHtmlChromeSaveRef.current = false;
return;
}
saveHtmlChromeState({ toolsHidden: htmlToolsHidden, sidebarOpen: sidebar.isOpen });
}, [isHtmlSurface, htmlToolsHidden, sidebar.isOpen]);
saveHtmlChromeState({ toolsHidden: htmlToolsHidden, sidebarOpen: sidebar.isOpen, panelOpen: isPanelOpen });
}, [isHtmlSurface, htmlToolsHidden, sidebar.isOpen, isPanelOpen]);
const ensureShareLink = useCallback(async (): Promise<string | null> => {
const existing = shortShareUrl || shareUrl;
@@ -3325,6 +3326,15 @@ const App: React.FC = () => {
setAnnotations(prev => [...prev, ann]);
setSelectedAnnotationId(ann.id);
setSelectedCodeAnnotationId(null);
// Annotation activity keeps the HTML-surface preferences alive: re-stamp
// the input method and chrome records so they only expire for users who
// have not annotated HTML within the staleness TTL (see preferenceTtl.ts).
if (isHtmlSurface) {
refreshInputMethodStamp(inputMethod);
if (htmlChromeRestoredRef.current) {
saveHtmlChromeState({ toolsHidden: htmlToolsHidden, sidebarOpen: sidebar.isOpen, panelOpen: isPanelOpen });
}
}
};
// Keep selection behavior explicit across mobile/wide-mode transitions.
+46 -36
View File
@@ -1,13 +1,15 @@
/**
* Chrome persistence for raw-HTML annotate sessions (DOM-gated).
*
* Contract under test: the FIRST-EVER HTML session opens minimal (all chrome
* hidden, sidebar closed); from then on a session opens with exactly the
* chrome state the user last left — showing tools persists across a fresh
* mount, and re-hiding them persists too.
* Contract under test: the default HTML session opens minimal (all chrome
* hidden, sidebar closed, annotations drawer closed); an explicit change the
* user makes persists across a fresh mount, but only while the record stays
* fresh: state older than the staleness TTL (or a legacy record with no
* timestamp) expires back to the minimal defaults.
*/
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
import { resetStorageBackend, setStorageBackend, type StorageBackend } from './storage';
import { STALE_PREFERENCE_TTL_MS } from './preferenceTtl';
const hasDom = typeof document !== 'undefined';
const htmlChromeModule = hasDom ? await import('./htmlChrome') : null;
@@ -31,61 +33,69 @@ afterAll(() => {
resetStorageBackend();
});
const MINIMAL = { toolsHidden: true, sidebarOpen: false, panelOpen: false };
const NOW = 1_800_000_000_000;
const stamp = (state: object, age = 0) => JSON.stringify({ ...state, savedAt: NOW - age });
describe.if(hasDom)('resolveHtmlChromeState (pure)', () => {
test('first run (nothing saved): everything hidden', () => {
expect(htmlChromeModule!.resolveHtmlChromeState(null)).toEqual({
toolsHidden: true,
sidebarOpen: false,
});
test('first run (nothing saved): everything hidden, both side surfaces closed', () => {
expect(htmlChromeModule!.resolveHtmlChromeState(null, NOW)).toEqual(MINIMAL);
});
test('malformed cookie values fall back to the minimal default', () => {
expect(htmlChromeModule!.resolveHtmlChromeState('not-json')).toEqual({
toolsHidden: true,
expect(htmlChromeModule!.resolveHtmlChromeState('not-json', NOW)).toEqual(MINIMAL);
expect(htmlChromeModule!.resolveHtmlChromeState('"just-a-string"', NOW)).toEqual(MINIMAL);
expect(htmlChromeModule!.resolveHtmlChromeState(stamp({ toolsHidden: 'yes' }), NOW)).toEqual(MINIMAL);
});
test('a fresh record wins; partial state merges over the defaults', () => {
expect(htmlChromeModule!.resolveHtmlChromeState(stamp({ toolsHidden: false }), NOW)).toEqual({
toolsHidden: false,
sidebarOpen: false,
panelOpen: false,
});
expect(htmlChromeModule!.resolveHtmlChromeState('"just-a-string"')).toEqual({
toolsHidden: true,
sidebarOpen: false,
});
expect(htmlChromeModule!.resolveHtmlChromeState('{"toolsHidden":"yes"}')).toEqual({
toolsHidden: true,
sidebarOpen: false,
expect(
htmlChromeModule!.resolveHtmlChromeState(stamp({ toolsHidden: false, panelOpen: true }), NOW),
).toEqual({ toolsHidden: false, sidebarOpen: false, panelOpen: true });
});
test('a record older than the TTL expires back to the minimal defaults', () => {
const stale = stamp({ toolsHidden: false, sidebarOpen: true, panelOpen: true }, STALE_PREFERENCE_TTL_MS + 1);
expect(htmlChromeModule!.resolveHtmlChromeState(stale, NOW)).toEqual(MINIMAL);
const inside = stamp({ toolsHidden: false, sidebarOpen: true, panelOpen: true }, STALE_PREFERENCE_TTL_MS - 1);
expect(htmlChromeModule!.resolveHtmlChromeState(inside, NOW)).toEqual({
toolsHidden: false,
sidebarOpen: true,
panelOpen: true,
});
});
test('partial state merges over the defaults', () => {
expect(htmlChromeModule!.resolveHtmlChromeState('{"toolsHidden":false}')).toEqual({
toolsHidden: false,
sidebarOpen: false,
});
test('a legacy record without a timestamp is treated as expired', () => {
expect(
htmlChromeModule!.resolveHtmlChromeState('{"toolsHidden":false,"sidebarOpen":true}', NOW),
).toEqual(MINIMAL);
});
});
describe.if(hasDom)('getHtmlChromeState / saveHtmlChromeState (cookie round trip)', () => {
test('first run reads the minimal default', () => {
expect(htmlChromeModule!.getHtmlChromeState()).toEqual({
toolsHidden: true,
sidebarOpen: false,
});
expect(htmlChromeModule!.getHtmlChromeState()).toEqual(MINIMAL);
});
test('a "user showed tools" state persists across a fresh mount', () => {
// Session 1: user shows tools and opens the sidebar, then leaves.
htmlChromeModule!.saveHtmlChromeState({ toolsHidden: false, sidebarOpen: true });
test('a "user showed tools / opened surfaces" state persists across a fresh mount', () => {
// Session 1: user shows tools, opens the sidebar and the drawer, then leaves.
htmlChromeModule!.saveHtmlChromeState({ toolsHidden: false, sidebarOpen: true, panelOpen: true });
// Session 2 (fresh mount, same cookies): opens exactly as left.
expect(htmlChromeModule!.getHtmlChromeState()).toEqual({
toolsHidden: false,
sidebarOpen: true,
panelOpen: true,
});
});
test('a "user re-hid everything" state persists too', () => {
htmlChromeModule!.saveHtmlChromeState({ toolsHidden: false, sidebarOpen: true });
htmlChromeModule!.saveHtmlChromeState({ toolsHidden: true, sidebarOpen: false });
expect(htmlChromeModule!.getHtmlChromeState()).toEqual({
toolsHidden: true,
sidebarOpen: false,
});
htmlChromeModule!.saveHtmlChromeState({ toolsHidden: false, sidebarOpen: true, panelOpen: true });
htmlChromeModule!.saveHtmlChromeState({ toolsHidden: true, sidebarOpen: false, panelOpen: false });
expect(htmlChromeModule!.getHtmlChromeState()).toEqual(MINIMAL);
});
});
+24 -10
View File
@@ -1,16 +1,20 @@
import { storage } from './storage';
import { isStalePreference } from './preferenceTtl';
/**
* Cross-session chrome visibility for raw-HTML annotate sessions.
*
* A raw-HTML session should open as close to "just the page" as possible, so
* the FIRST-EVER open hides everything the header's "Hide tools" toggle
* controls (toolstrip, tongue tabs, floating action cluster) and keeps the
* sidebar closed. From then on the session opens with exactly the chrome the
* user last left: showing tools persists, re-hiding them persists, and the
* sidebar's open state rides along. Persisted as a cookie (like every other
* cross-session UI pref — hook servers run on random ports, and cookies are
* scoped by domain, not port). Markdown sessions are untouched.
* the default is minimal paint: tools hidden, sidebar closed, annotations
* drawer closed. An explicit change the user makes (showing tools, opening the
* drawer) persists for later HTML sessions, but only while they keep using
* HTML annotate: state not refreshed within the staleness TTL (explicit
* changes or annotation activity re-stamp it) expires back to the minimal
* defaults. Persisted as a cookie (like every other cross-session UI pref;
* hook servers run on random ports, and cookies are scoped by domain, not
* port). Markdown sessions are untouched. A legacy record without a timestamp
* has an unknowable age and is treated as expired, which one-time resets
* everyone to the minimal defaults.
*/
const STORAGE_KEY = 'plannotator-html-chrome';
@@ -20,16 +24,22 @@ export interface HtmlChromeState {
toolsHidden: boolean;
/** Whether the left sidebar was open when the user last left. */
sidebarOpen: boolean;
/** Whether the right annotations drawer was open when the user last left. */
panelOpen: boolean;
}
/** First-run default: minimal paint — everything hidden. */
/** Default: minimal paint — everything hidden, both side surfaces closed. */
export const DEFAULT_HTML_CHROME_STATE: HtmlChromeState = {
toolsHidden: true,
sidebarOpen: false,
panelOpen: false,
};
/** Pure resolution logic (exported for tests): raw cookie value → state. */
export function resolveHtmlChromeState(raw: string | null): HtmlChromeState {
export function resolveHtmlChromeState(
raw: string | null,
now: number = Date.now(),
): HtmlChromeState {
if (!raw) return DEFAULT_HTML_CHROME_STATE;
try {
const parsed: unknown = JSON.parse(raw);
@@ -37,6 +47,7 @@ export function resolveHtmlChromeState(raw: string | null): HtmlChromeState {
return DEFAULT_HTML_CHROME_STATE;
}
const record = parsed as Record<string, unknown>;
if (isStalePreference(record.savedAt, now)) return DEFAULT_HTML_CHROME_STATE;
return {
toolsHidden: typeof record.toolsHidden === 'boolean'
? record.toolsHidden
@@ -44,6 +55,9 @@ export function resolveHtmlChromeState(raw: string | null): HtmlChromeState {
sidebarOpen: typeof record.sidebarOpen === 'boolean'
? record.sidebarOpen
: DEFAULT_HTML_CHROME_STATE.sidebarOpen,
panelOpen: typeof record.panelOpen === 'boolean'
? record.panelOpen
: DEFAULT_HTML_CHROME_STATE.panelOpen,
};
} catch {
return DEFAULT_HTML_CHROME_STATE;
@@ -55,5 +69,5 @@ export function getHtmlChromeState(): HtmlChromeState {
}
export function saveHtmlChromeState(state: HtmlChromeState): void {
storage.setItem(STORAGE_KEY, JSON.stringify(state));
storage.setItem(STORAGE_KEY, JSON.stringify({ ...state, savedAt: Date.now() }));
}
+45 -12
View File
@@ -4,10 +4,13 @@
* Decision under test: HTML sessions keep a SEPARATE input-method preference
* from markdown sessions. First-ever HTML session defaults to Pinpoint even
* when a markdown-era "drag" cookie exists; an explicit choice made inside an
* HTML session persists for later HTML sessions only.
* HTML session persists for later HTML sessions only, and only while the user
* keeps annotating HTML: a record older than the staleness TTL (or a legacy
* record with no timestamp) expires back to Pinpoint.
*/
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
import { resetStorageBackend, setStorageBackend, type StorageBackend } from './storage';
import { STALE_PREFERENCE_TTL_MS } from './preferenceTtl';
const hasDom = typeof document !== 'undefined';
const inputMethodModule = hasDom ? await import('./inputMethod') : null;
@@ -31,32 +34,53 @@ afterAll(() => {
resetStorageBackend();
});
const NOW = 1_800_000_000_000;
const fresh = (m: string, age = 0) => JSON.stringify({ m, savedAt: NOW - age });
describe.if(hasDom)('resolveInputMethod (pure)', () => {
test('HTML surface with nothing saved defaults to pinpoint', () => {
expect(inputMethodModule!.resolveInputMethod('html', null, null)).toBe('pinpoint');
expect(inputMethodModule!.resolveInputMethod('html', null, null, NOW)).toBe('pinpoint');
});
test('markdown surface with nothing saved keeps the drag default', () => {
expect(inputMethodModule!.resolveInputMethod('markdown', null, null)).toBe('drag');
expect(inputMethodModule!.resolveInputMethod('markdown', null, null, NOW)).toBe('drag');
});
test('a markdown-era saved preference never suppresses the HTML default', () => {
expect(inputMethodModule!.resolveInputMethod('html', 'drag', null)).toBe('pinpoint');
expect(inputMethodModule!.resolveInputMethod('html', 'drag', null, NOW)).toBe('pinpoint');
});
test('an explicit HTML-session choice wins over the HTML default', () => {
expect(inputMethodModule!.resolveInputMethod('html', null, 'drag')).toBe('drag');
expect(inputMethodModule!.resolveInputMethod('html', 'pinpoint', 'drag')).toBe('drag');
test('an explicit recent HTML-session choice wins over the HTML default', () => {
expect(inputMethodModule!.resolveInputMethod('html', null, fresh('drag'), NOW)).toBe('drag');
expect(inputMethodModule!.resolveInputMethod('html', 'pinpoint', fresh('drag'), NOW)).toBe('drag');
});
test('an HTML choice older than the TTL expires back to pinpoint', () => {
const stale = fresh('drag', STALE_PREFERENCE_TTL_MS + 1);
expect(inputMethodModule!.resolveInputMethod('html', null, stale, NOW)).toBe('pinpoint');
// Just inside the TTL still honors the choice.
const inside = fresh('drag', STALE_PREFERENCE_TTL_MS - 1);
expect(inputMethodModule!.resolveInputMethod('html', null, inside, NOW)).toBe('drag');
});
test('a legacy plain-string HTML record (no timestamp) is treated as expired', () => {
expect(inputMethodModule!.resolveInputMethod('html', null, 'drag', NOW)).toBe('pinpoint');
});
test('HTML choice never leaks into markdown resolution', () => {
expect(inputMethodModule!.resolveInputMethod('markdown', null, 'pinpoint')).toBe('drag');
expect(inputMethodModule!.resolveInputMethod('markdown', 'pinpoint', 'drag')).toBe('pinpoint');
expect(inputMethodModule!.resolveInputMethod('markdown', null, fresh('pinpoint'), NOW)).toBe('drag');
expect(inputMethodModule!.resolveInputMethod('markdown', 'pinpoint', fresh('drag'), NOW)).toBe('pinpoint');
});
test('the markdown preference deliberately has no TTL', () => {
expect(inputMethodModule!.resolveInputMethod('markdown', 'pinpoint', null, NOW)).toBe('pinpoint');
});
test('garbage saved values fall back to the surface default', () => {
expect(inputMethodModule!.resolveInputMethod('html', 'bogus', 'bogus')).toBe('pinpoint');
expect(inputMethodModule!.resolveInputMethod('markdown', 'bogus', 'bogus')).toBe('drag');
expect(inputMethodModule!.resolveInputMethod('html', 'bogus', 'bogus', NOW)).toBe('pinpoint');
expect(inputMethodModule!.resolveInputMethod('html', null, '{"m":"bogus","savedAt":' + NOW + '}', NOW)).toBe('pinpoint');
expect(inputMethodModule!.resolveInputMethod('html', null, '{"m":"drag","savedAt":"soon"}', NOW)).toBe('pinpoint');
expect(inputMethodModule!.resolveInputMethod('markdown', 'bogus', 'bogus', NOW)).toBe('drag');
});
});
@@ -67,7 +91,7 @@ describe.if(hasDom)('getInputMethod / saveInputMethod (cookie round trip)', () =
expect(inputMethodModule!.getInputMethod()).toBe('drag');
});
test('saving on the HTML surface persists for HTML only', () => {
test('saving on the HTML surface persists (timestamped) for HTML only', () => {
inputMethodModule!.saveInputMethod('drag', 'html');
expect(inputMethodModule!.getInputMethod('html')).toBe('drag');
expect(inputMethodModule!.getInputMethod('markdown')).toBe('drag');
@@ -77,6 +101,15 @@ describe.if(hasDom)('getInputMethod / saveInputMethod (cookie round trip)', () =
expect(inputMethodModule!.getInputMethod('markdown')).toBe('drag');
});
test('refreshInputMethodStamp re-stamps the HTML record', () => {
inputMethodModule!.refreshInputMethodStamp('drag');
const raw = memory.get('plannotator-input-method-html');
expect(raw).toBeTruthy();
const record = JSON.parse(raw!) as { m: string; savedAt: number };
expect(record.m).toBe('drag');
expect(typeof record.savedAt).toBe('number');
});
test('saving on the markdown surface leaves the HTML default intact', () => {
inputMethodModule!.saveInputMethod('drag', 'markdown');
expect(inputMethodModule!.getInputMethod('markdown')).toBe('drag');
+41 -5
View File
@@ -1,4 +1,5 @@
import { storage } from './storage';
import { isStalePreference } from './preferenceTtl';
import type { InputMethod } from '../types';
const STORAGE_KEY = 'plannotator-input-method';
@@ -13,10 +14,28 @@ const DEFAULT_HTML_METHOD: InputMethod = 'pinpoint';
/** Which document surface the input method applies to. */
export type InputMethodSurface = 'markdown' | 'html';
function parseInputMethod(value: string | null): InputMethod | null {
function parseInputMethod(value: unknown): InputMethod | null {
return value === 'drag' || value === 'pinpoint' ? value : null;
}
/**
* The HTML preference is stored as JSON `{ m, savedAt }` so it can expire.
* A plain legacy string (pre-TTL cookie) has an unknowable age and is treated
* as expired, which one-time resets everyone to the Pinpoint default.
*/
function parseHtmlRecord(raw: string | null, now: number): InputMethod | null {
if (!raw) return null;
try {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null) return null;
const record = parsed as Record<string, unknown>;
if (isStalePreference(record.savedAt, now)) return null;
return parseInputMethod(record.m);
} catch {
return null;
}
}
/**
* Pure resolution logic (exported for tests).
*
@@ -24,16 +43,20 @@ function parseInputMethod(value: string | null): InputMethod | null {
* shared key was only ever written from markdown sessions, so honoring it for
* HTML would let a markdown-era "drag" choice silently suppress the new HTML
* default. HTML sessions therefore read/write their own key: first run
* defaults to Pinpoint, and an explicit switch made inside an HTML session
* wins on every later HTML session — without touching the markdown default.
* defaults to Pinpoint, an explicit switch made inside an HTML session wins on
* later HTML sessions, and a switch not refreshed within the staleness TTL
* (explicit change or annotation activity, see refreshInputMethodStamp)
* expires back to Pinpoint — without touching the markdown default, which
* deliberately has no TTL.
*/
export function resolveInputMethod(
surface: InputMethodSurface,
savedShared: string | null,
savedHtml: string | null,
now: number = Date.now(),
): InputMethod {
if (surface === 'html') {
return parseInputMethod(savedHtml) ?? DEFAULT_HTML_METHOD;
return parseHtmlRecord(savedHtml, now) ?? DEFAULT_HTML_METHOD;
}
return parseInputMethod(savedShared) ?? DEFAULT_METHOD;
}
@@ -50,5 +73,18 @@ export function saveInputMethod(
method: InputMethod,
surface: InputMethodSurface = 'markdown',
): void {
storage.setItem(surface === 'html' ? HTML_STORAGE_KEY : STORAGE_KEY, method);
if (surface === 'html') {
storage.setItem(HTML_STORAGE_KEY, JSON.stringify({ m: method, savedAt: Date.now() }));
return;
}
storage.setItem(STORAGE_KEY, method);
}
/**
* Re-stamp the persisted HTML preference with the given method and a fresh
* timestamp. Called on annotation activity so an actively-used preference
* never expires mid-habit; a no-op for the markdown surface.
*/
export function refreshInputMethodStamp(method: InputMethod): void {
saveInputMethod(method, 'html');
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Staleness window for raw-HTML session preferences (input method, chrome
* visibility, drawer state). An explicit choice sticks while the user is
* actively annotating HTML; after this long without a refresh the preference
* expires and the session opens on the product defaults again (Pinpoint,
* tools hidden, drawer closed). Timestamps refresh on explicit changes and on
* annotation activity, so regulars keep their setup and lapsed sessions reset.
*/
export const STALE_PREFERENCE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
/** True when a persisted `savedAt` is missing, malformed, or older than the TTL. */
export function isStalePreference(savedAt: unknown, now: number): boolean {
if (typeof savedAt !== "number" || !Number.isFinite(savedAt)) return true;
return now - savedAt > STALE_PREFERENCE_TTL_MS;
}