@karyl-chan/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +58 -0
  2. package/src/components/AppBadge.vue +155 -0
  3. package/src/components/AppButton.vue +171 -0
  4. package/src/components/AppConfirmDialog.vue +176 -0
  5. package/src/components/AppItemCard.vue +216 -0
  6. package/src/components/AppMenu.vue +197 -0
  7. package/src/components/AppMenuItem.vue +43 -0
  8. package/src/components/AppModal.vue +257 -0
  9. package/src/components/AppPopover.vue +275 -0
  10. package/src/components/AppSelect.vue +89 -0
  11. package/src/components/AppSelectField.vue +260 -0
  12. package/src/components/AppTabs.vue +310 -0
  13. package/src/components/AppTabsRouted.vue +409 -0
  14. package/src/components/AppTextArea.vue +128 -0
  15. package/src/components/AppTextField.vue +165 -0
  16. package/src/components/AppToast.vue +65 -0
  17. package/src/components/AppToggle.vue +105 -0
  18. package/src/components/Draggable.vue +178 -0
  19. package/src/components/GlobalConfirmDialog.vue +25 -0
  20. package/src/components/UnreadPill.vue +28 -0
  21. package/src/components/UserAvatar.vue +114 -0
  22. package/src/components/UserCard.vue +274 -0
  23. package/src/components/UserItem.vue +167 -0
  24. package/src/components/tab-types.ts +9 -0
  25. package/src/composables/use-app-shell.ts +65 -0
  26. package/src/composables/use-breakpoint.ts +14 -0
  27. package/src/composables/use-click-outside-stack.ts +67 -0
  28. package/src/composables/use-color-scheme.ts +75 -0
  29. package/src/composables/use-confirm.ts +36 -0
  30. package/src/composables/use-drawer.css +47 -0
  31. package/src/composables/use-drawer.ts +58 -0
  32. package/src/composables/use-escape-stack.ts +80 -0
  33. package/src/composables/use-file-drop.ts +47 -0
  34. package/src/composables/use-long-press.ts +62 -0
  35. package/src/composables/use-popover.css +32 -0
  36. package/src/composables/use-popover.ts +1324 -0
  37. package/src/composables/use-shift-key.ts +18 -0
  38. package/src/index.ts +75 -0
  39. package/src/lib/avatar.ts +35 -0
  40. package/src/reset.css +53 -0
  41. package/src/stores/toastStore.ts +26 -0
  42. package/src/tokens.css +159 -0
