@tastic/hud 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -28,8 +28,12 @@ corner of the screen.
28
28
  `maxHeight` for the caller to cap content and scroll when neither direction has enough room.
29
29
  - **`SectionedDropdown`** — a popover holding any mix of single-select ("pick one", radio-style) and
30
30
  multi-select ("pick some", checkbox-style, optional all/clear footer) sections, divided by rules.
31
- - **`InlineColorPicker`** — swatch-grid color popover, auto-sized columns, with an optional "taken"
32
- color (shown disabled, or swappable).
31
+ - **`InlineColorPicker`** — swatch-grid color popover, columns auto-clamped to 4 or 5 (the default
32
+ `swatches` list is exactly 20 colors, so that's the only range that fills every row completely),
33
+ with an optional "taken" color (shown disabled, or swappable). The trigger itself is a `size`-
34
+ configurable circle showing either an `icon` or a short `tag` (e.g. a saved player identity's own
35
+ initial or emoji) — `tag` renders at a larger ratio automatically when it's an emoji, since an
36
+ emoji glyph reads visually smaller than a bold letter at the same font size.
33
37
  - **`TriggerGauge`** — decorative ring of tick marks around a trigger, showing which option(s) are
34
38
  active without opening the popover.
35
39
  - **`ReadyButton`** — a per-player or standalone ready toggle.
@@ -1,5 +1,6 @@
1
1
  // src/TriggerGauge.tsx
2
2
  import { Canvas, Path, Skia } from "@shopify/react-native-skia";
3
+ import { clamp } from "@tastic/core";
3
4
  import { useCallback, useLayoutEffect, useRef, useState } from "react";
4
5
  import { StyleSheet, View } from "react-native";
5
6
  import { Easing, useDerivedValue, useSharedValue, withTiming } from "react-native-reanimated";
@@ -60,7 +61,7 @@ function TriggerGauge({ segments, litIndices, size, accentColor, mutedColor, das
60
61
  if (mountProgress.value < 1) {
61
62
  const startFrac = i * MOUNT_STAGGER_MS / totalDurationMs;
62
63
  const endFrac = startFrac + MOUNT_DURATION_MS / totalDurationMs;
63
- local = Math.min(1, Math.max(0, (mountProgress.value - startFrac) / (endFrac - startFrac)));
64
+ local = clamp((mountProgress.value - startFrac) / (endFrac - startFrac), 0, 1);
64
65
  }
65
66
  const sweep = dashAngleDeg * local;
66
67
  if (sweep <= 0) continue;
@@ -84,7 +85,7 @@ function TriggerGauge({ segments, litIndices, size, accentColor, mutedColor, das
84
85
  if (!isLitNow) continue;
85
86
  const startFrac = i * MOUNT_STAGGER_MS / totalDurationMs;
86
87
  const endFrac = startFrac + MOUNT_DURATION_MS / totalDurationMs;
87
- local = Math.min(1, Math.max(0, (mountProgress.value - startFrac) / (endFrac - startFrac)));
88
+ local = clamp((mountProgress.value - startFrac) / (endFrac - startFrac), 0, 1);
88
89
  } else if (isLitNow === wasLit) {
89
90
  local = 1;
90
91
  } else if (isLitNow) {
package/dist/index.d.mts CHANGED
@@ -23,10 +23,13 @@ interface Props$4 {
23
23
  dark: boolean;
24
24
  align?: 'left' | 'right' | 'center';
25
25
  icon?: string;
26
+ tag?: string;
27
+ labelFontFamily?: string;
26
28
  autoDismiss?: boolean;
27
29
  columns?: number;
30
+ size?: number;
28
31
  }
29
- declare function InlineColorPicker({ id, host, value, onChange, swatches, takenValue, allowSwapTaken, dark, align: alignOverride, icon, autoDismiss, columns }: Props$4): react.JSX.Element;
32
+ declare function InlineColorPicker({ id, host, value, onChange, swatches, takenValue, allowSwapTaken, dark, align: alignOverride, icon, tag, labelFontFamily, autoDismiss, columns, size }: Props$4): react.JSX.Element;
30
33
 
31
34
  declare function loadSkiaWeb(): Promise<void>;
32
35
 
package/dist/index.d.ts CHANGED
@@ -23,10 +23,13 @@ interface Props$4 {
23
23
  dark: boolean;
24
24
  align?: 'left' | 'right' | 'center';
25
25
  icon?: string;
26
+ tag?: string;
27
+ labelFontFamily?: string;
26
28
  autoDismiss?: boolean;
27
29
  columns?: number;
30
+ size?: number;
28
31
  }
29
- declare function InlineColorPicker({ id, host, value, onChange, swatches, takenValue, allowSwapTaken, dark, align: alignOverride, icon, autoDismiss, columns }: Props$4): react.JSX.Element;
32
+ declare function InlineColorPicker({ id, host, value, onChange, swatches, takenValue, allowSwapTaken, dark, align: alignOverride, icon, tag, labelFontFamily, autoDismiss, columns, size }: Props$4): react.JSX.Element;
30
33
 
31
34
  declare function loadSkiaWeb(): Promise<void>;
32
35
 
package/dist/index.js CHANGED
@@ -72,7 +72,7 @@ function TriggerGauge({ segments, litIndices, size, accentColor, mutedColor, das
72
72
  if (mountProgress.value < 1) {
73
73
  const startFrac = i * MOUNT_STAGGER_MS / totalDurationMs;
74
74
  const endFrac = startFrac + MOUNT_DURATION_MS / totalDurationMs;
75
- local = Math.min(1, Math.max(0, (mountProgress.value - startFrac) / (endFrac - startFrac)));
75
+ local = (0, import_core2.clamp)((mountProgress.value - startFrac) / (endFrac - startFrac), 0, 1);
76
76
  }
77
77
  const sweep = dashAngleDeg * local;
78
78
  if (sweep <= 0) continue;
@@ -96,7 +96,7 @@ function TriggerGauge({ segments, litIndices, size, accentColor, mutedColor, das
96
96
  if (!isLitNow) continue;
97
97
  const startFrac = i * MOUNT_STAGGER_MS / totalDurationMs;
98
98
  const endFrac = startFrac + MOUNT_DURATION_MS / totalDurationMs;
99
- local = Math.min(1, Math.max(0, (mountProgress.value - startFrac) / (endFrac - startFrac)));
99
+ local = (0, import_core2.clamp)((mountProgress.value - startFrac) / (endFrac - startFrac), 0, 1);
100
100
  } else if (isLitNow === wasLit) {
101
101
  local = 1;
102
102
  } else if (isLitNow) {
@@ -125,11 +125,12 @@ function TriggerGauge({ segments, litIndices, size, accentColor, mutedColor, das
125
125
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_react_native_skia.Path, { path: litPath, style: "stroke", strokeWidth: LIT_THICKNESS, strokeCap: "round", color: accentColor, opacity: 1 })
126
126
  ] }) });
127
127
  }
128
- var import_react_native_skia, import_react2, import_react_native7, import_react_native_reanimated, import_jsx_runtime5, UNLIT_THICKNESS, LIT_THICKNESS, DASH_GAP_PX, ARC_GAP, DEFAULT_START_DEG, MIN_DASH_ANGLE_DEG, MOUNT_DURATION_MS, MOUNT_STAGGER_MS, SELECTION_TRANSITION_DURATION_MS, styles4;
128
+ var import_react_native_skia, import_core2, import_react2, import_react_native7, import_react_native_reanimated, import_jsx_runtime5, UNLIT_THICKNESS, LIT_THICKNESS, DASH_GAP_PX, ARC_GAP, DEFAULT_START_DEG, MIN_DASH_ANGLE_DEG, MOUNT_DURATION_MS, MOUNT_STAGGER_MS, SELECTION_TRANSITION_DURATION_MS, styles4;
129
129
  var init_TriggerGauge = __esm({
130
130
  "src/TriggerGauge.tsx"() {
131
131
  "use strict";
132
132
  import_react_native_skia = require("@shopify/react-native-skia");
133
+ import_core2 = require("@tastic/core");
133
134
  import_react2 = require("react");
134
135
  import_react_native7 = require("react-native");
135
136
  import_react_native_reanimated = require("react-native-reanimated");
@@ -176,6 +177,7 @@ var MONO_FONT = import_react_native.Platform.select({ ios: "Menlo", android: "mo
176
177
  // src/InlineColorPicker.tsx
177
178
  var import_auto_paper = require("@rific/auto-paper");
178
179
  var import_feedback_press = require("@rific/feedback-press");
180
+ var import_core = require("@tastic/core");
179
181
  var import_react_native4 = require("react-native");
180
182
  var import_react_native_paper = require("react-native-paper");
181
183
 
@@ -285,21 +287,22 @@ function useAutoAlign(open, contentWidth, contentHeight) {
285
287
 
286
288
  // src/InlineColorPicker.tsx
287
289
  var import_jsx_runtime2 = require("react/jsx-runtime");
288
- var SIZE = 48;
290
+ var EMOJI_PATTERN = /\p{Extended_Pictographic}/u;
291
+ var DEFAULT_SIZE = 48;
289
292
  var SWATCH_SIZE = 28;
290
293
  var SWATCHES_PADDING = 8;
291
294
  var SWATCHES_GAP = 6;
292
295
  var SWATCHES_BORDER_WIDTH = 2;
293
- var MIN_COLUMNS = 3;
294
- var MAX_COLUMNS = 6;
296
+ var MIN_COLUMNS = 4;
297
+ var MAX_COLUMNS = 5;
295
298
  var SCREEN_MARGIN = 40;
296
299
  function widthForColumns(columns) {
297
300
  return SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * columns + SWATCHES_GAP * (columns - 1);
298
301
  }
299
- function InlineColorPicker({ id, host, value, onChange, swatches = import_auto_paper.defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, icon = "palette", autoDismiss = true, columns }) {
302
+ function InlineColorPicker({ id, host, value, onChange, swatches = import_auto_paper.defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, icon = "palette", tag, labelFontFamily = MONO_FONT, autoDismiss = true, columns, size = DEFAULT_SIZE }) {
300
303
  const menuBg = dark ? "#000000" : "#FFFFFF";
301
304
  const { width: windowWidth } = (0, import_react_native4.useWindowDimensions)();
302
- const autoColumns = Math.min(MAX_COLUMNS, Math.max(MIN_COLUMNS, Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP))));
305
+ const autoColumns = (0, import_core.clamp)(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS);
303
306
  const resolvedColumns = columns ?? autoColumns;
304
307
  const swatchesWidth = widthForColumns(resolvedColumns);
305
308
  const swatchRows = Math.ceil(swatches.length / resolvedColumns);
@@ -307,9 +310,11 @@ function InlineColorPicker({ id, host, value, onChange, swatches = import_auto_p
307
310
  const open = host.openId === id;
308
311
  const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight);
309
312
  const align = alignOverride ?? autoAlign;
313
+ const contrastColor = (0, import_auto_paper.getContrastColor)(value);
314
+ const tagFontSize = size * (tag && EMOJI_PATTERN.test(tag) ? 0.6 : 0.4);
310
315
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_react_native4.View, { style: [styles2.anchor, open && styles2.anchorOpen], children: [
311
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_feedback_press.TouchableRipple, { onPress: () => host.toggle(id), borderless: true, style: [styles2.trigger, { backgroundColor: value }], children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native4.View, { ref: triggerRef, collapsable: false, style: styles2.triggerMeasure, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native_paper.Icon, { source: icon, size: 20, color: (0, import_auto_paper.getContrastColor)(value) }) }) }),
312
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(PopoverBody, { visible: open && measured, align, verticalAlign, caretColor: menuBg, caretBorderColor: value, caretBorderWidth: SWATCHES_BORDER_WIDTH, triggerSize: SIZE, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native4.ScrollView, { style: [styles2.swatches, { backgroundColor: menuBg, borderColor: value, width: swatchesWidth, maxHeight }], contentContainerStyle: styles2.swatchesContent, showsVerticalScrollIndicator: false, children: swatches.map((swatch) => {
316
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_feedback_press.TouchableRipple, { onPress: () => host.toggle(id), borderless: true, style: [styles2.trigger, { backgroundColor: value, borderRadius: size / 2, height: size, width: size }], children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native4.View, { ref: triggerRef, collapsable: false, style: styles2.triggerMeasure, children: tag ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native_paper.Text, { style: [styles2.tagLabel, { color: contrastColor, fontFamily: labelFontFamily, fontSize: tagFontSize }], numberOfLines: 1, adjustsFontSizeToFit: true, children: tag }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native_paper.Icon, { source: icon, size: size * 0.6, color: contrastColor }) }) }),
317
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(PopoverBody, { visible: open && measured, align, verticalAlign, caretColor: menuBg, caretBorderColor: value, caretBorderWidth: SWATCHES_BORDER_WIDTH, triggerSize: size, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native4.ScrollView, { style: [styles2.swatches, { backgroundColor: menuBg, borderColor: value, width: swatchesWidth, maxHeight }], contentContainerStyle: styles2.swatchesContent, showsVerticalScrollIndicator: false, children: swatches.map((swatch) => {
313
318
  const selected = swatch.value.toLowerCase() === value.toLowerCase();
314
319
  const taken = !selected && !!takenValue && swatch.value.toLowerCase() === takenValue.toLowerCase();
315
320
  const swappable = taken && allowSwapTaken;
@@ -376,22 +381,28 @@ var styles2 = import_react_native4.StyleSheet.create({
376
381
  gap: SWATCHES_GAP,
377
382
  padding: SWATCHES_PADDING
378
383
  },
384
+ // borderRadius/height/width come from the `size` prop instead (see render-site override) — no
385
+ // fixed default here, since this style object is shared by every caller regardless of size.
379
386
  trigger: {
380
387
  alignItems: "center",
381
388
  alignSelf: "flex-start",
382
- borderRadius: SIZE / 2,
383
- height: SIZE,
384
- justifyContent: "center",
385
- width: SIZE
389
+ justifyContent: "center"
386
390
  },
387
- // Wraps just the icon, inside the TouchableRipple, purely so useAutoAlign has a plain View to
388
- // attach its measurement ref to — TouchableRipple itself doesn't forward a ref to a measurable
389
- // native node.
391
+ // Wraps just the icon/tag, inside the TouchableRipple, purely so useAutoAlign has a plain View
392
+ // to attach its measurement ref to — TouchableRipple itself doesn't forward a ref to a
393
+ // measurable native node. Padded horizontally so a multi-character tag doesn't butt right up
394
+ // against the trigger's own circular edge (an icon alone doesn't need it, but this style is
395
+ // shared by both).
390
396
  triggerMeasure: {
391
397
  alignItems: "center",
392
398
  height: "100%",
393
399
  justifyContent: "center",
400
+ paddingHorizontal: 4,
394
401
  width: "100%"
402
+ },
403
+ tagLabel: {
404
+ fontWeight: "700",
405
+ letterSpacing: 0.5
395
406
  }
396
407
  });
397
408
 
package/dist/index.mjs CHANGED
@@ -5,8 +5,9 @@ var MONO_FONT = Platform.select({ ios: "Menlo", android: "monospace", default: "
5
5
  // src/InlineColorPicker.tsx
6
6
  import { defaultColors, getContrastColor } from "@rific/auto-paper";
7
7
  import { TouchableRipple } from "@rific/feedback-press";
8
+ import { clamp } from "@tastic/core";
8
9
  import { ScrollView, StyleSheet as StyleSheet2, useWindowDimensions as useWindowDimensions2, View as View3 } from "react-native";
9
- import { Icon } from "react-native-paper";
10
+ import { Icon, Text } from "react-native-paper";
10
11
 
11
12
  // src/PopoverBody.tsx
12
13
  import { StyleSheet, View } from "react-native";
@@ -114,21 +115,22 @@ function useAutoAlign(open, contentWidth, contentHeight) {
114
115
 
115
116
  // src/InlineColorPicker.tsx
116
117
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
117
- var SIZE = 48;
118
+ var EMOJI_PATTERN = /\p{Extended_Pictographic}/u;
119
+ var DEFAULT_SIZE = 48;
118
120
  var SWATCH_SIZE = 28;
119
121
  var SWATCHES_PADDING = 8;
120
122
  var SWATCHES_GAP = 6;
121
123
  var SWATCHES_BORDER_WIDTH = 2;
122
- var MIN_COLUMNS = 3;
123
- var MAX_COLUMNS = 6;
124
+ var MIN_COLUMNS = 4;
125
+ var MAX_COLUMNS = 5;
124
126
  var SCREEN_MARGIN = 40;
125
127
  function widthForColumns(columns) {
126
128
  return SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * columns + SWATCHES_GAP * (columns - 1);
127
129
  }
128
- function InlineColorPicker({ id, host, value, onChange, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, icon = "palette", autoDismiss = true, columns }) {
130
+ function InlineColorPicker({ id, host, value, onChange, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, icon = "palette", tag, labelFontFamily = MONO_FONT, autoDismiss = true, columns, size = DEFAULT_SIZE }) {
129
131
  const menuBg = dark ? "#000000" : "#FFFFFF";
130
132
  const { width: windowWidth } = useWindowDimensions2();
131
- const autoColumns = Math.min(MAX_COLUMNS, Math.max(MIN_COLUMNS, Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP))));
133
+ const autoColumns = clamp(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS);
132
134
  const resolvedColumns = columns ?? autoColumns;
133
135
  const swatchesWidth = widthForColumns(resolvedColumns);
134
136
  const swatchRows = Math.ceil(swatches.length / resolvedColumns);
@@ -136,9 +138,11 @@ function InlineColorPicker({ id, host, value, onChange, swatches = defaultColors
136
138
  const open = host.openId === id;
137
139
  const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight);
138
140
  const align = alignOverride ?? autoAlign;
141
+ const contrastColor = getContrastColor(value);
142
+ const tagFontSize = size * (tag && EMOJI_PATTERN.test(tag) ? 0.6 : 0.4);
139
143
  return /* @__PURE__ */ jsxs2(View3, { style: [styles2.anchor, open && styles2.anchorOpen], children: [
140
- /* @__PURE__ */ jsx2(TouchableRipple, { onPress: () => host.toggle(id), borderless: true, style: [styles2.trigger, { backgroundColor: value }], children: /* @__PURE__ */ jsx2(View3, { ref: triggerRef, collapsable: false, style: styles2.triggerMeasure, children: /* @__PURE__ */ jsx2(Icon, { source: icon, size: 20, color: getContrastColor(value) }) }) }),
141
- /* @__PURE__ */ jsx2(PopoverBody, { visible: open && measured, align, verticalAlign, caretColor: menuBg, caretBorderColor: value, caretBorderWidth: SWATCHES_BORDER_WIDTH, triggerSize: SIZE, children: /* @__PURE__ */ jsx2(ScrollView, { style: [styles2.swatches, { backgroundColor: menuBg, borderColor: value, width: swatchesWidth, maxHeight }], contentContainerStyle: styles2.swatchesContent, showsVerticalScrollIndicator: false, children: swatches.map((swatch) => {
144
+ /* @__PURE__ */ jsx2(TouchableRipple, { onPress: () => host.toggle(id), borderless: true, style: [styles2.trigger, { backgroundColor: value, borderRadius: size / 2, height: size, width: size }], children: /* @__PURE__ */ jsx2(View3, { ref: triggerRef, collapsable: false, style: styles2.triggerMeasure, children: tag ? /* @__PURE__ */ jsx2(Text, { style: [styles2.tagLabel, { color: contrastColor, fontFamily: labelFontFamily, fontSize: tagFontSize }], numberOfLines: 1, adjustsFontSizeToFit: true, children: tag }) : /* @__PURE__ */ jsx2(Icon, { source: icon, size: size * 0.6, color: contrastColor }) }) }),
145
+ /* @__PURE__ */ jsx2(PopoverBody, { visible: open && measured, align, verticalAlign, caretColor: menuBg, caretBorderColor: value, caretBorderWidth: SWATCHES_BORDER_WIDTH, triggerSize: size, children: /* @__PURE__ */ jsx2(ScrollView, { style: [styles2.swatches, { backgroundColor: menuBg, borderColor: value, width: swatchesWidth, maxHeight }], contentContainerStyle: styles2.swatchesContent, showsVerticalScrollIndicator: false, children: swatches.map((swatch) => {
142
146
  const selected = swatch.value.toLowerCase() === value.toLowerCase();
143
147
  const taken = !selected && !!takenValue && swatch.value.toLowerCase() === takenValue.toLowerCase();
144
148
  const swappable = taken && allowSwapTaken;
@@ -205,22 +209,28 @@ var styles2 = StyleSheet2.create({
205
209
  gap: SWATCHES_GAP,
206
210
  padding: SWATCHES_PADDING
207
211
  },
212
+ // borderRadius/height/width come from the `size` prop instead (see render-site override) — no
213
+ // fixed default here, since this style object is shared by every caller regardless of size.
208
214
  trigger: {
209
215
  alignItems: "center",
210
216
  alignSelf: "flex-start",
211
- borderRadius: SIZE / 2,
212
- height: SIZE,
213
- justifyContent: "center",
214
- width: SIZE
217
+ justifyContent: "center"
215
218
  },
216
- // Wraps just the icon, inside the TouchableRipple, purely so useAutoAlign has a plain View to
217
- // attach its measurement ref to — TouchableRipple itself doesn't forward a ref to a measurable
218
- // native node.
219
+ // Wraps just the icon/tag, inside the TouchableRipple, purely so useAutoAlign has a plain View
220
+ // to attach its measurement ref to — TouchableRipple itself doesn't forward a ref to a
221
+ // measurable native node. Padded horizontally so a multi-character tag doesn't butt right up
222
+ // against the trigger's own circular edge (an icon alone doesn't need it, but this style is
223
+ // shared by both).
219
224
  triggerMeasure: {
220
225
  alignItems: "center",
221
226
  height: "100%",
222
227
  justifyContent: "center",
228
+ paddingHorizontal: 4,
223
229
  width: "100%"
230
+ },
231
+ tagLabel: {
232
+ fontWeight: "700",
233
+ letterSpacing: 0.5
224
234
  }
225
235
  });
226
236
 
@@ -240,13 +250,13 @@ function PressAwayOverlay({ active, onPress, style }) {
240
250
  // src/ReadyButton.tsx
241
251
  import { TouchableRipple as TouchableRipple2 } from "@rific/feedback-press";
242
252
  import { StyleSheet as StyleSheet4 } from "react-native";
243
- import { Text } from "react-native-paper";
253
+ import { Text as Text2 } from "react-native-paper";
244
254
  import { jsx as jsx4 } from "react/jsx-runtime";
245
255
  function ReadyButton({ color, ready, onToggleReady, style, labelFontFamily = MONO_FONT }) {
246
256
  const readyButtonStyle = [styles3.readyButton, { borderColor: color }, ready && { backgroundColor: color }, style];
247
257
  const labelTextStyle = { fontFamily: labelFontFamily, fontWeight: "bold" };
248
258
  const labelColorStyle = { color: ready ? "#000000" : color };
249
- return /* @__PURE__ */ jsx4(TouchableRipple2, { onPress: onToggleReady, borderless: true, style: readyButtonStyle, children: /* @__PURE__ */ jsx4(Text, { variant: "labelLarge", style: [labelTextStyle, labelColorStyle], children: ready ? "READY \u2713" : "READY?" }) });
259
+ return /* @__PURE__ */ jsx4(TouchableRipple2, { onPress: onToggleReady, borderless: true, style: readyButtonStyle, children: /* @__PURE__ */ jsx4(Text2, { variant: "labelLarge", style: [labelTextStyle, labelColorStyle], children: ready ? "READY \u2713" : "READY?" }) });
250
260
  }
251
261
  var styles3 = StyleSheet4.create({
252
262
  readyButton: {
@@ -264,12 +274,12 @@ var styles3 = StyleSheet4.create({
264
274
  import { getBlendedColor, getColorRoles } from "@rific/auto-paper";
265
275
  import { IconButton, TouchableRipple as TouchableRipple3 } from "@rific/feedback-press";
266
276
  import { ScrollView as ScrollView2, StyleSheet as StyleSheet5, View as View4 } from "react-native";
267
- import { Icon as Icon2, Text as Text2 } from "react-native-paper";
277
+ import { Icon as Icon2, Text as Text3 } from "react-native-paper";
268
278
 
269
279
  // src/TriggerGaugeHost.tsx
270
280
  import { lazy, Suspense } from "react";
271
281
  import { jsx as jsx5 } from "react/jsx-runtime";
272
- var LazyTriggerGauge = lazy(() => loadSkiaWeb().then(() => import("./TriggerGauge-XBKZ3KER.mjs").then((m) => ({ default: m.TriggerGauge }))));
282
+ var LazyTriggerGauge = lazy(() => loadSkiaWeb().then(() => import("./TriggerGauge-2OJOOF6I.mjs").then((m) => ({ default: m.TriggerGauge }))));
273
283
  function TriggerGaugeHost(props) {
274
284
  return /* @__PURE__ */ jsx5(Suspense, { fallback: null, children: /* @__PURE__ */ jsx5(LazyTriggerGauge, { ...props }) });
275
285
  }
@@ -365,8 +375,8 @@ function SingleSection({
365
375
  return /* @__PURE__ */ jsx6(TouchableRipple3, { onPress: () => onSelect(option.value), style: itemStyle, children: /* @__PURE__ */ jsxs3(View4, { style: styles4.itemRow, children: [
366
376
  option.icon && /* @__PURE__ */ jsx6(Icon2, { source: option.icon, size: MENU_ITEM_ICON_SIZE, color: rowColor }),
367
377
  /* @__PURE__ */ jsxs3(View4, { style: styles4.itemLabelColumn, children: [
368
- /* @__PURE__ */ jsx6(Text2, { variant: "labelLarge", style: labelStyle, children: option.label }),
369
- option.description && /* @__PURE__ */ jsx6(Text2, { variant: "labelSmall", style: descriptionStyle, children: option.description })
378
+ /* @__PURE__ */ jsx6(Text3, { variant: "labelLarge", style: labelStyle, children: option.label }),
379
+ option.description && /* @__PURE__ */ jsx6(Text3, { variant: "labelSmall", style: descriptionStyle, children: option.description })
370
380
  ] })
371
381
  ] }) }, option.value);
372
382
  }) });
@@ -390,10 +400,10 @@ function MultiSection({
390
400
  const labelStyle = { fontFamily: labelFontFamily, color: rowColor, fontWeight: selected ? "bold" : "normal" };
391
401
  return /* @__PURE__ */ jsx6(TouchableRipple3, { onPress: () => section.onChange(selected ? section.value.filter((v) => v !== option.value) : [...section.value, option.value]), style: itemStyle, children: /* @__PURE__ */ jsxs3(View4, { style: styles4.itemRow, children: [
392
402
  option.icon && /* @__PURE__ */ jsx6(Icon2, { source: option.icon, size: MENU_ITEM_ICON_SIZE, color: rowColor }),
393
- /* @__PURE__ */ jsx6(Text2, { variant: "labelLarge", style: [styles4.itemLabelFlex, labelStyle], children: option.label })
403
+ /* @__PURE__ */ jsx6(Text3, { variant: "labelLarge", style: [styles4.itemLabelFlex, labelStyle], children: option.label })
394
404
  ] }) }, option.value);
395
405
  }),
396
- section.allClear && /* @__PURE__ */ jsx6(TouchableRipple3, { onPress: () => section.onChange(allSelected ? [] : section.options.map((o) => o.value)), style: styles4.item, children: /* @__PURE__ */ jsx6(Text2, { variant: "labelLarge", style: [styles4.allClearLabel, { fontFamily: labelFontFamily, color: accentColor }], children: allSelected ? allClearLabels.clear : allClearLabels.all }) })
406
+ section.allClear && /* @__PURE__ */ jsx6(TouchableRipple3, { onPress: () => section.onChange(allSelected ? [] : section.options.map((o) => o.value)), style: styles4.item, children: /* @__PURE__ */ jsx6(Text3, { variant: "labelLarge", style: [styles4.allClearLabel, { fontFamily: labelFontFamily, color: accentColor }], children: allSelected ? allClearLabels.clear : allClearLabels.all }) })
397
407
  ] });
398
408
  }
399
409
  var styles4 = StyleSheet5.create({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/hud",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Visual component kit for local-multiplayer React Native games: inline (non-modal) popovers, dropdowns, color pickers, gauges, ready buttons, and dialogs, built to stay scoped to one player's own zone on a shared screen",
5
5
  "keywords": [
6
6
  "react-native",
@@ -29,6 +29,7 @@
29
29
  "exports": {
30
30
  ".": {
31
31
  "react-native": "./src/index.ts",
32
+ "browser": "./src/index.ts",
32
33
  "types": "./dist/index.d.ts",
33
34
  "import": "./dist/index.mjs",
34
35
  "require": "./dist/index.js"
@@ -63,6 +64,7 @@
63
64
  "@rific/auto-paper": "^0.9.4",
64
65
  "@rific/feedback-press": "^0.10.4",
65
66
  "@shopify/react-native-skia": "^2.6.2",
67
+ "@tastic/core": "^0.1.2",
66
68
  "@testing-library/dom": "^10.4.1",
67
69
  "@testing-library/react": "^16.3.2",
68
70
  "@types/jest": "^30.0.0",
@@ -92,6 +94,7 @@
92
94
  "@rific/auto-paper": ">=0.9.0",
93
95
  "@rific/feedback-press": ">=0.10.0",
94
96
  "@shopify/react-native-skia": ">=1.5.0",
97
+ "@tastic/core": ">=0.1.0",
95
98
  "react": ">=19.0.0",
96
99
  "react-native": ">=0.76.0",
97
100
  "react-native-paper": ">=5.0.0",
@@ -1,22 +1,31 @@
1
1
  import { defaultColors, getContrastColor, SeedColor } from '@rific/auto-paper'
2
2
  import { TouchableRipple } from '@rific/feedback-press'
3
+ import { clamp } from '@tastic/core'
3
4
  import { ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'
4
- import { Icon } from 'react-native-paper'
5
+ import { Icon, Text } from 'react-native-paper'
5
6
 
7
+ import { MONO_FONT } from './fonts'
6
8
  import { PopoverBody } from './PopoverBody'
7
9
  import { useAutoAlign } from './useAutoAlign'
8
10
  import { PopoverHost } from './usePopoverHost'
9
11
 
10
- const SIZE = 48
12
+ // Presence check, not a full-string "is this exactly one emoji" validation — this only decides a
13
+ // font-size ratio for the trigger's own `tag`, so it doesn't need that precision, just "does this
14
+ // tag contain a pictograph at all."
15
+ const EMOJI_PATTERN = /\p{Extended_Pictographic}/u
16
+
17
+ const DEFAULT_SIZE = 48
11
18
  const SWATCH_SIZE = 28
12
19
  const SWATCHES_PADDING = 8
13
20
  const SWATCHES_GAP = 6
14
21
  const SWATCHES_BORDER_WIDTH = 2
15
- // However wide the actual screen is, the grid never goes narrower than 3 columns or wider than 6 —
16
- // below 3 it reads as a cramped single-file list, above 6 the swatches themselves get lost in a
17
- // wall of tiny color chips instead of reading as a deliberate palette.
18
- const MIN_COLUMNS = 3
19
- const MAX_COLUMNS = 6
22
+ // Clamped to exactly 4 or 5, not a wider 3-6 range `defaultColors` (this component's own default
23
+ // `swatches`, and what every consumer actually ends up passing) is exactly 20 colors, so 4 or 5
24
+ // columns are the only counts that fill every row completely (4x5 or 5x4) instead of leaving a
25
+ // ragged, off-center last row. A caller passing a custom, non-20-length `swatches` list still gets
26
+ // a reasonable grid either way — just not guaranteed to be a full rectangle.
27
+ const MIN_COLUMNS = 4
28
+ const MAX_COLUMNS = 5
20
29
  // How much screen width the grid leaves alone on either side when computing how many columns fit —
21
30
  // this trigger can sit anywhere (including flush against a player's own screen edge in split-screen
22
31
  // mode), so the budget is against the whole window, not this trigger's own local space.
@@ -47,23 +56,36 @@ interface Props {
47
56
  // keeps it from overflowing the screen edge (see useAutoAlign).
48
57
  align?: 'left' | 'right' | 'center'
49
58
  // Trigger glyph — defaults to a plain palette. Pass a distinct icon per slot (e.g. a face for a
50
- // human, a robot for CPU) to convey identity through the icon itself.
59
+ // human, a robot for CPU) to convey identity through the icon itself. Overridden by `tag` below
60
+ // when present.
51
61
  icon?: string
62
+ // A short identity mark (an initial, an emoji, a couple of characters) shown as text on the
63
+ // trigger instead of `icon` — e.g. a saved player profile's own tag. Falls back to `icon` when
64
+ // empty/omitted, same as a seat with no such identity selected.
65
+ tag?: string
66
+ // Same convention as SectionedDropdown/ReadyButton's identical prop — defaults to this package's
67
+ // own MONO_FONT rather than inheriting whatever font the host app's theme happens to be using,
68
+ // so this trigger's tag reads consistently with the rest of this package's own chrome text.
69
+ labelFontFamily?: string
52
70
  // Whether picking a swatch closes the popover. Defaults to true — a color pick is a single,
53
71
  // complete choice the same way a single-select dropdown row is (see SectionedDropdown's identical
54
72
  // prop), so press-away isn't the only way out, but doesn't have to be either.
55
73
  autoDismiss?: boolean
56
74
  // Overrides the auto-computed column count (see MIN_COLUMNS/MAX_COLUMNS/SCREEN_MARGIN above).
57
75
  columns?: number
76
+ // Trigger circle's diameter — defaults to DEFAULT_SIZE. Overridable so a caller sharing a row
77
+ // with other fixed-size elements can line this trigger up with them instead of standing out at a
78
+ // mismatched size.
79
+ size?: number
58
80
  }
59
81
 
60
82
  // Renders inline rather than as a full-screen modal, scoped to its own panel — a modal color
61
83
  // picker would block the whole screen for one player while another can't touch their own panel at
62
84
  // the same time, which defeats the point of a split-screen lobby.
63
- export function InlineColorPicker({ id, host, value, onChange, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, icon = 'palette', autoDismiss = true, columns }: Props) {
85
+ export function InlineColorPicker({ id, host, value, onChange, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, icon = 'palette', tag, labelFontFamily = MONO_FONT, autoDismiss = true, columns, size = DEFAULT_SIZE }: Props) {
64
86
  const menuBg = dark ? '#000000' : '#FFFFFF'
65
87
  const { width: windowWidth } = useWindowDimensions()
66
- const autoColumns = Math.min(MAX_COLUMNS, Math.max(MIN_COLUMNS, Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP))))
88
+ const autoColumns = clamp(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS)
67
89
  const resolvedColumns = columns ?? autoColumns
68
90
  const swatchesWidth = widthForColumns(resolvedColumns)
69
91
  const swatchRows = Math.ceil(swatches.length / resolvedColumns)
@@ -72,19 +94,34 @@ export function InlineColorPicker({ id, host, value, onChange, swatches = defaul
72
94
  const open = host.openId === id
73
95
  const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight)
74
96
  const align = alignOverride ?? autoAlign
97
+ const contrastColor = getContrastColor(value)
98
+ // An emoji glyph reads visually smaller than a bold letter at the same fontSize (the system emoji
99
+ // font leaves more of its own em-box empty), so a plain-text ratio that looks right for an initial
100
+ // still looks small for an emoji tag at the identical size — this bumps emoji up to the icon's own
101
+ // ratio instead, since a single emoji is closer in visual weight to an icon glyph than to text.
102
+ const tagFontSize = size * (tag && EMOJI_PATTERN.test(tag) ? 0.6 : 0.4)
75
103
 
76
104
  return (
77
105
  <View style={[styles.anchor, open && styles.anchorOpen]}>
78
- <TouchableRipple onPress={() => host.toggle(id)} borderless style={[styles.trigger, { backgroundColor: value }]}>
106
+ <TouchableRipple onPress={() => host.toggle(id)} borderless style={[styles.trigger, { backgroundColor: value, borderRadius: size / 2, height: size, width: size }]}>
79
107
  <View ref={triggerRef} collapsable={false} style={styles.triggerMeasure}>
80
- <Icon source={icon} size={20} color={getContrastColor(value)} />
108
+ {/* Sized as a fraction of the trigger's own diameter rather than a fixed pixel size, so
109
+ the glyph still reads as a deliberate part of the circle instead of shrinking toward its
110
+ center on a larger trigger. */}
111
+ {tag ? (
112
+ <Text style={[styles.tagLabel, { color: contrastColor, fontFamily: labelFontFamily, fontSize: tagFontSize }]} numberOfLines={1} adjustsFontSizeToFit>
113
+ {tag}
114
+ </Text>
115
+ ) : (
116
+ <Icon source={icon} size={size * 0.6} color={contrastColor} />
117
+ )}
81
118
  </View>
82
119
  </TouchableRipple>
83
120
 
84
121
  {/* Gated on `measured`, not just `open` — see useAutoAlign's own comment and
85
122
  SectionedDropdown's identical fix: without it, the popover mounts for one frame at a stale or
86
123
  guessed alignment and visibly jumps once this open's own measurement lands. */}
87
- <PopoverBody visible={open && measured} align={align} verticalAlign={verticalAlign} caretColor={menuBg} caretBorderColor={value} caretBorderWidth={SWATCHES_BORDER_WIDTH} triggerSize={SIZE}>
124
+ <PopoverBody visible={open && measured} align={align} verticalAlign={verticalAlign} caretColor={menuBg} caretBorderColor={value} caretBorderWidth={SWATCHES_BORDER_WIDTH} triggerSize={size}>
88
125
  {/* Border matches this trigger's own current color (not a neutral gray) — two triggers can
89
126
  sit close together, so the popover needs a clear visual tie back to which one opened it,
90
127
  not just its screen position. maxHeight (see useAutoAlign) only actually clamps on a screen
@@ -163,21 +200,27 @@ const styles = StyleSheet.create({
163
200
  gap: SWATCHES_GAP,
164
201
  padding: SWATCHES_PADDING
165
202
  },
203
+ // borderRadius/height/width come from the `size` prop instead (see render-site override) — no
204
+ // fixed default here, since this style object is shared by every caller regardless of size.
166
205
  trigger: {
167
206
  alignItems: 'center',
168
207
  alignSelf: 'flex-start',
169
- borderRadius: SIZE / 2,
170
- height: SIZE,
171
- justifyContent: 'center',
172
- width: SIZE
208
+ justifyContent: 'center'
173
209
  },
174
- // Wraps just the icon, inside the TouchableRipple, purely so useAutoAlign has a plain View to
175
- // attach its measurement ref to — TouchableRipple itself doesn't forward a ref to a measurable
176
- // native node.
210
+ // Wraps just the icon/tag, inside the TouchableRipple, purely so useAutoAlign has a plain View
211
+ // to attach its measurement ref to — TouchableRipple itself doesn't forward a ref to a
212
+ // measurable native node. Padded horizontally so a multi-character tag doesn't butt right up
213
+ // against the trigger's own circular edge (an icon alone doesn't need it, but this style is
214
+ // shared by both).
177
215
  triggerMeasure: {
178
216
  alignItems: 'center',
179
217
  height: '100%',
180
218
  justifyContent: 'center',
219
+ paddingHorizontal: 4,
181
220
  width: '100%'
221
+ },
222
+ tagLabel: {
223
+ fontWeight: '700',
224
+ letterSpacing: 0.5
182
225
  }
183
226
  })
@@ -1,4 +1,5 @@
1
1
  import { Canvas, Path, Skia } from '@shopify/react-native-skia'
2
+ import { clamp } from '@tastic/core'
2
3
  import { useCallback, useLayoutEffect, useRef, useState } from 'react'
3
4
  import { StyleSheet, View } from 'react-native'
4
5
  import { Easing, useDerivedValue, useSharedValue, withTiming } from 'react-native-reanimated'
@@ -207,7 +208,7 @@ export function TriggerGauge({ segments, litIndices, size, accentColor, mutedCol
207
208
  if (mountProgress.value < 1) {
208
209
  const startFrac = (i * MOUNT_STAGGER_MS) / totalDurationMs
209
210
  const endFrac = startFrac + MOUNT_DURATION_MS / totalDurationMs
210
- local = Math.min(1, Math.max(0, (mountProgress.value - startFrac) / (endFrac - startFrac)))
211
+ local = clamp((mountProgress.value - startFrac) / (endFrac - startFrac), 0, 1)
211
212
  }
212
213
  const sweep = dashAngleDeg * local
213
214
  if (sweep <= 0) continue
@@ -245,7 +246,7 @@ export function TriggerGauge({ segments, litIndices, size, accentColor, mutedCol
245
246
  if (!isLitNow) continue
246
247
  const startFrac = (i * MOUNT_STAGGER_MS) / totalDurationMs
247
248
  const endFrac = startFrac + MOUNT_DURATION_MS / totalDurationMs
248
- local = Math.min(1, Math.max(0, (mountProgress.value - startFrac) / (endFrac - startFrac)))
249
+ local = clamp((mountProgress.value - startFrac) / (endFrac - startFrac), 0, 1)
249
250
  } else if (isLitNow === wasLit) {
250
251
  local = 1 // steady — lit before and after (or the mount-only branch above already handled unlit)
251
252
  } else if (isLitNow) {