@tastic/hud 0.1.3 → 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.
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
@@ -287,18 +287,19 @@ function useAutoAlign(open, contentWidth, contentHeight) {
287
287
 
288
288
  // src/InlineColorPicker.tsx
289
289
  var import_jsx_runtime2 = require("react/jsx-runtime");
290
- var SIZE = 48;
290
+ var EMOJI_PATTERN = /\p{Extended_Pictographic}/u;
291
+ var DEFAULT_SIZE = 48;
291
292
  var SWATCH_SIZE = 28;
292
293
  var SWATCHES_PADDING = 8;
293
294
  var SWATCHES_GAP = 6;
294
295
  var SWATCHES_BORDER_WIDTH = 2;
295
- var MIN_COLUMNS = 3;
296
- var MAX_COLUMNS = 6;
296
+ var MIN_COLUMNS = 4;
297
+ var MAX_COLUMNS = 5;
297
298
  var SCREEN_MARGIN = 40;
298
299
  function widthForColumns(columns) {
299
300
  return SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * columns + SWATCHES_GAP * (columns - 1);
300
301
  }
301
- 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 }) {
302
303
  const menuBg = dark ? "#000000" : "#FFFFFF";
303
304
  const { width: windowWidth } = (0, import_react_native4.useWindowDimensions)();
304
305
  const autoColumns = (0, import_core.clamp)(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS);
@@ -309,9 +310,11 @@ function InlineColorPicker({ id, host, value, onChange, swatches = import_auto_p
309
310
  const open = host.openId === id;
310
311
  const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight);
311
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);
312
315
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_react_native4.View, { style: [styles2.anchor, open && styles2.anchorOpen], children: [
313
- /* @__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) }) }) }),
314
- /* @__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) => {
315
318
  const selected = swatch.value.toLowerCase() === value.toLowerCase();
316
319
  const taken = !selected && !!takenValue && swatch.value.toLowerCase() === takenValue.toLowerCase();
317
320
  const swappable = taken && allowSwapTaken;
@@ -378,22 +381,28 @@ var styles2 = import_react_native4.StyleSheet.create({
378
381
  gap: SWATCHES_GAP,
379
382
  padding: SWATCHES_PADDING
380
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.
381
386
  trigger: {
382
387
  alignItems: "center",
383
388
  alignSelf: "flex-start",
384
- borderRadius: SIZE / 2,
385
- height: SIZE,
386
- justifyContent: "center",
387
- width: SIZE
389
+ justifyContent: "center"
388
390
  },
389
- // Wraps just the icon, inside the TouchableRipple, purely so useAutoAlign has a plain View to
390
- // attach its measurement ref to — TouchableRipple itself doesn't forward a ref to a measurable
391
- // 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).
392
396
  triggerMeasure: {
393
397
  alignItems: "center",
394
398
  height: "100%",
395
399
  justifyContent: "center",
400
+ paddingHorizontal: 4,
396
401
  width: "100%"
402
+ },
403
+ tagLabel: {
404
+ fontWeight: "700",
405
+ letterSpacing: 0.5
397
406
  }
398
407
  });
399
408
 
package/dist/index.mjs CHANGED
@@ -7,7 +7,7 @@ import { defaultColors, getContrastColor } from "@rific/auto-paper";
7
7
  import { TouchableRipple } from "@rific/feedback-press";
8
8
  import { clamp } from "@tastic/core";
9
9
  import { ScrollView, StyleSheet as StyleSheet2, useWindowDimensions as useWindowDimensions2, View as View3 } from "react-native";
10
- import { Icon } from "react-native-paper";
10
+ import { Icon, Text } from "react-native-paper";
11
11
 
12
12
  // src/PopoverBody.tsx
13
13
  import { StyleSheet, View } from "react-native";
@@ -115,18 +115,19 @@ function useAutoAlign(open, contentWidth, contentHeight) {
115
115
 
116
116
  // src/InlineColorPicker.tsx
117
117
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
118
- var SIZE = 48;
118
+ var EMOJI_PATTERN = /\p{Extended_Pictographic}/u;
119
+ var DEFAULT_SIZE = 48;
119
120
  var SWATCH_SIZE = 28;
120
121
  var SWATCHES_PADDING = 8;
121
122
  var SWATCHES_GAP = 6;
122
123
  var SWATCHES_BORDER_WIDTH = 2;
123
- var MIN_COLUMNS = 3;
124
- var MAX_COLUMNS = 6;
124
+ var MIN_COLUMNS = 4;
125
+ var MAX_COLUMNS = 5;
125
126
  var SCREEN_MARGIN = 40;
126
127
  function widthForColumns(columns) {
127
128
  return SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * columns + SWATCHES_GAP * (columns - 1);
128
129
  }
129
- 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 }) {
130
131
  const menuBg = dark ? "#000000" : "#FFFFFF";
131
132
  const { width: windowWidth } = useWindowDimensions2();
132
133
  const autoColumns = clamp(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS);
@@ -137,9 +138,11 @@ function InlineColorPicker({ id, host, value, onChange, swatches = defaultColors
137
138
  const open = host.openId === id;
138
139
  const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight);
139
140
  const align = alignOverride ?? autoAlign;
141
+ const contrastColor = getContrastColor(value);
142
+ const tagFontSize = size * (tag && EMOJI_PATTERN.test(tag) ? 0.6 : 0.4);
140
143
  return /* @__PURE__ */ jsxs2(View3, { style: [styles2.anchor, open && styles2.anchorOpen], children: [
141
- /* @__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) }) }) }),
142
- /* @__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) => {
143
146
  const selected = swatch.value.toLowerCase() === value.toLowerCase();
144
147
  const taken = !selected && !!takenValue && swatch.value.toLowerCase() === takenValue.toLowerCase();
145
148
  const swappable = taken && allowSwapTaken;
@@ -206,22 +209,28 @@ var styles2 = StyleSheet2.create({
206
209
  gap: SWATCHES_GAP,
207
210
  padding: SWATCHES_PADDING
208
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.
209
214
  trigger: {
210
215
  alignItems: "center",
211
216
  alignSelf: "flex-start",
212
- borderRadius: SIZE / 2,
213
- height: SIZE,
214
- justifyContent: "center",
215
- width: SIZE
217
+ justifyContent: "center"
216
218
  },
217
- // Wraps just the icon, inside the TouchableRipple, purely so useAutoAlign has a plain View to
218
- // attach its measurement ref to — TouchableRipple itself doesn't forward a ref to a measurable
219
- // 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).
220
224
  triggerMeasure: {
221
225
  alignItems: "center",
222
226
  height: "100%",
223
227
  justifyContent: "center",
228
+ paddingHorizontal: 4,
224
229
  width: "100%"
230
+ },
231
+ tagLabel: {
232
+ fontWeight: "700",
233
+ letterSpacing: 0.5
225
234
  }
226
235
  });
227
236
 
@@ -241,13 +250,13 @@ function PressAwayOverlay({ active, onPress, style }) {
241
250
  // src/ReadyButton.tsx
242
251
  import { TouchableRipple as TouchableRipple2 } from "@rific/feedback-press";
243
252
  import { StyleSheet as StyleSheet4 } from "react-native";
244
- import { Text } from "react-native-paper";
253
+ import { Text as Text2 } from "react-native-paper";
245
254
  import { jsx as jsx4 } from "react/jsx-runtime";
246
255
  function ReadyButton({ color, ready, onToggleReady, style, labelFontFamily = MONO_FONT }) {
247
256
  const readyButtonStyle = [styles3.readyButton, { borderColor: color }, ready && { backgroundColor: color }, style];
248
257
  const labelTextStyle = { fontFamily: labelFontFamily, fontWeight: "bold" };
249
258
  const labelColorStyle = { color: ready ? "#000000" : color };
250
- 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?" }) });
251
260
  }
252
261
  var styles3 = StyleSheet4.create({
253
262
  readyButton: {
@@ -265,7 +274,7 @@ var styles3 = StyleSheet4.create({
265
274
  import { getBlendedColor, getColorRoles } from "@rific/auto-paper";
266
275
  import { IconButton, TouchableRipple as TouchableRipple3 } from "@rific/feedback-press";
267
276
  import { ScrollView as ScrollView2, StyleSheet as StyleSheet5, View as View4 } from "react-native";
268
- import { Icon as Icon2, Text as Text2 } from "react-native-paper";
277
+ import { Icon as Icon2, Text as Text3 } from "react-native-paper";
269
278
 
270
279
  // src/TriggerGaugeHost.tsx
271
280
  import { lazy, Suspense } from "react";
@@ -366,8 +375,8 @@ function SingleSection({
366
375
  return /* @__PURE__ */ jsx6(TouchableRipple3, { onPress: () => onSelect(option.value), style: itemStyle, children: /* @__PURE__ */ jsxs3(View4, { style: styles4.itemRow, children: [
367
376
  option.icon && /* @__PURE__ */ jsx6(Icon2, { source: option.icon, size: MENU_ITEM_ICON_SIZE, color: rowColor }),
368
377
  /* @__PURE__ */ jsxs3(View4, { style: styles4.itemLabelColumn, children: [
369
- /* @__PURE__ */ jsx6(Text2, { variant: "labelLarge", style: labelStyle, children: option.label }),
370
- 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 })
371
380
  ] })
372
381
  ] }) }, option.value);
373
382
  }) });
