@stonedogcode/style 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +18 -0
  3. package/README.md +699 -0
  4. package/package.json +95 -0
  5. package/src/components/DictationControls.tsx +141 -0
  6. package/src/components/DictationPrompt.tsx +78 -0
  7. package/src/components/StyledBox.tsx +174 -0
  8. package/src/components/StyledButton.tsx +144 -0
  9. package/src/components/StyledCollapsible.tsx +127 -0
  10. package/src/components/StyledDefinitionList.tsx +134 -0
  11. package/src/components/StyledFieldset.tsx +157 -0
  12. package/src/components/StyledFlex.tsx +13 -0
  13. package/src/components/StyledFooter.tsx +399 -0
  14. package/src/components/StyledFormLabel.tsx +141 -0
  15. package/src/components/StyledGrid.tsx +109 -0
  16. package/src/components/StyledGridItem.tsx +19 -0
  17. package/src/components/StyledHStack.tsx +145 -0
  18. package/src/components/StyledHeading.tsx +79 -0
  19. package/src/components/StyledHrRule.tsx +33 -0
  20. package/src/components/StyledIcon.tsx +172 -0
  21. package/src/components/StyledIconButton.tsx +135 -0
  22. package/src/components/StyledInputBool.tsx +81 -0
  23. package/src/components/StyledInputRadio.tsx +141 -0
  24. package/src/components/StyledInputSelect.tsx +115 -0
  25. package/src/components/StyledInputSlider.tsx +83 -0
  26. package/src/components/StyledInputText.tsx +146 -0
  27. package/src/components/StyledInputTextArea.tsx +119 -0
  28. package/src/components/StyledInputToggle.tsx +224 -0
  29. package/src/components/StyledList.tsx +188 -0
  30. package/src/components/StyledScrollbar.tsx +53 -0
  31. package/src/components/StyledSearch.tsx +78 -0
  32. package/src/components/StyledSeparator.tsx +38 -0
  33. package/src/components/StyledSidebar.tsx +555 -0
  34. package/src/components/StyledSimpleGrid.tsx +99 -0
  35. package/src/components/StyledSparkLine.tsx +119 -0
  36. package/src/components/StyledSpinner.tsx +91 -0
  37. package/src/components/StyledStack.tsx +62 -0
  38. package/src/components/StyledText.tsx +99 -0
  39. package/src/components/StyledTooltip.tsx +398 -0
  40. package/src/components/StyledVStack.tsx +143 -0
  41. package/src/components/TitleLogo.tsx +223 -0
  42. package/src/components/create-icon.tsx +66 -0
  43. package/src/components/create-intent-button.tsx +134 -0
  44. package/src/components/dictation.ts +71 -0
  45. package/src/components/intent-buttons.ts +154 -0
  46. package/src/config/can-hover.ts +75 -0
  47. package/src/config/density.ts +138 -0
  48. package/src/config/font-size.ts +113 -0
  49. package/src/config/intent-icons.tsx +116 -0
  50. package/src/config/logger.ts +60 -0
  51. package/src/config/style-config.tsx +263 -0
  52. package/src/config/types.ts +137 -0
  53. package/src/index.ts +259 -0
  54. package/src/preset/index.ts +243 -0
  55. package/src/preset/recipes/arrows.ts +29 -0
  56. package/src/preset/recipes/box.ts +122 -0
  57. package/src/preset/recipes/button.ts +161 -0
  58. package/src/preset/recipes/dl-list.ts +109 -0
  59. package/src/preset/recipes/drawer.ts +125 -0
  60. package/src/preset/recipes/form.ts +95 -0
  61. package/src/preset/recipes/icon-button.ts +161 -0
  62. package/src/preset/recipes/icon.ts +34 -0
  63. package/src/preset/recipes/input-bool.ts +184 -0
  64. package/src/preset/recipes/input-dropdown.ts +93 -0
  65. package/src/preset/recipes/input-radio.ts +158 -0
  66. package/src/preset/recipes/input-surface.ts +152 -0
  67. package/src/preset/recipes/input-text.ts +17 -0
  68. package/src/preset/recipes/list.ts +196 -0
  69. package/src/preset/recipes/menu.ts +28 -0
  70. package/src/preset/recipes/separator.ts +89 -0
  71. package/src/preset/recipes/stack.ts +89 -0
  72. package/src/preset/recipes/striped.ts +34 -0
  73. package/src/preset/recipes/text.ts +41 -0
  74. package/src/preset/recipes/tooltip.ts +77 -0
  75. package/src/preset/semantic-variables.ts +283 -0
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ import { useSyncExternalStore } from "react";
4
+
5
+ /**
6
+ * Can the primary input on this device hover?
7
+ *
8
+ * Exists because a hover-triggered tooltip is not merely *degraded* on a touch
9
+ * screen — it is **unreachable**. There is no hover event to fire, and tapping
10
+ * a tooltipped control activates the control rather than explaining it. The
11
+ * help is rendered, correct, and impossible to see.
12
+ *
13
+ * That was a documented gap in this package for as long as `StyledTooltip` has
14
+ * existed, and it stopped being cosmetic when HopperGuard shipped an icon-only
15
+ * navigation rail: on a tablet the icons had no visible names *and* no way to
16
+ * ask for one.
17
+ *
18
+ * ## `(hover: none)`, not `(pointer: coarse)`
19
+ *
20
+ * They are different questions and only the first one is ours. `pointer:
21
+ * coarse` asks how *precise* the input is — a stylus and a TV remote are
22
+ * coarse, and both can hover perfectly well. `hover: none` asks whether the
23
+ * primary input can hover at all, which is exactly the capability a hover
24
+ * trigger depends on.
25
+ *
26
+ * ## Why it defaults to "can hover"
27
+ *
28
+ * `matchMedia` does not exist on the server, and a component that rendered one
29
+ * thing during SSR and another after hydration would produce a mismatch — and
30
+ * a control that appears a moment after the page settles, which for this
31
+ * audience is worse than one that was always there. So the server and the
32
+ * first client render both assume hover, and a device that cannot hover
33
+ * corrects itself on mount.
34
+ *
35
+ * The direction of that default matters: assuming hover means a touch device
36
+ * briefly renders what it has always rendered, then gains the control.
37
+ * Assuming *no* hover would make every desktop render an unnecessary control
38
+ * first and then remove it, which is a visible flicker on the majority case.
39
+ */
40
+
41
+ const QUERY = "(hover: none)";
42
+
43
+ function subscribe(onChange: () => void): () => void {
44
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
45
+ return () => {};
46
+ }
47
+ const list = window.matchMedia(QUERY);
48
+
49
+ // `addEventListener` where it exists, `addListener` where it does not. Safari
50
+ // gained the modern form late, and this package's audience skews towards
51
+ // older devices — exactly the population still on a browser that only has the
52
+ // deprecated one.
53
+ if (typeof list.addEventListener === "function") {
54
+ list.addEventListener("change", onChange);
55
+ return () => list.removeEventListener("change", onChange);
56
+ }
57
+ list.addListener(onChange);
58
+ return () => list.removeListener(onChange);
59
+ }
60
+
61
+ function getSnapshot(): boolean {
62
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return true;
63
+ return !window.matchMedia(QUERY).matches;
64
+ }
65
+
66
+ /** The server's answer: assume hover, and let the client correct it. */
67
+ function getServerSnapshot(): boolean {
68
+ return true;
69
+ }
70
+
71
+ export function useCanHover(): boolean {
72
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
73
+ }
74
+
75
+ export default useCanHover;
@@ -0,0 +1,138 @@
1
+ import { DENSITY_PROFILES, type DensityProfile } from "./types";
2
+
3
+ /**
4
+ * How tightly the UI packs — **one ladder, read from two places** (NEH-251).
5
+ *
6
+ * The proposal that led here had two vocabularies: an app-level preset
7
+ * (`Compact | Standard | Spacious`) and the user-facing profile this package
8
+ * already shipped (`compact | normal | comfortable`). Two axes sharing the word
9
+ * "compact" is how a call site ends up asking for one and getting the other.
10
+ *
11
+ * They are not two axes. They are a position and an offset on the same scale:
12
+ *
13
+ * ```
14
+ * tight ── compact ── standard ── spacious ── airy
15
+ * 0 1 2 3 4
16
+ *
17
+ * the APP picks a rung (densityBase)
18
+ * the USER shifts it by one (density: compact -1 / normal 0 / comfortable +1)
19
+ * ```
20
+ *
21
+ * So HopperGuard sits at `spacious` and its density button moves a user between
22
+ * `standard`, `spacious` and `airy`; Optima sits at `compact` and the same
23
+ * button moves between `tight`, `compact` and `standard`. One control, one
24
+ * scale, and the app's choice of baseline is the only thing that differs.
25
+ *
26
+ * ## The numbers are not new
27
+ *
28
+ * HopperGuard already shipped `compact: 4px`, `normal: 8px`, `comfortable: 16px`.
29
+ * Those are exactly the `compact`, `standard` and `spacious` rungs below — the
30
+ * ladder was extracted from what the product already did, then extended by one
31
+ * step at each end, rather than invented.
32
+ *
33
+ * `standard` is also this package's long-standing fallback (`8px`), which is why
34
+ * `DEFAULT_STYLE_CONFIG.densityBase` is `"standard"`: a host that says nothing
35
+ * gets exactly what it got before.
36
+ */
37
+ export const DENSITY_STEPS = [
38
+ "tight",
39
+ "compact",
40
+ "standard",
41
+ "spacious",
42
+ "airy",
43
+ ] as const;
44
+ export type DensityStep = (typeof DENSITY_STEPS)[number];
45
+
46
+ /**
47
+ * The rungs an application may sit on.
48
+ *
49
+ * Deliberately the middle three, not all five: `tight` and `airy` exist so that
50
+ * a user at either extreme still has somewhere to go. An app based at `airy`
51
+ * would give its "comfortable" users nothing, which is a worse failure than not
52
+ * offering the rung at all.
53
+ */
54
+ export const DENSITY_BASES = ["compact", "standard", "spacious"] as const;
55
+ export type DensityBase = (typeof DENSITY_BASES)[number];
56
+
57
+ /** How far each user profile shifts the app's baseline. */
58
+ const PROFILE_OFFSET: Record<DensityProfile, number> = {
59
+ compact: -1,
60
+ normal: 0,
61
+ comfortable: 1,
62
+ };
63
+
64
+ /**
65
+ * Spacing per rung, in the two custom properties the recipes read.
66
+ *
67
+ * `--panda-density-padding` and `--panda-density-margin` are an existing seam:
68
+ * six recipes already fold them into their own padding
69
+ * (`calc(.2rem + var(--panda-density-padding, 8px))`). This table gives hosts a
70
+ * single source for what to write into them, instead of each one keeping its
71
+ * own copy — which is what HopperGuard was doing.
72
+ *
73
+ * These do **not** affect the tap-target floor. That is a `min-height` stated in
74
+ * the recipes, so no density can push a control under it; the two compose
75
+ * rather than compete.
76
+ */
77
+ export const DENSITY_METRICS: Record<
78
+ DensityStep,
79
+ { readonly padding: string; readonly margin: string }
80
+ > = {
81
+ tight: { padding: "2px", margin: "2px" },
82
+ compact: { padding: "4px", margin: "4px" },
83
+ standard: { padding: "8px", margin: "8px" },
84
+ spacious: { padding: "16px", margin: "16px" },
85
+ airy: { padding: "24px", margin: "24px" },
86
+ };
87
+
88
+ export function isDensityStep(value: unknown): value is DensityStep {
89
+ return DENSITY_STEPS.includes(value as DensityStep);
90
+ }
91
+
92
+ export function isDensityBase(value: unknown): value is DensityBase {
93
+ return DENSITY_BASES.includes(value as DensityBase);
94
+ }
95
+
96
+ /**
97
+ * Where the app's baseline and the user's preference land, together.
98
+ *
99
+ * Clamped at both ends rather than wrapping or throwing: a user at the bottom
100
+ * of the scale choosing "compact" again should stay put, not silently jump to
101
+ * the loosest setting. Both inputs are validated, because at least one of them
102
+ * usually arrives from storage or an API and may predate a rename.
103
+ */
104
+ export function resolveDensityStep(
105
+ base: DensityBase,
106
+ profile: DensityProfile,
107
+ ): DensityStep {
108
+ const safeBase: DensityBase = isDensityBase(base) ? base : "standard";
109
+ const safeProfile: DensityProfile = DENSITY_PROFILES.includes(profile)
110
+ ? profile
111
+ : "normal";
112
+
113
+ const index = DENSITY_STEPS.indexOf(safeBase);
114
+ const shifted = index + (PROFILE_OFFSET[safeProfile] ?? 0);
115
+ const clamped = Math.min(Math.max(shifted, 0), DENSITY_STEPS.length - 1);
116
+
117
+ // The index is clamped into range, so this cannot miss — returning the base
118
+ // rather than asserting keeps the function total.
119
+ return DENSITY_STEPS[clamped] ?? safeBase;
120
+ }
121
+
122
+ /**
123
+ * The custom properties a host should write for a given rung.
124
+ *
125
+ * Returned as data rather than applied here on purpose: this package does not
126
+ * touch the document. Where those properties get written — `documentElement`, a
127
+ * wrapper's inline style, a server-rendered `<style>` — is a host decision, and
128
+ * a library that reaches for `document` breaks server rendering for everyone.
129
+ */
130
+ export function densityCustomProperties(
131
+ step: DensityStep,
132
+ ): Record<string, string> {
133
+ const metrics = DENSITY_METRICS[step];
134
+ return {
135
+ "--panda-density-padding": metrics.padding,
136
+ "--panda-density-margin": metrics.margin,
137
+ };
138
+ }
@@ -0,0 +1,113 @@
1
+ import type { FontSizeKey } from "./types";
2
+
3
+ /**
4
+ * Size key → CSS font-size.
5
+ *
6
+ * Every value is a `var(--font-sizes-*)` reference with a **rem** fallback, and
7
+ * both halves matter. The custom property lets a host retune the scale without
8
+ * touching this package; rem (never px) is what makes the whole UI respond to
9
+ * the browser's own font-size setting, which is the accessibility affordance
10
+ * that users with low vision actually reach for.
11
+ *
12
+ * ## The fallbacks are a conventional web scale, and that is a recent change
13
+ *
14
+ * `md` is `1rem` (16px), and the rest is the familiar Tailwind/Panda ramp.
15
+ * Until NEH-251 the fallbacks encoded HopperGuard's elder-sized scale instead —
16
+ * `md` was `1.375rem` (~22px) — because this package was extracted from that
17
+ * product and nothing else consumed it yet.
18
+ *
19
+ * That was the wrong default for a shared library. It is not a scale anyone
20
+ * *chose*; it was what a host got for saying nothing, and every new consumer
21
+ * inherited an eldercare product's typography by accident.
22
+ *
23
+ * **A host that needs a different scale defines the custom properties.**
24
+ * HopperGuard does exactly that now (its `globals.css` pins all thirteen tiers
25
+ * at the elder values), which is what made this change invisible there — and
26
+ * that pinning landed and was verified BEFORE this, deliberately, because
27
+ * flipping the fallback first would have shrunk every piece of text in that app
28
+ * with nothing failing anywhere.
29
+ *
30
+ * So: change these only with the same care. A fallback change is silent in
31
+ * every host that has not named its own scale.
32
+ */
33
+ export const fontSizeMap: Record<string, string> = {
34
+ xs: "var(--font-sizes-xs, 0.75rem)",
35
+ sm: "var(--font-sizes-sm, 0.875rem)",
36
+ md: "var(--font-sizes-md, 1rem)",
37
+ lg: "var(--font-sizes-lg, 1.125rem)",
38
+ xl: "var(--font-sizes-xl, 1.25rem)",
39
+ // Heading-only tiers. Not offerable as a global preference — see
40
+ // FONT_SIZE_PROFILES — so they ramp faster than the body range above.
41
+ "2xl": "var(--font-sizes-2xl, 1.5rem)",
42
+ "3xl": "var(--font-sizes-3xl, 1.875rem)",
43
+ "4xl": "var(--font-sizes-4xl, 2.25rem)",
44
+ "5xl": "var(--font-sizes-5xl, 3rem)",
45
+ "6xl": "var(--font-sizes-6xl, 3.75rem)",
46
+ "7xl": "var(--font-sizes-7xl, 4.5rem)",
47
+ "8xl": "var(--font-sizes-8xl, 6rem)",
48
+ "9xl": "var(--font-sizes-9xl, 8rem)",
49
+ };
50
+
51
+ /** Human-readable names for the five selectable profiles. */
52
+ const fontSizeLabelMap: Record<string, string> = {
53
+ xs: "Extra Small",
54
+ sm: "Small",
55
+ md: "Medium",
56
+ lg: "Large",
57
+ xl: "Extra Large",
58
+ };
59
+
60
+ /** Friendly name for a font-size profile (falls back to the raw key). */
61
+ export function getFontSizeLabel(size: string): string {
62
+ return fontSizeLabelMap[size] ?? size;
63
+ }
64
+
65
+ /**
66
+ * The literal fallback inside a `fontSizeMap` entry, e.g. `"1rem"`.
67
+ *
68
+ * Used where a real length is needed rather than a CSS reference — measuring,
69
+ * or a context that cannot resolve custom properties. Returns `"unknown"` for
70
+ * an unrecognised key rather than throwing, because this feeds display code.
71
+ */
72
+ export function getFontSizeValue(size: string): string {
73
+ const sizeString = fontSizeMap[size];
74
+ if (!sizeString) {
75
+ return "unknown";
76
+ }
77
+ const parts = sizeString.split(",");
78
+ // `parts.length > 1` does not narrow `parts[1]` under noUncheckedIndexedAccess,
79
+ // and destructuring says what we actually mean: take the fallback if there is one.
80
+ const [, fallback] = parts;
81
+ if (fallback !== undefined) {
82
+ return fallback.replace(")", "").trim();
83
+ }
84
+ return sizeString;
85
+ }
86
+
87
+ /** Order used to step a heading one tier above its base size. */
88
+ export const FONT_SIZE_ORDER: readonly FontSizeKey[] = [
89
+ "xs",
90
+ "sm",
91
+ "md",
92
+ "lg",
93
+ "xl",
94
+ "2xl",
95
+ "3xl",
96
+ "4xl",
97
+ "5xl",
98
+ "6xl",
99
+ "7xl",
100
+ "8xl",
101
+ "9xl",
102
+ ] as const;
103
+
104
+ /** The next size up, clamped at the top of the scale. */
105
+ export function stepUpFontSize(size: FontSizeKey, steps = 1): FontSizeKey {
106
+ const index = FONT_SIZE_ORDER.indexOf(size);
107
+ if (index === -1) return size;
108
+ const next = FONT_SIZE_ORDER[Math.min(index + steps, FONT_SIZE_ORDER.length - 1)];
109
+ // The index is clamped into range, so this cannot miss — but returning `size`
110
+ // rather than asserting keeps the function total, and a future change to the
111
+ // clamp fails safe instead of returning undefined to a caller typed otherwise.
112
+ return next ?? size;
113
+ }
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ import React, { createContext, useContext } from "react";
4
+
5
+ /**
6
+ * The intent icon registry.
7
+ *
8
+ * This is what lets one `StyledDeleteButton` serve three products that draw a
9
+ * trash can differently. The component knows it needs "the delete icon"; the
10
+ * host says what that *is*, once, at the root:
11
+ *
12
+ * ```tsx
13
+ * // HopperGuard — Font Awesome Pro, via hopper-icons
14
+ * <StonedogStyleProvider icons={{ delete: <StyledTrash />, save: <StyledSave /> }}>
15
+ *
16
+ * // optima-filings — Lucide, via the same seam
17
+ * <StonedogStyleProvider icons={{ delete: <Trash2 />, save: <Save /> }}>
18
+ * ```
19
+ *
20
+ * ## Why a registry rather than an `icon` prop on every call site
21
+ *
22
+ * A prop would work, but it moves the decision to ~500 call sites and loses the
23
+ * thing an intent button exists for: that *every* delete button in the product
24
+ * looks the same. Registering once keeps that guarantee while leaving the
25
+ * artwork entirely with the host — which is what allows this package to stay
26
+ * public and Apache-2.0 while HopperGuard uses a per-seat licensed icon set.
27
+ *
28
+ * A per-call-site `icon` prop still overrides, for the rare one-off.
29
+ *
30
+ * ## Nothing is required
31
+ *
32
+ * An unregistered intent renders no icon rather than throwing — a missing icon
33
+ * should not take down a page. `missingIntentIcons()` exists so a host can
34
+ * assert it registered the ones it uses, the same way
35
+ * `requiredCssCustomProperties()` works for the token contract.
36
+ */
37
+
38
+ /**
39
+ * Every intent the shared buttons ask for.
40
+ *
41
+ * Named for what the button DOES, not what the glyph looks like — `delete`, not
42
+ * `trash`. A host that maps `delete` to a broom is free to; the components only
43
+ * care about the meaning.
44
+ *
45
+ * **Exactly the intents the shipped buttons use — no more.** `missingIntentIcons`
46
+ * is only meaningful if this list means "what a host must register for the
47
+ * components to draw"; padding it with intents nothing renders would make that
48
+ * function report work nobody needs to do. `close`, `copy`, `favorite` and
49
+ * `help` are absent for that reason: those buttons stayed in the app, because
50
+ * each carries app state or a stateful toggle rather than being a plain intent.
51
+ */
52
+ export const ICON_INTENTS = [
53
+ "add",
54
+ "analytics",
55
+ "back",
56
+ "cancel",
57
+ "clone",
58
+ "delete",
59
+ // Dictation. Only used by the text inputs, and only when a host passes them
60
+ // a `dictation` adapter — a product with no speech support registers neither
61
+ // and never renders either.
62
+ "dictate",
63
+ "edit",
64
+ "emoji",
65
+ "home",
66
+ "load",
67
+ "menu",
68
+ "new",
69
+ "next",
70
+ "play",
71
+ "redo",
72
+ "rename",
73
+ "resume",
74
+ "save",
75
+ "settings",
76
+ "url",
77
+ ] as const;
78
+
79
+ export type IconIntent = (typeof ICON_INTENTS)[number];
80
+ export type IntentIcons = Partial<Record<IconIntent, React.ReactNode>>;
81
+
82
+ const IntentIconContext = createContext<IntentIcons>({});
83
+
84
+ export function IntentIconProvider({
85
+ icons,
86
+ children,
87
+ }: {
88
+ icons: IntentIcons;
89
+ children: React.ReactNode;
90
+ }) {
91
+ return (
92
+ <IntentIconContext.Provider value={icons}>
93
+ {children}
94
+ </IntentIconContext.Provider>
95
+ );
96
+ }
97
+
98
+ /** The whole registry. Empty outside a provider — never throws. */
99
+ export function useIntentIcons(): IntentIcons {
100
+ return useContext(IntentIconContext);
101
+ }
102
+
103
+ /** One icon, or `undefined` if the host did not register it. */
104
+ export function useIntentIcon(intent: IconIntent): React.ReactNode {
105
+ return useIntentIcons()[intent];
106
+ }
107
+
108
+ /**
109
+ * Intents a host has not registered.
110
+ *
111
+ * For a startup assertion or a test. A missing icon is silent by design — the
112
+ * button still renders and still works — so this is the only way to notice.
113
+ */
114
+ export function missingIntentIcons(icons: IntentIcons): IconIntent[] {
115
+ return ICON_INTENTS.filter((intent) => icons[intent] === undefined);
116
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The logging seam.
3
+ *
4
+ * The components this package was extracted from called `hopper-logger`
5
+ * directly — a private package, and the single biggest reason the library could
6
+ * not be shared. Rather than swap one hard dependency for another, the library
7
+ * logs through an interface that **does nothing by default**.
8
+ *
9
+ * Silence is the right default for a UI library: a component rendering a few
10
+ * hundred times a second must not decide on the host's behalf that its console
11
+ * should fill up. A host that wants the traces calls `setStyleLogger` once at
12
+ * startup — HopperGuard passes `hopper-logger`'s `log` straight in, since the
13
+ * shape matches.
14
+ */
15
+
16
+ export interface StyleLogger {
17
+ trace(message: string, meta?: unknown): void;
18
+ debug(message: string, meta?: unknown): void;
19
+ info(message: string, meta?: unknown): void;
20
+ warn(message: string, meta?: unknown): void;
21
+ error(message: string, meta?: unknown): void;
22
+ }
23
+
24
+ const noop = () => {};
25
+
26
+ const NOOP_LOGGER: StyleLogger = {
27
+ trace: noop,
28
+ debug: noop,
29
+ info: noop,
30
+ warn: noop,
31
+ error: noop,
32
+ };
33
+
34
+ let current: StyleLogger = NOOP_LOGGER;
35
+
36
+ /**
37
+ * Route this library's logging into the host's logger.
38
+ *
39
+ * Call once, before rendering. Passing `null` restores silence, which is what
40
+ * test teardown wants.
41
+ */
42
+ export function setStyleLogger(logger: StyleLogger | null): void {
43
+ current = logger ?? NOOP_LOGGER;
44
+ }
45
+
46
+ /**
47
+ * The library's logger.
48
+ *
49
+ * Deliberately a getter-backed object rather than the bare `current` reference:
50
+ * modules capture `log` at import time, so handing out the value directly would
51
+ * freeze whichever logger happened to be installed first — and in practice that
52
+ * is always the no-op, making `setStyleLogger` appear to do nothing.
53
+ */
54
+ export const log: StyleLogger = {
55
+ trace: (message, meta) => current.trace(message, meta),
56
+ debug: (message, meta) => current.debug(message, meta),
57
+ info: (message, meta) => current.info(message, meta),
58
+ warn: (message, meta) => current.warn(message, meta),
59
+ error: (message, meta) => current.error(message, meta),
60
+ };