@leavepulse/ui 0.27.0 → 0.28.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.
@@ -14,7 +14,15 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
14
14
  },
15
15
  setup(__props) {
16
16
  const card = tv({
17
- base: "rounded-card border bg-surface-raised",
17
+ // break-words: a card is where text with no spaces in it ends up (a
18
+ // hostname, a URL, a container id, a stack frame), and an unbreakable run
19
+ // otherwise pushes straight out through the border, over whatever sits
20
+ // beside it. Set here rather than on the components that go inside, so that
21
+ // a paragraph written by hand behaves the same as one a kit component
22
+ // renders: two lines that look identical must not wrap differently.
23
+ // Anything that genuinely must not break (a code block, a log line) sets its
24
+ // own overflow and scrolls instead.
25
+ base: "rounded-card border bg-surface-raised break-words",
18
26
  variants: {
19
27
  variant: {
20
28
  // raised opts into the skin painter (.lp-skin-panel) so it follows the
@@ -1,5 +1,5 @@
1
1
  import { defineComponent, computed, openBlock, createBlock, resolveDynamicComponent, normalizeClass, withCtx, renderSlot } from "vue";
2
- const revealClass = "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";
2
+ const REVEAL_BASE = "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)] after:ease-[var(--ease-emphasized)] motion-reduce:after:transition-none";
3
3
  const _sfc_main = /* @__PURE__ */ defineComponent({
4
4
  __name: "LpLink",
5
5
  props: {
@@ -8,7 +8,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
8
8
  muted: { type: Boolean },
9
9
  tone: {},
10
10
  as: { default: "a" },
11
- variant: { default: "default" }
11
+ variant: { default: "default" },
12
+ revealOn: { default: "self" }
12
13
  },
13
14
  setup(__props) {
14
15
  const props = __props;
@@ -28,6 +29,13 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
28
29
  return "text-brand hover:text-brand-hover";
29
30
  }
30
31
  });
32
+ const REVEAL_TRIGGER = {
33
+ self: "hover:after:scale-x-100 focus-visible:after:scale-x-100",
34
+ group: "group-hover:after:scale-x-100 group-focus-visible:after:scale-x-100 focus-visible:after:scale-x-100"
35
+ };
36
+ const revealClass = computed(
37
+ () => `${REVEAL_BASE} ${REVEAL_TRIGGER[props.revealOn]}`
38
+ );
31
39
  return (_ctx, _cache) => {
32
40
  return openBlock(), createBlock(resolveDynamicComponent(__props.as), {
33
41
  href: isAnchor.value ? __props.href : void 0,
@@ -35,7 +43,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
35
43
  rel: rel.value,
36
44
  class: normalizeClass(["inline-flex items-center gap-1 underline-offset-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring", [
37
45
  colorClass.value,
38
- __props.variant === "reveal" ? revealClass : __props.variant === "underline" ? "underline decoration-current/40 hover:decoration-current" : "hover:underline"
46
+ __props.variant === "reveal" ? revealClass.value : __props.variant === "underline" ? "underline decoration-current/40 hover:decoration-current" : "hover:underline"
39
47
  ]])
40
48
  }, {
41
49
  default: withCtx(() => [
@@ -1,7 +1,7 @@
1
- import { defineComponent, computed, openBlock, createElementBlock, normalizeClass, createElementVNode, createBlock, createCommentVNode, resolveDynamicComponent, withCtx, renderSlot, createTextVNode, toDisplayString } from "vue";
1
+ import { defineComponent, useSlots, computed, openBlock, createElementBlock, normalizeClass, createElementVNode, createBlock, createCommentVNode, resolveDynamicComponent, withCtx, renderSlot, createTextVNode, toDisplayString } from "vue";
2
2
  import { tv } from "tailwind-variants";
3
3
  import { _ as _sfc_main$1 } from "./LpIcon-CCnX5_2j.js";
4
- const _hoisted_1 = { class: "min-w-0" };
4
+ const _hoisted_1 = { class: "min-w-0 flex-1 break-words" };
5
5
  const _hoisted_2 = { class: "flex flex-wrap items-center gap-2" };
6
6
  const _sfc_main = /* @__PURE__ */ defineComponent({
7
7
  __name: "LpSection",
@@ -15,14 +15,21 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
15
15
  setup(__props) {
16
16
  const section = tv({
17
17
  slots: {
18
- // items-start, not items-center: once a description is present the actions
19
- // must stay level with the TITLE, not drift to the middle of the block.
20
- root: "flex flex-wrap items-start justify-between gap-x-4 gap-y-2",
18
+ root: "flex flex-wrap justify-between gap-x-4 gap-y-2",
21
19
  title: "font-semibold text-ink",
22
20
  description: "mt-1 text-sm text-muted",
23
21
  actions: "flex shrink-0 flex-wrap items-center gap-2"
24
22
  },
25
23
  variants: {
24
+ // Where the actions sit against a two-line block. With a description the
25
+ // heading is the top line, so the actions belong level with THAT rather
26
+ // than floating to the middle of the pair. With no description there is
27
+ // only one line, and pinning to the top instead misaligns anything taller
28
+ // than the text (an input, a segmented control) by a couple of pixels.
29
+ described: {
30
+ true: { root: "items-start" },
31
+ false: { root: "items-center" }
32
+ },
26
33
  level: {
27
34
  sm: { title: "text-sm" },
28
35
  md: { title: "text-base" },
@@ -30,10 +37,14 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
30
37
  xl: { title: "text-xl tracking-tight" }
31
38
  }
32
39
  },
33
- defaultVariants: { level: "md" }
40
+ defaultVariants: { level: "md", described: false }
34
41
  });
35
42
  const props = __props;
36
- const classes = computed(() => section({ level: props.level }));
43
+ const slots = useSlots();
44
+ const hasDescription = computed(() => !!props.description || !!slots.description);
45
+ const classes = computed(
46
+ () => section({ level: props.level, described: hasDescription.value })
47
+ );
37
48
  return (_ctx, _cache) => {
38
49
  return openBlock(), createElementBlock("div", {
39
50
  class: normalizeClass(classes.value.root())
@@ -58,10 +69,14 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
58
69
  }, 8, ["class"])),
59
70
  renderSlot(_ctx.$slots, "meta")
60
71
  ]),
61
- __props.description ? (openBlock(), createElementBlock("p", {
72
+ hasDescription.value ? (openBlock(), createElementBlock("p", {
62
73
  key: 0,
63
74
  class: normalizeClass(classes.value.description())
64
- }, toDisplayString(__props.description), 3)) : createCommentVNode("", true)
75
+ }, [
76
+ renderSlot(_ctx.$slots, "description", {}, () => [
77
+ createTextVNode(toDisplayString(__props.description), 1)
78
+ ])
79
+ ], 2)) : createCommentVNode("", true)
65
80
  ]),
66
81
  _ctx.$slots.actions ? (openBlock(), createElementBlock("div", {
67
82
  key: 0,
@@ -12,7 +12,7 @@ import '../assets/LpSkeleton-D7xU8IPV.css';const _sfc_main = /* @__PURE__ */ def
12
12
  "aria-hidden": "true",
13
13
  class: normalizeClass(["lp-skeleton", [
14
14
  // `rounded` describes the PLACEHOLDER's own shape, so it only applies to
15
- // the bare form. A wrapper is invisible — rounding it would clip the
15
+ // the bare form. A wrapper is invisible, so rounding it would clip the
16
16
  // layout inside without anything being drawn to justify the corner.
17
17
  isBlock.value ? [
18
18
  "lp-skeleton-item bg-surface-soft",
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpCard-BmrTvWOe.js";
1
+ import { _ as _sfc_main } from "../chunks/LpCard-gu11ZmSt.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -28,6 +28,16 @@ type __VLS_Props = {
28
28
  * good for inline prose links and nav items that want a bit of motion.
29
29
  */
30
30
  variant?: "default" | "underline" | "reveal";
31
+ /**
32
+ * Which hover draws the `reveal` bar.
33
+ *
34
+ * "self" — the link's own hover (default), for an inline prose link.
35
+ * "group" — the hover of an ancestor marked `group`, for a link inside a
36
+ * row or card that is itself the click target. The row lights up and the
37
+ * name sweeps with it, instead of the bar waiting for the pointer to
38
+ * cross the text exactly.
39
+ */
40
+ revealOn?: "self" | "group";
31
41
  };
32
42
  declare var __VLS_8: {};
33
43
  type __VLS_Slots = {} & {
@@ -36,6 +46,7 @@ type __VLS_Slots = {} & {
36
46
  declare const __VLS_base: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {
37
47
  as: string | Component;
38
48
  variant: "default" | "underline" | "reveal";
49
+ revealOn: "self" | "group";
39
50
  }, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
40
51
  declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
41
52
  declare const _default: typeof __VLS_export;
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpLink-Bu01SZzB.js";
1
+ import { _ as _sfc_main } from "../chunks/LpLink-BHZ1CLlF.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,5 +1,13 @@
1
1
  import { VariantProps } from 'tailwind-variants';
2
2
  declare const section: import('tailwind-variants').TVReturnType<{
3
+ described: {
4
+ true: {
5
+ root: string;
6
+ };
7
+ false: {
8
+ root: string;
9
+ };
10
+ };
3
11
  level: {
4
12
  sm: {
5
13
  title: string;
@@ -20,6 +28,14 @@ declare const section: import('tailwind-variants').TVReturnType<{
20
28
  description: string;
21
29
  actions: string;
22
30
  }, undefined, {
31
+ described: {
32
+ true: {
33
+ root: string;
34
+ };
35
+ false: {
36
+ root: string;
37
+ };
38
+ };
23
39
  level: {
24
40
  sm: {
25
41
  title: string;
@@ -40,6 +56,14 @@ declare const section: import('tailwind-variants').TVReturnType<{
40
56
  description: string;
41
57
  actions: string;
42
58
  }, import('tailwind-variants').TVReturnType<{
59
+ described: {
60
+ true: {
61
+ root: string;
62
+ };
63
+ false: {
64
+ root: string;
65
+ };
66
+ };
43
67
  level: {
44
68
  sm: {
45
69
  title: string;
@@ -67,7 +91,7 @@ type __VLS_Props = {
67
91
  description?: string;
68
92
  /** Iconify name shown before the title, e.g. "lucide:server". */
69
93
  icon?: string;
70
- /** Visual size. Independent of `as` — see the note above. */
94
+ /** Visual size. Independent of `as`, see the note above. */
71
95
  level?: SectionVariants["level"];
72
96
  /** The heading element to render. Pick it for the document outline. */
73
97
  as?: "h1" | "h2" | "h3" | "h4" | "p";
@@ -75,9 +99,11 @@ type __VLS_Props = {
75
99
  type __VLS_Slots = {
76
100
  /** Title markup, when a plain string is not enough. */
77
101
  title?: () => unknown;
102
+ /** Description markup, when a plain string is not enough. */
103
+ description?: () => unknown;
78
104
  /** Right-aligned controls: buttons, filters, a count. */
79
105
  actions?: () => unknown;
80
- /** Extra content beside the title — a badge, a count, a status dot. */
106
+ /** Extra content beside the title: a badge, a count, a status dot. */
81
107
  meta?: () => unknown;
82
108
  };
83
109
  declare const __VLS_base: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpSection-BIvwZyE1.js";
1
+ import { _ as _sfc_main } from "../chunks/LpSection-aM6J_u51.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpSkeleton-DpXLDcQm.js";
1
+ import { _ as _sfc_main } from "../chunks/LpSkeleton-CTI-vBgW.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import { _ as _8 } from "./chunks/LpBadge-BPodVcJa.js";
10
10
  import { _ as _9 } from "./chunks/LpBreadcrumbs-5uvViCXi.js";
11
11
  import { _ as _10 } from "./chunks/LpButton-5VXXCjwF.js";
12
12
  import { _ as _11 } from "./chunks/LpCalendar-B7ZL0ty3.js";
13
- import { _ as _12 } from "./chunks/LpCard-BmrTvWOe.js";
13
+ import { _ as _12 } from "./chunks/LpCard-gu11ZmSt.js";
14
14
  import { _ as _13 } from "./chunks/LpChart-tNXs1ttr.js";
15
15
  import { CHART_PALETTE, axisTicks, formatTick, niceScale, resolveSlices } from "./components/chart.js";
16
16
  import { _ as _14 } from "./chunks/LpCheckbox-BO0zuuPh.js";
@@ -32,7 +32,7 @@ import { _ as _27 } from "./chunks/LpInfraNode-qIL3l7IO.js";
32
32
  import { _ as _28 } from "./chunks/LpInput-jwqXlAy0.js";
33
33
  import { _ as _29 } from "./chunks/LpLightbox-Ci35Y_xP.js";
34
34
  import { fileNameOf } from "./components/lightbox.js";
35
- import { _ as _30 } from "./chunks/LpLink-Bu01SZzB.js";
35
+ import { _ as _30 } from "./chunks/LpLink-BHZ1CLlF.js";
36
36
  import { _ as _31 } from "./chunks/LpLogViewer-CcQ3s3Fp.js";
37
37
  import { _ as _32 } from "./chunks/LpModal-D_o-56To.js";
38
38
  import { _ as _33 } from "./chunks/LpNotificationBell-Dvu-b6Vn.js";
@@ -51,13 +51,13 @@ import { _ as _44 } from "./chunks/LpRadioGroup-B4yDTmi6.js";
51
51
  import { _ as _45 } from "./chunks/LpRollingText-CM5gkSeF.js";
52
52
  import { _ as _46 } from "./chunks/LpScrollArea-D8xyKXcF.js";
53
53
  import { _ as _47 } from "./chunks/LpSegmented-BmpanyAo.js";
54
- import { _ as _48 } from "./chunks/LpSection-BIvwZyE1.js";
54
+ import { _ as _48 } from "./chunks/LpSection-aM6J_u51.js";
55
55
  import { _ as _49 } from "./chunks/LpSelect-Cw8-gL5F.js";
56
56
  import { _ as _50 } from "./chunks/LpServiceNode-D8uJ8sGk.js";
57
57
  import { _ as _51 } from "./chunks/LpShift-DXNsauQZ.js";
58
58
  import { default as default3 } from "./components/LpShortcutsDialog.vue.js";
59
59
  import { _ as _52 } from "./chunks/LpSidebar-7Vpy2IJc.js";
60
- import { _ as _53 } from "./chunks/LpSkeleton-DpXLDcQm.js";
60
+ import { _ as _53 } from "./chunks/LpSkeleton-CTI-vBgW.js";
61
61
  import { _ as _54 } from "./chunks/LpSlider-B1SF3pRV.js";
62
62
  import { _ as _55 } from "./chunks/LpSparkline-C6xQ2dwv.js";
63
63
  import { _ as _56 } from "./chunks/LpStat-CDanpzU0.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -11,7 +11,15 @@ import { computed } from "vue"
11
11
  import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
12
12
 
13
13
  const card = tv({
14
- base: "rounded-card border bg-surface-raised",
14
+ // break-words: a card is where text with no spaces in it ends up (a
15
+ // hostname, a URL, a container id, a stack frame), and an unbreakable run
16
+ // otherwise pushes straight out through the border, over whatever sits
17
+ // beside it. Set here rather than on the components that go inside, so that
18
+ // a paragraph written by hand behaves the same as one a kit component
19
+ // renders: two lines that look identical must not wrap differently.
20
+ // Anything that genuinely must not break (a code block, a log line) sets its
21
+ // own overflow and scrolls instead.
22
+ base: "rounded-card border bg-surface-raised break-words",
15
23
  variants: {
16
24
  variant: {
17
25
  // raised opts into the skin painter (.lp-skin-panel) so it follows the
@@ -31,8 +31,18 @@ const props = withDefaults(
31
31
  * good for inline prose links and nav items that want a bit of motion.
32
32
  */
33
33
  variant?: "default" | "underline" | "reveal"
34
+ /**
35
+ * Which hover draws the `reveal` bar.
36
+ *
37
+ * "self" — the link's own hover (default), for an inline prose link.
38
+ * "group" — the hover of an ancestor marked `group`, for a link inside a
39
+ * row or card that is itself the click target. The row lights up and the
40
+ * name sweeps with it, instead of the bar waiting for the pointer to
41
+ * cross the text exactly.
42
+ */
43
+ revealOn?: "self" | "group"
34
44
  }>(),
35
- { variant: "default", as: "a" },
45
+ { variant: "default", as: "a", revealOn: "self" },
36
46
  )
37
47
 
38
48
  // A router link component owns its destination through its own prop (`to`),
@@ -61,8 +71,29 @@ const colorClass = computed(() => {
61
71
  // but scaled to 0 on the x-axis from the left, growing to 1 on hover/focus. The
62
72
  // bar colour follows the link colour (brand, or ink when muted) so it re-skins
63
73
  // with the theme.
64
- const revealClass =
65
- "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"
74
+ //
75
+ // motion-reduce drops the travel rather than the feedback: the bar still
76
+ // appears, it just stops sweeping. A link that gave no hover response at all
77
+ // would be worse for the people that setting is for, not better.
78
+ const REVEAL_BASE =
79
+ "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)] after:ease-[var(--ease-emphasized)] motion-reduce:after:transition-none"
80
+
81
+ // Whose hover draws the bar. `self` is the ordinary inline link. `group` is for
82
+ // a link inside a row, card or tile that is ITSELF the click target: the whole
83
+ // row highlights, and the name in it should sweep with that rather than only
84
+ // when the pointer happens to cross the few characters of text. Tailwind needs
85
+ // the `group` class on that ancestor.
86
+ //
87
+ // Both spelled out as whole literals: the consuming app scans this file as
88
+ // TEXT, so a class assembled at runtime is one it never emits.
89
+ const REVEAL_TRIGGER = {
90
+ self: "hover:after:scale-x-100 focus-visible:after:scale-x-100",
91
+ group: "group-hover:after:scale-x-100 group-focus-visible:after:scale-x-100 focus-visible:after:scale-x-100",
92
+ } as const
93
+
94
+ const revealClass = computed(
95
+ () => `${REVEAL_BASE} ${REVEAL_TRIGGER[props.revealOn]}`,
96
+ )
66
97
  </script>
67
98
 
68
99
  <template>
@@ -9,8 +9,8 @@
9
9
  *
10
10
  * This is the block every app rebuilt: ~140 hand-written headings across the
11
11
  * three of them, each picking its own size, its own gap, its own alignment.
12
- * LeaveHosting had already extracted its own `PageHeader.vue` — same title,
13
- * same description, same actions slot — which is the clearest evidence the
12
+ * LeaveHosting had already extracted its own `PageHeader.vue` with the same
13
+ * title, description and actions slot, which is the clearest evidence the
14
14
  * shape is shared rather than one app's habit.
15
15
  *
16
16
  * What it deliberately does NOT do:
@@ -19,7 +19,7 @@
19
19
  * the cabinet they sit inside `glass rounded-3xl` blocks and, more often,
20
20
  * directly on the page. Owning a border here would fight two of the three.
21
21
  * - It has no icon slot of its own beyond `icon`. The panel coloured each
22
- * heading's icon from the raw palette — amber beside rose beside sky — in
22
+ * heading's icon from the raw palette (amber beside rose beside sky) in
23
23
  * pages where none of those states were amber, rose or blue. One muted
24
24
  * colour, or none.
25
25
  *
@@ -34,14 +34,21 @@ import LpIcon from "./LpIcon.vue"
34
34
 
35
35
  const section = tv({
36
36
  slots: {
37
- // items-start, not items-center: once a description is present the actions
38
- // must stay level with the TITLE, not drift to the middle of the block.
39
- root: "flex flex-wrap items-start justify-between gap-x-4 gap-y-2",
37
+ root: "flex flex-wrap justify-between gap-x-4 gap-y-2",
40
38
  title: "font-semibold text-ink",
41
39
  description: "mt-1 text-sm text-muted",
42
40
  actions: "flex shrink-0 flex-wrap items-center gap-2",
43
41
  },
44
42
  variants: {
43
+ // Where the actions sit against a two-line block. With a description the
44
+ // heading is the top line, so the actions belong level with THAT rather
45
+ // than floating to the middle of the pair. With no description there is
46
+ // only one line, and pinning to the top instead misaligns anything taller
47
+ // than the text (an input, a segmented control) by a couple of pixels.
48
+ described: {
49
+ true: { root: "items-start" },
50
+ false: { root: "items-center" },
51
+ },
45
52
  level: {
46
53
  sm: { title: "text-sm" },
47
54
  md: { title: "text-base" },
@@ -49,7 +56,7 @@ const section = tv({
49
56
  xl: { title: "text-xl tracking-tight" },
50
57
  },
51
58
  },
52
- defaultVariants: { level: "md" },
59
+ defaultVariants: { level: "md", described: false },
53
60
  })
54
61
 
55
62
  type SectionVariants = VariantProps<typeof section>
@@ -61,7 +68,7 @@ const props = withDefaults(
61
68
  description?: string
62
69
  /** Iconify name shown before the title, e.g. "lucide:server". */
63
70
  icon?: string
64
- /** Visual size. Independent of `as` — see the note above. */
71
+ /** Visual size. Independent of `as`, see the note above. */
65
72
  level?: SectionVariants["level"]
66
73
  /** The heading element to render. Pick it for the document outline. */
67
74
  as?: "h1" | "h2" | "h3" | "h4" | "p"
@@ -69,21 +76,48 @@ const props = withDefaults(
69
76
  { level: "md", as: "h2" },
70
77
  )
71
78
 
72
- defineSlots<{
79
+ /*
80
+ * `title` and `description` each come in two forms, and they are one opening
81
+ * rather than two: the prop is what the slot renders when nothing fills it.
82
+ * Both are a plain string nine times out of ten (usually a t() call), and
83
+ * making those pass through a <template> would tax the common case for the
84
+ * sake of the rare one. But the rare one is real (a name with an id after it,
85
+ * a description carrying a link), and without the slot it is what sends people
86
+ * back to hand-writing the block the component exists to standardise.
87
+ */
88
+ const slots = defineSlots<{
73
89
  /** Title markup, when a plain string is not enough. */
74
90
  title?: () => unknown
91
+ /** Description markup, when a plain string is not enough. */
92
+ description?: () => unknown
75
93
  /** Right-aligned controls: buttons, filters, a count. */
76
94
  actions?: () => unknown
77
- /** Extra content beside the title — a badge, a count, a status dot. */
95
+ /** Extra content beside the title: a badge, a count, a status dot. */
78
96
  meta?: () => unknown
79
97
  }>()
80
98
 
81
- const classes = computed(() => section({ level: props.level }))
99
+ // Either form counts as "described": the actions align against a two-line
100
+ // block whether the second line came from the prop or the slot.
101
+ const hasDescription = computed(() => !!props.description || !!slots.description)
102
+
103
+ const classes = computed(() =>
104
+ section({ level: props.level, described: hasDescription.value }),
105
+ )
82
106
  </script>
83
107
 
84
108
  <template>
85
109
  <div :class="classes.root()">
86
- <div class="min-w-0">
110
+ <!-- `flex-1` matters as much as `min-w-0`: without it a long description
111
+ stretches this column across the whole row, and the actions wrap to a
112
+ line of their own even when they would have fitted beside the title.
113
+ With it the column takes the space that is left and shrinks first.
114
+
115
+ `break-words` covers the title AND the description in one place. Both
116
+ routinely carry something with no spaces in it (a hostname, a URL, a
117
+ container id), and an unbreakable run pushes straight out through the
118
+ side of whatever card the section sits in. min-w-0 lets the column
119
+ shrink; only this lets the text inside it give way. -->
120
+ <div class="min-w-0 flex-1 break-words">
87
121
  <div class="flex flex-wrap items-center gap-2">
88
122
  <LpIcon v-if="icon" :name="icon" :size="16" class="shrink-0 text-muted" />
89
123
  <component :is="as" :class="classes.title()">
@@ -91,7 +125,9 @@ const classes = computed(() => section({ level: props.level }))
91
125
  </component>
92
126
  <slot name="meta" />
93
127
  </div>
94
- <p v-if="description" :class="classes.description()">{{ description }}</p>
128
+ <p v-if="hasDescription" :class="classes.description()">
129
+ <slot name="description">{{ description }}</slot>
130
+ </p>
95
131
  </div>
96
132
 
97
133
  <div v-if="$slots.actions" :class="classes.actions()">
@@ -44,7 +44,7 @@ const isBlock = computed(() => !slots.default)
44
44
  class="lp-skeleton"
45
45
  :class="[
46
46
  // `rounded` describes the PLACEHOLDER's own shape, so it only applies to
47
- // the bare form. A wrapper is invisible — rounding it would clip the
47
+ // the bare form. A wrapper is invisible, so rounding it would clip the
48
48
  // layout inside without anything being drawn to justify the corner.
49
49
  isBlock
50
50
  ? [