@hadoku/task-ui-components 4.23.13 → 4.23.15

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.
@@ -17,5 +17,24 @@
17
17
  * endorsement.
18
18
  */
19
19
  import React from 'react';
20
+ import type { AvatarSettings, LinkProvider } from '@wolffm/prefs-client/avatar';
21
+ /**
22
+ * Link `provider` in a popup, and wear its picture when it came back with one:
23
+ * linking from either place means "use this". Call it inside the click, with
24
+ * nothing awaited first — the popup opens before its first await, which is
25
+ * what popup blockers allow.
26
+ */
27
+ export declare function linkAndWear(provider: LinkProvider): Promise<AvatarSettings>;
28
+ /**
29
+ * What linking reads and keeps, then Continue. Shared by Account → Linked
30
+ * accounts and the avatar dialog's Picture tab, so the consent is one text.
31
+ */
32
+ export declare function LinkConsent({ provider, busy, onLink, onCancel }: {
33
+ provider: LinkProvider;
34
+ busy: boolean;
35
+ /** Must open the popup synchronously: call {@link linkAndWear} directly. */
36
+ onLink: () => void;
37
+ onCancel?: () => void;
38
+ }): React.JSX.Element;
20
39
  export declare function LinkedAccounts(): React.JSX.Element;
21
40
  //# sourceMappingURL=LinkedAccounts.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"LinkedAccounts.d.ts","sourceRoot":"","sources":["../../src/components/LinkedAccounts.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,KAA8B,MAAM,OAAO,CAAA;AAmClD,wBAAgB,cAAc,sBA0H7B"}
1
+ {"version":3,"file":"LinkedAccounts.d.ts","sourceRoot":"","sources":["../../src/components/LinkedAccounts.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,KAA8B,MAAM,OAAO,CAAA;AAClD,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAA;AAkC/E;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,YAAY,GAAG,OAAO,CAAC,cAAc,CAAC,CAI3E;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,EAC1B,QAAQ,EACR,IAAI,EACJ,MAAM,EACN,QAAQ,EACT,EAAE;IACD,QAAQ,EAAE,YAAY,CAAA;IACtB,IAAI,EAAE,OAAO,CAAA;IACb,4EAA4E;IAC5E,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB,qBAyBA;AAED,wBAAgB,cAAc,sBAoG7B"}
@@ -29,6 +29,32 @@ const S = {
29
29
  grow: { flex: 1, minWidth: 0 },
30
30
  list: { margin: 0, paddingLeft: 18, fontSize: "0.75rem", lineHeight: 1.45 }
31
31
  };
32
+ function linkAndWear(provider) {
33
+ return avatarClient.linkInPopup(provider).then((next) => next.linked[provider]?.avatarUrl ? avatarClient.choose(provider) : next);
34
+ }
35
+ function LinkConsent({
36
+ provider,
37
+ busy,
38
+ onLink,
39
+ onCancel
40
+ }) {
41
+ return /* @__PURE__ */ jsxs("div", { style: S.stack, children: [
42
+ /* @__PURE__ */ jsx("ul", { style: S.list, children: CONSENT[provider].points.map((t) => /* @__PURE__ */ jsx("li", { children: t }, t)) }),
43
+ /* @__PURE__ */ jsxs("span", { style: S.line, children: [
44
+ /* @__PURE__ */ jsx(
45
+ "button",
46
+ {
47
+ type: "button",
48
+ className: "settings-popout__btn settings-popout__btn--primary",
49
+ disabled: busy,
50
+ onClick: onLink,
51
+ children: CONSENT[provider].go
52
+ }
53
+ ),
54
+ onCancel && /* @__PURE__ */ jsx("button", { type: "button", className: "settings-popout__btn", onClick: onCancel, children: "Cancel" })
55
+ ] })
56
+ ] });
57
+ }
32
58
  function LinkedAccounts() {
33
59
  const [settings, setSettings] = useState(null);
34
60
  const [busy, setBusy] = useState(false);
@@ -57,13 +83,6 @@ function LinkedAccounts() {
57
83
  if (!settings) {
58
84
  return /* @__PURE__ */ jsx("span", { className: "settings-popout__hint", role: error ? "alert" : void 0, children: error ?? "…" });
59
85
  }
60
- const link = (provider) => {
61
- setConsent(null);
62
- void run(async () => {
63
- const next = await avatarClient.linkInPopup(provider);
64
- return next.linked[provider]?.avatarUrl ? avatarClient.choose(provider) : next;
65
- });
66
- };
67
86
  return /* @__PURE__ */ jsxs("div", { style: S.stack, children: [
68
87
  ["discord", "steam"].map((p) => {
69
88
  const linked = settings.linked[p];
@@ -94,26 +113,24 @@ function LinkedAccounts() {
94
113
  )
95
114
  ] }, p);
96
115
  }),
97
- consent && /* @__PURE__ */ jsxs("div", { style: S.stack, children: [
98
- /* @__PURE__ */ jsx("ul", { style: S.list, children: CONSENT[consent].points.map((t) => /* @__PURE__ */ jsx("li", { children: t }, t)) }),
99
- /* @__PURE__ */ jsxs("span", { style: S.line, children: [
100
- /* @__PURE__ */ jsx(
101
- "button",
102
- {
103
- type: "button",
104
- className: "settings-popout__btn settings-popout__btn--primary",
105
- disabled: busy,
106
- onClick: () => link(consent),
107
- children: CONSENT[consent].go
108
- }
109
- ),
110
- /* @__PURE__ */ jsx("button", { type: "button", className: "settings-popout__btn", onClick: () => setConsent(null), children: "Cancel" })
111
- ] })
112
- ] }),
116
+ consent && /* @__PURE__ */ jsx(
117
+ LinkConsent,
118
+ {
119
+ provider: consent,
120
+ busy,
121
+ onLink: () => {
122
+ setConsent(null);
123
+ void run(() => linkAndWear(consent));
124
+ },
125
+ onCancel: () => setConsent(null)
126
+ }
127
+ ),
113
128
  error && /* @__PURE__ */ jsx("span", { className: "settings-popout__error", role: "alert", children: error })
114
129
  ] });
