@estiva-app/ui 0.40.0 → 0.42.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 (47) hide show
  1. package/dist/Chip.d.ts +10 -2
  2. package/dist/Chip.d.ts.map +1 -1
  3. package/dist/CommandPalette.d.ts.map +1 -1
  4. package/dist/Field.d.ts.map +1 -1
  5. package/dist/Form.d.ts +18 -1
  6. package/dist/Form.d.ts.map +1 -1
  7. package/dist/Link.d.ts +15 -1
  8. package/dist/Link.d.ts.map +1 -1
  9. package/dist/Select.d.ts.map +1 -1
  10. package/dist/eslint/index.js +16 -2
  11. package/dist/eslint/index.js.map +3 -3
  12. package/dist/eslint/no-restyled-part.d.ts +4 -0
  13. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  14. package/dist/gates/app-checks.d.ts.map +1 -1
  15. package/dist/gates/index.js +14 -0
  16. package/dist/gates/index.js.map +2 -2
  17. package/dist/index.js +53 -40
  18. package/dist/index.js.map +3 -3
  19. package/dist/looks.d.ts +6 -4
  20. package/dist/looks.d.ts.map +1 -1
  21. package/package.json +1 -1
  22. package/registry.json +54 -18
  23. package/src/Chip.mdx +13 -2
  24. package/src/Chip.stories.tsx +2 -0
  25. package/src/Chip.test.tsx +36 -0
  26. package/src/Chip.tsx +17 -5
  27. package/src/ChipInput.tsx +1 -1
  28. package/src/CommandPalette.tsx +6 -4
  29. package/src/DialogShell.stories.tsx +4 -3
  30. package/src/Field.mdx +5 -0
  31. package/src/Field.tsx +4 -1
  32. package/src/Form.mdx +9 -3
  33. package/src/Form.stories.tsx +18 -1
  34. package/src/Form.test.tsx +17 -3
  35. package/src/Form.tsx +20 -2
  36. package/src/Link.mdx +32 -0
  37. package/src/Link.stories.tsx +49 -0
  38. package/src/Link.test.tsx +15 -0
  39. package/src/Link.tsx +23 -2
  40. package/src/ListColumn.stories.tsx +1 -1
  41. package/src/Select.tsx +3 -0
  42. package/src/eslint/index.test.ts +1 -1
  43. package/src/eslint/no-restyled-part.test.ts +22 -6
  44. package/src/eslint/no-restyled-part.ts +27 -2
  45. package/src/gates/app-checks.ts +14 -0
  46. package/src/gates/create-app.test.ts +7 -2
  47. package/src/looks.ts +6 -4
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}
@@ -97,7 +97,7 @@ export const WithARowAbove: Story = {
97
97
  <ListColumn
98
98
  title="Items"
99
99
  above={
100
- <Form onSubmit={() => setName('')} className="flex items-center gap-2 px-3 pt-3 pb-1">
100
+ <Form onSubmit={() => setName('')} layout="free" className="flex items-center gap-2 px-3 pt-3 pb-1">
101
101
  <TextInput className="min-w-0 flex-1" value={name} onChange={(e) => setName(e.target.value)} placeholder="New item name" aria-label="New item name" />
102
102
  <Button className="shrink-0" type="submit" disabled={!name.trim()}>
103
103
  Create
package/src/Select.tsx CHANGED
@@ -94,7 +94,10 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
94
94
  // The focused border survives a hover: hover alone strengthens the
95
95
  // hairline, but hover while focused must not grey the focus colour —
96
96
  // the stacked variant outranks plain hover by specificity.
97
+ // Anywhere in the `Field` around it counts as over it (Katerina, 28 September).
97
98
  !held && 'hover:border-border-strong focus-visible:hover:border-border-focus aria-expanded:hover:border-border-focus',
99
+ // Left out while focused or open, so their colour stays under the pointer.
100
+ !held && 'group-hover/field:border-border-strong group-hover/field:focus-visible:border-border-focus group-hover/field:aria-expanded:border-border-focus',
98
101
  FIELD_DISABLED_CLASSES,
99
102
  held && 'cursor-not-allowed bg-bg-disabled text-text-disabled',
100
103
  'focus-visible:border-border-focus aria-expanded:border-border-focus',
@@ -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
  })