@@ -391,10 +400,10 @@ function MultiSection({
391
400
  const labelStyle = { fontFamily: labelFontFamily, color: rowColor, fontWeight: selected ? "bold" : "normal" };
392
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: [
393
402
  option.icon && /* @__PURE__ */ jsx6(Icon2, { source: option.icon, size: MENU_ITEM_ICON_SIZE, color: rowColor }),
394
- /* @__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 })
395
404
  ] }) }, option.value);
396
405
  }),
397
- 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 }) })
398
407
  ] });
399
408
  }
400
409
  var styles4 = StyleSheet5.create({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/hud",
3
- "version": "0.1.3",
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",
@@ -2,22 +2,30 @@ import { defaultColors, getContrastColor, SeedColor } from '@rific/auto-paper'
2
2
  import { TouchableRipple } from '@rific/feedback-press'
3
3
  import { clamp } from '@tastic/core'
4
4
  import { ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'
5
- import { Icon } from 'react-native-paper'
5
+ import { Icon, Text } from 'react-native-paper'
6
6
 
7
+ import { MONO_FONT } from './fonts'
7
8
  import { PopoverBody } from './PopoverBody'
8
9
  import { useAutoAlign } from './useAutoAlign'
9
10
  import { PopoverHost } from './usePopoverHost'
10
11
 
11
- 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
12
18
  const SWATCH_SIZE = 28
13
19
  const SWATCHES_PADDING = 8
14
20
  const SWATCHES_GAP = 6
15
21
  const SWATCHES_BORDER_WIDTH = 2
16
- // However wide the actual screen is, the grid never goes narrower than 3 columns or wider than 6 —
17
- // below 3 it reads as a cramped single-file list, above 6 the swatches themselves get lost in a
18
- // wall of tiny color chips instead of reading as a deliberate palette.
19
- const MIN_COLUMNS = 3
20
- 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
21
29
  // How much screen width the grid leaves alone on either side when computing how many columns fit —
22
30
  // this trigger can sit anywhere (including flush against a player's own screen edge in split-screen
23
31
  // mode), so the budget is against the whole window, not this trigger's own local space.
@@ -48,20 +56,33 @@ interface Props {
48
56
  // keeps it from overflowing the screen edge (see useAutoAlign).
49
57
  align?: 'left' | 'right' | 'center'
50
58
  // Trigger glyph — defaults to a plain palette. Pass a distinct icon per slot (e.g. a face for a
51
- // 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.
52
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
53
70
  // Whether picking a swatch closes the popover. Defaults to true — a color pick is a single,
54
71
  // complete choice the same way a single-select dropdown row is (see SectionedDropdown's identical
55
72
  // prop), so press-away isn't the only way out, but doesn't have to be either.
56
73
  autoDismiss?: boolean
57
74
  // Overrides the auto-computed column count (see MIN_COLUMNS/MAX_COLUMNS/SCREEN_MARGIN above).
58
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
59
80
  }
60
81
 
61
82
  // Renders inline rather than as a full-screen modal, scoped to its own panel — a modal color
62
83
  // picker would block the whole screen for one player while another can't touch their own panel at
63
84
  // the same time, which defeats the point of a split-screen lobby.
64
- 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) {
65
86
  const menuBg = dark ? '#000000' : '#FFFFFF'
66
87
  const { width: windowWidth } = useWindowDimensions()
67
88
  const autoColumns = clamp(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS)
@@ -73,19 +94,34 @@ export function InlineColorPicker({ id, host, value, onChange, swatches = defaul
73
94
  const open = host.openId === id
74
95
  const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight)
75
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)
76
103
 
77
104
  return (
78
105
  <View style={[styles.anchor, open && styles.anchorOpen]}>
79
- <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 }]}>
80
107
  <View ref={triggerRef} collapsable={false} style={styles.triggerMeasure}>
81
- <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
+ )}
82
118
  </View>
83
119
  </TouchableRipple>
84
120
 
85
121
  {/* Gated on `measured`, not just `open` — see useAutoAlign's own comment and
86
122
  SectionedDropdown's identical fix: without it, the popover mounts for one frame at a stale or
87
123
  guessed alignment and visibly jumps once this open's own measurement lands. */}
88
- <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}>
89
125
  {/* Border matches this trigger's own current color (not a neutral gray) — two triggers can
90
126
  sit close together, so the popover needs a clear visual tie back to which one opened it,
91
127
  not just its screen position. maxHeight (see useAutoAlign) only actually clamps on a screen
@@ -164,21 +200,27 @@ const styles = StyleSheet.create({
164
200
  gap: SWATCHES_GAP,
165
201
  padding: SWATCHES_PADDING
166
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.
167
205
  trigger: {
168
206
  alignItems: 'center',
169
207
  alignSelf: 'flex-start',
170
- borderRadius: SIZE / 2,
171
- height: SIZE,
172
- justifyContent: 'center',
173
- width: SIZE
208
+ justifyContent: 'center'
174
209
  },
175
- // Wraps just the icon, inside the TouchableRipple, purely so useAutoAlign has a plain View to
176
- // attach its measurement ref to — TouchableRipple itself doesn't forward a ref to a measurable
177
- // 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).
178
215
  triggerMeasure: {
179
216
  alignItems: 'center',
180
217
  height: '100%',
181
218
  justifyContent: 'center',
219
+ paddingHorizontal: 4,
182
220
  width: '100%'
221
+ },
222
+ tagLabel: {
223
+ fontWeight: '700',
224
+ letterSpacing: 0.5
183
225
  }
184
226
  })