@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,223 @@
1
+ "use client";
2
+
3
+ import { log } from "../config/logger";
4
+ import React from "react";
5
+ import { styled } from "styled-system/jsx";
6
+ import type { HTMLStyledProps } from "styled-system/types";
7
+
8
+ /**
9
+ * A mark beside a wordmark, at one of three sizes.
10
+ *
11
+ * Copyright (C) 2026 StoneDogCode L.L.C. All rights reserved.
12
+ *
13
+ * ## Why this exists
14
+ *
15
+ * HopperGuard carries **nine** logo files — `logos/{elder,hopperguard,kids}/
16
+ * logo-{large,medium,small}-*.tsx` — that are ~95% identical markup differing
17
+ * only in size constants and colour tokens. NEH-287 names the consequence: the
18
+ * per-size numbers drifted apart, so the logo's proportions were not stable
19
+ * across breakpoints, and a fix had to be applied nine times or not at all.
20
+ * Optima Filings Cloud was about to become the tenth.
21
+ *
22
+ * So the *structure* lives here once and the *content* is passed in. This
23
+ * package owns shape and knows nothing about any brand — no artwork, no
24
+ * colours, no product names. `logo` and `title` are both `ReactNode`, which is
25
+ * what lets HopperGuard pass its two-part `HOPPER`+`Guard` wordmark (two fonts,
26
+ * two colour tokens, one letter-spacing) while Optima passes a plain string.
27
+ *
28
+ * ## What it deliberately does NOT do
29
+ *
30
+ * **No trademark glyph.** The previous implementation nudged a `™` into place
31
+ * with hand-tuned pixel offsets that were different at every size — three magic
32
+ * pairs whose only job was to make one glyph land, and which a screen reader
33
+ * announced as "trade mark sign" on every page. NEH-287 moves that notice to
34
+ * the footer, where it is a stronger notice and read once. A caller that wants
35
+ * a symbol can put one in `title`; nothing here positions it.
36
+ *
37
+ * **No by-line.** Same issue: only one brand had one, and centring the column
38
+ * that held it pushed the wordmark visibly above the mark's optical centre.
39
+ * Provenance belongs beside the copyright, not in the chrome on every screen.
40
+ *
41
+ * With both gone the row is a mark and a single line of text, so
42
+ * `alignItems: center` centres it correctly with no compensating offset — which
43
+ * is the whole reason those offsets existed.
44
+ */
45
+
46
+ /** The three rungs. Named, not numeric, so a caller cannot invent a tenth. */
47
+ export const TITLE_LOGO_SIZES = ["small", "medium", "large"] as const;
48
+ export type TitleLogoSize = (typeof TITLE_LOGO_SIZES)[number];
49
+
50
+ export function isTitleLogoSize(value: unknown): value is TitleLogoSize {
51
+ return TITLE_LOGO_SIZES.includes(value as TitleLogoSize);
52
+ }
53
+
54
+ interface SizeMetrics {
55
+ /** Box the mark is given. Square: every mark here is a round or square badge. */
56
+ readonly mark: string;
57
+ /** Wordmark size. */
58
+ readonly title: string;
59
+ readonly subtitle: string;
60
+ readonly gap: string;
61
+ }
62
+
63
+ /**
64
+ * Every value is real CSS, applied inline — NOT a Panda token key.
65
+ *
66
+ * These are consumed as `style={{ … }}` because Panda cannot statically resolve
67
+ * a runtime Record lookup (see the render below). A token key like `"lg"` would
68
+ * be meaningless as an inline value, so the type scale is reached through the
69
+ * host's own custom properties instead, exactly as `fontSizeMap` does: the host
70
+ * that defines `--font-sizes-lg` gets its scale, and the fallback covers a host
71
+ * that defines nothing.
72
+ *
73
+ * rem throughout, never px. A logo pinned in px ignores the browser's own
74
+ * font-size setting — the affordance people with low vision actually reach for
75
+ * — and this is the one piece of chrome on every page.
76
+ */
77
+ export const TITLE_LOGO_METRICS: Record<TitleLogoSize, SizeMetrics> = {
78
+ small: {
79
+ mark: "2rem",
80
+ title: "var(--font-sizes-lg, 1.125rem)",
81
+ subtitle: "var(--font-sizes-xs, 0.75rem)",
82
+ gap: "0.5rem",
83
+ },
84
+ medium: {
85
+ mark: "3rem",
86
+ title: "var(--font-sizes-2xl, 1.5rem)",
87
+ subtitle: "var(--font-sizes-sm, 0.875rem)",
88
+ gap: "0.75rem",
89
+ },
90
+ large: {
91
+ mark: "4.5rem",
92
+ title: "var(--font-sizes-4xl, 2.25rem)",
93
+ subtitle: "var(--font-sizes-md, 1rem)",
94
+ gap: "1rem",
95
+ },
96
+ };
97
+
98
+ const Row = styled("span", {
99
+ base: {
100
+ display: "inline-flex",
101
+ alignItems: "center",
102
+ minWidth: 0,
103
+ },
104
+ });
105
+
106
+ const Mark = styled("span", {
107
+ base: {
108
+ display: "inline-flex",
109
+ alignItems: "center",
110
+ justifyContent: "center",
111
+ flexShrink: 0,
112
+ // Anything handed in — an <img>, an <svg>, a self-sizing component — is
113
+ // contained rather than allowed to set the row's height. Without this a
114
+ // logo with its own intrinsic size silently drives the header's height and
115
+ // the three rungs stop meaning anything.
116
+ "& > *": { maxWidth: "100%", maxHeight: "100%" },
117
+ "& img, & svg": { width: "100%", height: "100%", objectFit: "contain" },
118
+ },
119
+ });
120
+
121
+ const TextColumn = styled("span", {
122
+ base: {
123
+ display: "inline-flex",
124
+ flexDirection: "column",
125
+ justifyContent: "center",
126
+ minWidth: 0,
127
+ },
128
+ });
129
+
130
+ export interface TitleLogoProps extends Omit<HTMLStyledProps<"span">, "title"> {
131
+ /** The wordmark. A string, or nodes when the brand styles it in parts. */
132
+ title: React.ReactNode;
133
+ /** The mark. Omit for a text-only lockup. */
134
+ logo?: React.ReactNode | undefined;
135
+ /** Optional second line. Prefer the footer for provenance — see the header. */
136
+ subtitle?: React.ReactNode | undefined;
137
+ size?: TitleLogoSize | undefined;
138
+ /**
139
+ * Accessible name for the lockup as a whole.
140
+ *
141
+ * Given, the row becomes a single labelled image to assistive tech and the
142
+ * decorative mark is hidden — otherwise a screen reader announces the mark
143
+ * and the wordmark as two separate things, which is one thing too many for a
144
+ * logo. Defaults to `title` when that is a plain string.
145
+ */
146
+ label?: string | undefined;
147
+ }
148
+
149
+ export const TitleLogo = React.forwardRef<HTMLSpanElement, TitleLogoProps>(
150
+ function TitleLogo(
151
+ { title, logo, subtitle, size = "medium", label, ...rest },
152
+ ref,
153
+ ) {
154
+ log.trace("TitleLogo rendered");
155
+
156
+ const metrics = TITLE_LOGO_METRICS[isTitleLogoSize(size) ? size : "medium"];
157
+ const accessibleName = label ?? (typeof title === "string" ? title : undefined);
158
+
159
+ return (
160
+ <Row
161
+ ref={ref}
162
+ // INLINE STYLE, not a Panda prop, and this is load-bearing.
163
+ //
164
+ // Panda finds styles by STATICALLY parsing source at the consumer's
165
+ // build. `gap={metrics.gap}` is a runtime lookup on a Record — Panda
166
+ // sees a non-literal, cannot resolve it, and emits no rule. The class
167
+ // still lands in the DOM, so it fails completely silently.
168
+ //
169
+ // Caught in optima-cloud-saas: the row rendered `gap: normal` and the
170
+ // mark at its natural 512px instead of 2rem, blowing the masthead out
171
+ // to 612px tall — while the literal styles in the `base` blocks below
172
+ // (display, alignItems, flexShrink, objectFit) all applied correctly.
173
+ // That split is the tell: literals extract, variables do not.
174
+ //
175
+ // Anything size-dependent is therefore inline. It also means this
176
+ // component cannot be broken by a consumer's `include` glob, which for
177
+ // a published design-system component is worth more than the styling
178
+ // indirection it gives up.
179
+ style={{ gap: metrics.gap, ...(rest.style ?? {}) }}
180
+ data-testid="title-logo"
181
+ data-size={size}
182
+ {...(accessibleName ? { role: "img", "aria-label": accessibleName } : {})}
183
+ {...rest}
184
+ >
185
+ {logo ? (
186
+ <Mark
187
+ data-testid="title-logo-mark"
188
+ style={{ width: metrics.mark, height: metrics.mark }}
189
+ // Hidden when the row already carries the name: announcing the mark
190
+ // separately would repeat it.
191
+ {...(accessibleName ? { "aria-hidden": true } : {})}
192
+ >
193
+ {logo}
194
+ </Mark>
195
+ ) : null}
196
+
197
+ <TextColumn>
198
+ <styled.span
199
+ data-testid="title-logo-title"
200
+ style={{ fontSize: metrics.title }}
201
+ fontWeight="600"
202
+ lineHeight="1.1"
203
+ letterSpacing="-0.01em"
204
+ >
205
+ {title}
206
+ </styled.span>
207
+ {subtitle ? (
208
+ <styled.span
209
+ data-testid="title-logo-subtitle"
210
+ style={{ fontSize: metrics.subtitle }}
211
+ lineHeight="1.2"
212
+ opacity={0.75}
213
+ >
214
+ {subtitle}
215
+ </styled.span>
216
+ ) : null}
217
+ </TextColumn>
218
+ </Row>
219
+ );
220
+ },
221
+ );
222
+
223
+ export default TitleLogo;
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import StyledIcon, { type StyledIconProps } from "./StyledIcon";
5
+
6
+ /**
7
+ * Build a named icon component from any node.
8
+ *
9
+ * An icon set is a few hundred near-identical wrappers, and hand-writing them
10
+ * is how a set drifts — one forgets to forward `size`, another hardcodes a
11
+ * colour, a third omits `displayName` and shows up as `Unknown` in every stack
12
+ * trace. This makes each one a single line and forces them to agree.
13
+ *
14
+ * ```tsx
15
+ * import { Home } from "lucide-react";
16
+ * export const StyledHome = createIcon("StyledHome", <Home />);
17
+ * ```
18
+ *
19
+ * The artwork stays yours: nothing about the icon set reaches this package, so
20
+ * a commercially licensed set can be wrapped in a private package while the
21
+ * components that lay it out stay open.
22
+ */
23
+ export function createIcon(
24
+ displayName: string,
25
+ node: React.ReactNode,
26
+ ): React.FC<Omit<StyledIconProps, "icon" | "children">> {
27
+ const Icon: React.FC<Omit<StyledIconProps, "icon" | "children">> = (props) => (
28
+ <StyledIcon {...props} icon={node} />
29
+ );
30
+ Icon.displayName = displayName;
31
+ return Icon;
32
+ }
33
+
34
+ /**
35
+ * The same, for sets whose icons are components taking their own props —
36
+ * Lucide, Heroicons, react-icons, and anything else exporting one component per
37
+ * glyph.
38
+ *
39
+ * ```tsx
40
+ * import { Home } from "lucide-react";
41
+ * export const StyledHome = createIconFromComponent("StyledHome", Home);
42
+ * ```
43
+ *
44
+ * The wrapped component is rendered with `width`/`height` at 100% so it fills
45
+ * the box `size` establishes rather than fighting it — most sets default to
46
+ * 24px and would otherwise ignore the size prop entirely.
47
+ */
48
+ export function createIconFromComponent<P extends object>(
49
+ displayName: string,
50
+ Component: React.ComponentType<P>,
51
+ componentProps?: P,
52
+ ): React.FC<Omit<StyledIconProps, "icon" | "children">> {
53
+ const Icon: React.FC<Omit<StyledIconProps, "icon" | "children">> = (props) => (
54
+ <StyledIcon
55
+ {...props}
56
+ icon={
57
+ <Component
58
+ {...({ width: "100%", height: "100%" } as unknown as P)}
59
+ {...(componentProps ?? ({} as P))}
60
+ />
61
+ }
62
+ />
63
+ );
64
+ Icon.displayName = displayName;
65
+ return Icon;
66
+ }
@@ -0,0 +1,134 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import StyledButton, { type StyledButtonProps } from "./StyledButton";
5
+ import { useDensity } from "../config/style-config";
6
+ import { useIntentIcon, type IconIntent } from "../config/intent-icons";
7
+
8
+ /**
9
+ * Builds an "intent button" — Save, Delete, Edit and the rest.
10
+ *
11
+ * Each is a `StyledButton` that already knows three things a caller would
12
+ * otherwise repeat: which icon to draw, what the label says, and what the
13
+ * spinner says while it works. The point is not to save typing; it is that
14
+ * every Delete button in a product is the same Delete button.
15
+ *
16
+ * The icon comes from the **intent registry**, not from a prop, so this package
17
+ * ships no artwork and each host supplies its own set — Font Awesome in
18
+ * HopperGuard, Lucide in optima-filings. A per-call-site `icon` prop still
19
+ * wins for the rare one-off.
20
+ *
21
+ * ## Compact density drops the label — and that used to lose the name
22
+ *
23
+ * At `compact`, these render icon-only. In the originating app that produced a
24
+ * button with **no text and no `aria-label`**, so a screen reader announced it
25
+ * as "button" — the same defect as NEH-226, except this one appeared and
26
+ * disappeared with a user preference, which is worse: it passes review at
27
+ * `normal` and fails for the user who chose `compact`.
28
+ *
29
+ * So when the label is hidden it becomes the accessible name. The visible text
30
+ * and the announced name are the same string either way, which is the point of
31
+ * WCAG 2.5.3 (Label in Name).
32
+ */
33
+
34
+ export interface IntentButtonProps
35
+ extends Omit<StyledButtonProps, "leftIcon" | "children"> {
36
+ children?: React.ReactNode;
37
+ /**
38
+ * Force icon-only, or force the label on. Defaults to icon-only at `compact`
39
+ * density.
40
+ */
41
+ iconOnly?: boolean;
42
+ /** Override the registry for this one call site. */
43
+ icon?: React.ReactNode;
44
+ }
45
+
46
+ export interface IntentButtonSpec {
47
+ /** Component name — shows in React DevTools and stack traces. */
48
+ displayName: string;
49
+ /** Which registry entry to draw. */
50
+ intent: IconIntent;
51
+ /** Visible text, and the accessible name when the label is hidden. */
52
+ defaultLabel: string;
53
+ /** What the spinner says while `loading`. Prefer naming the action. */
54
+ loadText?: string;
55
+ /**
56
+ * Default hover/focus explanation.
57
+ *
58
+ * Deliberately more than the label repeated: "Delete" is the label, "Delete
59
+ * the item" is what the tooltip adds. A caller's own `tooltip` overrides.
60
+ */
61
+ tooltip?: string;
62
+ }
63
+
64
+ export function createIntentButton({
65
+ displayName,
66
+ intent,
67
+ defaultLabel,
68
+ loadText,
69
+ tooltip: defaultTooltip,
70
+ }: IntentButtonSpec) {
71
+ const IntentButton = React.forwardRef<HTMLButtonElement, IntentButtonProps>(
72
+ function IntentButton(
73
+ { children = defaultLabel, iconOnly, icon, tooltip = defaultTooltip, ...props },
74
+ ref,
75
+ ) {
76
+ const density = useDensity();
77
+ const registered = useIntentIcon(intent);
78
+ const glyph = icon ?? registered;
79
+
80
+ /**
81
+ * Bound to the USER's profile, not the resolved density step (NEH-251).
82
+ *
83
+ * The obvious reading of the ladder is "collapse the label when spacing
84
+ * gets tight", i.e. test `useDensityStep()`. That is wrong, and the
85
+ * counter-example is Optima: it bases at `compact`, so a user who has
86
+ * expressed no preference at all resolves to the `compact` rung and every
87
+ * intent button in the product would silently lose its text.
88
+ *
89
+ * Dropping a visible label is a change to what a control *says*. It
90
+ * should answer "did this user ask for tighter?", which is exactly what
91
+ * the profile means — it is relative to whatever the app chose. An app
92
+ * that wants dense spacing has not thereby asked for unlabelled buttons.
93
+ */
94
+ const isIconOnly = iconOnly ?? density === "compact";
95
+
96
+ // The label as a string, for the accessible name. A node child cannot be
97
+ // flattened reliably, so fall back to the intent's own label rather than
98
+ // rendering `[object Object]` into aria-label.
99
+ const label = typeof children === "string" ? children : defaultLabel;
100
+
101
+ if (isIconOnly) {
102
+ return (
103
+ <StyledButton
104
+ ref={ref}
105
+ loadText={loadText}
106
+ // Without this the button has no text and no name at all — see the
107
+ // note above. An explicit aria-label from the caller still wins,
108
+ // because `props` is spread after.
109
+ aria-label={label}
110
+ tooltip={tooltip}
111
+ {...props}
112
+ >
113
+ {glyph}
114
+ </StyledButton>
115
+ );
116
+ }
117
+
118
+ return (
119
+ <StyledButton
120
+ ref={ref}
121
+ loadText={loadText}
122
+ leftIcon={glyph}
123
+ tooltip={tooltip}
124
+ {...props}
125
+ >
126
+ {children}
127
+ </StyledButton>
128
+ );
129
+ },
130
+ );
131
+
132
+ IntentButton.displayName = displayName;
133
+ return IntentButton;
134
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The dictation seam.
3
+ *
4
+ * `StyledInputText` and `StyledInputTextArea` can render a microphone, but this
5
+ * package contains no speech code and never will. Recognition is a product
6
+ * decision with product consequences — which engine, whether audio may leave
7
+ * the device, what a regulated deployment is allowed to send where — and none
8
+ * of that belongs in a component library.
9
+ *
10
+ * So the host builds the behaviour and hands it over as one object. The input
11
+ * owns the *presentation*: where the button sits, how much padding the text
12
+ * needs so it does not run underneath, what it is called, what it announces
13
+ * while recording. The host owns everything else.
14
+ *
15
+ * HopperGuard's `useDictation()` already returns this shape, so its call sites
16
+ * pass the hook's result straight through. That is the intended fit: an adapter
17
+ * that needs a translation layer is usually a sign the seam is in the wrong
18
+ * place.
19
+ *
20
+ * ## Why an object rather than a set of props
21
+ *
22
+ * These fields are meaningless apart — `showRedo` with no `redo`, `isListening`
23
+ * on an input with no `onMicClick` — and passing one bundle makes the
24
+ * all-or-nothing nature obvious at the call site. It also means adding a
25
+ * capability later does not widen the component's prop surface.
26
+ *
27
+ * ## What the input does NOT do
28
+ *
29
+ * It does not decide whether dictation is *appropriate*. A password field, a
30
+ * PIN, a card number — the host omits the adapter, and no mic renders. Putting
31
+ * that judgement here would mean guessing from `type`, which is exactly the
32
+ * kind of guess that puts a microphone on a field that should never have one.
33
+ *
34
+ * Errors are the host's too. They arrive asynchronously and belong wherever
35
+ * that product shows problems — a toast, an inline message, a live region —
36
+ * which the input cannot know.
37
+ */
38
+ export interface Dictation {
39
+ /**
40
+ * Whether this browser/engine can dictate at all. `false` renders no mic —
41
+ * a control that cannot work is worse than an absent one, because the user
42
+ * has to press it to find out.
43
+ */
44
+ isSupported: boolean;
45
+ /** Recording now. Drives `aria-pressed` and the recording colour. */
46
+ isListening: boolean;
47
+ /** Offer "record again" — typically after a result the user may reject. */
48
+ showRedo?: boolean;
49
+ /** Start or stop recording. */
50
+ onMicClick: () => void;
51
+ /** Discard the last result and record again. Required when `showRedo`. */
52
+ redo?: () => void;
53
+
54
+ /**
55
+ * Ask what a new recording should do to text that is already there.
56
+ *
57
+ * Only meaningful for multi-entry fields, where prose is written in passes.
58
+ * Overwriting someone's paragraph because they pressed the mic a second time
59
+ * is a data-loss bug, not a UX preference — so when the host raises this, the
60
+ * field stops and asks instead of guessing.
61
+ *
62
+ * Requires `chooseContinue` and `chooseStartOver`. While it is raised the
63
+ * redo button is suppressed: three competing choices about the same text is
64
+ * not a decision anyone can make quickly.
65
+ */
66
+ continuePrompt?: boolean;
67
+ /** Append the new recording to what is already there. */
68
+ chooseContinue?: () => void;
69
+ /** Replace what is already there. */
70
+ chooseStartOver?: () => void;
71
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The intent buttons.
3
+ *
4
+ * One line each, because that is genuinely all they are: a `StyledButton` that
5
+ * knows its icon, its label and its loading text. The behaviour lives in
6
+ * `createIntentButton`; this file is the vocabulary.
7
+ *
8
+ * `loadText` is set only where the action takes long enough to show a spinner
9
+ * and where naming it helps — "Saving" tells the user more than "Loading".
10
+ *
11
+ * Not every wrapper in the originating app belongs here. Ones that call an API,
12
+ * read a feature flag or drive app state (`StyledColorButton`,
13
+ * `StyledDensityButton`, `StyledThemeButton`, `StyledVariantButton`,
14
+ * `StyledStyleResetButton`, `StyledHelpButton`, `StyledCopyButton`) are
15
+ * application components that happen to be buttons, and they stay in the app.
16
+ */
17
+ import { createIntentButton } from "./create-intent-button";
18
+
19
+ export const StyledAddButton = createIntentButton({
20
+ displayName: "StyledAddButton",
21
+ intent: "add",
22
+ defaultLabel: "Add",
23
+ tooltip: "Add item",
24
+ });
25
+
26
+ export const StyledSaveButton = createIntentButton({
27
+ displayName: "StyledSaveButton",
28
+ intent: "save",
29
+ defaultLabel: "Save",
30
+ loadText: "Saving",
31
+ });
32
+
33
+ export const StyledDeleteButton = createIntentButton({
34
+ displayName: "StyledDeleteButton",
35
+ intent: "delete",
36
+ defaultLabel: "Delete",
37
+ loadText: "Deleting",
38
+ tooltip: "Delete the item",
39
+ });
40
+
41
+ export const StyledEditButton = createIntentButton({
42
+ displayName: "StyledEditButton",
43
+ intent: "edit",
44
+ defaultLabel: "Edit",
45
+ tooltip: "Modify this item.",
46
+ });
47
+
48
+ export const StyledCancelButton = createIntentButton({
49
+ displayName: "StyledCancelButton",
50
+ intent: "cancel",
51
+ defaultLabel: "Cancel",
52
+ });
53
+
54
+ export const StyledCloneButton = createIntentButton({
55
+ displayName: "StyledCloneButton",
56
+ intent: "clone",
57
+ defaultLabel: "Clone",
58
+ tooltip: "Create a copy of the item.",
59
+ });
60
+
61
+ export const StyledBackButton = createIntentButton({
62
+ displayName: "StyledBackButton",
63
+ intent: "back",
64
+ defaultLabel: "Back",
65
+ });
66
+
67
+ export const StyledNextButton = createIntentButton({
68
+ displayName: "StyledNextButton",
69
+ intent: "next",
70
+ defaultLabel: "Next",
71
+ tooltip: "Proceed to next step.",
72
+ });
73
+
74
+ export const StyledNewButton = createIntentButton({
75
+ displayName: "StyledNewButton",
76
+ intent: "new",
77
+ defaultLabel: "New",
78
+ tooltip: "Create a new item.",
79
+ });
80
+
81
+ export const StyledRenameButton = createIntentButton({
82
+ displayName: "StyledRenameButton",
83
+ intent: "rename",
84
+ defaultLabel: "Rename",
85
+ tooltip: "Rename the item",
86
+ });
87
+
88
+ export const StyledLoadButton = createIntentButton({
89
+ displayName: "StyledLoadButton",
90
+ intent: "load",
91
+ defaultLabel: "Load",
92
+ loadText: "Loading",
93
+ tooltip: "Load the item",
94
+ });
95
+
96
+ export const StyledResumeButton = createIntentButton({
97
+ displayName: "StyledResumeButton",
98
+ intent: "resume",
99
+ defaultLabel: "Resume",
100
+ tooltip: "Resume activity",
101
+ });
102
+
103
+ export const StyledPlayButton = createIntentButton({
104
+ displayName: "StyledPlayButton",
105
+ intent: "play",
106
+ defaultLabel: "Play",
107
+ });
108
+
109
+ // StyledFavoriteButton is deliberately absent. It is a stateful TOGGLE — its
110
+ // icon colour and its tooltip both flip on `isFavorite` ("Mark as favorite" /
111
+ // "Remove from favorites") — and the on-state colour is an app constant. That
112
+ // is a different component from "a button with a star on it", so it stays in
113
+ // the app rather than being bent into this factory.
114
+
115
+ export const StyledSettingsButton = createIntentButton({
116
+ displayName: "StyledSettingsButton",
117
+ intent: "settings",
118
+ defaultLabel: "Settings",
119
+ tooltip: "Changes settings",
120
+ });
121
+
122
+ export const StyledHomeButton = createIntentButton({
123
+ displayName: "StyledHomeButton",
124
+ intent: "home",
125
+ defaultLabel: "Home",
126
+ tooltip: "Go to main page",
127
+ });
128
+
129
+ export const StyledMenuButton = createIntentButton({
130
+ displayName: "StyledMenuButton",
131
+ intent: "menu",
132
+ defaultLabel: "Menu",
133
+ tooltip: "Open menu with additional options",
134
+ });
135
+
136
+ export const StyledAnalyticsButton = createIntentButton({
137
+ displayName: "StyledAnalyticsButton",
138
+ intent: "analytics",
139
+ defaultLabel: "Analytics",
140
+ tooltip: "View data summary",
141
+ });
142
+
143
+ export const StyledEmojiButton = createIntentButton({
144
+ displayName: "StyledEmojiButton",
145
+ intent: "emoji",
146
+ defaultLabel: "Emoji",
147
+ });
148
+
149
+ export const StyledUrlButton = createIntentButton({
150
+ displayName: "StyledUrlButton",
151
+ intent: "url",
152
+ defaultLabel: "From URL",
153
+ tooltip: "Retrieve colors from external location.",
154
+ });