@leavepulse/ui 0.12.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/dist/component-names.d.ts +1 -0
  4. package/dist/components/LayoutCanvas.vue.d.ts +28 -0
  5. package/dist/components/LayoutNode.vue.d.ts +40 -0
  6. package/dist/components/LpAlert.vue.d.ts +44 -0
  7. package/dist/components/LpAppShell.vue.d.ts +83 -0
  8. package/dist/components/LpAutocomplete.vue.d.ts +50 -0
  9. package/dist/components/LpAvatar.vue.d.ts +20 -0
  10. package/dist/components/LpBadge.vue.d.ts +47 -0
  11. package/dist/components/LpBreadcrumbs.vue.d.ts +14 -0
  12. package/dist/components/LpButton.vue.d.ts +113 -0
  13. package/dist/components/LpCalendar.vue.d.ts +20 -0
  14. package/dist/components/LpCard.vue.d.ts +29 -0
  15. package/dist/components/LpCheckbox.vue.d.ts +28 -0
  16. package/dist/components/LpCodeBlock.vue.d.ts +39 -0
  17. package/dist/components/LpCommandPalette.vue.d.ts +42 -0
  18. package/dist/components/LpConfirmDialog.vue.d.ts +32 -0
  19. package/dist/components/LpContextMenu.vue.d.ts +30 -0
  20. package/dist/components/LpDatePicker.vue.d.ts +25 -0
  21. package/dist/components/LpDisclosure.vue.d.ts +37 -0
  22. package/dist/components/LpDivider.vue.d.ts +17 -0
  23. package/dist/components/LpDrawer.vue.d.ts +88 -0
  24. package/dist/components/LpDropdownMenu.vue.d.ts +23 -0
  25. package/dist/components/LpEmptyState.vue.d.ts +18 -0
  26. package/dist/components/LpFormField.vue.d.ts +21 -0
  27. package/dist/components/LpIcon.vue.d.ts +8 -0
  28. package/dist/components/LpInfraNode.vue.d.ts +21 -0
  29. package/dist/components/LpInput.vue.d.ts +68 -0
  30. package/dist/components/LpLaneNode.vue.d.ts +24 -0
  31. package/dist/components/LpLink.vue.d.ts +27 -0
  32. package/dist/components/LpLogViewer.vue.d.ts +92 -0
  33. package/dist/components/LpModal.vue.d.ts +43 -0
  34. package/dist/components/LpNotificationBell.vue.d.ts +51 -0
  35. package/dist/components/LpNumberField.vue.d.ts +14 -0
  36. package/dist/components/LpOtpInput.vue.d.ts +27 -0
  37. package/dist/components/LpPagination.vue.d.ts +25 -0
  38. package/dist/components/LpPasswordInput.vue.d.ts +28 -0
  39. package/dist/components/LpPhoneInput.vue.d.ts +37 -0
  40. package/dist/components/LpPopover.vue.d.ts +34 -0
  41. package/dist/components/LpProgress.vue.d.ts +12 -0
  42. package/dist/components/LpRadio.vue.d.ts +18 -0
  43. package/dist/components/LpRadioGroup.vue.d.ts +26 -0
  44. package/dist/components/LpScrollArea.vue.d.ts +24 -0
  45. package/dist/components/LpSegmented.vue.d.ts +24 -0
  46. package/dist/components/LpSelect.vue.d.ts +45 -0
  47. package/dist/components/LpServiceNode.vue.d.ts +14 -0
  48. package/dist/components/LpSidebar.vue.d.ts +85 -0
  49. package/dist/components/LpSidebarNav.vue.d.ts +34 -0
  50. package/dist/components/LpSkeleton.vue.d.ts +6 -0
  51. package/dist/components/LpSlider.vue.d.ts +23 -0
  52. package/dist/components/LpStat.vue.d.ts +22 -0
  53. package/dist/components/LpStepper.vue.d.ts +10 -0
  54. package/dist/components/LpSwitch.vue.d.ts +11 -0
  55. package/dist/components/LpTable.vue.d.ts +63 -0
  56. package/dist/components/LpTableOfContents.vue.d.ts +19 -0
  57. package/dist/components/LpTabs.vue.d.ts +45 -0
  58. package/dist/components/LpTextarea.vue.d.ts +17 -0
  59. package/dist/components/LpThemeSwitcher.vue.d.ts +40 -0
  60. package/dist/components/LpToaster.vue.d.ts +3 -0
  61. package/dist/components/LpTooltip.vue.d.ts +19 -0
  62. package/dist/components/LpTopologyCanvas.vue.d.ts +154 -0
  63. package/dist/components/LpUptimeBar.vue.d.ts +30 -0
  64. package/dist/components/codeHighlight.d.ts +8 -0
  65. package/dist/components/countries-data.d.ts +2 -0
  66. package/dist/components/countries.d.ts +20 -0
  67. package/dist/components/dropdown.d.ts +29 -0
  68. package/dist/components/sidebar.d.ts +18 -0
  69. package/dist/composables/useInputFilter.d.ts +14 -0
  70. package/dist/composables/usePillTransition.d.ts +9 -0
  71. package/dist/composables/useToast.d.ts +39 -0
  72. package/dist/countries-data-9rgqJWHo.js +203 -0
  73. package/dist/icons.d.ts +1 -0
  74. package/dist/index.css +1 -0
  75. package/dist/index.d.ts +100 -0
  76. package/dist/index.js +17600 -0
  77. package/dist/layout/registry.d.ts +26 -0
  78. package/dist/layout/tree.d.ts +75 -0
  79. package/dist/layout/useLayout.d.ts +39 -0
  80. package/dist/theme/config.d.ts +16 -0
  81. package/dist/theme/define.d.ts +12 -0
  82. package/dist/theme/presets.d.ts +20 -0
  83. package/dist/theme/tokens.d.ts +72 -0
  84. package/dist/theme/useTheme.d.ts +36 -0
  85. package/package.json +93 -0
  86. package/src/canvas.css +67 -0
  87. package/src/component-names.ts +65 -0
  88. package/src/components/LayoutCanvas.vue +76 -0
  89. package/src/components/LayoutNode.vue +248 -0
  90. package/src/components/LpAlert.vue +59 -0
  91. package/src/components/LpAppShell.vue +210 -0
  92. package/src/components/LpAutocomplete.vue +244 -0
  93. package/src/components/LpAvatar.vue +67 -0
  94. package/src/components/LpBadge.vue +32 -0
  95. package/src/components/LpBreadcrumbs.vue +35 -0
  96. package/src/components/LpButton.vue +111 -0
  97. package/src/components/LpCalendar.vue +134 -0
  98. package/src/components/LpCard.vue +71 -0
  99. package/src/components/LpCheckbox.vue +44 -0
  100. package/src/components/LpCodeBlock.vue +298 -0
  101. package/src/components/LpCommandPalette.vue +323 -0
  102. package/src/components/LpConfirmDialog.vue +49 -0
  103. package/src/components/LpContextMenu.vue +111 -0
  104. package/src/components/LpDatePicker.vue +117 -0
  105. package/src/components/LpDisclosure.vue +92 -0
  106. package/src/components/LpDivider.vue +20 -0
  107. package/src/components/LpDrawer.vue +402 -0
  108. package/src/components/LpDropdownMenu.vue +54 -0
  109. package/src/components/LpEmptyState.vue +21 -0
  110. package/src/components/LpFormField.vue +31 -0
  111. package/src/components/LpIcon.vue +49 -0
  112. package/src/components/LpInfraNode.vue +108 -0
  113. package/src/components/LpInput.vue +147 -0
  114. package/src/components/LpLaneNode.vue +48 -0
  115. package/src/components/LpLink.vue +48 -0
  116. package/src/components/LpLogViewer.vue +569 -0
  117. package/src/components/LpModal.vue +123 -0
  118. package/src/components/LpNotificationBell.vue +238 -0
  119. package/src/components/LpNumberField.vue +56 -0
  120. package/src/components/LpOtpInput.vue +81 -0
  121. package/src/components/LpPagination.vue +165 -0
  122. package/src/components/LpPasswordInput.vue +50 -0
  123. package/src/components/LpPhoneInput.vue +241 -0
  124. package/src/components/LpPopover.vue +42 -0
  125. package/src/components/LpProgress.vue +30 -0
  126. package/src/components/LpRadio.vue +28 -0
  127. package/src/components/LpRadioGroup.vue +43 -0
  128. package/src/components/LpScrollArea.vue +82 -0
  129. package/src/components/LpSegmented.vue +83 -0
  130. package/src/components/LpSelect.vue +0 -0
  131. package/src/components/LpServiceNode.vue +57 -0
  132. package/src/components/LpSidebar.vue +200 -0
  133. package/src/components/LpSidebarNav.vue +118 -0
  134. package/src/components/LpSkeleton.vue +16 -0
  135. package/src/components/LpSlider.vue +53 -0
  136. package/src/components/LpStat.vue +90 -0
  137. package/src/components/LpStepper.vue +46 -0
  138. package/src/components/LpSwitch.vue +19 -0
  139. package/src/components/LpTable.vue +252 -0
  140. package/src/components/LpTableOfContents.vue +183 -0
  141. package/src/components/LpTabs.vue +120 -0
  142. package/src/components/LpTextarea.vue +39 -0
  143. package/src/components/LpThemeSwitcher.vue +168 -0
  144. package/src/components/LpToaster.vue +216 -0
  145. package/src/components/LpTooltip.vue +30 -0
  146. package/src/components/LpTopologyCanvas.vue +370 -0
  147. package/src/components/LpUptimeBar.vue +126 -0
  148. package/src/components/codeHighlight.ts +137 -0
  149. package/src/components/countries-data.ts +207 -0
  150. package/src/components/countries.ts +67 -0
  151. package/src/components/dropdown.ts +64 -0
  152. package/src/components/sidebar.ts +26 -0
  153. package/src/composables/useInputFilter.ts +89 -0
  154. package/src/composables/usePillTransition.ts +19 -0
  155. package/src/composables/useToast.ts +92 -0
  156. package/src/icons.ts +11 -0
  157. package/src/index.css +21 -0
  158. package/src/index.ts +146 -0
  159. package/src/layout/registry.ts +44 -0
  160. package/src/layout/tree.ts +288 -0
  161. package/src/layout/useLayout.ts +134 -0
  162. package/src/nuxt.ts +22 -0
  163. package/src/theme/config.ts +48 -0
  164. package/src/theme/define.ts +50 -0
  165. package/src/theme/presets.ts +279 -0
  166. package/src/theme/tokens.ts +151 -0
  167. package/src/theme/useTheme.ts +288 -0
  168. package/src/tokens/scrollbar.css +138 -0
  169. package/src/tokens/tokens.css +333 -0
  170. package/src/tokens/utilities.css +23 -0
