1// The browser end of the one event stream (issue #5 §9).2//3// The system narrates itself through a single leveled, structured stream. In4// Go that is `log/slog`; here it is this module, backed by the console, with5// the same discipline: a message that reads as a sentence, and everything6// else as named fields. A headless driver captures the console, so a7// failing tour step ships its context for free — but only if everything goes8// through here, which is why an ESLint rule forbids bare `console.*` outside9// this file and names this contract when it fires.10//11// Levels are used with intent, as in `docs/logging.md`:12//13// debug internal mechanics, off by default — a tile requested, a plan drawn14// info one line per meaningful unit of work — a world opened, a lens swapped15// warn tolerated, but a human should eventually see it — a tile 40416// error an operation failed17//18// The gate is read once, from `?atlas-log=debug` or `localStorage`, because a19// level that can change mid-run makes two captures of one tour disagree.2021/** The four levels, in ascending severity. */22export type Level = "debug" | "info" | "warn" | "error";2324/** Named facts about the work, never about the code that did it. */25export type Fields = Readonly<Record<string, unknown>>;2627const ORDER: Readonly<Record<Level, number>> = { debug: 10, info: 20, warn: 30, error: 40 };2829const QUERY_PARAM = "atlas-log";30const STORAGE_KEY = "atlas.log.level";3132function isLevel(value: string | null | undefined): value is Level {33 return value === "debug" || value === "info" || value === "warn" || value === "error";34}3536/**37 * The level this run speaks at: the query parameter first (a driver can set38 * it without touching storage), then `localStorage`, then `info`.39 *40 * Storage can throw in a hardened context, which is not worth failing a page41 * over: a seam that cannot read a preference still draws a map.42 */43function configuredLevel(): Level {44 try {45 const asked = new URLSearchParams(globalThis.location?.search ?? "").get(QUERY_PARAM);46 if (isLevel(asked)) return asked;47 const stored = globalThis.localStorage?.getItem(STORAGE_KEY);48 if (isLevel(stored)) return stored;49 } catch {50 // A context that refuses the query string or storage gets the default.51 }52 return "info";53}5455let threshold = ORDER[configuredLevel()];5657/** One record on the stream: a sentence, and the facts around it. */58export interface Logger {59 debug(message: string, fields?: Fields): void;60 info(message: string, fields?: Fields): void;61 warn(message: string, fields?: Fields): void;62 error(message: string, fields?: Fields): void;63 /** A child logger carrying fields every record of it repeats. */64 with(fields: Fields): Logger;65}6667const sinks: Readonly<Record<Level, (...args: unknown[]) => void>> = {68 debug: (...args) => console.debug(...args),69 info: (...args) => console.info(...args),70 warn: (...args) => console.warn(...args),71 error: (...args) => console.error(...args),72};7374function emit(level: Level, name: string, message: string, fields: Fields): void {75 if (ORDER[level] < threshold) return;76 const payload = { level, logger: name, ...fields };77 sinks[level](`atlas ${name}: ${message}`, payload);78}7980/**81 * A logger under a name. The name is the emitting component — a logger82 * concern, deliberately not a vocabulary key (§9), so `op`, `volume`,83 * `world`, `lens` and `path` keep meaning what they mean in the Go stream.84 */85export function logger(name: string, bound: Fields = {}): Logger {86 return {87 debug: (message, fields) => emit("debug", name, message, { ...bound, ...fields }),88 info: (message, fields) => emit("info", name, message, { ...bound, ...fields }),89 warn: (message, fields) => emit("warn", name, message, { ...bound, ...fields }),90 error: (message, fields) => emit("error", name, message, { ...bound, ...fields }),91 with: (fields) => logger(name, { ...bound, ...fields }),92 };93}9495/** Open the stream up (or close it down) from a console or a test. */96export function setLevel(level: Level): void {97 threshold = ORDER[level];98}99100/** The level the stream is gated at, for the diagnostics object to report. */101export function level(): Level {102 const found = (Object.keys(ORDER) as Level[]).find((name) => ORDER[name] === threshold);103 return found ?? "info";104}