@karyl-chan/ui 0.2.0 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karyl-chan/ui",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Shared Vue UI primitives, composables, and design tokens used by karyl-chan's bot frontend and plugin webuis.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -85,11 +85,9 @@ const emit = defineEmits<{
85
85
  </template>
86
86
 
87
87
  <style scoped>
88
- /* Inner wrapper: provides padding + flex-column layout + gap.
89
- AppModal's body slots are structural-only (no padding), so each leaf
90
- component is responsible for its own spacing. */
88
+ /* Inner wrapper: flex-column layout + gap. Padding comes from
89
+ AppModal's body default. */
91
90
  .acd-body {
92
- padding: 0.9rem 1rem 0.75rem;
93
91
  display: flex;
94
92
  flex-direction: column;
95
93
  gap: 0.75rem;
@@ -13,7 +13,11 @@ import { useDrawer } from '../composables/use-drawer';
13
13
  * The caller owns everything inside the slot — typical usage is to drop
14
14
  * a `<form @submit.prevent>` containing fields + an actions footer
15
15
  * directly into the default slot. AppModal supplies the chrome (backdrop,
16
- * panel, optional title header, close button) only.
16
+ * panel, optional title header, close button) and pads the body with
17
+ * `padding` (default `0.8rem 0.9rem`, both viewport branches). Slot
18
+ * content therefore needs no padding of its own; pass `padding="0"` for
19
+ * full-bleed content (tables, pickers, croppers) that manages its own
20
+ * edges.
17
21
  *
18
22
  * `closeOnBackdrop` / `closeOnEscape` default to true. Escape closure on
19
23
  * desktop is wired locally; on mobile it's handled by useDrawer's escape
@@ -27,11 +31,17 @@ const props = withDefaults(defineProps<{
27
31
  closeOnEscape?: boolean;
28
32
  /** Width of the desktop panel; mobile drawer ignores this. */
29
33
  width?: string;
34
+ /**
35
+ * CSS padding of the scrollable body around the default slot. Use
36
+ * `'0'` for full-bleed content that manages its own edges.
37
+ */
38
+ padding?: string;
30
39
  }>(), {
31
40
  title: '',
32
41
  closeOnBackdrop: true,
33
42
  closeOnEscape: true,
34
- width: 'min(440px, 92vw)'
43
+ width: 'min(440px, 92vw)',
44
+ padding: '0.8rem 0.9rem'
35
45
  });
36
46
 