115
130
  }
116
131
  export {
117
- LinkedAccounts
132
+ LinkConsent,
133
+ LinkedAccounts,
134
+ linkAndWear
118
135
  };
119
136
  //# sourceMappingURL=LinkedAccounts.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"LinkedAccounts.js","sources":["../../src/components/LinkedAccounts.tsx"],"sourcesContent":["/**\n * LinkedAccounts — Discord and Steam, linked and unlinked from the settings\n * popout's Account tab (operator, 2026-11-02: linking is an account matter, not\n * part of changing the avatar). A linked account's picture then shows up as a\n * choice in the avatar dialog's Picture tab.\n *\n * Mounted only with the Account tab, never when the gear opens elsewhere: the\n * read can refresh a linked picture from Discord or Steam, which opening the\n * gear must not cost (settings-prefetch e2e).\n *\n * Linking signs in through a popup (`linkInPopup`), so the page under the\n * popout never navigates and a game keeps running.\n *\n * COMPLIANCE (Steam Web API terms; Discord developer terms), carried over from\n * hadoku.me/account: nothing is fetched from a provider until the person reads\n * what is read and kept and presses Continue; no provider logos, and no implied\n * endorsement.\n */\nimport React, { useEffect, useState } from 'react'\nimport type { AvatarSettings, LinkProvider } from '@wolffm/prefs-client/avatar'\nimport { AVATAR_EVENT } from '../lib/avatarEvent'\nimport { avatarClient, avatarErrorText } from '../lib/avatarData'\n\nconst LABEL: Record<LinkProvider, string> = { discord: 'Discord', steam: 'Steam' }\n\nconst CONSENT: Record<LinkProvider, { points: string[]; go: string }> = {\n discord: {\n points: [\n 'Discord asks you to share your basic profile (\"identify\").',\n 'Kept: your Discord user ID and the address of your profile picture, refreshed while linked.',\n 'Not kept: your username, email, servers or any token.',\n 'Unlink deletes both.'\n ],\n go: 'Continue to Discord'\n },\n steam: {\n points: [\n 'You sign in on steamcommunity.com; your password never reaches hadoku.me.',\n 'Your public Steam profile picture is then read through the Steam Web API, now and periodically while linked.',\n 'Kept: your Steam ID and the address of that picture.',\n 'Unlink deletes both and stops the reads.'\n ],\n go: 'Continue to Steam'\n }\n}\n\nconst S = {\n stack: { display: 'flex', flexDirection: 'column', gap: 8, width: '100%' },\n line: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },\n grow: { flex: 1, minWidth: 0 },\n list: { margin: 0, paddingLeft: 18, fontSize: '0.75rem', lineHeight: 1.45 }\n} satisfies Record<string, React.CSSProperties>\n\nexport function LinkedAccounts() {\n const [settings, setSettings] = useState<AvatarSettings | null>(null)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const [consent, setConsent] = useState<LinkProvider | null>(null)\n\n useEffect(() => {\n let live = true\n avatarClient\n .get()\n .then(s => live && setSettings(s))\n .catch((err: unknown) => live && setError(avatarErrorText(err)))\n return () => {\n live = false\n }\n }, [])\n\n const run = async (work: () => Promise<AvatarSettings>) => {\n setBusy(true)\n setError(null)\n try {\n const next = await work()\n setSettings(next)\n // The page under the popout (a watchparty room, a game) re-reads the\n // avatar from this, as it does after a change in the avatar dialog.\n window.dispatchEvent(new window.CustomEvent<AvatarSettings>(AVATAR_EVENT, { detail: next }))\n } catch (err) {\n setError(avatarErrorText(err))\n } finally {\n setBusy(false)\n }\n }\n\n if (!settings) {\n return (\n <span className=\"settings-popout__hint\" role={error ? 'alert' : undefined}>\n {error ?? '…'}\n </span>\n )\n }\n\n // Must stay synchronous up to linkInPopup: the popup opens before its first\n // await, inside this click, which is what popup blockers allow.\n const link = (provider: LinkProvider) => {\n setConsent(null)\n void run(async () => {\n const next = await avatarClient.linkInPopup(provider)\n // Linking is choosing, when the account came back with a picture.\n return next.linked[provider]?.avatarUrl ? avatarClient.choose(provider) : next\n })\n }\n\n return (\n <div style={S.stack}>\n {(['discord', 'steam'] as const).map(p => {\n const linked = settings.linked[p]\n return (\n <div key={p} style={S.line}>\n <span className=\"settings-popout__label\" style={S.grow}>\n {LABEL[p]}\n {!settings.providers[p]\n ? ' · not available yet'\n : linked\n ? linked.avatarUrl\n ? ' · linked'\n : ' · linked, no picture'\n : ''}\n </span>\n {settings.providers[p] && !linked && (\n <button\n type=\"button\"\n className=\"settings-popout__btn\"\n disabled={busy}\n onClick={() => setConsent(consent === p ? null : p)}\n >\n Link\n </button>\n )}\n {linked && (\n <button\n type=\"button\"\n className=\"settings-popout__btn\"\n disabled={busy}\n onClick={() => void run(() => avatarClient.unlink(p))}\n >\n Unlink\n </button>\n )}\n </div>\n )\n })}\n\n {consent && (\n <div style={S.stack}>\n <ul style={S.list}>\n {CONSENT[consent].points.map(t => (\n <li key={t}>{t}</li>\n ))}\n </ul>\n <span style={S.line}>\n <button\n type=\"button\"\n className=\"settings-popout__btn settings-popout__btn--primary\"\n disabled={busy}\n onClick={() => link(consent)}\n >\n {CONSENT[consent].go}\n </button>\n <button type=\"button\" className=\"settings-popout__btn\" onClick={() => setConsent(null)}>\n Cancel\n </button>\n </span>\n </div>\n )}\n\n {error && (\n <span className=\"settings-popout__error\" role=\"alert\">\n {error}\n </span>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"LinkedAccounts.js","sources":["../../src/components/LinkedAccounts.tsx"],"sourcesContent":["/**\n * LinkedAccounts — Discord and Steam, linked and unlinked from the settings\n * popout's Account tab (operator, 2026-11-02: linking is an account matter, not\n * part of changing the avatar). A linked account's picture then shows up as a\n * choice in the avatar dialog's Picture tab.\n *\n * Mounted only with the Account tab, never when the gear opens elsewhere: the\n * read can refresh a linked picture from Discord or Steam, which opening the\n * gear must not cost (settings-prefetch e2e).\n *\n * Linking signs in through a popup (`linkInPopup`), so the page under the\n * popout never navigates and a game keeps running.\n *\n * COMPLIANCE (Steam Web API terms; Discord developer terms), carried over from\n * hadoku.me/account: nothing is fetched from a provider until the person reads\n * what is read and kept and presses Continue; no provider logos, and no implied\n * endorsement.\n */\nimport React, { useEffect, useState } from 'react'\nimport type { AvatarSettings, LinkProvider } from '@wolffm/prefs-client/avatar'\nimport { AVATAR_EVENT } from '../lib/avatarEvent'\nimport { avatarClient, avatarErrorText } from '../lib/avatarData'\n\nconst LABEL: Record<LinkProvider, string> = { discord: 'Discord', steam: 'Steam' }\n\nconst CONSENT: Record<LinkProvider, { points: string[]; go: string }> = {\n discord: {\n points: [\n 'Discord asks you to share your basic profile (\"identify\").',\n 'Kept: your Discord user ID and the address of your profile picture, refreshed while linked.',\n 'Not kept: your username, email, servers or any token.',\n 'Unlink deletes both.'\n ],\n go: 'Continue to Discord'\n },\n steam: {\n points: [\n 'You sign in on steamcommunity.com; your password never reaches hadoku.me.',\n 'Your public Steam profile picture is then read through the Steam Web API, now and periodically while linked.',\n 'Kept: your Steam ID and the address of that picture.',\n 'Unlink deletes both and stops the reads.'\n ],\n go: 'Continue to Steam'\n }\n}\n\nconst S = {\n stack: { display: 'flex', flexDirection: 'column', gap: 8, width: '100%' },\n line: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },\n grow: { flex: 1, minWidth: 0 },\n list: { margin: 0, paddingLeft: 18, fontSize: '0.75rem', lineHeight: 1.45 }\n} satisfies Record<string, React.CSSProperties>\n\n/**\n * Link `provider` in a popup, and wear its picture when it came back with one:\n * linking from either place means \"use this\". Call it inside the click, with\n * nothing awaited first — the popup opens before its first await, which is\n * what popup blockers allow.\n */\nexport function linkAndWear(provider: LinkProvider): Promise<AvatarSettings> {\n return avatarClient\n .linkInPopup(provider)\n .then(next => (next.linked[provider]?.avatarUrl ? avatarClient.choose(provider) : next))\n}\n\n/**\n * What linking reads and keeps, then Continue. Shared by Account → Linked\n * accounts and the avatar dialog's Picture tab, so the consent is one text.\n */\nexport function LinkConsent({\n provider,\n busy,\n onLink,\n onCancel\n}: {\n provider: LinkProvider\n busy: boolean\n /** Must open the popup synchronously: call {@link linkAndWear} directly. */\n onLink: () => void\n onCancel?: () => void\n}) {\n return (\n <div style={S.stack}>\n <ul style={S.list}>\n {CONSENT[provider].points.map(t => (\n <li key={t}>{t}</li>\n ))}\n </ul>\n <span style={S.line}>\n <button\n type=\"button\"\n className=\"settings-popout__btn settings-popout__btn--primary\"\n disabled={busy}\n onClick={onLink}\n >\n {CONSENT[provider].go}\n </button>\n {onCancel && (\n <button type=\"button\" className=\"settings-popout__btn\" onClick={onCancel}>\n Cancel\n </button>\n )}\n </span>\n </div>\n )\n}\n\nexport function LinkedAccounts() {\n const [settings, setSettings] = useState<AvatarSettings | null>(null)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const [consent, setConsent] = useState<LinkProvider | null>(null)\n\n useEffect(() => {\n let live = true\n avatarClient\n .get()\n .then(s => live && setSettings(s))\n .catch((err: unknown) => live && setError(avatarErrorText(err)))\n return () => {\n live = false\n }\n }, [])\n\n const run = async (work: () => Promise<AvatarSettings>) => {\n setBusy(true)\n setError(null)\n try {\n const next = await work()\n setSettings(next)\n // The page under the popout (a watchparty room, a game) re-reads the\n // avatar from this, as it does after a change in the avatar dialog.\n window.dispatchEvent(new window.CustomEvent<AvatarSettings>(AVATAR_EVENT, { detail: next }))\n } catch (err) {\n setError(avatarErrorText(err))\n } finally {\n setBusy(false)\n }\n }\n\n if (!settings) {\n return (\n <span className=\"settings-popout__hint\" role={error ? 'alert' : undefined}>\n {error ?? '…'}\n </span>\n )\n }\n\n return (\n <div style={S.stack}>\n {(['discord', 'steam'] as const).map(p => {\n const linked = settings.linked[p]\n return (\n <div key={p} style={S.line}>\n <span className=\"settings-popout__label\" style={S.grow}>\n {LABEL[p]}\n {!settings.providers[p]\n ? ' · not available yet'\n : linked\n ? linked.avatarUrl\n ? ' · linked'\n : ' · linked, no picture'\n : ''}\n </span>\n {settings.providers[p] && !linked && (\n <button\n type=\"button\"\n className=\"settings-popout__btn\"\n disabled={busy}\n onClick={() => setConsent(consent === p ? null : p)}\n >\n Link\n </button>\n )}\n {linked && (\n <button\n type=\"button\"\n className=\"settings-popout__btn\"\n disabled={busy}\n onClick={() => void run(() => avatarClient.unlink(p))}\n >\n Unlink\n </button>\n )}\n </div>\n )\n })}\n\n {consent && (\n <LinkConsent\n provider={consent}\n busy={busy}\n onLink={() => {\n setConsent(null)\n void run(() => linkAndWear(consent))\n }}\n onCancel={() => setConsent(null)}\n />\n )}\n\n {error && (\n <span className=\"settings-popout__error\" role=\"alert\">\n {error}\n </span>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"ThemePicker.d.ts","sourceRoot":"","sources":["../../src/components/ThemePicker.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAA4C,MAAM,OAAO,CAAA;AAEhE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAA;AAIhD,OAAO,qBAAqB,CAAA;AAE5B;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,WAAW,CAAC,EAC1B,YAAY,EACZ,MAAM,EACN,aAAa,EACb,aAAa,EACb,QAAQ,EACR,eAAe,EACf,YAAY,EACZ,SAAc,EACd,iBAA0B,EAC3B,EAAE,gBAAgB,qBA0GlB"}
