e8d201dd9a
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>
106 lines
2.4 KiB
TypeScript
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);
|
|
};
|
|
}
|