37
47
  const emit = defineEmits<{
@@ -101,7 +111,7 @@ function onBackdropClick() {
101
111
  <Icon icon="material-symbols:close-rounded" width="18" height="18" />
102
112
  </button>
103
113
  </header>
104
- <div class="app-modal-body">
114
+ <div class="app-modal-body" :style="{ padding }">
105
115
  <slot />
106
116
  </div>
107
117
  </div>
@@ -141,7 +151,7 @@ function onBackdropClick() {
141
151
  <Icon icon="material-symbols:close-rounded" width="18" height="18" />
142
152
  </button>
143
153
  </header>
144
- <div class="app-modal-drawer-body">
154
+ <div class="app-modal-drawer-body" :style="{ padding }">
145
155
  <slot />
146
156
  </div>
147
157
  </div>
@@ -245,9 +255,8 @@ function onBackdropClick() {
245
255
 
246
256
  /* ── Desktop body wrapper ───────────────────────────────────────── */
247
257
  /* Structural wrapper that matches the mobile .app-modal-drawer-body.
248
- Padding is intentionally 0 — callers are responsible for spacing
249
- their slot content. AppConfirmDialog and similar leaf components
250
- supply their own inner padding. */
258
+ Padding comes from the `padding` prop (inline style) on both
259
+ branches so slot content needs none of its own. */
251
260
  .app-modal-body {
252
261
  flex: 1;
253
262
  min-height: 0;
@@ -32,10 +32,19 @@ const props = withDefaults(defineProps<{
32
32
  subTabs?: TabDef[];
33
33
  /** Where sub-tabs render on desktop. Has no effect on mobile. */
34
34
  subLayout?: 'top' | 'sidebar';
35
+ /**
36
+ * Primary-tab appearance (desktop only — mobile always uses the
37
+ * dropdown). `'pill'` (default) = filled/bordered pill buttons;
38
+ * `'underline'` = borderless tabs with an accent underline on the
39
+ * active one (the quieter, header-strip look). Sub-tabs are always
40
+ * underline-styled regardless.
41
+ */
42
+ variant?: 'pill' | 'underline';
35
43
  }>(), {
36
44
  subModelValue: undefined,
37
45
  subTabs: () => [],
38
- subLayout: 'top'
46
+ subLayout: 'top',
47
+ variant: 'pill'
39
48
  });
40
49
 
41
50
  const emit = defineEmits<{
@@ -82,7 +91,7 @@ const subOptions = computed<SelectOption<string>[]>(() =>
82
91
  <!-- Desktop, sub-tabs below primary. -->
83
92
  <div
84
93
  v-else-if="!hasSub || subLayout === 'top'"
85
- class="tabs-root desktop top"
94
+ :class="['tabs-root', 'desktop', 'top', { underline: variant === 'underline' }]"
86
95
  >
87
96
  <nav class="primary-row" role="tablist">
88
97
  <button
@@ -120,7 +129,7 @@ const subOptions = computed<SelectOption<string>[]>(() =>
120
129
  </div>
121
130
 
122
131
  <!-- Desktop, sub-tabs as a vertical sidebar. -->
123
- <div v-else class="tabs-root desktop sidebar">
132
+ <div v-else :class="['tabs-root', 'desktop', 'sidebar', { underline: variant === 'underline' }]">
124
133
  <nav class="primary-row" role="tablist">
125
134
  <button
126
135
  v-for="t in tabs"
@@ -213,6 +222,34 @@ const subOptions = computed<SelectOption<string>[]>(() =>
213
222
  }
214
223
  .tab:disabled { opacity: 0.45; cursor: default; }
215
224
 
225
+ /* Underline variant — borderless primary tabs with an accent underline on
226
+ the active one (mirrors the plugin-aktest nav strip and the sub-tab
227
+ treatment). Railless: the row drops its bottom border so each tab owns
228
+ its own indicator. */
229
+ .tabs-root.underline .primary-row {
230
+ border-bottom: none;
231
+ padding-bottom: 0;
232
+ gap: 0.15rem;
233
+ }
234
+ .tabs-root.underline .tab {
235
+ background: none;
236
+ border: none;
237
+ border-radius: 0;
238
+ border-bottom: 2px solid transparent;
239
+ color: var(--text-muted);
240
+ padding: 0.45rem 0.6rem;
241
+ }
242
+ .tabs-root.underline .tab:hover:not(:disabled) {
243
+ background: none;
244
+ color: var(--text);
245
+ }
246
+ .tabs-root.underline .tab.active {
247
+ background: none;
248
+ border-color: transparent;
249
+ border-bottom-color: var(--accent);
250
+ color: var(--accent-text-strong);
251
+ }
252
+
216
253
  .sub-row {
217
254
  display: flex;
218
255
  gap: 0.6rem;
@@ -36,11 +36,16 @@ const props = withDefaults(defineProps<{
36
36
  * routed AppTabs share a page. Empty (default) = plain `tab` /
37
37
  * `sub`; non-empty = `${name}-tab` / `${name}-sub`. */
38
38
  name?: string;
39
+ /** Primary-tab appearance (desktop only). `'pill'` (default) = filled
40
+ * pill buttons; `'underline'` = borderless with an accent underline on
41
+ * the active tab. Matches AppTabs' `variant`. */
42
+ variant?: 'pill' | 'underline';
39
43
  }>(), {
40
44
  subModelValue: undefined,
41
45
  subTabs: () => [],
42
46
  subLayout: 'top',
43
- name: ''
47
+ name: '',
48
+ variant: 'pill'
44
49
  });
45
50
 
46
51
  const emit = defineEmits<{
@@ -154,7 +159,7 @@ function subLinkTo(key: string) {
154
159
  <!-- Desktop, sub-tabs below primary. -->
155
160
  <div
156
161
  v-else-if="!hasSub || subLayout === 'top'"
157
- class="tabs-root desktop top"
162
+ :class="['tabs-root', 'desktop', 'top', { underline: variant === 'underline' }]"
158
163
  >
159
164
  <nav class="primary-row" role="tablist">
160
165
  <template v-for="t in tabs" :key="t.key">
@@ -212,7 +217,7 @@ function subLinkTo(key: string) {
212
217
  </div>
213
218
 
214
219
  <!-- Desktop, sub-tabs as a vertical sidebar. -->
215
- <div v-else class="tabs-root desktop sidebar">
220
+ <div v-else :class="['tabs-root', 'desktop', 'sidebar', { underline: variant === 'underline' }]">
216
221
  <nav class="primary-row" role="tablist">
217
222
  <template v-for="t in tabs" :key="t.key">
218
223
  <RouterLink
@@ -313,6 +318,31 @@ function subLinkTo(key: string) {
313
318
  }
314
319
  .tab:disabled { opacity: 0.45; cursor: default; }
315
320
 
321
+ /* Underline variant — see AppTabs.vue. Kept in sync with its plain sibling. */
322
+ .tabs-root.underline .primary-row {
323
+ border-bottom: none;
324
+ padding-bottom: 0;
325
+ gap: 0.15rem;
326
+ }
327
+ .tabs-root.underline .tab {
328
+ background: none;
329
+ border: none;
330
+ border-radius: 0;
331
+ border-bottom: 2px solid transparent;
332
+ color: var(--text-muted);
333
+ padding: 0.45rem 0.6rem;
334
+ }
335
+ .tabs-root.underline .tab:hover:not(:disabled) {
336
+ background: none;
337
+ color: var(--text);
338
+ }
339
+ .tabs-root.underline .tab.active {
340
+ background: none;
341
+ border-color: transparent;
342
+ border-bottom-color: var(--accent);
343
+ color: var(--accent-text-strong);
344
+ }
345
+
316
346
  .sub-row {
317
347
  display: flex;
318
348
  gap: 0.6rem;
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Cluster — a horizontal group that wraps, owning the gap between items.
4
+ *
5
+ * For toolbars, tag/badge rows, button groups — anywhere items flow
6
+ * horizontally and should wrap rather than overflow. Defaults to
7
+ * vertically-centered, wrapping items. Pair with <Spacer /> to push a
8
+ * trailing item to the end instead of a one-off `margin-left:auto`:
9
+ *
10
+ * <Cluster>
11
+ * <h2>Title</h2>
12
+ * <Spacer />
13
+ * <AppButton>Action</AppButton>
14
+ * </Cluster>
15
+ *
16
+ * `gap` takes a spacing-scale step (0–8 → --space-*) or a raw CSS length.
17
+ */
18
+ import { computed, type CSSProperties } from "vue";
19
+ import {
20
+ resolveSpace,
21
+ ALIGN_ITEMS,
22
+ JUSTIFY_CONTENT,
23
+ type Align,
24
+ type Justify,
25
+ } from "../lib/space";
26
+
27
+ const props = withDefaults(
28
+ defineProps<{
29
+ gap?: string | number;
30
+ align?: Align;
31
+ justify?: Justify;
32
+ /** Wrap onto multiple lines when items don't fit. On by default —
33
+ * that's the whole point of a cluster. */
34
+ wrap?: boolean;
35
+ }>(),
36
+ {
37
+ gap: 3,
38
+ align: "center",
39
+ justify: "start",
40
+ wrap: true,
41
+ },
42
+ );
43
+
44
+ const style = computed<CSSProperties>(() => ({
45
+ display: "flex",
46
+ flexWrap: props.wrap ? "wrap" : "nowrap",
47
+ gap: resolveSpace(props.gap),
48
+ alignItems: ALIGN_ITEMS[props.align],
49
+ justifyContent: JUSTIFY_CONTENT[props.justify],
50
+ }));
51
+ </script>
52
+
53
+ <template>
54
+ <div class="ui-cluster" :style="style"><slot /></div>
55
+ </template>
56
+
57
+ <style scoped>
58
+ .ui-cluster { min-width: 0; }
59
+ </style>
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Spacer — an empty flex-grow filler that pushes its siblings apart
4
+ * inside a Stack / Cluster (or any flex container), replacing the
5
+ * one-off `margin-left:auto` / `margin-top:auto` idiom:
6
+ *
7
+ * <Cluster>
8
+ * <Title />
9
+ * <Spacer /> <!-- eats the free space -->
10
+ * <Actions />
11
+ * </Cluster>
12
+ *
13
+ * Purely presentational, so it's hidden from the accessibility tree.
14
+ */
15
+ </script>
16
+
17
+ <template>
18
+ <div class="ui-spacer" aria-hidden="true"></div>
19
+ </template>
20
+
21
+ <style scoped>
22
+ .ui-spacer { flex: 1 1 auto; }
23
+ </style>
@@ -0,0 +1,70 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Stack — a flex layout that OWNS the gap between its children.
4
+ *
5
+ * Reach for this instead of hand-authoring `display:flex; flex-direction:
6
+ * column; gap:…` (or per-element margins) in a view's scoped CSS — that
7
+ * pattern appears hundreds of times across the app and is exactly the
8
+ * responsibility a layout primitive should carry, not each component.
9
+ * Because the parent Stack owns the spacing, the components inside it
10
+ * never need (or should have) outer margins of their own.
11
+ *
12
+ * <Stack gap="4"> vertical, 1rem gaps (--space-4)
13
+ * <VoiceCard />
14
+ * <AppTabs ... />
15
+ * <section>…</section>
16
+ * </Stack>
17
+ *
18
+ * `gap` takes a spacing-scale step (0–8 → --space-*) or a raw CSS length.
19
+ */
20
+ import { computed, type CSSProperties } from "vue";
21
+ import {
22
+ resolveSpace,
23
+ ALIGN_ITEMS,
24
+ JUSTIFY_CONTENT,
25
+ type Align,
26
+ type Justify,
27
+ } from "../lib/space";
28
+
29
+ const props = withDefaults(
30
+ defineProps<{
31
+ /** Gap between children: a scale step (0–8) or a raw CSS length. */
32
+ gap?: string | number;
33
+ /** Main-axis direction. Default vertical. */
34
+ direction?: "column" | "row";
35
+ align?: Align;
36
+ justify?: Justify;
37
+ /** Allow children to wrap onto multiple lines. */
38
+ wrap?: boolean;
39
+ /** Render as inline-flex (shrink-to-content) instead of flex. */
40
+ inline?: boolean;
41
+ }>(),
42
+ {
43
+ gap: 4,
44
+ direction: "column",
45
+ align: "stretch",
46
+ justify: "start",
47
+ wrap: false,
48
+ inline: false,
49
+ },
50
+ );
51
+
52
+ const style = computed<CSSProperties>(() => ({
53
+ display: props.inline ? "inline-flex" : "flex",
54
+ flexDirection: props.direction,
55
+ gap: resolveSpace(props.gap),
56
+ alignItems: ALIGN_ITEMS[props.align],
57
+ justifyContent: JUSTIFY_CONTENT[props.justify],
58
+ flexWrap: props.wrap ? "wrap" : "nowrap",
59
+ }));
60
+ </script>
61
+
62
+ <template>
63
+ <div class="ui-stack" :style="style"><slot /></div>
64
+ </template>
65
+
66
+ <style scoped>
67
+ /* min-width:0 lets a column stack's children (e.g. text) truncate instead
68
+ of forcing the stack wider than its parent. */
69
+ .ui-stack { min-width: 0; }
70
+ </style>
package/src/index.ts CHANGED
@@ -7,6 +7,19 @@
7
7
  // import "@karyl-chan/ui/use-drawer.css";
8
8
  // import "@karyl-chan/ui/use-popover.css";
9
9
 
10
+ // ── Layout primitives ────────────────────────────────────────────────────
11
+ // Own the space BETWEEN elements so views (and the components inside them)
12
+ // don't hand-author flex+gap or per-element margins. See lib/space.ts for
13
+ // the `gap` scale.
14
+ export { default as Stack } from "./components/Stack.vue";
15
+ export { default as Cluster } from "./components/Cluster.vue";
16
+ export { default as Spacer } from "./components/Spacer.vue";
17
+ export {
18
+ resolveSpace,
19
+ type Align,
20
+ type Justify,
21
+ } from "./lib/space";
22
+
10
23
  // ── Components ───────────────────────────────────────────────────────────
11
24
  export { default as AppBadge } from "./components/AppBadge.vue";
12
25
  export type { BadgeSize, BadgeTone, BadgeVariant } from "./components/AppBadge.vue";
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Spacing helpers for the layout primitives (Stack / Cluster / Spacer).
3
+ *
4
+ * `gap` and friends take a spacing-scale STEP (0–8, as a number or
5
+ * string) which maps to the corresponding `--space-*` token defined in
6
+ * tokens.css, or a raw CSS length (e.g. `"2px"`, `"1.5rem"`) as an
7
+ * escape hatch. Keeping the scale in tokens.css means the whole app
8
+ * shares one rhythm instead of 200 hand-picked gap values.
9
+ */
10
+
11
+ /** Steps that exist as `--space-N` tokens in tokens.css. */
12
+ const STEPS = new Set(["0", "1", "2", "3", "4", "5", "6", "7", "8"]);
13
+
14
+ /** Resolve a gap/space prop to a CSS length: a scale step → `var(--space-N)`,
15
+ * anything else → the value verbatim (raw CSS length escape hatch). */
16
+ export function resolveSpace(
17
+ value: string | number | undefined,
18
+ fallback = "0",
19
+ ): string {
20
+ if (value === undefined || value === null || value === "") return fallback;
21
+ const s = String(value);
22
+ return STEPS.has(s) ? `var(--space-${s})` : s;
23
+ }
24
+
25
+ export type Align = "start" | "center" | "end" | "stretch" | "baseline";
26
+ export type Justify = "start" | "center" | "end" | "between" | "around";
27
+
28
+ /** Friendly `align` prop → CSS `align-items` value. */
29
+ export const ALIGN_ITEMS: Record<Align, string> = {
30
+ start: "flex-start",
31
+ center: "center",
32
+ end: "flex-end",
33
+ stretch: "stretch",
34
+ baseline: "baseline",
35
+ };
36
+
37
+ /** Friendly `justify` prop → CSS `justify-content` value. */
38
+ export const JUSTIFY_CONTENT: Record<Justify, string> = {
39
+ start: "flex-start",
40
+ center: "center",
41
+ end: "flex-end",
42
+ between: "space-between",
43
+ around: "space-around",
44
+ };
package/src/tokens.css CHANGED
@@ -66,6 +66,21 @@
66
66
  --radius-lg: 8px;
67
67
  --radius-xl: 12px;
68
68
  --radius-pill: 999px;
69
+
70
+ /* Spacing scale — the single source of truth for gaps between
71
+ * elements. Consume via the <Stack> / <Cluster> layout primitives
72
+ * (gap="4") rather than hand-authoring margins/gaps per view, so
73
+ * inter-element rhythm is owned by the layout layer, not by each
74
+ * component. Theme-independent (spacing doesn't change light/dark). */
75
+ --space-0: 0;
76
+ --space-1: 0.25rem;
77
+ --space-2: 0.5rem;
78
+ --space-3: 0.75rem;
79
+ --space-4: 1rem;
80
+ --space-5: 1.5rem;
81
+ --space-6: 2rem;
82
+ --space-7: 3rem;
83
+ --space-8: 4rem;
69
84
  }
70
85
 
71
86
  /* Pin the UA-side color-scheme hint so native widgets (form controls,