@astryxdesign/cli 0.7.12-canary.b87846c → 0.7.12-canary.c5cc8f4

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 (29) hide show
  1. package/api/component/__fixtures__/resolutions/modules.txt +2 -2
  2. package/api/template/template.mjs +1 -0
  3. package/assets/discover/official-packages.json +12 -0
  4. package/assets/docs/tree/type-scale.doc.mjs +19 -2
  5. package/assets/templates/blocks/components/Hooks/useInputStatusIconHookUsage.tsx +1 -0
  6. package/assets/templates/blocks/components/Hooks/useInteractiveRoleHookUsage.doc.mjs +14 -0
  7. package/assets/templates/blocks/components/Hooks/useInteractiveRoleHookUsage.tsx +56 -0
  8. package/assets/templates/blocks/components/Hooks/useStreamingTextHookUsage.doc.mjs +2 -2
  9. package/assets/templates/blocks/components/Hooks/useStreamingTextHookUsage.tsx +66 -10
  10. package/assets/templates/blocks/components/Link/useLinkifyHookUsage.doc.mjs +14 -0
  11. package/assets/templates/blocks/components/Link/useLinkifyHookUsage.tsx +39 -0
  12. package/assets/templates/blocks/components/Table/RowsVirtualizationHookUsage.doc.mjs +14 -0
  13. package/assets/templates/blocks/components/Table/RowsVirtualizationHookUsage.tsx +35 -0
  14. package/assets/templates/blocks/components/Table/useTableColumnSettingsStateHookUsage.doc.mjs +14 -0
  15. package/assets/templates/blocks/components/Table/useTableColumnSettingsStateHookUsage.tsx +97 -0
  16. package/assets/templates/pages/table-filter/page.tsx +15 -23
  17. package/assets/templates/pages/table-inbox/page.tsx +3 -6
  18. package/assets/templates/themes/butter/butterTheme.ts +1 -1
  19. package/foundation/discovery/hook-discovery.mjs +31 -8
  20. package/foundation/doc-compiler/import.mjs +5 -4
  21. package/foundation/fs/import-url.d.mts +20 -0
  22. package/foundation/fs/import-url.mjs +22 -0
  23. package/foundation/fs/module-loader.d.mts +14 -3
  24. package/foundation/fs/module-loader.mjs +56 -47
  25. package/foundation/text/levenshtein.d.mts +6 -1
  26. package/foundation/text/levenshtein.mjs +25 -9
  27. package/foundation/text/suggest-order.d.mts +8 -1
  28. package/foundation/text/suggest-order.mjs +72 -6
  29. package/package.json +6 -6
@@ -271,7 +271,7 @@ useIsomorphicLayoutEffect useIsomorphicLayoutEffect hooks/useAnimatedLayout.doc.
271
271
  useLayerDismissal useLayerDismissal Layer/Layer.doc.mjs Layer/useLayerDismissal.ts
272
272
  useLightbox useLightbox Lightbox/useLightbox.doc.mjs Lightbox/useLightbox.tsx
273
273
  useLinkComponent useLinkComponent Link/useLinkComponent.doc.mjs Link/useLinkComponent.ts
274
- useLinkify useLinkify Link/Link.doc.mjs Link/useLinkify.tsx
274
+ useLinkify useLinkify Link/useLinkify.doc.mjs Link/useLinkify.tsx
275
275
  useListFormatter useListFormatter i18n/InternationalizationProvider.doc.mjs i18n/useListFormatter.ts
276
276
  useMenuDrillIn useMenuDrillIn DropdownMenu/DropdownMenu.doc.mjs DropdownMenu/useMenuDrillIn.tsx
277
277
  useMenuHover useMenuHover hooks/useAnimatedLayout.doc.mjs hooks/useMenuHover.ts
@@ -298,7 +298,7 @@ useSpeechRecognition useSpeechRecognition Chat/Chat.doc.mjs Chat/useSpeechRecogn
298
298
  useSwipeAction useSwipeAction Item/Item.doc.mjs Item/useSwipeAction.ts
299
299
  useSwitchTouch useSwitchTouch Switch/Switch.doc.mjs Switch/useSwitchTouch.ts
300
300
  useTableCellStyles useTableCellStyles Table/Table.doc.mjs Table/useTableCellStyles.ts
301
- useTableColumnSettingsState useTableColumnSettingsState Table/Table.doc.mjs Table/plugins/columnSettings/useTableColumnSettingsState.tsx
301
+ useTableColumnSettingsState useTableColumnSettingsState Table/plugins/columnSettings/useTableColumnSettingsState.doc.mjs Table/plugins/columnSettings/useTableColumnSettingsState.tsx
302
302
  useTableSortableState useTableSortableState Table/plugins/sortable/useTableSortableState.doc.mjs Table/plugins/sortable/useTableSortableState.tsx
303
303
  useToastGesture useToastGesture Toast/Toast.doc.mjs Toast/useToastGesture.ts
304
304
  useTouchTrigger useTouchTrigger Layer/Layer.doc.mjs Layer/useTouchTrigger.ts
