Files
Jyotisha/frontend/src/lib/composer-draft.ts
T
Jesse_Chen e8d201dd9a
Independent Staging Quality Gate / validate (push) Successful in 13m15s
Independent Staging Quality Gate / publish (push) Successful in 10m9s
perf(chat): isolate the composer, split heavy chunks, fix dead tokens
Second batch from the staging UX audit (BUG-248..253).

- css: expose the 32 palette tokens through @theme. Seven utilities
  including text-ink, text-danger and text-warning compiled to no CSS
  at all, so 30 call sites had been silently inert (BUG-248)
- chat: move the composer into its own component behind a draft store,
  so a keystroke no longer re-renders a 2723-line component, and
  persist the draft across reloads (BUG-249)
- chat: load gsap, react-markdown and thinking-orbs on demand. First
  Load JS for / drops 549.5 kB to 476.3 kB gzipped (BUG-250)
- chat: route the five in-app destinations through router.push, and
  keep the five auth redirects and the bootstrap retry as hard loads
  on purpose (BUG-251)
- a11y: announce reply completion, and move the live region out of the
  aria-busy subtree that was likely suppressing even the start
  announcement (BUG-252)
- docs: give the 27 collided bug ids unique numbers and repair their
  inbound references; require search rather than a full read of a
  3690-line file (BUG-253)

Verified: tsc, eslint, next build, and 1592 assertions across the 199
non-database test files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 14:16:05 +08:00

106 lines
2.4 KiB
TypeScript

export const composerDraftStorageKey = "jyotisha.composer-draft";
const composerDraftLimit = 500;
const composerDraftMaxAgeMs = 24 * 60 * 60 * 1000;
const listeners = new Set<() => void>();
let draft = "";
let restored = false;
function draftStorage(): Storage | null {
if (typeof window === "undefined") return null;
try {
return window.sessionStorage;
} catch {
// Private-mode browsers without session storage keep the draft in memory only.
return null;
}
}
function clearStoredDraft() {
const storage = draftStorage();
if (!storage) return;
try {
storage.removeItem(composerDraftStorageKey);
} catch {
// A rejected write never blocks the composer.
}
}
export function readStoredComposerDraft(): string {
const storage = draftStorage();
if (!storage) return "";
let stored: string | null = null;
try {
stored = storage.getItem(composerDraftStorageKey);
} catch {
return "";
}
if (!stored) return "";
try {
const parsed = JSON.parse(stored) as Record<string, unknown>;
const text = parsed.text;
const savedAt = parsed.savedAt;
if (typeof text !== "string"
|| typeof savedAt !== "number"
|| !Number.isFinite(savedAt)
|| savedAt > Date.now()
|| Date.now() - savedAt > composerDraftMaxAgeMs) {
clearStoredDraft();
return "";
}
return text.slice(0, composerDraftLimit);
} catch {
clearStoredDraft();
return "";
}
}
function writeStoredComposerDraft(value: string) {
if (!value) {
clearStoredDraft();
return;
}
const storage = draftStorage();
if (!storage) return;
try {
storage.setItem(composerDraftStorageKey, JSON.stringify({
text: value.slice(0, composerDraftLimit),
savedAt: Date.now(),
}));
} catch {
// A full or blocked storage never blocks typing.
}
}
function restoreComposerDraft() {
if (restored) return;
restored = true;
draft = readStoredComposerDraft();
}
export function composerDraftSnapshot(): string {
restoreComposerDraft();
return draft;
}
export function serverComposerDraftSnapshot(): string {
return "";
}
export function setComposerDraft(value: string) {
restoreComposerDraft();
if (draft === value) return;
draft = value;
writeStoredComposerDraft(value);
for (const listener of [...listeners]) listener();
}
export function subscribeComposerDraft(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}