@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.
- package/LICENSE +201 -0
- package/NOTICE +18 -0
- package/README.md +699 -0
- package/package.json +95 -0
- package/src/components/DictationControls.tsx +141 -0
- package/src/components/DictationPrompt.tsx +78 -0
- package/src/components/StyledBox.tsx +174 -0
- package/src/components/StyledButton.tsx +144 -0
- package/src/components/StyledCollapsible.tsx +127 -0
- package/src/components/StyledDefinitionList.tsx +134 -0
- package/src/components/StyledFieldset.tsx +157 -0
- package/src/components/StyledFlex.tsx +13 -0
- package/src/components/StyledFooter.tsx +399 -0
- package/src/components/StyledFormLabel.tsx +141 -0
- package/src/components/StyledGrid.tsx +109 -0
- package/src/components/StyledGridItem.tsx +19 -0
- package/src/components/StyledHStack.tsx +145 -0
- package/src/components/StyledHeading.tsx +79 -0
- package/src/components/StyledHrRule.tsx +33 -0
- package/src/components/StyledIcon.tsx +172 -0
- package/src/components/StyledIconButton.tsx +135 -0
- package/src/components/StyledInputBool.tsx +81 -0
- package/src/components/StyledInputRadio.tsx +141 -0
- package/src/components/StyledInputSelect.tsx +115 -0
- package/src/components/StyledInputSlider.tsx +83 -0
- package/src/components/StyledInputText.tsx +146 -0
- package/src/components/StyledInputTextArea.tsx +119 -0
- package/src/components/StyledInputToggle.tsx +224 -0
- package/src/components/StyledList.tsx +188 -0
- package/src/components/StyledScrollbar.tsx +53 -0
- package/src/components/StyledSearch.tsx +78 -0
- package/src/components/StyledSeparator.tsx +38 -0
- package/src/components/StyledSidebar.tsx +555 -0
- package/src/components/StyledSimpleGrid.tsx +99 -0
- package/src/components/StyledSparkLine.tsx +119 -0
- package/src/components/StyledSpinner.tsx +91 -0
- package/src/components/StyledStack.tsx +62 -0
- package/src/components/StyledText.tsx +99 -0
- package/src/components/StyledTooltip.tsx +398 -0
- package/src/components/StyledVStack.tsx +143 -0
- package/src/components/TitleLogo.tsx +223 -0
- package/src/components/create-icon.tsx +66 -0
- package/src/components/create-intent-button.tsx +134 -0
- package/src/components/dictation.ts +71 -0
- package/src/components/intent-buttons.ts +154 -0
- package/src/config/can-hover.ts +75 -0
- package/src/config/density.ts +138 -0
- package/src/config/font-size.ts +113 -0
- package/src/config/intent-icons.tsx +116 -0
- package/src/config/logger.ts +60 -0
- package/src/config/style-config.tsx +263 -0
- package/src/config/types.ts +137 -0
- package/src/index.ts +259 -0
- package/src/preset/index.ts +243 -0
- package/src/preset/recipes/arrows.ts +29 -0
- package/src/preset/recipes/box.ts +122 -0
- package/src/preset/recipes/button.ts +161 -0
- package/src/preset/recipes/dl-list.ts +109 -0
- package/src/preset/recipes/drawer.ts +125 -0
- package/src/preset/recipes/form.ts +95 -0
- package/src/preset/recipes/icon-button.ts +161 -0
- package/src/preset/recipes/icon.ts +34 -0
- package/src/preset/recipes/input-bool.ts +184 -0
- package/src/preset/recipes/input-dropdown.ts +93 -0
- package/src/preset/recipes/input-radio.ts +158 -0
- package/src/preset/recipes/input-surface.ts +152 -0
- package/src/preset/recipes/input-text.ts +17 -0
- package/src/preset/recipes/list.ts +196 -0
- package/src/preset/recipes/menu.ts +28 -0
- package/src/preset/recipes/separator.ts +89 -0
- package/src/preset/recipes/stack.ts +89 -0
- package/src/preset/recipes/striped.ts +34 -0
- package/src/preset/recipes/text.ts +41 -0
- package/src/preset/recipes/tooltip.ts +77 -0
- 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
|
+
};
|