@@ -193,6 +193,7 @@ export async function template(name, options = {}) {
193
193
  `Unknown template "${name}"`,
194
194
  orderByAffinity(name, templates, t => [t.dirName, t.name], {
195
195
  closest: true,
196
+ typos: true,
196
197
  }).map(t => ({
197
198
  name: t.dirName,
198
199
  reason: `${t.type} template`,
@@ -128,6 +128,10 @@
128
128
  "kind": "component",
129
129
  "name": "CommentThread"
130
130
  },
131
+ {
132
+ "kind": "component",
133
+ "name": "DialogHeroHeader"
134
+ },
131
135
  {
132
136
  "kind": "component",
133
137
  "name": "DiffViewer"
@@ -136,6 +140,10 @@
136
140
  "kind": "component",
137
141
  "name": "EmojiPicker"
138
142
  },
143
+ {
144
+ "kind": "component",
145
+ "name": "LineClamp"
146
+ },
139
147
  {
140
148
  "kind": "component",
141
149
  "name": "ListInput"
@@ -252,6 +260,10 @@
252
260
  "kind": "template",
253
261
  "name": "CommentThreadShowcase"
254
262
  },
263
+ {
264
+ "kind": "template",
265
+ "name": "DialogHeroHeaderShowcase"
266
+ },
255
267
  {
256
268
  "kind": "template",
257
269
  "name": "DiffViewerShowcase"
@@ -2,7 +2,8 @@
2
2
 
3
3
  /**
4
4
  * @file `astryx docs typography/type-scale`: line height, semantic type scale,
5
- * display text, headings and text components, and custom type scales.
5
+ * display text, headings and text components, custom type scales, and the
6
+ * reader's text size on iOS.
6
7
  */
7
8
 
8
9
  /** @type {import('@astryxdesign/cli/authoring').ReferenceDoc} */
@@ -14,13 +15,15 @@ export const docs = {
14
15
  placement: {parent: 'namespace:typography', slot: 'guides', order: 20},
15
16
  category: 'foundations',
16
17
  description:
17
- 'Line height, semantic type scale tokens, display text, Heading and Text components, and custom type scales via defineTheme.',
18
+ "Line height, semantic type scale tokens, display text, Heading and Text components, custom type scales via defineTheme, and following the reader's text size on iOS.",
18
19
  keywords: [
19
20
  'line height',
20
21
  'leading',
21
22
  'defineTheme',
22
23
  'base',
23
24
  'ratio',
25
+ 'dynamic type',
26
+ 'text size',
24
27
  ],
25
28
 
26
29
  sections: [
@@ -143,5 +146,19 @@ const denseTheme = defineTheme({
143
146
  },
144
147
  ],
145
148
  },
149
+ {
150
+ id: 'dynamic-type',
151
+ title: "Following the reader's text size on iOS",
152
+ content: [
153
+ {
154
+ type: 'prose',
155
+ text: "On iPhone, readers pick a text size in Settings (Display & Text Size, then Larger Text), and native apps grow or shrink their text with it. Every theme does the same in Safari on iPhone, which exposes that choice through `font: -apple-system-body`: each font size follows the reader's choice on the curve of the iOS text style it plays, so body text scales like Body, `heading-1` like Title 1 and display text like Large Title, and every role keeps its order. A size the theme fixes at a length, such as a 12px supporting size, scales on the curve of the step it reads by default; a size in `em` or `%` follows its parent, and one that reads a variable follows that variable. At the default size every font size is the theme's own. The family, spacing, radius and the root font size never change, line heights keep their ratios, text inputs keep their 16px floor on iOS, and on Android the system font scale grows every font size through `env(preferred-text-scale)` where the browser reports it; browsers with neither see no change. There is nothing to turn on: no theme option, prop or document attribute.",
156
+ },
157
+ {
158
+ type: 'prose',
159
+ text: 'Fixed-height controls such as the text inputs keep their height, so their text can overflow at the accessibility sizes. A labelled `Button` grows with its label.',
160
+ },
161
+ ],
162
+ },
146
163
  ],
147
164
  };
@@ -74,6 +74,7 @@ function CouponField({statusVariant}: {statusVariant: FieldStatusPlacement}) {
74
74
  <div
75
75
  {...stylex.props(
76
76
  inputWrapperStyles.base,
77
+ inputWrapperStyles.textField,
77
78
  styles.field,
78
79
  status && inputStatusBorderStyles[status.type],
79
80
  )}>
@@ -0,0 +1,14 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
4
+ export const doc = {
5
+ type: 'block',
6
+ exampleFor: 'useInteractiveRole',
7
+ name: 'useInteractiveRole — One Tile, Four Roles',
8
+ displayName: 'useInteractiveRole — One Tile, Four Roles',
9
+ description:
10
+ 'A polymorphic Tile used four ways: with an href it renders as a link, with an onClick as a button, with neither as plain content, and placed as a Popover trigger it becomes the trigger button through context — the resolution order in one place.',
11
+ isReady: true,
12
+ aspectRatio: 4 / 3,
13
+ componentsUsed: ['Popover', 'Card', 'Text', 'Layout', 'Link'],
14
+ };
@@ -0,0 +1,56 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {useInteractiveRole} from '@astryxdesign/core/hooks';
6
+ import {Link} from '@astryxdesign/core/Link';
7
+ import {Popover} from '@astryxdesign/core/Popover';
8
+ import {Card} from '@astryxdesign/core/Card';
9
+ import {Text} from '@astryxdesign/core/Text';
10
+ import {VStack} from '@astryxdesign/core/Layout';
11
+
12
+ // A polymorphic Tile: the hook alone decides what it renders as — href wins,
13
+ // then onClick, then a trigger context (Popover), then inert.
14
+ function Tile({
15
+ href,
16
+ onClick,
17
+ children,
18
+ }: {
19
+ href?: string;
20
+ onClick?: () => void;
21
+ children: React.ReactNode;
22
+ }) {
23
+ const role = useInteractiveRole({href, onClick});
24
+ const body = (
25
+ <Card padding={3} width={200}>
26
+ <Text type="body">{children}</Text>
27
+ </Card>
28
+ );
29
+ switch (role) {
30
+ case 'link':
31
+ return <Link href={href}>{body}</Link>;
32
+ case 'button':
33
+ return <button onClick={onClick}>{body}</button>;
34
+ default:
35
+ return body;
36
+ }
37
+ }
38
+
39
+ export default function UseInteractiveRoleHookUsage() {
40
+ return (
41
+ <VStack gap={3} hAlign="center" style={{width: '100%'}}>
42
+ <Tile href="https://example.com/docs">Renders as a link</Tile>
43
+ <Tile onClick={() => {}}>Renders as a button</Tile>
44
+ <Popover
45
+ label="Tile details"
46
+ content={
47
+ <Card padding={3}>
48
+ <Text type="body">Opened through the tile's context role</Text>
49
+ </Card>
50
+ }>
51
+ <Tile>Trigger via context</Tile>
52
+ </Popover>
53
+ <Tile>Plain content</Tile>
54
+ </VStack>
55
+ );
56
+ }
@@ -7,8 +7,8 @@ export const doc = {
7
7
  name: 'useStreamingText — Streaming Response',
8
8
  displayName: 'useStreamingText — Streaming Response',
9
9
  description:
10
- 'Smooth bursty generated text into a steady reveal with useStreamingText.',
10
+ 'A chat-style reply card: while the response streams in bursts, useStreamingText reveals it steadily character by character; when streaming ends the full text snaps into place. Regenerate replays the stream.',
11
11
  isReady: true,
12
12
  aspectRatio: 4 / 3,
13
- componentsUsed: ['Card', 'Text', 'Layout'],
13
+ componentsUsed: ['Button', 'Card', 'Text', 'Layout'],
14
14
  };
@@ -2,29 +2,85 @@
2
2
 
3
3
  'use client';
4
4
 
5
+ import {useCallback, useEffect, useRef, useState} from 'react';
5
6
  import {useStreamingText} from '@astryxdesign/core/hooks';
7
+ import {Button} from '@astryxdesign/core/Button';
6
8
  import {Card} from '@astryxdesign/core/Card';
7
- import {VStack} from '@astryxdesign/core/Layout';
8
9
  import {Text} from '@astryxdesign/core/Text';
10
+ import {VStack} from '@astryxdesign/core/Layout';
11
+
12
+ const CHUNKS = [
13
+ 'Astryx hooks keep behavior reusable ',
14
+ 'while components keep visuals consistent. ',
15
+ 'Streaming text arrives in bursts; ',
16
+ 'the hook smooths it into a steady reveal.',
17
+ ];
9
18
 
10
- const response =
11
- 'Astryx hooks keep behavior reusable while components keep visuals consistent.';
19
+ // A fake stream: chunks land in bursts, the way a model streams tokens.
20
+ // Starting again stops the stream in progress, and the last chunk ends it.
21
+ function useFakeStream(onChunk: (chunk: string) => void, onEnd: () => void) {
22
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
23
+ const start = useCallback(() => {
24
+ if (timer.current) {
25
+ clearTimeout(timer.current);
26
+ }
27
+ let index = 0;
28
+ const tick = () => {
29
+ onChunk(CHUNKS[index]);
30
+ index += 1;
31
+ if (index < CHUNKS.length) {
32
+ timer.current = setTimeout(tick, 350);
33
+ } else {
34
+ timer.current = null;
35
+ onEnd();
36
+ }
37
+ };
38
+ timer.current = setTimeout(tick, 350);
39
+ }, [onChunk, onEnd]);
40
+ useEffect(
41
+ () => () => {
42
+ if (timer.current) {
43
+ clearTimeout(timer.current);
44
+ }
45
+ },
46
+ [],
47
+ );
48
+ return start;
49
+ }
12
50
 
13
51
  export default function UseStreamingTextHookUsage() {
14
- const displayedText = useStreamingText(response, false, {
15
- speed: 'fast',
16
- });
52
+ const [target, setTarget] = useState('');
53
+ const [isStreaming, setIsStreaming] = useState(false);
54
+
55
+ const appendChunk = useCallback((chunk: string) => {
56
+ setTarget(previous => previous + chunk);
57
+ }, []);
58
+ // When the last chunk has landed, end the stream so the hook snaps to the
59
+ // full text.
60
+ const endStream = useCallback(() => setIsStreaming(false), []);
61
+ const startStream = useFakeStream(appendChunk, endStream);
62
+
63
+ const regenerate = useCallback(() => {
64
+ setTarget('');
65
+ setIsStreaming(true);
66
+ startStream();
67
+ }, [startStream]);
68
+
69
+ const displayedText = useStreamingText(target, isStreaming, {speed: 'fast'});
17
70
 
18
71
  return (
19
72
  <Card width={420} padding={4}>
20
- <VStack gap={2}>
73
+ <VStack gap={3}>
21
74
  <Text type="body" weight="bold">
22
75
  Assistant response
23
76
  </Text>
24
77
  <Text type="body">{displayedText}</Text>
25
- <Text type="supporting" color="secondary">
26
- Complete
27
- </Text>
78
+ <Button
79
+ label={isStreaming ? 'Streaming…' : 'Regenerate'}
80
+ variant="secondary"
81
+ size="sm"
82
+ onClick={regenerate}
83
+ />
28
84
  </VStack>
29
85
  </Card>
30
86
  );
@@ -0,0 +1,14 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
4
+ export const doc = {
5
+ type: 'block',
6
+ exampleFor: 'useLinkify',
7
+ name: 'useLinkify — Links in a Comment Thread',
8
+ displayName: 'useLinkify — Links in a Comment Thread',
9
+ description:
10
+ 'Plain-text comments where useLinkify turns the URL and the email address into Links, and a custom pattern turns issue references like #123 into tracker links — the patterns option in action. The comment without matches stays plain text.',
11
+ isReady: true,
12
+ aspectRatio: 4 / 3,
13
+ componentsUsed: ['Link', 'Layout', 'Text'],
14
+ };
@@ -0,0 +1,39 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {useLinkify, type LinkifyPattern} from '@astryxdesign/core/Link';
6
+ import {VStack} from '@astryxdesign/core/Layout';
7
+ import {Text} from '@astryxdesign/core/Text';
8
+
9
+ const COMMENTS = [
10
+ 'See https://example.com/spec or email support@example.com.',
11
+ 'Fixed by #123, tracking the regression in #456.',
12
+ 'No links here, just a plain note.',
13
+ ];
14
+
15
+ // Issue references like #123 link into the tracker; the capture group maps
16
+ // onto its URL scheme, showing the patterns option.
17
+ const ISSUE_PATTERN: LinkifyPattern = {
18
+ pattern: /#(\d+)/g,
19
+ href: match => `https://example.com/issues/${match[1]}`,
20
+ };
21
+
22
+ function Comment({children}: {children: string}) {
23
+ const nodes = useLinkify(children, {patterns: [ISSUE_PATTERN]});
24
+ return (
25
+ <Text type="body" color="secondary">
26
+ {nodes}
27
+ </Text>
28
+ );
29
+ }
30
+
31
+ export default function UseLinkifyHookUsage() {
32
+ return (
33
+ <VStack gap={3} style={{width: '100%', maxWidth: 360}}>
34
+ {COMMENTS.map(comment => (
35
+ <Comment key={comment}>{comment}</Comment>
36
+ ))}
37
+ </VStack>
38
+ );
39
+ }
@@ -0,0 +1,14 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
4
+ export const doc = {
5
+ type: 'block',
6
+ exampleFor: 'useTableRowsVirtualization',
7
+ name: 'useTableRowsVirtualization - Fixed Height Rows',
8
+ displayName: 'useTableRowsVirtualization - Fixed Height Rows',
9
+ description:
10
+ 'A Table with 1,000 product rows in a fixed-height viewport. Scroll vertically to mount the visible rows. The caller keeps all data and columns; this hook does not virtualize columns.',
11
+ isReady: true,
12
+ aspectRatio: 16 / 9,
13
+ componentsUsed: ['Table'],
14
+ };
@@ -0,0 +1,35 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {
6
+ Table,
7
+ useTableRowsVirtualization,
8
+ type TableColumn,
9
+ } from '@astryxdesign/core/Table';
10
+
11
+ interface Product extends Record<string, unknown> {
12
+ id: string;
13
+ name: string;
14
+ }
15
+
16
+ const products: Product[] = Array.from({length: 1000}, (_, index) => ({
17
+ id: `product-${index}`,
18
+ name: `Product ${index + 1}`,
19
+ }));
20
+ const columns: TableColumn<Product>[] = [{key: 'name', header: 'Product'}];
21
+
22
+ export default function RowsVirtualizationHookUsage() {
23
+ const virtualization = useTableRowsVirtualization({
24
+ rowHeight: 56,
25
+ viewportHeight: 320,
26
+ });
27
+ return (
28
+ <Table
29
+ data={products}
30
+ columns={columns}
31
+ idKey="id"
32
+ virtualization={virtualization}
33
+ />
34
+ );
35
+ }
@@ -0,0 +1,14 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
4
+ export const doc = {
5
+ type: 'block',
6
+ exampleFor: 'useTableColumnSettingsState',
7
+ name: 'useTableColumnSettingsState — Column Visibility Picker',
8
+ displayName: 'useTableColumnSettingsState — Column Visibility Picker',
9
+ description:
10
+ 'A Table toolbar with a Columns menu of checkboxes: toggling Email updates active columns, Reset restores defaults, and the config is passed directly to useTableColumnSettings.',
11
+ isReady: true,
12
+ aspectRatio: 16 / 9,
13
+ componentsUsed: ['Button', 'CheckboxInput', 'Popover', 'Table', 'Layout'],
14
+ };
@@ -0,0 +1,97 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {useState} from 'react';
6
+ import {Button} from '@astryxdesign/core/Button';
7
+ import {CheckboxInput} from '@astryxdesign/core/CheckboxInput';
8
+ import {HStack, VStack} from '@astryxdesign/core/Layout';
9
+ import {Popover} from '@astryxdesign/core/Popover';
10
+ import {
11
+ Table,
12
+ useTableColumnSettings,
13
+ useTableColumnSettingsState,
14
+ } from '@astryxdesign/core/Table';
15
+ import type {TableColumn} from '@astryxdesign/core/Table';
16
+
17
+ interface Member extends Record<string, unknown> {
18
+ id: string;
19
+ name: string;
20
+ email: string;
21
+ role: string;
22
+ }
23
+
24
+ const members: Member[] = [
25
+ {id: '1', name: 'Alice', email: 'alice@example.com', role: 'Engineer'},
26
+ {id: '2', name: 'Bob', email: 'bob@example.com', role: 'Designer'},
27
+ {id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'Manager'},
28
+ ];
29
+
30
+ const allColumns: TableColumn<Member>[] = [
31
+ {key: 'name', header: 'Name'},
32
+ {key: 'email', header: 'Email'},
33
+ {key: 'role', header: 'Role'},
34
+ ];
35
+
36
+ const columnOptions = [
37
+ {key: 'name' as const, label: 'Name', isAlwaysVisible: true},
38
+ {key: 'email' as const, label: 'Email'},
39
+ {key: 'role' as const, label: 'Role'},
40
+ ];
41
+
42
+ const defaultKeys = ['name', 'email', 'role'];
43
+
44
+ export default function UseTableColumnSettingsStateHookUsage() {
45
+ const [activeKeys, setActiveKeys] =
46
+ useState<ReadonlyArray<string>>(defaultKeys);
47
+
48
+ const state = useTableColumnSettingsState({
49
+ columns: columnOptions,
50
+ activeColumnKeys: activeKeys,
51
+ onChangeActiveColumnKeys: setActiveKeys,
52
+ defaultColumnKeys: defaultKeys,
53
+ });
54
+
55
+ const plugin = useTableColumnSettings<Member>(state.columnSettingsConfig);
56
+
57
+ return (
58
+ <VStack gap={3}>
59
+ <HStack hAlign="start">
60
+ <Popover
61
+ placement="below"
62
+ label="Column settings"
63
+ width={200}
64
+ content={
65
+ <VStack gap={2}>
66
+ {columnOptions.map(col => (
67
+ <CheckboxInput
68
+ key={col.key}
69
+ label={col.label}
70
+ value={state.isColumnActive(col.key)}
71
+ isDisabled={!state.isColumnToggleable(col.key)}
72
+ onChange={() => state.toggleColumn(col.key)}
73
+ />
74
+ ))}
75
+ <Button
76
+ label="Reset"
77
+ size="sm"
78
+ variant="secondary"
79
+ onClick={state.resetToDefault}>
80
+ Reset
81
+ </Button>
82
+ </VStack>
83
+ }>
84
+ <Button label="Columns" size="sm">
85
+ Columns
86
+ </Button>
87
+ </Popover>
88
+ </HStack>
89
+ <Table
90
+ data={members}
91
+ columns={allColumns}
92
+ idKey="id"
93
+ plugins={{columnSettings: plugin}}
94
+ />
95
+ </VStack>
96
+ );
97
+ }
@@ -1129,18 +1129,6 @@ const INITIAL_SAVED_VIEWS: SavedView[] = [
1129
1129
  const COMPACT_SURFACE_QUERY =
1130
1130
  '(max-width: 768px), (max-width: 1024px) and (pointer: coarse) and (hover: none)';
1131
1131
 
1132
- /**
1133
- * Top padding for whatever opens a BottomSheet, on the spacing scale.
1134
- *
1135
- * The panel's grab pill floats over the content instead of reserving layout
1136
- * space for itself: it is 4px centered in a 24px band, so it covers the top
1137
- * 10-14px of the sheet. Content that starts at the usual 16px gutter clears it
1138
- * by 2px, which does not read as a gap — it reads as the sheet being cut off.
1139
- * Opening at the full 24px band puts real space under the pill, and every
1140
- * sheet here uses this one value so they all start on the same line.
1141
- */
1142
- const SHEET_TOP_INSET = 6;
1143
-
1144
1132
  /**
1145
1133
  * Width the filter row holds back, in px, for the readout that trails the
1146
1134
  * clauses — the density toggle, the result count and Clear all.
@@ -1698,6 +1686,7 @@ const styles = stylex.create({
1698
1686
  fontSize: typeScaleVars['--text-body-size'],
1699
1687
  fontWeight: typeScaleVars['--text-body-weight'],
1700
1688
  height: 'auto',
1689
+ minHeight: 0,
1701
1690
  lineHeight: typeScaleVars['--text-body-leading'],
1702
1691
  paddingBlock: 0,
1703
1692
  paddingInline: 0,
@@ -2052,7 +2041,8 @@ function FilterOverflow({
2052
2041
  <VStack
2053
2042
  gap={2}
2054
2043
  padding={isCompactSurface ? 4 : 0}
2055
- paddingBlockStart={isCompactSurface ? SHEET_TOP_INSET : 0}>
2044
+ // In a sheet the sheet's band under its grab bar is the top inset.
2045
+ paddingBlockStart={0}>
2056
2046
  {items.map(({child, index}) => (
2057
2047
  <StackItem key={index} xstyle={styles.overflowItem}>
2058
2048
  {child}
@@ -2145,7 +2135,8 @@ function AdaptiveSavedViewForm({
2145
2135
  <Section
2146
2136
  variant="transparent"
2147
2137
  padding={4}
2148
- paddingBlockStart={SHEET_TOP_INSET}>
2138
+ // The sheet's band under its grab bar is the top inset.
2139
+ paddingBlockStart={0}>
2149
2140
  <VStack gap={4}>
2150
2141
  <HStack gap={2}>
2151
2142
  <StackItem size="fill">
@@ -4090,11 +4081,11 @@ export default function TableFilterTemplate() {
4090
4081
  label="View options"
4091
4082
  height="tall"
4092
4083
  // The tab strip compensates against a container inset, so the sheet
4093
- // publishes none and the wrapper below owns the top band.
4084
+ // publishes none; its band under the grab bar is the only top inset,
4085
+ // so the strip's own box starts at the tab row and its scroll arrow
4086
+ // lands on the tabs rather than above them.
4094
4087
  padding={0}>
4095
- {/* The inset is the wrapper's, so the strip's own box starts at the tab
4096
- row and its scroll arrow lands on the tabs rather than above them. */}
4097
- <VStack gap={0} paddingBlockStart={SHEET_TOP_INSET}>
4088
+ <VStack gap={0}>
4098
4089
  {/* The heading the popover puts above the pane is dropped here: it
4099
4090
  restates the selected tab word for word, and the strip already
4100
4091
  marks which one that is. On a phone that is a line of vertical
@@ -4148,14 +4139,15 @@ export default function TableFilterTemplate() {
4148
4139
  const detailBody =
4149
4140
  activeJob == null ? null : (
4150
4141
  <VStack gap={0} xstyle={styles.detailPanel}>
4151
- {/* The only part of the body that knows which host it is in: the sheet
4152
- needs the pill cleared, the docked panel has no pill and would just
4153
- sit 8px lower than the panels beside it. The two never coexist —
4154
- Layout drops the panel entirely on a compact surface. */}
4142
+ {/* The only part of the body that knows which host it is in: in the
4143
+ sheet, the sheet's band under its grab bar is the top inset; the
4144
+ docked panel has no pill and keeps the 16px gutter of the panels
4145
+ beside it. The two never coexist — Layout drops the panel entirely
4146
+ on a compact surface. */}
4155
4147
  <Section
4156
4148
  variant="transparent"
4157
4149
  padding={4}
4158
- paddingBlockStart={isCompactSurface ? SHEET_TOP_INSET : 4}>
4150
+ paddingBlockStart={isCompactSurface ? 0 : 4}>
4159
4151
  <VStack gap={4}>
4160
4152
  <HStack gap={2}>
4161
4153
  <StackItem size="fill">
@@ -1785,12 +1785,9 @@ function ComposeWindow({
1785
1785
  hasScrim={false}
1786
1786
  // The Layout below owns the inset; the sheet adds none of its own.
1787
1787
  padding={0}>
1788
- {/* The grab handle floats over the top edge, out of flow, about 12px
1789
- down. The Layout's own padding is not quite enough to clear it — the
1790
- heading came up level with the pill, four pixels under it — so the
1791
- sheet buys the handle its own band of space before the content
1792
- starts. */}
1793
- <VStack paddingBlockStart={3}>
1788
+ {/* The sheet keeps a band under its grab handle at rest, so the Layout
1789
+ starts below the pill without a band of its own. */}
1790
+ <VStack>
1794
1791
  <Layout
1795
1792
  // auto, not fill: the sheet is sized by this Layout now rather than
1796
1793
  // the other way round. `fill` would stretch it to a height that
@@ -394,8 +394,8 @@ export const butterTheme = defineTheme({
394
394
  // Radius intentionally not pinned, so buttons keep core's
395
395
  // `var(--_button-radius, --radius-element)`: standalone buttons stay 8px,
396
396
  // while the chat composer (which sets --_button-radius) rounds them fully.
397
+ // Inline padding only. Button sets its own block padding and height.
397
398
  base: {
398
- paddingBlock: 'var(--spacing-3)',
399
399
  paddingInline: 'var(--spacing-4)',
400
400
  },
401
401
  'variant:secondary': SECONDARY_BUTTON,
@@ -12,6 +12,13 @@ import {levenshteinDistance} from '../text/string-utils.mjs';
12
12
 
13
13
  const SKIP_DIRS = new Set(['utils', '__tests__', 'node_modules']);
14
14
 
15
+ // A hook's doc outside src/hooks/: a `use…` hook, or a `create…` factory
16
+ // documented as one (its doc typed `HookDoc`, such as createReorderable).
17
+ const HOOK_DOC_FILE_RE = /^(?:use|create)[A-Z]\w*\.doc\.mjs$/;
18
+
19
+ // Matches the top-level `relatedHooks: [...]` field in a hook .doc.mjs file.
20
+ const RELATED_HOOKS_RE = /(?:^|\n) {0,4}relatedHooks:\s*\[([^\]]*)\]/;
21
+
15
22
  // Matches the top-level `category: 'categoryName'` field in a hook .doc.mjs file.
16
23
  const CATEGORY_RE = /(?:^|\n) {0,4}category:\s*['"]([^'"]+)['"]/;
17
24
 
@@ -32,6 +39,25 @@ function readHookMeta(docPath) {
32
39
  }
33
40
  }
34
41
 
42
+ /**
43
+ * Whether a `create…` factory's doc names its own `use…` hook in
44
+ * `relatedHooks`, as createAnimator's names useAnimator. Such a factory is
45
+ * listed beside that hook, not on its own.
46
+ * @param {string} docPath
47
+ * @param {string} hookName
48
+ * @returns {boolean}
49
+ */
50
+ function isBesideOwnHook(docPath, hookName) {
51
+ if (!hookName.startsWith('create')) return false;
52
+ const ownHook = `use${hookName.slice('create'.length)}`;
53
+ try {
54
+ const related = RELATED_HOOKS_RE.exec(fs.readFileSync(docPath, 'utf-8'));
55
+ return related != null && related[1].includes(`'${ownHook}'`);
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
35
61
  /**
36
62
  * Extract the hook name from a doc filename.
37
63
  * e.g. 'useFocusTrap.doc.mjs' → 'useFocusTrap'
@@ -70,7 +96,8 @@ export function discoverHooks(coreDir) {
70
96
  }
71
97
  }
72
98
 
73
- // 2. Scan ALL component directories for files matching use*.doc.mjs
99
+ // 2. Scan ALL component directories for hook docs: use*.doc.mjs, and
100
+ // create*.doc.mjs for a factory documented as a hook (createReorderable)
74
101
  if (pathExists(srcDir)) {
75
102
  const topEntries = listDirEntries(srcDir);
76
103
  for (const entry of topEntries) {
@@ -120,14 +147,10 @@ function scanDirForHookDocs(dirPath, hookCategories) {
120
147
  for (const entry of entries) {
121
148
  if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
122
149
  scanDirForHookDocs(path.join(dirPath, entry.name), hookCategories);
123
- } else if (
124
- entry.isFile() &&
125
- entry.name.startsWith('use') &&
126
- entry.name.endsWith('.doc.mjs')
127
- ) {
150
+ } else if (entry.isFile() && HOOK_DOC_FILE_RE.test(entry.name)) {
128
151
  const hookName = hookNameFromFile(entry.name);
129
- if (!hookCategories.has(hookName)) {
130
- const docPath = path.join(dirPath, entry.name);
152
+ const docPath = path.join(dirPath, entry.name);
153
+ if (!hookCategories.has(hookName) && !isBesideOwnHook(docPath, hookName)) {
131
154
  const {category} = readHookMeta(docPath);
132
155
  hookCategories.set(hookName, category || 'Other');
133
156
  }
@@ -24,6 +24,7 @@
24
24
 
25
25
  import {pathToFileURL} from 'node:url';
26
26
  import {createJiti} from 'jiti';
27
+ import {importUrl} from '../fs/import-url.mjs';
27
28
  import {importUserModule} from '../fs/module-loader.mjs';
28
29
  import {fileStamp, recordContentRead} from '../fs/read-record.mjs';
29
30
 
@@ -105,14 +106,14 @@ export function importTemplateModule(file) {
105
106
  jsxJiti ??= createJiti(import.meta.url, {jsx: true});
106
107
  return jsxJiti.import(first);
107
108
  }
108
- return import(`file://${first}`);
109
+ return importUrl(`file://${first}`);
109
110
  },
110
111
  changed =>
111
112
  changed.endsWith('.ts')
112
113
  ? createJiti(import.meta.url, {jsx: true, moduleCache: false}).import(
113
114
  changed,
114
115
  )
115
- : import(freshUrl(changed)),
116
+ : importUrl(freshUrl(changed)),
116
117
  );
117
118
  }
118
119
 
@@ -126,7 +127,7 @@ export function importNativeModule(file) {
126
127
  return importCurrent(
127
128
  file,
128
129
  'native',
129
- first => import(pathToFileURL(first).href),
130
- changed => import(freshUrl(changed)),
130
+ first => importUrl(pathToFileURL(first).href),
131
+ changed => importUrl(freshUrl(changed)),
131
132
  );
132
133
  }
@@ -0,0 +1,20 @@
1
+ // @generated by scripts/sync-api-types.mjs from the JSDoc in foundation/**/*.mjs.
2
+ // DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
3
+
4
+ /**
5
+ * @file The dynamic import of an authored module by its file URL.
6
+ *
7
+ * @input An absolute `file:` URL, with any query its caller adds to name a
8
+ * fresh module.
9
+ * @output The module's namespace object, as a plain `import()` gives it.
10
+ * @position Called by the doc importers (../doc-compiler/import.mjs). The
11
+ * node test project loads this file natively, as the CLI does
12
+ * (vitest.config.ts). The doc files that a catalog command imports then load
13
+ * natively as well, without a request to the test runner for each of them.
14
+ */
15
+ /**
16
+ * Import the module at `url`.
17
+ * @param {string} url an absolute `file:` URL
18
+ * @returns {Promise<any>}
19
+ */
20
+ export function importUrl(url: string): Promise<any>;
@@ -0,0 +1,22 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file The dynamic import of an authored module by its file URL.
5
+ *
6
+ * @input An absolute `file:` URL, with any query its caller adds to name a
7
+ * fresh module.
8
+ * @output The module's namespace object, as a plain `import()` gives it.
9
+ * @position Called by the doc importers (../doc-compiler/import.mjs). The
10
+ * node test project loads this file natively, as the CLI does
11
+ * (vitest.config.ts). The doc files that a catalog command imports then load
12
+ * natively as well, without a request to the test runner for each of them.
13
+ */
14
+
15
+ /**
16
+ * Import the module at `url`.
17
+ * @param {string} url an absolute `file:` URL
18
+ * @returns {Promise<any>}
19
+ */
20
+ export function importUrl(url) {
21
+ return import(url);
22
+ }
@@ -4,9 +4,9 @@
4
4
  /**
5
5
  * Import a user-authored module. `.ts` is loaded via jiti; `.mjs`/`.js` use
6
6
  * native dynamic import for ordinary cached reads. A fresh `.ts` read uses a
7
- * no-cache jiti instance. A fresh CommonJS `.js` read evicts and reloads through
8
- * `require`; an ESM `.js` or `.mjs` read uses a cache-busted file URL. The
9
- * nearest package.json `type` decides `.js`, matching Node's package scopes.
7
+ * no-cache jiti instance. Any other fresh read drops the module from the
8
+ * require cache and imports a cache-busted file URL. Node then chooses its
9
+ * format as for the first read, so a fresh read gives what a first read gives.
10
10
  * Normal loads retain module caching. `fresh` rereads a file that changed
11
11
  * during this process: one a codemod rewrote, or an edited doc file (the doc
12
12
  * importers in ../doc-compiler/import.mjs read each change once).
@@ -29,6 +29,17 @@ export function importUserModule(file: string, { fresh }?: {
29
29
  * @returns {Promise<Record<string, unknown>>}
30
30
  */
31
31
  export function importThemeArtifact(specifier: string, cwd: string): Promise<Record<string, unknown>>;
32
+ /**
33
+ * Run `read`, an import of `file`, once the imports of `file` started before
34
+ * it have ended. Imports of other files do not wait. A fresh read removes the
35
+ * file from Node's CommonJS cache. A CommonJS import still in progress needs
36
+ * that entry until it ends.
37
+ * @template T
38
+ * @param {string} file absolute path
39
+ * @param {() => Promise<T>} read
40
+ * @returns {Promise<T>}
41
+ */
42
+ export function readInTurn<T>(file: string, read: () => Promise<T>): Promise<T>;
32
43
  /**
33
44
  * Import a user-authored module, take its default export, and validate it
34
45
  * through an authoring parser. This is the single load/validation boundary for
@@ -40,30 +40,6 @@ function getJiti() {
40
40
  return jitiInstance;
41
41
  }
42
42
 
43
- /**
44
- * Node treats `.js` as ESM only inside the nearest package scope whose
45
- * package.json declares `"type": "module"`; no package.json defaults to
46
- * CommonJS.
47
- * @param {string} file
48
- * @returns {boolean}
49
- */
50
- function isCommonJsFile(file) {
51
- let dir = path.dirname(file);
52
- for (;;) {
53
- const packageJson = path.join(dir, 'package.json');
54
- if (fs.existsSync(packageJson)) {
55
- try {
56
- return JSON.parse(fs.readFileSync(packageJson, 'utf-8')).type !== 'module';
57
- } catch {
58
- return true;
59
- }
60
- }
61
- const parent = path.dirname(dir);
62
- if (parent === dir) return true;
63
- dir = parent;
64
- }
65
- }
66
-
67
43
  let loadsInFlight = 0;
68
44
  /** @type {typeof process.stdout.write | undefined} */
69
45
  let realStdoutWrite;
@@ -104,9 +80,9 @@ async function withStdoutOnStderr(load) {
104
80
  /**
105
81
  * Import a user-authored module. `.ts` is loaded via jiti; `.mjs`/`.js` use
106
82
  * native dynamic import for ordinary cached reads. A fresh `.ts` read uses a
107
- * no-cache jiti instance. A fresh CommonJS `.js` read evicts and reloads through
108
- * `require`; an ESM `.js` or `.mjs` read uses a cache-busted file URL. The
109
- * nearest package.json `type` decides `.js`, matching Node's package scopes.
83
+ * no-cache jiti instance. Any other fresh read drops the module from the
84
+ * require cache and imports a cache-busted file URL. Node then chooses its
85
+ * format as for the first read, so a fresh read gives what a first read gives.
110
86
  * Normal loads retain module caching. `fresh` rereads a file that changed
111
87
  * during this process: one a codemod rewrote, or an edited doc file (the doc
112
88
  * importers in ../doc-compiler/import.mjs read each change once).
@@ -251,31 +227,64 @@ async function importModule(file, fresh) {
251
227
  if (fresh && file.endsWith('.ts')) {
252
228
  return await createJiti(import.meta.url, {moduleCache: false}).import(file);
253
229
  }
254
- if (fresh && file.endsWith('.js') && isCommonJsFile(file)) {
255
- const resolved = requireFromHere.resolve(file);
256
- delete requireFromHere.cache[resolved];
257
- const value = requireFromHere(file);
258
- if (
259
- value != null &&
260
- typeof value === 'object' &&
261
- value.__esModule === true &&
262
- Object.prototype.hasOwnProperty.call(value, 'default')
263
- ) {
264
- return value;
265
- }
266
- return {
267
- ...(value != null && typeof value === 'object' ? value : {}),
268
- default: value,
269
- };
270
- }
271
230
  if (file.endsWith('.ts')) {
272
231
  return await getJiti().import(file);
273
232
  }
274
233
  const url = pathToFileURL(file);
275
- if (fresh) {
276
- url.searchParams.set('astryx-fresh', String(++freshImportNonce));
234
+ if (!fresh) return await readInTurn(file, () => import(url.href));
235
+ // Node chooses the format again, as for the first import: a package's
236
+ // "type", the extension, or the source's syntax. The query names a new ES
237
+ // module. A CommonJS module also needs its cache entry gone.
238
+ url.searchParams.set('astryx-fresh', String(++freshImportNonce));
239
+ return await readInTurn(file, () => {
240
+ delete requireFromHere.cache[cacheKey(file)];
241
+ return import(url.href);
242
+ });
243
+ }
244
+
245
+ /**
246
+ * The name Node's CommonJS cache gives `file`: its real path, so a link and
247
+ * its target name one entry. A file that does not resolve keeps its path.
248
+ * @param {string} file absolute path
249
+ * @returns {string}
250
+ */
251
+ function cacheKey(file) {
252
+ try {
253
+ return requireFromHere.resolve(file);
254
+ } catch {
255
+ return file;
277
256
  }
278
- return await import(url.href);
257
+ }
258
+
259
+ /**
260
+ * Each file's latest read, by its cache name, settled once that read ends.
261
+ * @type {Map<string, Promise<void>>}
262
+ */
263
+ const readsInProgress = new Map();
264
+
265
+ /**
266
+ * Run `read`, an import of `file`, once the imports of `file` started before
267
+ * it have ended. Imports of other files do not wait. A fresh read removes the
268
+ * file from Node's CommonJS cache. A CommonJS import still in progress needs
269
+ * that entry until it ends.
270
+ * @template T
271
+ * @param {string} file absolute path
272
+ * @param {() => Promise<T>} read
273
+ * @returns {Promise<T>}
274
+ */
275
+ export function readInTurn(file, read) {
276
+ const key = cacheKey(file);
277
+ const before = readsInProgress.get(key) ?? Promise.resolve();
278
+ const result = before.then(read);
279
+ const ended = result.then(
280
+ () => {},
281
+ () => {},
282
+ );
283
+ readsInProgress.set(key, ended);
284
+ void ended.then(() => {
285
+ if (readsInProgress.get(key) === ended) readsInProgress.delete(key);
286
+ });
287
+ return result;
279
288
  }
280
289
 
281
290
 
@@ -19,6 +19,11 @@
19
19
  * @param {number} [max] - The largest distance the caller can use. When
20
20
  * given, a larger distance answers `max + 1`, and only the cells within
21
21
  * `max` of the diagonal are computed.
22
+ * @param {{swaps?: boolean}} [options] - With `swaps` and a `max`, two
23
+ * adjacent letters swapped cost one edit, not two (the optimal string
24
+ * alignment distance).
22
25
  * @returns {number}
23
26
  */
24
- export function levenshteinDistance(a: string, b: string, max?: number): number;
27
+ export function levenshteinDistance(a: string, b: string, max?: number, { swaps }?: {
28
+ swaps?: boolean;
29
+ }): number;
@@ -19,11 +19,15 @@
19
19
  * @param {number} [max] - The largest distance the caller can use. When
20
20
  * given, a larger distance answers `max + 1`, and only the cells within
21
21
  * `max` of the diagonal are computed.
22
+ * @param {{swaps?: boolean}} [options] - With `swaps` and a `max`, two
23
+ * adjacent letters swapped cost one edit, not two (the optimal string
24
+ * alignment distance).
22
25
  * @returns {number}
23
26
  */
24
- export function levenshteinDistance(a, b, max) {
25
- if (max !== undefined) return distanceWithin(a, b, max);
26
- const m = a.length, n = b.length;
27
+ export function levenshteinDistance(a, b, max, {swaps = false} = {}) {
28
+ if (max !== undefined) return distanceWithin(a, b, max, swaps);
29
+ const m = a.length,
30
+ n = b.length;
27
31
  // Early exit: the distance is always ≥ |m − n|, so once the length gap
28
32
  // exceeds the loosest fuzzy-match threshold any caller uses (the hook
29
33
  // suggester keeps distance ≤ 5) the pair can never match — skip the O(m·n)
@@ -36,9 +40,10 @@ export function levenshteinDistance(a, b, max) {
36
40
  for (let j = 0; j <= n; j++) dp[0][j] = j;
37
41
  for (let i = 1; i <= m; i++) {
38
42
  for (let j = 1; j <= n; j++) {
39
- dp[i][j] = a[i-1] === b[j-1]
40
- ? dp[i-1][j-1]
41
- : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
43
+ dp[i][j] =
44
+ a[i - 1] === b[j - 1]
45
+ ? dp[i - 1][j - 1]
46
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
42
47
  }
43
48
  }
44
49
  return dp[m][n];
@@ -51,11 +56,13 @@ export function levenshteinDistance(a, b, max) {
51
56
  * @param {string} a
52
57
  * @param {string} b
53
58
  * @param {number} max
59
+ * @param {boolean} swaps - two adjacent letters swapped cost one edit
54
60
  * @returns {number}
55
61
  */
56
- function distanceWithin(a, b, max) {
62
+ function distanceWithin(a, b, max, swaps) {
57
63
  const over = max + 1;
58
64
  if (Math.abs(a.length - b.length) > max) return over;
65
+ let before = new Array(b.length + 1).fill(over);
59
66
  let previous = new Array(b.length + 1).fill(over);
60
67
  let current = new Array(b.length + 1).fill(over);
61
68
  for (let j = 0; j <= Math.min(b.length, max); j++) previous[j] = j;
@@ -67,18 +74,27 @@ function distanceWithin(a, b, max) {
67
74
  let rowMin = current[0];
68
75
  for (let j = from; j <= to; j++) {
69
76
  const cost = a[i - 1] === b[j - 1] ? 0 : 1;
70
- const value = Math.min(
77
+ let value = Math.min(
71
78
  previous[j - 1] + cost,
72
79
  previous[j] + 1,
73
80
  current[j - 1] + 1,
74
81
  over,
75
82
  );
83
+ if (
84
+ swaps &&
85
+ i > 1 &&
86
+ j > 1 &&
87
+ a[i - 1] === b[j - 2] &&
88
+ a[i - 2] === b[j - 1]
89
+ ) {
90
+ value = Math.min(value, before[j - 2] + 1);
91
+ }
76
92
  current[j] = value;
77
93
  if (value < rowMin) rowMin = value;
78
94
  }
79
95
  // Every later cell costs at least this row's least.
80
96
  if (rowMin >= over) return over;
81
- [previous, current] = [current, previous];
97
+ [before, previous, current] = [previous, current, before];
82
98
  }
83
99
  return Math.min(previous[b.length], over);
84
100
  }
@@ -18,15 +18,22 @@ export function nameWords(text: string): string[];
18
18
  * when it carries some of them, the items that carry none are left out. When
19
19
  * no item carries one, nothing is left out. The list only ever gets shorter,
20
20
  * and its order does not change.
21
+ *
22
+ * With `typos`, a near name (one or two slips from the typed name, as
23
+ * `ButonGroup` or `ButtonGorup` for ButtonGroup) ranks just below the names
24
+ * that carry every typed word and above the rest. With `closest` too, near
25
+ * names stay in the list whatever the cut leaves out, and the list is never
26
+ * longer than it is without `typos`.
21
27
  * @template T
22
28
  * @param {string} typedName
23
29
  * @param {T[]} items
24
30
  * @param {(item: T) => Array<string | undefined>} namesOf
25
- * @param {{closest?: boolean}} [options]
31
+ * @param {{closest?: boolean, typos?: boolean}} [options]
26
32
  * @returns {T[]}
27
33
  */
28
34
  export function orderByAffinity<T>(typedName: string, items: T[], namesOf: (item: T) => Array<string | undefined>, options?: {
29
35
  closest?: boolean;
36
+ typos?: boolean;
30
37
  }): T[];
31
38
  /**
32
39
  * Docs route suggestions, closest to the typed route first. When every
@@ -19,6 +19,12 @@
19
19
 
20
20
  import {levenshteinDistance} from './levenshtein.mjs';
21
21
 
22
+ /**
23
+ * The score of a near name (nearNameSlips), less a little per slip: above any
24
+ * name that carries only some of the typed words, below one that carries all
25
+ * of them.
26
+ */
27
+ const NEAR_NAME_SCORE = 19.6;
22
28
  /** The score a synonym match gets: an exact keyword's. */
23
29
  const SYNONYM_SCORE = 90;
24
30
  /** The score a name with exactly the typed words gets: an exact name's. */
@@ -59,17 +65,22 @@ export function nameWords(text) {
59
65
  * closer. Tiers, best first: the same words; every typed word present; some
60
66
  * typed word present; none. Within a tier, more of the typed words and more of
61
67
  * the candidate's own words matched rank higher, and a name that starts with
62
- * the typed name (or the reverse) gets a small lift.
68
+ * the typed name (or the reverse) gets a small lift. With `typos`, a near name
69
+ * (one or two slips from the typed name) ranks just below the names that
70
+ * carry every typed word.
63
71
  * @param {string[]} typed - {@link nameWords} of the typed name
64
72
  * @param {string} name
73
+ * @param {{typos?: boolean}} [options]
65
74
  * @returns {number}
66
75
  */
67
- function affinityOf(typed, name) {
76
+ function affinityOf(typed, name, {typos = false} = {}) {
68
77
  const words = nameWords(name);
69
78
  if (typed.length === 0 || words.length === 0) return 0;
70
79
  const typedJoined = typed.join('');
71
80
  const joined = words.join('');
72
81
  if (joined === typedJoined) return 40;
82
+ const slips = typos ? nearNameSlips(typedJoined, joined) : 0;
83
+ const near = slips > 0 ? NEAR_NAME_SCORE - slips * 0.2 : 0;
73
84
  const have = new Set(words);
74
85
  const want = new Set(typed);
75
86
  const covered = typed.filter(w => have.has(w)).length / typed.length;
@@ -77,7 +88,29 @@ function affinityOf(typed, name) {
77
88
  const prefix =
78
89
  joined.startsWith(typedJoined) || typedJoined.startsWith(joined) ? 1 : 0;
79
90
  const tier = covered === 1 ? 2 : covered > 0 ? 1 : 0;
80
- return tier * 10 + covered * 4 + precise * 2 + prefix + precise * 0.01;
91
+ // A near name keeps its word score when that is higher: a name that also
92
+ // carries every typed word ranks among the other full matches.
93
+ return Math.max(
94
+ near,
95
+ tier * 10 + covered * 4 + precise * 2 + prefix + precise * 0.01,
96
+ );
97
+ }
98
+
99
+ /**
100
+ * How many slips separate a typed name from a candidate, both with their
101
+ * words joined, when it is a near name: an edit or two adjacent letters
102
+ * swapped count as one slip, one slip is allowed from five letters and two from
103
+ * eight. Zero when it is not a near name.
104
+ * @param {string} typed
105
+ * @param {string} name
106
+ * @returns {number}
107
+ */
108
+ function nearNameSlips(typed, name) {
109
+ const shorter = Math.min(typed.length, name.length);
110
+ const cap = shorter >= 8 ? 2 : shorter >= 5 ? 1 : 0;
111
+ if (cap === 0 || Math.abs(typed.length - name.length) > cap) return 0;
112
+ const slips = levenshteinDistance(typed, name, cap, {swaps: true});
113
+ return slips <= cap ? slips : 0;
81
114
  }
82
115
 
83
116
  /**
@@ -87,14 +120,15 @@ function affinityOf(typed, name) {
87
120
  * @param {string} typedName
88
121
  * @param {T[]} items
89
122
  * @param {(item: T) => Array<string | undefined>} namesOf
123
+ * @param {{typos?: boolean}} [options]
90
124
  * @returns {Array<{item: T, best: number}>}
91
125
  */
92
- function scoreByAffinity(typedName, items, namesOf) {
126
+ function scoreByAffinity(typedName, items, namesOf, options = {}) {
93
127
  const typed = nameWords(typedName);
94
128
  const scored = items.map((item, index) => {
95
129
  let best = 0;
96
130
  for (const name of namesOf(item)) {
97
- if (name) best = Math.max(best, affinityOf(typed, name));
131
+ if (name) best = Math.max(best, affinityOf(typed, name, options));
98
132
  }
99
133
  return {item, index, best};
100
134
  });
@@ -112,14 +146,46 @@ function scoreByAffinity(typedName, items, namesOf) {
112
146
  * when it carries some of them, the items that carry none are left out. When
113
147
  * no item carries one, nothing is left out. The list only ever gets shorter,
114
148
  * and its order does not change.
149
+ *
150
+ * With `typos`, a near name (one or two slips from the typed name, as
151
+ * `ButonGroup` or `ButtonGorup` for ButtonGroup) ranks just below the names
152
+ * that carry every typed word and above the rest. With `closest` too, near
153
+ * names stay in the list whatever the cut leaves out, and the list is never
154
+ * longer than it is without `typos`.
115
155
  * @template T
116
156
  * @param {string} typedName
117
157
  * @param {T[]} items
118
158
  * @param {(item: T) => Array<string | undefined>} namesOf
119
- * @param {{closest?: boolean}} [options]
159
+ * @param {{closest?: boolean, typos?: boolean}} [options]
120
160
  * @returns {T[]}
121
161
  */
122
162
  export function orderByAffinity(typedName, items, namesOf, options = {}) {
163
+ if (!options.typos) return orderByWords(typedName, items, namesOf, options);
164
+ const scored = scoreByAffinity(typedName, items, namesOf, {typos: true});
165
+ if (!options.closest) return scored.map(s => s.item);
166
+ const kept = new Set(orderByWords(typedName, items, namesOf, options));
167
+ return scored
168
+ .filter(s => kept.has(s.item) || isNearScore(s.best))
169
+ .slice(0, kept.size)
170
+ .map(s => s.item);
171
+ }
172
+
173
+ /**
174
+ * Whether a score is a near name's.
175
+ * @param {number} score
176
+ */
177
+ const isNearScore = score => score < 20 && score > NEAR_NAME_SCORE - 1;
178
+
179
+ /**
180
+ * {@link orderByAffinity} without `typos`.
181
+ * @template T
182
+ * @param {string} typedName
183
+ * @param {T[]} items
184
+ * @param {(item: T) => Array<string | undefined>} namesOf
185
+ * @param {{closest?: boolean}} options
186
+ * @returns {T[]}
187
+ */
188
+ function orderByWords(typedName, items, namesOf, options) {
123
189
  const scored = scoreByAffinity(typedName, items, namesOf);
124
190
  if (!options.closest) return scored.map(s => s.item);
125
191
  // One-letter words (`a` in `a11y`, `use-a-theme`) say nothing about which
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/cli",
3
- "version": "0.7.12-canary.b87846c",
3
+ "version": "0.7.12-canary.c5cc8f4",
4
4
  "displayName": "CLI",
5
5
  "description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
6
6
  "author": "Meta Open Source",
@@ -125,7 +125,7 @@
125
125
  "zod": "^4.4.3"
126
126
  },
127
127
  "peerDependencies": {
128
- "@astryxdesign/core": "0.7.12-canary.b87846c"
128
+ "@astryxdesign/core": "0.7.12-canary.c5cc8f4"
129
129
  },
130
130
  "peerDependenciesMeta": {
131
131
  "@astryxdesign/core": {
@@ -133,10 +133,10 @@
133
133
  }
134
134
  },
135
135
  "devDependencies": {
136
- "@astryxdesign/charts": "0.7.12-canary.b87846c",
137
- "@astryxdesign/core": "0.7.12-canary.b87846c",
138
- "@astryxdesign/lab": "0.7.12-canary.b87846c",
139
- "@astryxdesign/theme-neutral": "0.7.12-canary.b87846c",
136
+ "@astryxdesign/charts": "0.7.12-canary.c5cc8f4",
137
+ "@astryxdesign/core": "0.7.12-canary.c5cc8f4",
138
+ "@astryxdesign/lab": "0.7.12-canary.c5cc8f4",
139
+ "@astryxdesign/theme-neutral": "0.7.12-canary.c5cc8f4",
140
140
  "@heroicons/react": "^2.2.0",
141
141
  "@stylexjs/stylex": "^0.19.0",
142
142
  "@types/babel__core": "^7.20.5",