@estiva-app/ui 0.40.0 → 0.41.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": "@estiva-app/ui",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/registry.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.40.0",
7
+ "packageVersion": "0.41.0",
8
8
  "exports": 90,
9
9
  "typeExports": 101,
10
10
  "files": null
@@ -792,6 +792,18 @@
792
792
  "required": false,
793
793
  "note": null
794
794
  },
795
+ {
796
+ "name": "href",
797
+ "takes": "text",
798
+ "required": false,
799
+ "note": "The chip is a link: a real anchor with the chip's own round shape, so the keyboard ring is round too."
800
+ },
801
+ {
802
+ "name": "onClick",
803
+ "takes": "MouseEventHandler<HTMLAnchorElement>",
804
+ "required": false,
805
+ "note": "With `href`: a router app takes the click here, like `Link`."
806
+ },
795
807
  {
796
808
  "name": "className",
797
809
  "takes": "text",
@@ -825,13 +837,13 @@
825
837
  "bg-warning-muted text-warning-default signal:border signal:border-warning-outline signal:shadow-glow-warning",
826
838
  "bg-success-muted text-success-default signal:border signal:border-success-outline",
827
839
  "bg-error-muted text-error-default signal:border signal:border-error-outline",
840
+ "overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide text-chip signal:font-mono signal:text-small signal:font-semibold signal:tabular-nums",
841
+ "overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide",
828
842
  "rounded-full bg-accent-muted text-accent-primary signal:border signal:border-accent-outline",
829
843
  "rounded-full bg-info-muted text-info-default signal:border signal:border-info-outline",
830
844
  "rounded-full bg-warning-muted text-warning-default signal:border signal:border-warning-outline signal:shadow-glow-warning",
831
845
  "rounded-full bg-success-muted text-success-default signal:border signal:border-success-outline",
832
- "rounded-full bg-error-muted text-error-default signal:border signal:border-error-outline",
833
- "overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide text-chip signal:font-mono signal:text-small signal:font-semibold signal:tabular-nums",
834
- "overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide"
846
+ "rounded-full bg-error-muted text-error-default signal:border signal:border-error-outline"
835
847
  ]
836
848
  },
837
849
  {
@@ -2749,6 +2761,12 @@
2749
2761
  "required": false,
2750
2762
  "note": "One line, cut with an ellipsis when it does not fit — a title that is a link."
2751
2763
  },
2764
+ {
2765
+ "name": "cover",
2766
+ "takes": "true/false",
2767
+ "required": false,
2768
+ "note": "The link covers the row it sits in: a click anywhere on the row opens it, and the keyboard ring goes round the whole row."
2769
+ },
2752
2770
  {
2753
2771
  "name": "children",
2754
2772
  "takes": "anything",
@@ -2785,8 +2803,13 @@
2785
2803
  "app": null,
2786
2804
  "looks": [
2787
2805
  "text-info-default underline underline-offset-2 hover:opacity-80",
2806
+ "text-info-default underline underline-offset-2 hover:opacity-80 after:rounded-md focus-visible:outline-none",
2788
2807
  "text-info-default underline underline-offset-2 hover:opacity-80 truncate",
2789
- "underline underline-offset-2 hover:text-text-primary truncate"
2808
+ "text-info-default underline underline-offset-2 hover:opacity-80 truncate after:rounded-md focus-visible:outline-none",
2809
+ "hover:underline truncate after:rounded-md focus-visible:outline-none",
2810
+ "underline underline-offset-2 hover:text-text-primary after:rounded-md focus-visible:outline-none",
2811
+ "underline underline-offset-2 hover:text-text-primary truncate",
2812
+ "underline underline-offset-2 hover:text-text-primary truncate after:rounded-md focus-visible:outline-none"
2790
2813
  ]
2791
2814
  },