1
+ {"version":3,"file":"ThemePicker.d.ts","sourceRoot":"","sources":["../../src/components/ThemePicker.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAuD,MAAM,OAAO,CAAA;AAE3E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAA;AAIhD,OAAO,qBAAqB,CAAA;AAE5B;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,WAAW,CAAC,EAC1B,YAAY,EACZ,MAAM,EACN,aAAa,EACb,aAAa,EACb,QAAQ,EACR,eAAe,EACf,YAAY,EACZ,SAAc,EACd,iBAA0B,EAC3B,EAAE,gBAAgB,qBAsIlB"}
@@ -1,5 +1,5 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
- import { useRef, useState, useLayoutEffect } from "react";
2
+ import { useRef, useState, useLayoutEffect, useEffect } from "react";
3
3
  import { Tooltip } from "./Tooltip.js";
4
4
  import { getFallbackIcon, SettingsIcon } from "./ThemeIcons.js";
5
5
  import { MoonIcon } from "@wolffm/themes";
@@ -16,6 +16,9 @@ function ThemePicker({
16
16
  dropdownPlacement = "auto"
17
17
  }) {
18
18
  const dropdownRef = useRef(null);
19
+ const toggleRef = useRef(null);
20
+ const onToggleRef = useRef(onToggle);
21
+ onToggleRef.current = onToggle;
19
22
  const [resolvedPlacement, setResolvedPlacement] = useState("right");
20
23
  useLayoutEffect(() => {
21
24
  if (!isOpen) return;
@@ -34,15 +37,32 @@ function ThemePicker({
34
37
  });
35
38
  return () => cancelAnimationFrame(frameId);
36
39
  }, [isOpen, dropdownPlacement]);
40
+ useEffect(() => {
41
+ if (!isOpen) return;
42
+ const dropdown = dropdownRef.current;
43
+ const target = dropdown?.querySelector(".theme-pill__btn.active") ?? dropdown?.querySelector("button");
44
+ target?.focus();
45
+ const onKey = (e) => {
46
+ if (!isCloseKey(e)) return;
47
+ e.preventDefault();
48
+ onToggleRef.current();
49
+ toggleRef.current?.focus();
50
+ };
51
+ window.addEventListener("keydown", onKey);
52
+ return () => window.removeEventListener("keydown", onKey);
53
+ }, [isOpen]);
37
54
  const currentIcon = getThemeIcon ? getThemeIcon(currentTheme) : /* @__PURE__ */ jsx(MoonIcon, {});
38
55
  const openLeftClass = resolvedPlacement === "left" ? "theme-picker--open-left" : "";
39
56
  return /* @__PURE__ */ jsxs("div", { className: `theme-picker ${openLeftClass} ${className}`.trim(), children: [
40
57
  /* @__PURE__ */ jsx(Tooltip, { content: "Choose theme", children: /* @__PURE__ */ jsx(
41
58
  "button",
42
59
  {
60
+ ref: toggleRef,
43
61
  className: "app-header__icon-btn theme-toggle-btn",
44
62
  onClick: onToggle,
45
63
  "aria-label": "Choose theme",
64
+ "aria-expanded": isOpen,
65
+ "aria-haspopup": "true",
46
66
  children: currentIcon
47
67
  }
48
68
  ) }),
@@ -95,6 +115,9 @@ function ThemePicker({
95
115
  isOpen && /* @__PURE__ */ jsx("div", { className: "theme-picker__overlay", onClick: onToggle })
96
116
  ] });
97
117
  }
118
+ function isCloseKey(e) {
119
+ return e.key === "Escape" || e.key === "GoBack" || e.key === "BrowserBack" || e.keyCode === 461 || e.keyCode === 10009;
120
+ }
98
121
  export {
99
122
  ThemePicker
100
123
  };
@@ -1 +1 @@
1
- {"version":3,"file":"ThemePicker.js","sources":["../../src/components/ThemePicker.tsx"],"sourcesContent":["/**\n * ThemePicker component\n * A beautiful theme picker with light/dark variants for each theme family\n * Designed to work with any theme system via configuration\n */\n\nimport React, { useRef, useLayoutEffect, useState } from 'react'\nimport { Tooltip } from './Tooltip'\nimport type { ThemePickerProps } from '../types'\nimport { SettingsIcon, getFallbackIcon } from './ThemeIcons'\n// MoonIcon is a theme-family icon and lives with the theme model now.\nimport { MoonIcon } from '@wolffm/themes'\nimport '../theme-picker.css'\n\n/**\n * ThemePicker - Dropdown theme selector with optional settings button\n *\n * @example\n * ```tsx\n * import { ThemePicker } from '@wolffm/task-ui-components'\n * import '@wolffm/task-ui-components/theme-picker.css'\n *\n * <ThemePicker\n * currentTheme=\"dark\"\n * isOpen={showPicker}\n * themeFamilies={MY_THEME_FAMILIES}\n * onThemeChange={setTheme}\n * onToggle={() => setShowPicker(!showPicker)}\n * onSettingsClick={() => openSettings()}\n * />\n * ```\n */\nexport function ThemePicker({\n currentTheme,\n isOpen,\n themeFamilies,\n onThemeChange,\n onToggle,\n onSettingsClick,\n getThemeIcon,\n className = '',\n dropdownPlacement = 'auto'\n}: ThemePickerProps) {\n const dropdownRef = useRef<HTMLDivElement>(null)\n const [resolvedPlacement, setResolvedPlacement] = useState<'left' | 'right'>('right')\n\n // Edge detection: when 'auto', measure the dropdown after render and flip if it overflows\n useLayoutEffect(() => {\n if (!isOpen) return\n\n if (dropdownPlacement !== 'auto') {\n setResolvedPlacement(dropdownPlacement)\n return\n }\n\n // Start with right (default), then check if it overflows\n setResolvedPlacement('right')\n\n // Use rAF to measure after the browser has laid out the dropdown\n const frameId = requestAnimationFrame(() => {\n const el = dropdownRef.current\n if (!el) return\n const rect = el.getBoundingClientRect()\n if (rect.right > window.innerWidth) {\n setResolvedPlacement('left')\n }\n })\n\n return () => cancelAnimationFrame(frameId)\n }, [isOpen, dropdownPlacement])\n\n // Get icon for current theme (use provided function or fallback to MoonIcon)\n const currentIcon = getThemeIcon ? getThemeIcon(currentTheme) : <MoonIcon />\n\n const openLeftClass = resolvedPlacement === 'left' ? 'theme-picker--open-left' : ''\n\n return (\n <div className={`theme-picker ${openLeftClass} ${className}`.trim()}>\n <Tooltip content=\"Choose theme\">\n <button\n className=\"app-header__icon-btn theme-toggle-btn\"\n onClick={onToggle}\n aria-label=\"Choose theme\"\n >\n {currentIcon}\n </button>\n </Tooltip>\n {isOpen && (\n <div\n ref={dropdownRef}\n className=\"theme-picker__dropdown\"\n onClick={e => e.stopPropagation()}\n >\n <div className=\"theme-picker__main\">\n <div className=\"theme-picker__pills\">\n {themeFamilies.map((family, idx) => {\n // Use provided icons or fallback to generic shapes\n const lightIcon = family.lightIcon ?? getFallbackIcon(idx)\n const darkIcon = family.darkIcon ?? getFallbackIcon(idx)\n\n return (\n <div key={idx} className=\"theme-pill\">\n {/* Light variant button */}\n <Tooltip content={family.lightLabel}>\n <button\n className={`theme-pill__btn theme-pill__btn--light ${currentTheme === family.lightTheme ? 'active' : ''}`}\n onClick={() => onThemeChange(family.lightTheme)}\n aria-label={family.lightLabel}\n >\n <div className=\"theme-pill__icon\">{lightIcon}</div>\n </button>\n </Tooltip>\n\n {/* Dark variant button */}\n <Tooltip content={family.darkLabel}>\n <button\n className={`theme-pill__btn theme-pill__btn--dark ${currentTheme === family.darkTheme ? 'active' : ''}`}\n onClick={() => onThemeChange(family.darkTheme)}\n aria-label={family.darkLabel}\n >\n <div className=\"theme-pill__icon\">{darkIcon}</div>\n </button>\n </Tooltip>\n </div>\n )\n })}\n </div>\n {/* Settings button - separate column on the right */}\n {onSettingsClick && (\n <Tooltip content=\"Settings\">\n <button\n className=\"theme-picker__settings-icon\"\n onClick={() => {\n onSettingsClick()\n onToggle() // Close picker after opening settings\n }}\n aria-label=\"Settings\"\n >\n <SettingsIcon />\n </button>\n </Tooltip>\n )}\n </div>\n </div>\n )}\n {isOpen && <div className=\"theme-picker__overlay\" onClick={onToggle} />}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ThemePicker.js","sources":["../../src/components/ThemePicker.tsx"],"sourcesContent":["/**\n * ThemePicker component\n * A beautiful theme picker with light/dark variants for each theme family\n * Designed to work with any theme system via configuration\n */\n\nimport React, { useEffect, useRef, useLayoutEffect, useState } from 'react'\nimport { Tooltip } from './Tooltip'\nimport type { ThemePickerProps } from '../types'\nimport { SettingsIcon, getFallbackIcon } from './ThemeIcons'\n// MoonIcon is a theme-family icon and lives with the theme model now.\nimport { MoonIcon } from '@wolffm/themes'\nimport '../theme-picker.css'\n\n/**\n * ThemePicker - Dropdown theme selector with optional settings button\n *\n * @example\n * ```tsx\n * import { ThemePicker } from '@wolffm/task-ui-components'\n * import '@wolffm/task-ui-components/theme-picker.css'\n *\n * <ThemePicker\n * currentTheme=\"dark\"\n * isOpen={showPicker}\n * themeFamilies={MY_THEME_FAMILIES}\n * onThemeChange={setTheme}\n * onToggle={() => setShowPicker(!showPicker)}\n * onSettingsClick={() => openSettings()}\n * />\n * ```\n */\nexport function ThemePicker({\n currentTheme,\n isOpen,\n themeFamilies,\n onThemeChange,\n onToggle,\n onSettingsClick,\n getThemeIcon,\n className = '',\n dropdownPlacement = 'auto'\n}: ThemePickerProps) {\n const dropdownRef = useRef<HTMLDivElement>(null)\n const toggleRef = useRef<HTMLButtonElement>(null)\n // The latest onToggle, so the open/close effect below runs on isOpen alone:\n // callers pass a new arrow every render, and re-running it would steal focus.\n const onToggleRef = useRef(onToggle)\n onToggleRef.current = onToggle\n const [resolvedPlacement, setResolvedPlacement] = useState<'left' | 'right'>('right')\n\n // Edge detection: when 'auto', measure the dropdown after render and flip if it overflows\n useLayoutEffect(() => {\n if (!isOpen) return\n\n if (dropdownPlacement !== 'auto') {\n setResolvedPlacement(dropdownPlacement)\n return\n }\n\n // Start with right (default), then check if it overflows\n setResolvedPlacement('right')\n\n // Use rAF to measure after the browser has laid out the dropdown\n const frameId = requestAnimationFrame(() => {\n const el = dropdownRef.current\n if (!el) return\n const rect = el.getBoundingClientRect()\n if (rect.right > window.innerWidth) {\n setResolvedPlacement('left')\n }\n })\n\n return () => cancelAnimationFrame(frameId)\n }, [isOpen, dropdownPlacement])\n\n // Keyboard and TV remote: opening moves focus into the dropdown (onto the\n // theme in use), and Escape or a TV's Back key closes it and hands focus back\n // to the toggle. Without this a remote could open the picker and never leave.\n useEffect(() => {\n if (!isOpen) return\n const dropdown = dropdownRef.current\n const target =\n dropdown?.querySelector<HTMLButtonElement>('.theme-pill__btn.active') ??\n dropdown?.querySelector<HTMLButtonElement>('button')\n target?.focus()\n const onKey = (e: KeyboardEvent) => {\n if (!isCloseKey(e)) return\n e.preventDefault()\n onToggleRef.current()\n toggleRef.current?.focus()\n }\n window.addEventListener('keydown', onKey)\n return () => window.removeEventListener('keydown', onKey)\n }, [isOpen])\n\n // Get icon for current theme (use provided function or fallback to MoonIcon)\n const currentIcon = getThemeIcon ? getThemeIcon(currentTheme) : <MoonIcon />\n\n const openLeftClass = resolvedPlacement === 'left' ? 'theme-picker--open-left' : ''\n\n return (\n <div className={`theme-picker ${openLeftClass} ${className}`.trim()}>\n <Tooltip content=\"Choose theme\">\n <button\n ref={toggleRef}\n className=\"app-header__icon-btn theme-toggle-btn\"\n onClick={onToggle}\n aria-label=\"Choose theme\"\n aria-expanded={isOpen}\n aria-haspopup=\"true\"\n >\n {currentIcon}\n </button>\n </Tooltip>\n {isOpen && (\n <div\n ref={dropdownRef}\n className=\"theme-picker__dropdown\"\n onClick={e => e.stopPropagation()}\n >\n <div className=\"theme-picker__main\">\n <div className=\"theme-picker__pills\">\n {themeFamilies.map((family, idx) => {\n // Use provided icons or fallback to generic shapes\n const lightIcon = family.lightIcon ?? getFallbackIcon(idx)\n const darkIcon = family.darkIcon ?? getFallbackIcon(idx)\n\n return (\n <div key={idx} className=\"theme-pill\">\n {/* Light variant button */}\n <Tooltip content={family.lightLabel}>\n <button\n className={`theme-pill__btn theme-pill__btn--light ${currentTheme === family.lightTheme ? 'active' : ''}`}\n onClick={() => onThemeChange(family.lightTheme)}\n aria-label={family.lightLabel}\n >\n <div className=\"theme-pill__icon\">{lightIcon}</div>\n </button>\n </Tooltip>\n\n {/* Dark variant button */}\n <Tooltip content={family.darkLabel}>\n <button\n className={`theme-pill__btn theme-pill__btn--dark ${currentTheme === family.darkTheme ? 'active' : ''}`}\n onClick={() => onThemeChange(family.darkTheme)}\n aria-label={family.darkLabel}\n >\n <div className=\"theme-pill__icon\">{darkIcon}</div>\n </button>\n </Tooltip>\n </div>\n )\n })}\n </div>\n {/* Settings button - separate column on the right */}\n {onSettingsClick && (\n <Tooltip content=\"Settings\">\n <button\n className=\"theme-picker__settings-icon\"\n onClick={() => {\n onSettingsClick()\n onToggle() // Close picker after opening settings\n }}\n aria-label=\"Settings\"\n >\n <SettingsIcon />\n </button>\n </Tooltip>\n )}\n </div>\n </div>\n )}\n {isOpen && <div className=\"theme-picker__overlay\" onClick={onToggle} />}\n </div>\n )\n}\n\n/** Escape, or a TV remote's Back: GoBack/BrowserBack, webOS 461, Tizen 10009. */\nfunction isCloseKey(e: KeyboardEvent): boolean {\n return (\n e.key === 'Escape' ||\n e.key === 'GoBack' ||\n e.key === 'BrowserBack' ||\n e.keyCode === 461 ||\n e.keyCode === 10009\n )\n}\n"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hadoku/task-ui-components",
3
- "version": "4.23.13",
4
- "gitHead": "0b6541432e037dd38a49f7563fa240ffb1d97580",
3
+ "version": "4.23.15",
4
+ "gitHead": "0d94c5ddb49d5f9eae0d8f36504010489218fe40",
5
5
  "description": "Reusable UI components with Bento grid and metallic gradient support",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -201,7 +201,12 @@ const S = {
201
201
  gap: 5,
202
202
  padding: 6,
203
203
  borderRadius: 8,
204
- border: '1px solid var(--color-border)',
204
+ // Longhands, not the `border` shorthand: the selected styles override
205
+ // borderColor, and when one comes off React clears borderColor to '', which
206
+ // would reset it to currentColor and leave a text-coloured ring behind.
207
+ borderWidth: 1,
208
+ borderStyle: 'solid',
209
+ borderColor: 'var(--color-border)',
205
210
  background: 'var(--color-bg)',
206
211
  color: 'var(--color-text)',
207
212
  cursor: 'pointer',
@@ -225,16 +230,7 @@ const S = {
225
230
  fontSize: '0.8rem'
226
231
  },
227
232
  tileName: { fontSize: '0.8rem', fontWeight: 500, display: 'flex', alignItems: 'center', gap: 5 },
228
- tileEarn: { fontSize: '0.7rem', lineHeight: 1.3, color: 'var(--color-text-muted)' },
229
- foot: {
230
- display: 'flex',
231
- justifyContent: 'space-between',
232
- alignItems: 'center',
233
- gap: 8,
234
- flexWrap: 'wrap',
235
- fontSize: '0.75rem',
236
- color: 'var(--color-text-muted)'
237
- }
233
+ tileEarn: { fontSize: '0.7rem', lineHeight: 1.3, color: 'var(--color-text-muted)' }
238
234
  } satisfies Record<string, React.CSSProperties>
239
235
 
240
236
  /**
@@ -867,77 +863,64 @@ function SlotGrid({
867
863
  onTry
868
864
  }: SlotGridProps) {
869
865
  const items = COSMETICS.filter(i => i.slot === slot && (!i.hidden || isAdmin))
870
- const locked = items.filter(i => !i.hidden && !owned.has(i.id))
871
866
 
872
867
  return (
873
- <>
874
- <div style={S.grid}>
875
- <button
876
- type="button"
877
- aria-pressed={equipped === null}
878
- style={{ ...S.tile, ...(equipped === null ? S.tileOn : {}) }}
879
- onClick={() => onEquip(null)}
880
- >
881
- <Html html={preview(null)} style={{ ...S.tilePv, ...colour }} />
882
- <span style={S.tileName}>None</span>
883
- </button>
884
- {items.map(item => {
885
- if (item.hidden) {
886
- return (
887
- <button
888
- key={item.id}
889
- type="button"
890
- disabled
891
- aria-label={`${item.label}, archived`}
892
- style={{ ...S.tile, cursor: 'not-allowed', opacity: 0.45 }}
893
- >
894
- <Html html={preview(item.id)} style={{ ...S.tilePv, ...colour }} />
895
- <span style={S.tileName}>{item.label}</span>
896
- <span style={S.tileEarn}>Archived</span>
897
- </button>
898
- )
899
- }
900
- const own = owned.has(item.id)
901
- const on = equipped === item.id
902
- const trying = tryOn?.slot === slot && tryOn.id === item.id
868
+ <div style={S.grid}>
869
+ <button
870
+ type="button"
871
+ aria-pressed={equipped === null}
872
+ style={{ ...S.tile, ...(equipped === null ? S.tileOn : {}) }}
873
+ onClick={() => onEquip(null)}
874
+ >
875
+ <Html html={preview(null)} style={{ ...S.tilePv, ...colour }} />
876
+ <span style={S.tileName}>None</span>
877
+ </button>
878
+ {items.map(item => {
879
+ if (item.hidden) {
903
880
  return (
904
881
  <button
905
882
  key={item.id}
906
883
  type="button"
907
- aria-pressed={own ? on : trying}
908
- aria-label={
909
- own ? item.label : `${item.label}, locked. ${item.earn ?? ''}. Try it on.`
910
- }
911
- style={{
912
- ...S.tile,
913
- ...(on ? S.tileOn : {}),
914
- ...(trying ? S.tileTrying : {})
915
- }}
916
- onClick={() => (own ? onEquip(item.id) : onTry(item.id))}
917
- // A message reveal plays once; hovering its tile plays it again.
918
- onMouseEnter={e => slot === 'message' && replayPreview(e.currentTarget)}
884
+ disabled
885
+ aria-label={`${item.label}, archived`}
886
+ style={{ ...S.tile, cursor: 'not-allowed', opacity: 0.45 }}
919
887
  >
920
- <Html
921
- html={preview(item.id)}
922
- style={{ ...S.tilePv, ...colour, opacity: own ? 1 : 0.55 }}
923
- />
924
- <span style={{ ...S.tileName, color: own ? undefined : 'var(--color-text-muted)' }}>
925
- {!own && <Icon name="lock" size={12} />}
926
- {item.label}
927
- </span>
928
- {!own && <span style={S.tileEarn}>{item.earn}</span>}
888
+ <Html html={preview(item.id)} style={{ ...S.tilePv, ...colour }} />
889
+ <span style={S.tileName}>{item.label}</span>
890
+ <span style={S.tileEarn}>Archived</span>
929
891
  </button>
930
892
  )
931
- })}
932
- </div>
933
- {locked.length > 0 && (
934
- <div style={S.foot}>
935
- <span>
936
- {locked.length === 1 ? 'One to earn.' : `${locked.length} to earn.`} Pick one to try it
937
- on.
938
- </span>
939
- </div>
940
- )}
941
- </>
893
+ }
894
+ const own = owned.has(item.id)
895
+ const on = equipped === item.id
896
+ const trying = tryOn?.slot === slot && tryOn.id === item.id
897
+ return (
898
+ <button
899
+ key={item.id}
900
+ type="button"
901
+ aria-pressed={own ? on : trying}
902
+ aria-label={own ? item.label : `${item.label}, locked. ${item.earn ?? ''}. Try it on.`}
903
+ style={{
904
+ ...S.tile,
905
+ ...(on ? S.tileOn : {}),
906
+ ...(trying ? S.tileTrying : {})
907
+ }}
908
+ onClick={() => (own ? onEquip(item.id) : onTry(item.id))}
909
+ // A message reveal plays once; hovering its tile plays it again.
910
+ onMouseEnter={e => slot === 'message' && replayPreview(e.currentTarget)}
911
+ >
912
+ <Html
913
+ html={preview(item.id)}
914
+ style={{ ...S.tilePv, ...colour, opacity: own ? 1 : 0.55 }}
915
+ />
916
+ <span style={{ ...S.tileName, color: own ? undefined : 'var(--color-text-muted)' }}>
917
+ {!own && <Icon name="lock" size={12} />}
918
+ {item.label}
919
+ </span>
920
+ {!own && <span style={S.tileEarn}>{item.earn}</span>}
921
+ </button>
922
+ )
923
+ })}
924
+ </div>
942
925
  )
943
926
  }
@@ -7,8 +7,9 @@
7
7
  * protocol cannot drift from the callback page).
8
8
  *
9
9
  * Everything happens in place: picking, uploading and removing are requests
10
- * from here. Linking Discord or Steam lives in the popout's Account tab
11
- * (LinkedAccounts); a linked account's picture is a choice here.
10
+ * from here. Discord and Steam are managed in the popout's Account tab
11
+ * (LinkedAccounts); one not linked yet can also be linked from its tile here,
12
+ * with the same consent step, and its picture is then worn.
12
13
  *
13
14
  * AvatarBuilder owns the settings and the character table and loads them only
14
15
  * when the dialog opens; this component draws them and sends changes through
@@ -21,6 +22,7 @@
21
22
  import React, { useEffect, useState } from 'react'
22
23
  import { type AvatarSettings, type AvatarSource } from '@wolffm/prefs-client/avatar'
23
24
  import { avatarClient, portraitUrl, type CharacterSet } from '../lib/avatarData'
25
+ import { LinkConsent, linkAndWear } from './LinkedAccounts'
24
26
 
25
27
  const LABEL: Record<AvatarSource, string> = {
26
28
  sprite: 'Character',
@@ -40,7 +42,12 @@ const S = {
40
42
  gap: 4,
41
43
  padding: 6,
42
44
  borderRadius: 8,
43
- border: '1px solid var(--color-border)',
45
+ // Longhands, not the `border` shorthand: the selected styles override
46
+ // borderColor, and when one comes off React clears borderColor to '', which
47
+ // would reset it to currentColor and leave a text-coloured ring behind.
48
+ borderWidth: 1,
49
+ borderStyle: 'solid',
50
+ borderColor: 'var(--color-border)',
44
51
  background: 'var(--color-bg)',
45
52
  color: 'var(--color-text)',
46
53
  fontSize: '0.72rem',
@@ -128,11 +135,10 @@ export function AvatarPicker({ settings, sets, busy, run }: AvatarPickerProps) {
128
135
  }
129
136
  const shown = settings.avatar
130
137
  const current = sets?.[setIdx]
131
- // The tab on show. Picking an available source also wears it; Upload opens
132
- // with nothing uploaded yet, to choose a file.
138
+ // The tab on show. Picking an available source also wears it; one that is
139
+ // not yet opens to get it: Upload to choose a file, Discord or Steam to link.
133
140
  const [view, setView] = useState<AvatarSource>(shown.source)
134
141
  useEffect(() => setView(shown.source), [shown.source])
135
- const openable = (src: AvatarSource) => src === 'upload' || available[src]
136
142
 
137
143
  return (
138
144
  <div style={S.stack}>
@@ -149,13 +155,8 @@ export function AvatarPicker({ settings, sets, busy, run }: AvatarPickerProps) {
149
155
  type="button"
150
156
  role="radio"
151
157
  aria-checked={view === src}
152
- disabled={busy || !openable(src)}
153
- title={openable(src) ? undefined : `Link ${LABEL[src]} under Settings › Account first`}
154
- style={{
155
- ...S.source,
156
- ...(view === src ? S.sourceOn : {}),
157
- opacity: openable(src) ? 1 : 0.5
158
- }}
158
+ disabled={busy}
159
+ style={{ ...S.source, ...(view === src ? S.sourceOn : {}) }}
159
160
  onClick={() => {
160
161
  setView(src)
161
162
  if (available[src] && shown.source !== src) {
@@ -213,6 +214,23 @@ export function AvatarPicker({ settings, sets, busy, run }: AvatarPickerProps) {
213
214
  <span className="settings-popout__hint">…</span>
214
215
  ))}
215
216
 
217
+ {(view === 'discord' || view === 'steam') &&
218
+ !available[view] &&
219
+ (!settings.providers[view] ? (
220
+ <span className="settings-popout__hint">{LABEL[view]} is not available yet.</span>
221
+ ) : settings.linked[view] ? (
222
+ <span className="settings-popout__hint">Your {LABEL[view]} account has no picture.</span>
223
+ ) : (
224
+ <LinkConsent
225
+ key={view}
226
+ provider={view}
227
+ busy={busy}
228
+ onLink={() => {
229
+ run(() => linkAndWear(view)).catch(reportErr)
230
+ }}
231
+ />
232
+ ))}
233
+
216
234
  {view === 'upload' && (
217
235
  <div style={S.line}>
218
236
  <span className="settings-popout__label" style={S.grow}>