@@ -74,6 +74,9 @@ tester.run('no-restyled-part', noRestyledPart, {
74
74
  { name: 'style the code works out while it runs passes', filename: app, code: ui('Card', ' <Card style={props.style} />') },
75
75
  { name: 'style on a component that hands on only its className is not read', filename: app, code: local('ConversationCard', './components/ConversationCard', ' <ConversationCard title="x" style={{ opacity: 0.5 }} />') },
76
76
  { name: 'EmptyState placed', filename: app, code: ui('EmptyState', ' <EmptyState className="mt-2 flex-1" message="Nothing yet" />') },
77
+ { name: 'Form placed, its fields its own', filename: app, code: ui('Form', ' <Form onSubmit={send} className="min-h-0 flex-1 px-5">x</Form>') },
78
+ { name: 'a free Form lays itself out', filename: app, code: ui('Form', ' <Form onSubmit={send} layout="free" className="flex items-center gap-2">x</Form>') },
79
+ { name: 'a Form whose layout the rule cannot read passes', filename: app, code: ui('Form', ' <Form onSubmit={send} layout={how} className="flex gap-2">x</Form>') },
77
80
  // escapes
78
81
  { name: 'escaped, with its reason', filename: app, code: ui('Link', ' // @estiva-escape(no-restyled-part): the whole row is the link, and no part does that yet\n <Link href="/x" className="after:absolute after:inset-0">x</Link>') },
79
82
  // the package itself
@@ -115,7 +118,7 @@ tester.run('no-restyled-part', noRestyledPart, {
115
118
  name: 'a look behind a variant, and an extra layer',
116
119
  filename: app,
117
120
  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`.') }],
121
+ errors: [{ message: restyled('`signal:hover:bg-success-muted` `after:absolute` `[&>*]:shrink-0`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }],
119
122
  },
120
123
  {
121
124
  name: 'an inner box’s class prop',
@@ -184,7 +187,19 @@ tester.run('no-restyled-part', noRestyledPart, {
184
187
  name: 'a wrapper declared in the same file',
185
188
  filename: app,
186
189
  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`.') }],
190
+ errors: [{ message: restyled('`text-h5`', '`RouterLink` (it hands its props to `Link`)', ' Use its `variant`, `truncate` or `cover`.') }],
191
+ },
192
+ {
193
+ name: 'a Form laid out by hand',
194
+ filename: app,
195
+ code: ui('Form', ' <Form onSubmit={send} className="flex flex-col gap-2 sm:gap-4 mt-2">x</Form>'),
196
+ errors: [{ message: '`flex` `flex-col` `gap-2` `sm:gap-4` lays out the fields of `Form`. A Form places its own fields, 24px apart (Katerina, 28 September), so every form reads the same: remove it. A form that is not a list of fields (a field and its button, a composer) says `layout="free"` and lays itself out.' }],
197
+ },
198
+ {
199
+ name: 'a Form that says fields is laid out by the Form',
200
+ filename: app,
201
+ code: ui('Form', ' <Form onSubmit={send} layout="fields" className="space-y-3">x</Form>'),
202
+ errors: [{ message: '`space-y-3` lays out the fields of `Form`. A Form places its own fields, 24px apart (Katerina, 28 September), so every form reads the same: remove it. A form that is not a list of fields (a field and its button, a composer) says `layout="free"` and lays itself out.' }],
188
203
  },
189
204
  {
190
205
  name: 'EmptyState padded directly',
@@ -196,19 +211,19 @@ tester.run('no-restyled-part', noRestyledPart, {
196
211
  name: 'an escape with no reason is reported, and so is the class',
197
212
  filename: app,
198
213
  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`.') }],
214
+ errors: [{ messageId: 'escapeWithoutReason' }, { message: restyled('`text-h2`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }],
200
215
  },
201
216
  {
202
217
  name: 'inside the package: a part imported from a sibling',
203
218
  filename: pkg,
204
219
  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`.') }],
220
+ errors: [{ message: restyled('`rounded-lg` `border`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }],
206
221
  },
207
222
  {
208
223
  name: 'inside the package: a part used in the file that declares it',
209
224
  filename: pkg,
210
225
  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`.') }],
226
+ errors: [{ message: restyled('`text-caption`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }],
212
227
  },
213
228
  ],
214
229
  })
@@ -307,7 +322,8 @@ describe('the look props the message names are the package’s own', () => {
307
322
  })
308
323
 
309
324
  /** Named like a look, and not one: AttachmentCard's `size` is the file's, in bytes. */
310
- const NOT_A_LOOK: Record<string, string[]> = { AttachmentCard: ['size'] }
325
+ // Form's `layout` says whether it places its fields at all, not how anything looks.
326
+ const NOT_A_LOOK: Record<string, string[]> = { AttachmentCard: ['size'], Form: ['layout'] }
311
327
 
312
328
  it('every look prop a part has is named', () => {
313
329
  for (const part of PARTS) {
@@ -36,6 +36,10 @@ import { ESCAPE_MESSAGES, isEscaped } from './escape'
36
36
  *
37
37
  * `EmptyState` takes no padding either (Katerina's ruling 6 of 14 September):
38
38
  * its room comes from the box its rows live in.
39
+ *
40
+ * `Form` takes no layout for its fields (Katerina, 28 September): it stacks
41
+ * them 24px apart itself, so every form reads the same. A form that is not a
42
+ * list of fields says `layout="free"`, and then lays itself out.
39
43
  */
40
44
 
41
45
  // ---------------------------------------------------------------- the classes
@@ -93,6 +97,14 @@ export function isPlacement(token: string): boolean {
93
97
  return PLACEMENT.some(({ pattern }) => pattern.test(utility))
94
98
  }
95
99
 
100
+ /** How a form's fields are laid out: its display, direction, gaps and alignment. */
101
+ const FIELDS_LAYOUT = /^(?:block|flex|grid|inline-flex|inline-grid|contents|flow-root)$|^(?:gap|gap-x|gap-y|space-x|space-y|grid-cols|grid-rows|grid-flow|auto-cols|auto-rows|items|justify|justify-items|place-content|place-items)-|^flex-(?:row|col|wrap|nowrap)|^content-(?!\[)/
102
+
103
+ const isFieldsLayout = (token: string) => {
104
+ const { variants, utility } = splitClass(token)
105
+ return !variants.some((variant) => REACHING_VARIANT.test(variant)) && FIELDS_LAYOUT.test(utility)
106
+ }
107
+
96
108
  const isPadding = (token: string) => {
97
109
  const { variants, utility } = splitClass(token)
98
110
  return !variants.some((variant) => REACHING_VARIANT.test(variant)) && /^p[xytrblse]?-/.test(utility)
@@ -119,7 +131,7 @@ export const PART_LOOK_PROPS: Record<string, string[]> = {
119
131
  IconButton: ['variant', 'pressed', 'glow'],
120
132
  InputChip: ['truncate'],
121
133
  Kbd: ['tone'],
122
- Link: ['variant', 'truncate'],
134
+ Link: ['variant', 'truncate', 'cover'],
123
135
  MenuItem: ['size', 'selected', 'destructive'],
124
136
  MenuSub: ['selected'],
125
137
  NavItem: ['active'],
@@ -608,6 +620,8 @@ export const noRestyledPart: Rule.RuleModule = {
608
620
  '{{keys}} in the `style` of {{where}} changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled, by class or by style: only space, size, flex and grid, and position pass in.{{use}} A look for what is around it goes on your own element around it.',
609
621
  emptyStatePadding:
610
622
  '{{classes}} pads {{where}}. EmptyState takes no padding (Katerina, 14 September): its room comes from the box its rows live in, so put the padding on that box.',
623
+ formLayout:
624
+ '{{classes}} lays out the fields of {{where}}. A Form places its own fields, 24px apart (Katerina, 28 September), so every form reads the same: remove it. A form that is not a list of fields (a field and its button, a composer) says `layout="free"` and lays itself out.',
611
625
  ...ESCAPE_MESSAGES,
612
626
  },
613
627
  },
@@ -623,6 +637,15 @@ export const noRestyledPart: Rule.RuleModule = {
623
637
  return escapes.get(anchor) === true
624
638
  }
625
639
 
640
+ /** A `Form` lays out its own fields unless it says `layout="free"`; a layout the rule cannot read passes. */
641
+ const placesItsFields = (element: Node) =>
642
+ !children(element, 'attributes').some((attribute) => {
643
+ if (attribute?.type !== 'JSXAttribute' || nameOf(child(attribute, 'name')) !== 'layout') return false
644
+ const value = child(attribute, 'value')
645
+ const literal = value?.type === 'JSXExpressionContainer' ? child(value, 'expression') : value
646
+ return !(literal?.type === 'Literal' && literal.value === 'fields')
647
+ })
648
+
626
649
  const check = (element: Node, binding: PartBinding, prop: string, value: Node | null | undefined, at: Node) => {
627
650
  const partProp = binding.props === 'all' ? prop : binding.props[prop]
628
651
  if (!partProp || !value) return
@@ -632,7 +655,8 @@ export const noRestyledPart: Rule.RuleModule = {
632
655
  const where = `${via}${partProp === 'className' ? '' : `'s \`${partProp}\``}`
633
656
  const restyled = [...new Set(classes.filter((token) => !isPlacement(token)))]
634
657
  const padding = binding.part === 'EmptyState' && binding.props === 'all' ? [...new Set(classes.filter(isPadding))] : []
635
- if (!restyled.length && !padding.length) return
658
+ const layout = binding.part === 'Form' && partProp === 'className' && placesItsFields(element) ? [...new Set(classes.filter(isFieldsLayout))] : []
659
+ if (!restyled.length && !padding.length && !layout.length) return
636
660
  if (escaped(element)) return
637
661
  if (restyled.length) {
638
662
  const props = PART_LOOK_PROPS[binding.part]
@@ -640,6 +664,7 @@ export const noRestyledPart: Rule.RuleModule = {
640
664
  context.report({ loc: at.loc as NonNullable<Rule.Node['loc']>, messageId: 'restyled', data: { classes: code(restyled), where, use } })
641
665
  }
642
666
  if (padding.length) context.report({ loc: at.loc as NonNullable<Rule.Node['loc']>, messageId: 'emptyStatePadding', data: { classes: code(padding), where } })
667
+ if (layout.length) context.report({ loc: at.loc as NonNullable<Rule.Node['loc']>, messageId: 'formLayout', data: { classes: code(layout), where } })
643
668
  }
644
669
 
645
670
  /** `style` on a part: every key it can read must be placement (B6). */
@@ -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),
package/src/looks.ts CHANGED
@@ -11,14 +11,16 @@
11
11
  * much room it takes stay in the part.
12
12
  */
13
13
 
14
- /** A field's box: TextInput and Textarea. The hairline strengthens on hover; focus comes after it, so a focused field keeps its colour under the pointer. */
15
- export const FIELD_BOX_CLASSES = 'bg-bg-field border border-border-field shadow-field hover:border-border-strong focus:border-border-focus rounded-lg'
14
+ /**
15
+ * A field's box: TextInput and Textarea. The hairline strengthens on hover — of the box, or of anywhere in the `Field` around it, label included, so the field is one thing under the pointer (Katerina, 28 September) — and not while switched off: a label passes its hover to its box, switched off or not (FIELD_DISABLED_CLASSES holds it back). Focus outranks it, so a focused field keeps its colour under the pointer.
16
+ */
17
+ export const FIELD_BOX_CLASSES = 'bg-bg-field border border-border-field shadow-field hover:border-border-strong group-hover/field:border-border-strong focus:border-border-focus group-hover/field:focus:border-border-focus rounded-lg'
16
18
 
17
19
  /** What a field's words look like, typed or waiting: TextInput, Textarea and Select. */
18
20
  export const FIELD_TEXT_CLASSES = 'text-text-primary placeholder:text-text-muted'
19
21
 
20
22
  /** A field that cannot be used: TextInput, Textarea and Select. */
21
- export const FIELD_DISABLED_CLASSES = 'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled'
23
+ export const FIELD_DISABLED_CLASSES = 'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-field group-hover/field:disabled:border-border-field'
22
24
 
23
25
  /** The ring a focused field wears in Signal: TextInput and Textarea. */
24
26
  export const FIELD_FOCUS_RING_CLASSES = 'signal:transition-shadow signal:focus:shadow-focus-ring'
@@ -30,7 +32,7 @@ export const FIELD_SIZE_CLASSES = {
30
32
  } as const
31
33
 
32
34
  /** The box around an input that sits inside it, before its focus look: SearchInput and ChipInput. */
33
- export const FIELD_SHELL_CLASSES = 'bg-bg-field border border-border-field shadow-field hover:border-border-strong rounded-lg transition-colors'
35
+ export const FIELD_SHELL_CLASSES = 'bg-bg-field border border-border-field shadow-field hover:border-border-strong group-hover/field:border-border-strong rounded-lg transition-colors'
34
36
 
35
37
  /** A panel that floats over the page: Menu's panel and Tooltip. */
36
38
  export const FLOATING_SURFACE_CLASSES = 'rounded-lg border border-border-default bg-bg-elevated shadow-lg'