@@ -0,0 +1,108 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Custom Vue Flow node for LpTopologyCanvas: an infra host/service card on kit
4
+ * tokens. Not used directly by consumers — LpTopologyCanvas registers it as the
5
+ * `infra` node type. Exported so apps can override the renderer if needed.
6
+ *
7
+ * Styled with Tailwind utilities (no scoped <style>) so the consumer's
8
+ * `@source` over the kit emits these classes — scoped CSS in a published
9
+ * component doesn't reach apps that only import the kit's token css.
10
+ */
11
+ import { Handle, Position } from "@vue-flow/core"
12
+ import { computed } from "vue"
13
+
14
+ export interface InfraNodeData {
15
+ name: string
16
+ role: string
17
+ overlay?: string
18
+ online?: boolean
19
+ kind?: string
20
+ project?: string
21
+ /** Control-plane firewall rule count on this host (badge when > 0). */
22
+ firewallCount?: number
23
+ /** Service count on this host (badge when defined — services toggle on). */
24
+ serviceCount?: number
25
+ /** Fade the node back (search miss / not part of the focused node's graph). */
26
+ dimmed?: boolean
27
+ }
28
+
29
+ const props = defineProps<{ data: InfraNodeData; selected?: boolean }>()
30
+
31
+ const roleVar: Record<string, string> = {
32
+ hypervisor: "var(--color-accent)",
33
+ game: "var(--color-action)",
34
+ router: "var(--color-muted-strong)",
35
+ edge: "var(--color-brand)",
36
+ }
37
+ const accent = computed(() => roleVar[props.data.role] ?? "var(--color-line-strong)")
38
+ const roleIcon: Record<string, string> = {
39
+ hypervisor: "▣",
40
+ game: "◈",
41
+ router: "⇄",
42
+ edge: "☁",
43
+ }
44
+ const icon = computed(() => roleIcon[props.data.role] ?? "●")
45
+ </script>
46
+
47
+ <template>
48
+ <div
49
+ class="w-[168px] overflow-hidden rounded-card border bg-surface-raised shadow-panel transition-[border-color,box-shadow]"
50
+ :class="selected ? 'border-[var(--accent)]' : 'border-line'"
51
+ :style="{
52
+ '--accent': accent,
53
+ opacity: data.dimmed ? 0.3 : data.online === false ? 0.5 : 1,
54
+ ...(selected
55
+ ? { boxShadow: '0 0 0 2px color-mix(in srgb, var(--accent) 40%, transparent), var(--shadow-panel)' }
56
+ : {}),
57
+ }"
58
+ >
59
+ <Handle
60
+ type="target"
61
+ :position="Position.Left"
62
+ class="!size-2 !border-2 !border-[var(--accent)] !bg-surface"
63
+ />
64
+ <Handle
65
+ type="source"
66
+ :position="Position.Right"
67
+ class="!size-2 !border-2 !border-[var(--accent)] !bg-surface"
68
+ />
69
+
70
+ <div class="h-[3px] bg-[var(--accent)]" />
71
+ <div class="px-[11px] pb-2.5 pt-[9px]">
72
+ <div class="flex items-center gap-[7px]">
73
+ <span class="text-[13px] text-[var(--accent)]">{{ icon }}</span>
74
+ <span class="flex-1 truncate text-[13px] font-semibold text-ink">{{ data.name }}</span>
75
+ <span
76
+ v-if="data.online !== undefined"
77
+ class="size-[7px] rounded-full"
78
+ :class="data.online ? 'bg-action shadow-[0_0_6px_var(--color-action)]' : 'bg-danger'"
79
+ />
80
+ </div>
81
+ <div class="mt-1.5 flex items-center justify-between">
82
+ <span class="text-[10px] font-semibold uppercase tracking-wide text-[var(--accent)]">
83
+ {{ data.role }}
84
+ </span>
85
+ <span v-if="data.overlay" class="font-mono text-[10px] text-muted">{{ data.overlay }}</span>
86
+ </div>
87
+ <div
88
+ v-if="data.firewallCount || data.serviceCount !== undefined"
89
+ class="mt-1.5 flex items-center gap-1.5"
90
+ >
91
+ <span
92
+ v-if="data.firewallCount"
93
+ class="inline-flex items-center gap-1 rounded-pill bg-danger-soft px-1.5 py-0.5 text-[9px] font-medium text-danger"
94
+ :title="`${data.firewallCount} firewall rule(s)`"
95
+ >
96
+ <span>⛨</span>{{ data.firewallCount }}
97
+ </span>
98
+ <span
99
+ v-if="data.serviceCount !== undefined"
100
+ class="inline-flex items-center gap-1 rounded-pill bg-surface-soft px-1.5 py-0.5 text-[9px] font-medium text-muted"
101
+ :title="`${data.serviceCount} service(s)`"
102
+ >
103
+ <span>◇</span>{{ data.serviceCount }}
104
+ </span>
105
+ </div>
106
+ </div>
107
+ </div>
108
+ </template>
@@ -0,0 +1,147 @@
1
+ <script lang="ts">
2
+ export default { inheritAttrs: false }
3
+ </script>
4
+
5
+ <script setup lang="ts">
6
+ import { tv, type VariantProps } from "tailwind-variants"
7
+ import { computed, ref, useAttrs, useSlots } from "vue"
8
+ import { useInputFilter } from "../composables/useInputFilter"
9
+ import LpFormField from "./LpFormField.vue"
10
+ import LpIcon from "./LpIcon.vue"
11
+
12
+ // Single source of truth for the field shell — bare input and adorned input
13
+ // (e.g. LpPasswordInput) both use it, so the look never drifts.
14
+ const shell = tv({
15
+ base: [
16
+ "flex w-full items-center bg-surface-soft text-ink",
17
+ "rounded-control border border-line",
18
+ "transition-colors duration-[var(--duration-fast)]",
19
+ "focus-within:border-brand focus-within:ring-2 focus-within:ring-ring",
20
+ "has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-55",
21
+ ],
22
+ variants: {
23
+ size: {
24
+ sm: "h-(--size-control-sm) text-xs",
25
+ md: "h-(--size-control-md) text-sm",
26
+ lg: "h-(--size-control-lg) text-sm",
27
+ },
28
+ invalid: {
29
+ true: "border-danger focus-within:border-danger focus-within:ring-danger-soft",
30
+ },
31
+ },
32
+ defaultVariants: { size: "md" },
33
+ })
34
+
35
+ const padX = { sm: "px-2.5", md: "px-3", lg: "px-3.5" } as const
36
+
37
+ type InputVariants = VariantProps<typeof shell>
38
+
39
+ const props = withDefaults(
40
+ defineProps<{
41
+ modelValue?: string
42
+ size?: InputVariants["size"]
43
+ invalid?: boolean
44
+ placeholder?: string
45
+ type?: string
46
+ disabled?: boolean
47
+ // Optional label/hint/error wraps the field in LpFormField. `icon` renders
48
+ // a leading icon (ignored when the `leading` slot is provided).
49
+ label?: string
50
+ hint?: string
51
+ error?: string
52
+ icon?: string
53
+ /**
54
+ * RegExp/string the whole value must match to be valid. Validation only —
55
+ * never blocks typing; a non-matching non-empty value shows the invalid
56
+ * style + aria-invalid. A bare string is anchored, like HTML `pattern`.
57
+ */
58
+ pattern?: RegExp | string
59
+ /**
60
+ * One-char RegExp of allowed characters (e.g. /[0-9]/). Hard-blocks
61
+ * disallowed input/paste at the source — cursor- and IME-safe.
62
+ */
63
+ restrict?: RegExp
64
+ }>(),
65
+ { type: "text", size: "md" },
66
+ )
67
+
68
+ defineEmits<{ (e: "update:modelValue", value: string): void }>()
69
+
70
+ const { isInvalid, onBeforeInput, onPaste } = useInputFilter({
71
+ pattern: () => props.pattern,
72
+ restrict: () => props.restrict,
73
+ })
74
+
75
+ // Pattern failure folds into the same invalid state as the explicit prop/error.
76
+ const patternInvalid = computed(() => isInvalid(props.modelValue))
77
+
78
+ const slots = useSlots()
79
+ const attrs = useAttrs()
80
+ const hasLeading = computed(() => !!slots.leading || !!props.icon)
81
+ const hasTrailing = computed(() => !!slots.trailing)
82
+ const hasField = computed(
83
+ () => !!props.label || !!props.hint || !!props.error,
84
+ )
85
+ const isInvalidState = computed(
86
+ () => props.invalid || !!props.error || patternInvalid.value,
87
+ )
88
+ const shellClass = computed(() =>
89
+ shell({ size: props.size, invalid: isInvalidState.value }),
90
+ )
91
+
92
+ // class/style belong to the wrapper (so `<LpInput class="...">` lays out the
93
+ // whole field); every other attr (autocomplete, name, @keyup, inputmode…) goes
94
+ // to the real <input>.
95
+ const rootClass = computed(() => attrs.class)
96
+ const rootStyle = computed(() => attrs.style)
97
+ const inputAttrs = computed(() => {
98
+ const { class: _c, style: _s, ...rest } = attrs
99
+ return rest
100
+ })
101
+
102
+ const inputEl = ref<HTMLInputElement | null>(null)
103
+ defineExpose({
104
+ focus: (options?: FocusOptions) => inputEl.value?.focus(options),
105
+ blur: () => inputEl.value?.blur(),
106
+ select: () => inputEl.value?.select(),
107
+ input: inputEl,
108
+ })
109
+ </script>
110
+
111
+ <template>
112
+ <component
113
+ :is="hasField ? LpFormField : 'div'"
114
+ :label="hasField ? label : undefined"
115
+ :hint="hasField ? hint : undefined"
116
+ :error="hasField ? error : undefined"
117
+ :class="hasField ? rootClass : undefined"
118
+ :style="hasField ? rootStyle : undefined"
119
+ >
120
+ <div
121
+ :class="[shellClass, hasLeading ? 'pl-2.5' : '', hasTrailing ? 'pr-1.5' : '', hasField ? '' : rootClass]"
122
+ :style="hasField ? undefined : rootStyle"
123
+ >
124
+ <span v-if="hasLeading" class="flex shrink-0 items-center text-muted">
125
+ <slot name="leading"><LpIcon v-if="icon" :name="icon" :size="16" /></slot>
126
+ </span>
127
+ <input
128
+ ref="inputEl"
129
+ v-bind="inputAttrs"
130
+ :type="type"
131
+ :value="modelValue"
132
+ :placeholder="placeholder"
133
+ :disabled="disabled"
134
+ :aria-invalid="isInvalidState || undefined"
135
+ :class="[
136
+ 'min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted',
137
+ hasLeading ? 'pl-2' : padX[size ?? 'md'],
138
+ hasTrailing ? '' : padX[size ?? 'md'],
139
+ ]"
140
+ @beforeinput="onBeforeInput"
141
+ @paste="onPaste"
142
+ @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
143
+ />
144
+ <slot name="trailing" />
145
+ </div>
146
+ </component>
147
+ </template>
@@ -0,0 +1,48 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Custom Vue Flow node for a project SWIMLANE on LpTopologyCanvas: a sized
4
+ * background zone that groups the nodes of one project (overlay network). It is
5
+ * a NODE (not an overlay) so Vue Flow transforms it with pan/zoom for free.
6
+ *
7
+ * Purely decorative: the lane never intercepts pointer events (pan/drag pass
8
+ * through to the canvas), is non-selectable / non-draggable (set on the node
9
+ * object by the canvas), and sits below real nodes via a low z-index. The label
10
+ * tab stays clickable-looking but is inert. Tailwind utilities only so styles
11
+ * reach consumer apps via @source.
12
+ */
13
+ export interface LaneNodeData {
14
+ label: string
15
+ /** Optional accent colour token for the lane border + label. */
16
+ accent?: string
17
+ width: number
18
+ height: number
19
+ }
20
+
21
+ const props = defineProps<{ data: LaneNodeData }>()
22
+ </script>
23
+
24
+ <template>
25
+ <div
26
+ class="pointer-events-none rounded-card border border-dashed"
27
+ :style="{
28
+ width: `${props.data.width}px`,
29
+ height: `${props.data.height}px`,
30
+ borderColor: props.data.accent ?? 'var(--color-line)',
31
+ background:
32
+ 'color-mix(in srgb, ' +
33
+ (props.data.accent ?? 'var(--color-line-strong)') +
34
+ ' 6%, transparent)',
35
+ }"
36
+ >
37
+ <div
38
+ class="inline-flex translate-y-[-50%] items-center gap-1.5 rounded-pill border bg-surface px-2.5 py-0.5 text-[11px] font-semibold tracking-wide"
39
+ :style="{
40
+ borderColor: props.data.accent ?? 'var(--color-line)',
41
+ color: props.data.accent ?? 'var(--color-muted-strong)',
42
+ marginLeft: '14px',
43
+ }"
44
+ >
45
+ {{ props.data.label }}
46
+ </div>
47
+ </div>
48
+ </template>
@@ -0,0 +1,48 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue"
3
+
4
+ const props = withDefaults(
5
+ defineProps<{
6
+ href?: string
7
+ external?: boolean
8
+ muted?: boolean
9
+ /**
10
+ * "default" — plain hover underline.
11
+ * "reveal" — an underline bar that grows in from the left on hover
12
+ * (origin-left scale-x 0→1). The LeavePulse landing "monitoring" link feel;
13
+ * good for inline prose links and nav items that want a bit of motion.
14
+ */
15
+ variant?: "default" | "reveal"
16
+ }>(),
17
+ { variant: "default" },
18
+ )
19
+
20
+ const rel = computed(() => (props.external ? "noopener noreferrer" : undefined))
21
+ const target = computed(() => (props.external ? "_blank" : undefined))
22
+
23
+ const colorClass = computed(() =>
24
+ props.muted ? "text-muted hover:text-ink" : "text-brand hover:text-brand-hover",
25
+ )
26
+
27
+ // The reveal bar: an ::after pseudo-element pinned to the baseline, full-width
28
+ // but scaled to 0 on the x-axis from the left, growing to 1 on hover/focus. The
29
+ // bar colour follows the link colour (brand, or ink when muted) so it re-skins
30
+ // with the theme.
31
+ const revealClass =
32
+ "relative after:absolute after:inset-x-0 after:-bottom-0.5 after:h-px after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-[var(--duration-medium)] hover:after:scale-x-100 focus-visible:after:scale-x-100"
33
+ </script>
34
+
35
+ <template>
36
+ <a
37
+ :href="href"
38
+ :target="target"
39
+ :rel="rel"
40
+ class="inline-flex items-center gap-1 underline-offset-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
41
+ :class="[
42
+ colorClass,
43
+ variant === 'reveal' ? revealClass : 'hover:underline',
44
+ ]"
45
+ >
46
+ <slot />
47
+ </a>
48
+ </template>