@@ -0,0 +1,18 @@
1
+ import { onMounted, onUnmounted, ref } from 'vue'
2
+
3
+ export function useShiftKey() {
4
+ const held = ref(false)
5
+ function track(event: KeyboardEvent) { held.value = event.shiftKey }
6
+ function release() { held.value = false }
7
+ onMounted(() => {
8
+ document.addEventListener('keydown', track)
9
+ document.addEventListener('keyup', track)
10
+ window.addEventListener('blur', release)
11
+ })
12
+ onUnmounted(() => {
13
+ document.removeEventListener('keydown', track)
14
+ document.removeEventListener('keyup', track)
15
+ window.removeEventListener('blur', release)
16
+ })
17
+ return held
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,75 @@
1
+ // @karyl-chan/ui — shared Vue UI for karyl-chan bot frontend and plugin webuis.
2
+ //
3
+ // Side-effect CSS imports must be done separately by the consumer (so they
4
+ // can choose order and avoid double-importing):
5
+ // import "@karyl-chan/ui/tokens.css";
6
+ // import "@karyl-chan/ui/reset.css";
7
+ // import "@karyl-chan/ui/use-drawer.css";
8
+ // import "@karyl-chan/ui/use-popover.css";
9
+
10
+ // ── Components ───────────────────────────────────────────────────────────
11
+ export { default as AppBadge } from "./components/AppBadge.vue";
12
+ export type { BadgeSize, BadgeTone, BadgeVariant } from "./components/AppBadge.vue";
13
+ export { default as AppButton } from "./components/AppButton.vue";
14
+ export { default as AppConfirmDialog } from "./components/AppConfirmDialog.vue";
15
+ export { default as AppItemCard } from "./components/AppItemCard.vue";
16
+ export type { AccentBarTone } from "./components/AppItemCard.vue";
17
+ export { default as AppMenu } from "./components/AppMenu.vue";
18
+ export { default as AppMenuItem } from "./components/AppMenuItem.vue";
19
+ export { default as AppModal } from "./components/AppModal.vue";
20
+ export { default as AppPopover } from "./components/AppPopover.vue";
21
+ export { default as AppSelect } from "./components/AppSelect.vue";
22
+ export { default as AppSelectField } from "./components/AppSelectField.vue";
23
+ export type { SelectOption } from "./components/AppSelectField.vue";
24
+ export { default as AppTabs } from "./components/AppTabs.vue";
25
+ export type { TabDef } from "./components/tab-types";
26
+ export { default as AppTextArea } from "./components/AppTextArea.vue";
27
+ export { default as AppTextField } from "./components/AppTextField.vue";
28
+ export { default as AppToggle } from "./components/AppToggle.vue";
29
+ // AppTabsRouted carries a vue-router dependency. Importing it from a
30
+ // plugin SPA that doesn't have vue-router installed will fail at build
31
+ // time — that's the intended boundary. The non-routed AppTabs above
32
+ // has no such dependency.
33
+ export { default as AppTabsRouted } from "./components/AppTabsRouted.vue";
34
+ export { default as AppToast } from "./components/AppToast.vue";
35
+ export { default as Draggable } from "./components/Draggable.vue";
36
+ export { default as GlobalConfirmDialog } from "./components/GlobalConfirmDialog.vue";
37
+ export { default as UnreadPill } from "./components/UnreadPill.vue";
38
+ export { default as UserAvatar } from "./components/UserAvatar.vue";
39
+ export { default as UserCard } from "./components/UserCard.vue";
40
+ export { default as UserItem } from "./components/UserItem.vue";
41
+
42
+ // ── Lib helpers ──────────────────────────────────────────────────────────
43
+ export {
44
+ isAnimatedAvatar,
45
+ isAnimatedBanner,
46
+ animatedAvatarUrl,
47
+ } from "./lib/avatar";
48
+
49
+ // ── Composables ──────────────────────────────────────────────────────────
50
+ export {
51
+ useAppShell,
52
+ provideAppShell,
53
+ useFlushMain,
54
+ useOverlayExtras,
55
+ } from "./composables/use-app-shell";
56
+ export type { AppShellContext, OverlayView } from "./composables/use-app-shell";
57
+ export { useBreakpoint } from "./composables/use-breakpoint";
58
+ export { useClickOutsideStack } from "./composables/use-click-outside-stack";
59
+ export { useColorScheme } from "./composables/use-color-scheme";
60
+ export type { ColorScheme } from "./composables/use-color-scheme";
61
+ export { useConfirm } from "./composables/use-confirm";
62
+ export type { ConfirmOptions } from "./composables/use-confirm";
63
+ export { useDrawer } from "./composables/use-drawer";
64
+ export type { DrawerPlacement, UseDrawerOptions } from "./composables/use-drawer";
65
+ export { useEscapeStack } from "./composables/use-escape-stack";
66
+ export { useFileDrop } from "./composables/use-file-drop";
67
+ export type { FileDropHandlers } from "./composables/use-file-drop";
68
+ export { useLongPress } from "./composables/use-long-press";
69
+ export { usePopover } from "./composables/use-popover";
70
+ export type { Placement, UsePopoverOptions } from "./composables/use-popover";
71
+ export { useShiftKey } from "./composables/use-shift-key";
72
+
73
+ // ── Stores ───────────────────────────────────────────────────────────────
74
+ export { useToastStore } from "./stores/toastStore";
75
+ export type { ToastItem } from "./stores/toastStore";
@@ -0,0 +1,35 @@
1
+ // Discord CDN avatar / banner helpers.
2
+ //
3
+ // Discord encodes animated assets with an `a_` hash prefix. The `.webp`
4
+ // endpoint serves the still frame by default and the animated variant
5
+ // when `&animated=true` is appended (Discord CDN honours this query
6
+ // parameter; the .gif endpoint returns 415 for many assets so we stay
7
+ // on .webp). Consumers opt into animation selectively — hover on a
8
+ // list row vs. always-playing inside a profile card.
9
+
10
+ /**
11
+ * True if the URL points at an animated avatar — either global
12
+ * (`/avatars/<userId>/a_…`) or guild-specific
13
+ * (`/guilds/<gid>/users/<uid>/avatars/a_…`).
14
+ */
15
+ export function isAnimatedAvatar(url: string | null | undefined): boolean {
16
+ if (!url) return false;
17
+ return /\/avatars\/(?:\d+\/)?a_/.test(url);
18
+ }
19
+
20
+ /** True if the URL points at an animated banner. */
21
+ export function isAnimatedBanner(url: string | null | undefined): boolean {
22
+ if (!url) return false;
23
+ return /\/banners\/\d+\/a_/.test(url);
24
+ }
25
+
26
+ /**
27
+ * Append `animated=true` so the Discord CDN returns the animated frame.
28
+ * Idempotent if the caller already added it; callers should still gate
29
+ * via `isAnimatedAvatar` / `isAnimatedBanner` first — appending the
30
+ * param to a still-only asset is a wasted request.
31
+ */
32
+ export function animatedAvatarUrl(url: string): string {
33
+ if (/[?&]animated=true(?:&|$)/.test(url)) return url;
34
+ return url.includes("?") ? `${url}&animated=true` : `${url}?animated=true`;
35
+ }
package/src/reset.css ADDED
@@ -0,0 +1,53 @@
1
+ /* @karyl-chan/ui — minimal reset + base typography.
2
+ * Import once from your app entry: `import "@karyl-chan/ui/reset.css"`.
3
+ * Pairs with tokens.css (provides --text / --bg-page / --link).
4
+ */
5
+ *,
6
+ *::before,
7
+ *::after {
8
+ box-sizing: border-box;
9
+ }
10
+
11
+ html,
12
+ body,
13
+ #app {
14
+ margin: 0;
15
+ padding: 0;
16
+ height: 100%;
17
+ overflow: hidden;
18
+ }
19
+
20
+ body {
21
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans TC", sans-serif;
22
+ color: var(--text);
23
+ background: var(--bg-page);
24
+ font-size: 15px;
25
+ line-height: 1.5;
26
+ overflow: hidden;
27
+ overscroll-behavior: none;
28
+ }
29
+
30
+ h1, h2, h3, h4 {
31
+ margin-top: 0;
32
+ color: var(--text-strong);
33
+ }
34
+
35
+ a {
36
+ color: var(--link);
37
+ }
38
+
39
+ /* Ghost (outlined, transparent bg) button base — covers the 80% that
40
+ is identical across every usage site. Padding, font-size, and
41
+ border-radius are NOT set here because they differ per context;
42
+ each call site provides its own scoped override. */
43
+ .btn-ghost {
44
+ background: none;
45
+ border: 1px solid var(--border);
46
+ color: var(--text);
47
+ cursor: pointer;
48
+ font: inherit;
49
+ }
50
+ .btn-ghost:hover:not(:disabled) { background: var(--bg-surface-hover); }
51
+ .btn-ghost:disabled { opacity: 0.5; cursor: default; }
52
+ .btn-ghost.danger { color: var(--danger); border-color: rgba(239, 68, 68, 0.45); }
53
+ .btn-ghost.danger:hover { background: rgba(239, 68, 68, 0.1); }
@@ -0,0 +1,26 @@
1
+ import { defineStore } from "pinia";
2
+ import { ref } from "vue";
3
+
4
+ export interface ToastItem {
5
+ id: number;
6
+ message: string;
7
+ type: "error" | "info";
8
+ }
9
+
10
+ let nextId = 0;
11
+
12
+ export const useToastStore = defineStore("toast", () => {
13
+ const items = ref<ToastItem[]>([]);
14
+
15
+ function show(message: string, type: ToastItem["type"] = "error") {
16
+ const id = nextId++;
17
+ items.value = [...items.value, { id, message, type }];
18
+ setTimeout(() => dismiss(id), 4000);
19
+ }
20
+
21
+ function dismiss(id: number) {
22
+ items.value = items.value.filter((t) => t.id !== id);
23
+ }
24
+
25
+ return { items, show, dismiss };
26
+ });
package/src/tokens.css ADDED
@@ -0,0 +1,159 @@
1
+ /* @karyl-chan/ui — design tokens.
2
+ * Import once from your app entry: `import "@karyl-chan/ui/tokens.css"`.
3
+ *
4
+ * Theme model:
5
+ * - Light tokens are the :root defaults.
6
+ * - Dark tokens apply when EITHER the OS reports prefers-color-scheme: dark
7
+ * AND no explicit light override is set, OR the consumer forces dark via
8
+ * `data-theme="dark"` on <html>.
9
+ * - `data-theme="light"` lets users opt out of system dark mode.
10
+ * - `useColorScheme()` (composable) is the usual driver of that attribute,
11
+ * including localStorage persistence; you can also set it yourself if
12
+ * you want to roll your own controller.
13
+ *
14
+ * Bot-specific tokens (e.g. --conv-header-height, --spoiler-*) live in
15
+ * the bot frontend's own global.css alongside its own component styles.
16
+ */
17
+ :root {
18
+ color-scheme: light dark;
19
+
20
+ --bg-page: #f9fafb;
21
+ --bg-surface: #ffffff;
22
+ --bg-surface-2: #f3f4f6;
23
+ --bg-surface-hover: rgba(0, 0, 0, 0.025);
24
+ --bg-surface-active: #eef2ff;
25
+ --bg-header: #1f2937;
26
+ --bg-overlay: rgba(0, 0, 0, 0.5);
27
+
28
+ --text: #111827;
29
+ --text-muted: #6b7280;
30
+ --text-strong: #111827;
31
+ --text-faint: #9ca3af;
32
+ --text-on-accent: #ffffff;
33
+ --text-on-header: #f9fafb;
34
+ --text-on-header-muted: #cbd5f5;
35
+
36
+ --border: #e5e7eb;
37
+ --border-strong: #d1d5db;
38
+
39
+ --accent: #5865f2;
40
+ --accent-bg: rgba(88, 101, 242, 0.15);
41
+ --accent-text: #5865f2;
42
+ --accent-text-strong: #4338ca;
43
+
44
+ --link: #2563eb;
45
+ --link-mask: #1d4ed8;
46
+
47
+ --danger: #b91c1c;
48
+
49
+ --success-bg: #d1fae5;
50
+ --success-text: #065f46;
51
+ --warn-bg: #fef3c7;
52
+ --warn-text: #92400e;
53
+
54
+ --code-bg: #f3f4f6;
55
+ --quote-border: #d1d5db;
56
+ --quote-text: #4b5563;
57
+
58
+ --pill-bg: #f3f4f6;
59
+
60
+ --transition-fast: 0.12s;
61
+ --transition-base: 0.15s;
62
+ --transition-slow: 0.2s;
63
+
64
+ --radius-sm: 4px;
65
+ --radius-base: 6px;
66
+ --radius-lg: 8px;
67
+ --radius-xl: 12px;
68
+ --radius-pill: 999px;
69
+ }
70
+
71
+ /* Pin the UA-side color-scheme hint so native widgets (form controls,
72
+ * scrollbars) follow the explicit choice instead of falling back to
73
+ * "light dark" and tracking the OS. */
74
+ :root[data-theme="light"] { color-scheme: light; }
75
+ :root[data-theme="dark"] { color-scheme: dark; }
76
+
77
+ /* Dark tokens — applied in two situations:
78
+ * 1. System reports dark AND the user hasn't forced light.
79
+ * 2. The user has explicitly chosen dark (`data-theme="dark"`).
80
+ * Kept as a single selector list so the two paths can't drift. */
81
+ @media (prefers-color-scheme: dark) {
82
+ :root:not([data-theme="light"]) {
83
+ --bg-page: #1e1f22;
84
+ --bg-surface: #2b2d31;
85
+ --bg-surface-2: #1e1f22;
86
+ --bg-surface-hover: rgba(255, 255, 255, 0.04);
87
+ --bg-surface-active: #3a3c44;
88
+ --bg-header: #18191c;
89
+
90
+ --text: #dbdee1;
91
+ --text-muted: #a3a6aa;
92
+ --text-strong: #f2f3f5;
93
+ --text-faint: #80848e;
94
+ --text-on-header: #f2f3f5;
95
+ --text-on-header-muted: #b5bac1;
96
+
97
+ --border: #3f4248;
98
+ --border-strong: #4e5058;
99
+
100
+ --accent-bg: rgba(88, 101, 242, 0.25);
101
+ --accent-text: #a5b4fc;
102
+ --accent-text-strong: #c7d2fe;
103
+
104
+ --link: #93c5fd;
105
+ --link-mask: #bfdbfe;
106
+
107
+ --danger: #f87171;
108
+
109
+ --success-bg: #064e3b;
110
+ --success-text: #a7f3d0;
111
+ --warn-bg: #78350f;
112
+ --warn-text: #fef3c7;
113
+
114
+ --code-bg: #1e1f22;
115
+ --quote-border: #4e5058;
116
+ --quote-text: #b5bac1;
117
+
118
+ --pill-bg: #313338;
119
+ }
120
+ }
121
+
122
+ :root[data-theme="dark"] {
123
+ --bg-page: #1e1f22;
124
+ --bg-surface: #2b2d31;
125
+ --bg-surface-2: #1e1f22;
126
+ --bg-surface-hover: rgba(255, 255, 255, 0.04);
127
+ --bg-surface-active: #3a3c44;
128
+ --bg-header: #18191c;
129
+
130
+ --text: #dbdee1;
131
+ --text-muted: #a3a6aa;
132
+ --text-strong: #f2f3f5;
133
+ --text-faint: #80848e;
134
+ --text-on-header: #f2f3f5;
135
+ --text-on-header-muted: #b5bac1;
136
+
137
+ --border: #3f4248;
138
+ --border-strong: #4e5058;
139
+
140
+ --accent-bg: rgba(88, 101, 242, 0.25);
141
+ --accent-text: #a5b4fc;
142
+ --accent-text-strong: #c7d2fe;
143
+
144
+ --link: #93c5fd;
145
+ --link-mask: #bfdbfe;
146
+
147
+ --danger: #f87171;
148
+
149
+ --success-bg: #064e3b;
150
+ --success-text: #a7f3d0;
151
+ --warn-bg: #78350f;
152
+ --warn-text: #fef3c7;
153
+
154
+ --code-bg: #1e1f22;
155
+ --quote-border: #4e5058;
156
+ --quote-text: #b5bac1;
157
+
158
+ --pill-bg: #313338;
159
+ }