2792
2815
  {
package/src/Chip.mdx CHANGED
@@ -20,8 +20,8 @@ muted tints; the label is the `chip` type token, 11px / 500.
20
20
 
21
21
  ## When not
22
22
 
23
- - Something clickable → **Button** (small) or **MenuItem** — a Chip is not
24
- a control.
23
+ - Something that does an action → **Button** (small) or **MenuItem**. A Chip
24
+ is not a control. It can only be a link, with `href`.
25
25
  - A removable token in a multi-select → **InputChip** (see ChipInput).
26
26
  - A count beside a tab label → **Tabs** draws its own mono number, not a
27
27
  Chip.
@@ -37,6 +37,17 @@ import { Chip } from '@estiva-app/ui'
37
37
  - `leadingIcon` / `trailingIcon` take a 12px icon; the slots centre it, so
38
38
  it never rides the text baseline.
39
39
  - A count alone works too — `label="3"`, no icon.
40
+ - `href` makes the chip a link to the thing it names: a real anchor with the
41
+ chip's round shape, so the keyboard ring is round too. A router app passes
42
+ `onClick`, as with **Link**. Inside a row whose **Link** has `cover`, it
43
+ stays clickable on its own: the row opens one thing, the chip another.
44
+
45
+ ```tsx
46
+ <div className="relative flex items-center gap-3">
47
+ <Link href={itemUrl} variant="plain" cover>Item one</Link>
48
+ <Chip href={groupUrl} label="Group" />
49
+ </div>
50
+ ```
40
51
 
41
52
  ## What it owns
42
53
 
@@ -27,6 +27,8 @@ export const WithLeadingIcon: Story = { args: { type: 'info', label: 'Replies',
27
27
  export const WithTrailingIcon: Story = { args: { type: 'success', label: 'Sorted', trailingIcon: <IconArrowUp className="size-3" stroke={1.5} /> } }
28
28
  /** A count alone. */
29
29
  export const CountOnly: Story = { args: { type: 'neutral', label: '2' } }
30
+ /** A link to the thing it names. Tab to it: the ring is round, like the chip. */
31
+ export const AsLink: Story = { args: { type: 'neutral', label: 'Group', href: '#' } }
30
32
 
31
33
  /** All six colour types. */
32
34
  export const AllTypes: Story = {
@@ -0,0 +1,36 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the Chip page claims about `href`, pinned: without it the chip is not a
4
+ * link; with it the chip is a real anchor named by its label, keeps its round
5
+ * shape, sits above a covering link (`relative`), and hands the click to a
6
+ * router app through `onClick`.
7
+ */
8
+ import { afterEach, describe, expect, it, vi } from 'vitest'
9
+ import { cleanup, render, screen } from '@testing-library/react'
10
+ import userEvent from '@testing-library/user-event'
11
+ import { Chip } from './Chip'
12
+
13
+ afterEach(cleanup)
14
+
15
+ describe('Chip', () => {
16
+ it('is not a link without href', () => {
17
+ render(<Chip label="Group" />)
18
+ expect(screen.queryByRole('link')).toBeNull()
19
+ expect(screen.getByText('Group').parentElement?.tagName).toBe('DIV')
20
+ })
21
+
22
+ it('with href is an anchor named by its label, round, and lifted above a covering link', () => {
23
+ render(<Chip label="Group" href="/groups/one" />)
24
+ const link = screen.getByRole('link', { name: 'Group' })
25
+ expect(link.getAttribute('href')).toBe('/groups/one')
26
+ expect(link.classList.contains('rounded-full')).toBe(true)
27
+ expect(link.classList.contains('relative')).toBe(true)
28
+ })
29
+
30
+ it('a router app takes the click through onClick', async () => {
31
+ const onClick = vi.fn((event: { preventDefault: () => void }) => event.preventDefault())
32
+ render(<Chip label="Group" href="/groups/one" onClick={onClick} />)
33
+ await userEvent.click(screen.getByRole('link', { name: 'Group' }))
34
+ expect(onClick).toHaveBeenCalledTimes(1)
35
+ })
36
+ })
package/src/Chip.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react'
1
+ import type { MouseEventHandler, ReactNode } from 'react'
2
2
  import { cn } from './cn'
3
3
  import { CHIP_TEXT_CLASSES } from './looks'
4
4
 
@@ -20,6 +20,14 @@ export interface ChipProps {
20
20
  label?: string
21
21
  leadingIcon?: ReactNode
22
22
  trailingIcon?: ReactNode
23
+ /**
24
+ * The chip is a link: a real anchor with the chip's own round shape, so the
25
+ * keyboard ring is round too. It sits above a row's covering `Link`, so the
26
+ * row opens one thing and the chip another (UIG-33, Katerina 28 September).
27
+ */
28
+ href?: string
29
+ /** With `href`: a router app takes the click here, like `Link`. */
30
+ onClick?: MouseEventHandler<HTMLAnchorElement>
23
31
  className?: string
24
32
  }
25
33
 
@@ -32,9 +40,10 @@ const typeStyles: Record<ChipType, string> = {
32
40
  error: 'bg-error-muted text-error-default signal:border signal:border-error-outline',
33
41
  }
34
42
 
35
- export function Chip({ type = 'neutral', label, leadingIcon, trailingIcon, className }: ChipProps) {
36
- return (
37
- <div className={cn('inline-flex min-w-0 items-center justify-center gap-1.5 rounded-full max-h-[20px] px-2 py-1', typeStyles[type], className)}>
43
+ export function Chip({ type = 'neutral', label, leadingIcon, trailingIcon, href, onClick, className }: ChipProps) {
44
+ const classes = cn('inline-flex min-w-0 items-center justify-center gap-1.5 rounded-full max-h-[20px] px-2 py-1', typeStyles[type], className)
45
+ const inside = (
46
+ <>
38
47
  {leadingIcon && <span className="flex size-3 shrink-0 items-center justify-center">{leadingIcon}</span>}
39
48
  {label && (
40
49
  // A chip given a `max-w-*` cuts a long label with an ellipsis instead
@@ -48,6 +57,9 @@ export function Chip({ type = 'neutral', label, leadingIcon, trailingIcon, class
48
57
  <span className={cn('min-w-0 overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide', CHIP_TEXT_CLASSES)}>{label}</span>
49
58
  )}
50
59
  {trailingIcon && <span className="flex size-3 shrink-0 items-center justify-center">{trailingIcon}</span>}
51
- </div>
60
+ </>
52
61
  )
62
+ // `relative` lifts a linked chip above a covering link that comes before it.
63
+ if (href) return <a href={href} onClick={onClick} className={cn('relative', classes)}>{inside}</a>
64
+ return <div className={classes}>{inside}</div>
53
65
  }
package/src/Link.mdx CHANGED
@@ -24,6 +24,11 @@ the app decides how it navigates.
24
24
  what it wraps draws itself.
25
25
  - `external` — another site or app: it opens in a new tab, with
26
26
  `noopener noreferrer`.
27
+ - **`cover`** — a row that opens one thing wherever you click it: an item in a
28
+ list, a row of a table. The link covers the row, and the keyboard ring goes
29
+ round the whole row.
30
+
31
+ <Canvas of={LinkStories.Cover} />
27
32
 
28
33
  ## When not
29
34
 
@@ -33,6 +38,13 @@ the app decides how it navigates.
33
38
  - A link that looks like an icon button → **IconButton** with `href`.
34
39
  - A word standing for a person or a thing, drawn in a small box → **InlineChip**
35
40
  with `href`.
41
+ - A box that is one link → **Card** with `href`. `cover` is for a row that has
42
+ no frame of its own.
43
+ - `cover` on a row whose parts each open something different, or a row with
44
+ nothing to open. A row with only a second link inside it (a chip, say) is
45
+ fine: see How.
46
+ - Stretching a link over its row by hand (`after:absolute`): the lint refuses
47
+ it. Use `cover`.
36
48
 
37
49
  ## How
38
50
 
@@ -59,6 +71,26 @@ import { Link } from '@estiva-app/ui'
59
71
 
60
72
  - `truncate` keeps a title that is a link on one line, cut with an ellipsis.
61
73
  Its size and colour still come from the text around it.
74
+
75
+ - `cover` covers the nearest box that is `relative`: give the row `relative`.
76
+ The row keeps its own size, fill and hover; the link only adds the layer
77
+ and the ring. Pointing anywhere on the row counts as pointing at the link,
78
+ so `quiet` underlines the title while the row is hovered.
79
+ - A second link inside the row comes **after** the covering link and is
80
+ `relative` itself, so it sits above the layer. A **Chip** with `href` already
81
+ is.
82
+
83
+ ```tsx
84
+ <div className="relative flex h-9 items-center gap-3 px-3 hover:bg-bg-hover">
85
+ <Link href={itemUrl} variant="plain" cover onClick={navigate}>Item one</Link>
86
+ <Chip href={groupUrl} label="Group" onClick={navigate} />
87
+ </div>
88
+
89
+ <tr className="relative">
90
+ <td><Link href={itemUrl} variant="quiet" cover>Item one</Link></td>
91
+ <td>…</td>
92
+ </tr>
93
+ ```
62
94
  - `quiet`, `underlined` and `plain` take their size and colour from where they
63
95
  sit, so put those on your own element around the link, not on the link —
64
96
  the lint refuses a look passed in `className`.
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { Link } from './Link'
3
3
  import { Card } from './Card'
4
+ import { Chip } from './Chip'
4
5
 
5
6
  const meta = {
6
7
  title: 'Navigation/Link',
@@ -70,6 +71,54 @@ export const External: Story = {
70
71
  ),
71
72
  }
72
73
 
74
+ const ROWS = [
75
+ { key: 'A-1', title: 'Item one', group: 'Group', time: '2h' },
76
+ { key: 'A-2', title: 'Item two', group: 'Group', time: '1d' },
77
+ { key: 'A-3', title: 'Item three', time: '3d' },
78
+ ]
79
+
80
+ /**
81
+ * A row that is one link: click anywhere on it, or Tab to it and the ring goes
82
+ * round the whole row. The chip is a second link, and opens something else.
83
+ */
84
+ export const Cover: Story = {
85
+ parameters: { controls: { disable: true } },
86
+ render: () => (
87
+ <div className="flex w-full max-w-xl flex-col">
88
+ {ROWS.map((row) => (
89
+ <div key={row.key} className="relative flex h-9 items-center gap-3 rounded-md px-3 transition-colors hover:bg-bg-hover">
90
+ <Link href="#" variant="plain" cover className="flex min-w-0 flex-1 items-center gap-3">
91
+ <span className="w-10 shrink-0 font-mono text-caption text-text-muted">{row.key}</span>
92
+ <span className="min-w-0 flex-1 truncate text-body-2 text-text-primary">{row.title}</span>
93
+ </Link>
94
+ {row.group && <Chip href="#" label={row.group} />}
95
+ <span className="w-8 shrink-0 text-right text-caption text-text-muted">{row.time}</span>
96
+ </div>
97
+ ))}
98
+ </div>
99
+ ),
100
+ }
101
+
102
+ /** The same in a table: the row is `relative`, the title a `quiet` link that covers it. */
103
+ export const CoverInTable: Story = {
104
+ parameters: { controls: { disable: true } },
105
+ render: () => (
106
+ <table className="w-full max-w-xl text-body-2">
107
+ <tbody>
108
+ {ROWS.map((row) => (
109
+ <tr key={row.key} className="relative border-b border-border-subtle last:border-b-0 hover:bg-bg-hover">
110
+ <td className="w-14 px-3 py-2 font-mono text-caption text-text-muted">{row.key}</td>
111
+ <td className="px-3 py-2 text-text-primary">
112
+ <Link href="#" variant="quiet" cover>{row.title}</Link>
113
+ </td>
114
+ <td className="w-16 px-3 py-2 text-right text-caption text-text-muted">{row.time}</td>
115
+ </tr>
116
+ ))}
117
+ </tbody>
118
+ </table>
119
+ ),
120
+ }
121
+
73
122
  /** The four looks, each where it belongs. */
74
123
  export const AllVariants: Story = {
75
124
  parameters: { controls: { disable: true } },
package/src/Link.test.tsx CHANGED
@@ -88,6 +88,21 @@ describe('Link', () => {
88
88
  expect(classesOf(screen.getByRole('link'))).toEqual([])
89
89
  })
90
90
 
91
+ it('cover lays a layer over the row and moves the ring onto it; without it, nothing of the kind', () => {
92
+ render(
93
+ <>
94
+ <Link href="#" variant="plain" cover>
95
+ Row
96
+ </Link>
97
+ <Link href="#" variant="plain">
98
+ Words
99
+ </Link>
100
+ </>,
101
+ )
102
+ expect(classesOf(screen.getByRole('link', { name: 'Row' }))).toEqual(['after:absolute', 'after:inset-0', 'after:rounded-md', 'focus-visible:outline-none', 'focus-visible:after:[outline-style:auto]'])
103
+ expect(classesOf(screen.getByRole('link', { name: 'Words' }))).toEqual([])
104
+ })
105
+
91
106
  it('a ref reaches the anchor', () => {
92
107
  const ref = createRef<HTMLAnchorElement>()
93
108
  render(
package/src/Link.tsx CHANGED
@@ -18,6 +18,15 @@ import { cn } from './cn'
18
18
  * and a solid version became this one, solid (her ruling, 13 September).
19
19
  * - `plain` — no look at all: a card or a row that is a link draws itself.
20
20
  *
21
+ * **`cover`: a row that is one link** (UIG-33, Katerina 28 September). The link
22
+ * stretches an invisible layer over the nearest box the app has given
23
+ * `relative` (a row, a table row), so a click anywhere on it opens the link, and
24
+ * the keyboard ring goes round that whole box rather than round the words. The
25
+ * row stays the app's own: its size, its fill, its hover. A second link inside
26
+ * the row stays clickable when it comes after the covering link and is
27
+ * `relative` itself, which a `Chip` with an `href` already is. Both apps
28
+ * stretched a link by hand before this, each under a written exception.
29
+ *
21
30
  * **Navigation is the app's.** The package cannot know a router, so like
22
31
  * `NavItem` it renders a real anchor and passes every anchor prop through: a
23
32
  * router app hands in `onClick`, prevents the default and navigates, and the
@@ -25,6 +34,13 @@ import { cn } from './cn'
25
34
  */
26
35
  export type LinkVariant = 'text' | 'quiet' | 'underlined' | 'plain'
27
36
 
37
+ /**
38
+ * The covering layer, and the ring moved onto it: `outline-none` takes the ring
39
+ * off the words, and `outline-style: auto` draws the browser's own ring round the
40
+ * layer, the same ring every other control shows, only the size of the row.
41
+ */
42
+ const COVER_CLASSES = 'after:absolute after:inset-0 after:rounded-md focus-visible:outline-none focus-visible:after:[outline-style:auto]'
43
+
28
44
  const VARIANT_CLASSES: Record<LinkVariant, string> = {
29
45
  text: 'text-info-default underline underline-offset-2 hover:opacity-80',
30
46
  quiet: 'hover:underline',
@@ -46,15 +62,20 @@ export interface LinkProps extends ComponentPropsWithRef<'a'> {
46
62
  * Its size and colour still come from the text around it (UIG-9, 17 September).
47
63
  */
48
64
  truncate?: boolean
65
+ /**
66
+ * The link covers the row it sits in: a click anywhere on the row opens it,
67
+ * and the keyboard ring goes round the whole row. Give the row `relative`.
68
+ */
69
+ cover?: boolean
49
70
  children: ReactNode
50
71
  }
51
72
 
52
- export function Link({ href, variant = 'text', external = false, truncate = false, className, children, ...props }: LinkProps) {
73
+ export function Link({ href, variant = 'text', external = false, truncate = false, cover = false, className, children, ...props }: LinkProps) {
53
74
  return (
54
75
  <a
55
76
  href={href}
56
77
  {...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
57
- className={cn(VARIANT_CLASSES[variant], truncate && 'truncate', className)}
78
+ className={cn(VARIANT_CLASSES[variant], truncate && 'truncate', cover && COVER_CLASSES, className)}
58
79
  {...props}
59
80
  >
60
81
  {children}
@@ -139,7 +139,7 @@ describe('an app lint with configs.recommended', () => {
139
139
  [
140
140
  'estiva/no-restyled-part',
141
141
  2,
142
- '`text-h2` on `Link` changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled: only space, size, flex and grid, and position pass in. Use its `variant` or `truncate`. A look for what is around it goes on your own element around it.',
142
+ '`text-h2` on `Link` changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled: only space, size, flex and grid, and position pass in. Use its `variant`, `truncate` or `cover`. A look for what is around it goes on your own element around it.',
143
143
  ],
144
144
  ])
145
145
  })
@@ -115,7 +115,7 @@ tester.run('no-restyled-part', noRestyledPart, {
115
115
  name: 'a look behind a variant, and an extra layer',
116
116
  filename: app,
117
117
  code: ui('Link', ' <Link href="/x" className="signal:hover:bg-success-muted after:absolute [&>*]:shrink-0">x</Link>'),
118
- errors: [{ message: restyled('`signal:hover:bg-success-muted` `after:absolute` `[&>*]:shrink-0`', '`Link`', ' Use its `variant` or `truncate`.') }],
118
+ errors: [{ message: restyled('`signal:hover:bg-success-muted` `after:absolute` `[&>*]:shrink-0`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }],
119
119
  },
120
120
  {
121
121
  name: 'an inner box’s class prop',
@@ -184,7 +184,7 @@ tester.run('no-restyled-part', noRestyledPart, {
184
184
  name: 'a wrapper declared in the same file',
185
185
  filename: app,
186
186
  code: "import { Link, type LinkProps } from '@estiva-app/ui'\nfunction RouterLink({ href, ...props }: LinkProps) {\n return <Link href={href} {...props} />\n}\nexport function Probe() {\n return <RouterLink href=\"/x\" className=\"text-h5\">x</RouterLink>\n}\n",
187
- errors: [{ message: restyled('`text-h5`', '`RouterLink` (it hands its props to `Link`)', ' Use its `variant` or `truncate`.') }],
187
+ errors: [{ message: restyled('`text-h5`', '`RouterLink` (it hands its props to `Link`)', ' Use its `variant`, `truncate` or `cover`.') }],
188
188
  },
189
189
  {
190
190
  name: 'EmptyState padded directly',
@@ -196,19 +196,19 @@ tester.run('no-restyled-part', noRestyledPart, {
196
196
  name: 'an escape with no reason is reported, and so is the class',
197
197
  filename: app,
198
198
  code: ui('Link', ' // @estiva-escape(no-restyled-part):\n <Link href="/x" className="text-h2">x</Link>'),
199
- errors: [{ messageId: 'escapeWithoutReason' }, { message: restyled('`text-h2`', '`Link`', ' Use its `variant` or `truncate`.') }],
199
+ errors: [{ messageId: 'escapeWithoutReason' }, { message: restyled('`text-h2`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }],
200
200
  },
201
201
  {
202
202
  name: 'inside the package: a part imported from a sibling',
203
203
  filename: pkg,
204
204
  code: local('Link', './Link', ' <Link href="/x" className="rounded-lg border">x</Link>'),
205
- errors: [{ message: restyled('`rounded-lg` `border`', '`Link`', ' Use its `variant` or `truncate`.') }],
205
+ errors: [{ message: restyled('`rounded-lg` `border`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }],
206
206
  },
207
207
  {
208
208
  name: 'inside the package: a part used in the file that declares it',
209
209
  filename: pkg,
210
210
  code: 'export function Link() {\n return null\n}\nexport function Probe() {\n return <Link className="text-caption" />\n}\n',
211
- errors: [{ message: restyled('`text-caption`', '`Link`', ' Use its `variant` or `truncate`.') }],
211
+ errors: [{ message: restyled('`text-caption`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }],
212
212
  },
213
213
  ],
214
214
  })
@@ -119,7 +119,7 @@ export const PART_LOOK_PROPS: Record<string, string[]> = {
119
119
  IconButton: ['variant', 'pressed', 'glow'],
120
120
  InputChip: ['truncate'],
121
121
  Kbd: ['tone'],
122
- Link: ['variant', 'truncate'],
122
+ Link: ['variant', 'truncate', 'cover'],
123
123
  MenuItem: ['size', 'selected', 'destructive'],
124
124
  MenuSub: ['selected'],
125
125
  NavItem: ['active'],
@@ -264,6 +264,20 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
264
264
  return own.length ? h.FAIL(`draws the text tree itself (${own.join(', ')}): pass the tree to RichText`) : h.PASS('formatted text is drawn by RichText only')
265
265
  } },
266
266
  ]),
267
+ // UIG-33, Katerina's picks of 28 September: a row that is one link is `Link` with `cover`, and a
268
+ // chip that is a link is `Chip` with `href`. Before, both apps stretched a link over its row by
269
+ // hand (`after:absolute`) and rounded a Link round a chip, each under an escape.
270
+ ticket('UIG-33', [
271
+ { what: 'the installed package has Link cover and Chip href', run: all(h, [
272
+ () => h.contains(at('node_modules/@estiva-app/ui/dist/Link.d.ts'), /\bcover\?: boolean/, 'the installed Link takes cover'),
273
+ () => h.contains(at('node_modules/@estiva-app/ui/dist/Chip.d.ts'), /\bhref\?: string/, 'the installed Chip takes href'),
274
+ ], 'the installed Link takes cover and Chip takes href') },
275
+ { what: 'no link is stretched over its row by hand', run: () => {
276
+ const sources = h.listFiles(at('src'), (n) => /\.tsx$/.test(n) && !/\.(test|stories)\.tsx$/.test(n))
277
+ const own = sources.filter((f) => /<(?:Link|a)\b[^>]*\bafter:(?:absolute|inset-0)\b/.test(h.read(f)))
278
+ return own.length ? h.FAIL(`stretches a link by hand (${own.join(', ')}): give the Link cover`) : h.PASS('every whole-row link is Link with cover')
279
+ } },
280
+ ]),
267
281
  // Katerina's ruling of 17 September (docs/GATES.md §23): one copy, in the package. An app made
268
282
  // by create-estiva-app passes this from its first commit; Peek and Ship do once UIG-32 lands.
269
283
  ticket('UIG-32', [
@@ -194,8 +194,13 @@ describe('a made app, under the checks every app runs', () => {
194
194
  const real = helpers(dir)
195
195
  // "The installed package exports Link" reads its declarations, which CI's
196
196
  // test step does not build (`pretest` bundles only): the same exports are read
197
- // from their source when the declarations are not there.
198
- const exportsFrom = (rel: string) => (rel.endsWith('dist/index.d.ts') && !real.exists(rel) ? join(process.cwd(), 'src', 'index.ts') : rel)
197
+ // from their source when the declarations are not there. A part's own props
198
+ // (UIG-33: `Link.d.ts`) are read from its source file the same way.
199
+ const exportsFrom = (rel: string) => {
200
+ const declared = rel.replace(/\\/g, '/').match(/dist\/(\w+)\.d\.ts$/)
201
+ if (!declared || real.exists(rel)) return rel
202
+ return declared[1] === 'index' ? join(process.cwd(), 'src', 'index.ts') : join(process.cwd(), 'src', `${declared[1]}.tsx`)
203
+ }
199
204
  const h: GateHelpers = {
200
205
  ...real,
201
206
  contains: (rel, needle, label) => real.contains(exportsFrom(rel), needle, label),