@mindlogic-ai/logician-ui 4.0.0-alpha.43 → 4.0.0-alpha.44

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 (108) hide show
  1. package/dist/components/Button/Button.styles.d.ts.map +1 -1
  2. package/dist/components/Button/Button.styles.js +6 -2
  3. package/dist/components/Button/Button.styles.js.map +1 -1
  4. package/dist/components/Button/Button.styles.mjs +6 -2
  5. package/dist/components/Button/Button.styles.mjs.map +1 -1
  6. package/dist/components/Checkbox/Checkbox.styles.js +1 -1
  7. package/dist/components/Checkbox/Checkbox.styles.js.map +1 -1
  8. package/dist/components/Checkbox/Checkbox.styles.mjs +1 -1
  9. package/dist/components/Checkbox/Checkbox.styles.mjs.map +1 -1
  10. package/dist/components/ErrorFallback/ErrorFallback.d.ts.map +1 -1
  11. package/dist/components/ErrorFallback/ErrorFallback.js +4 -1
  12. package/dist/components/ErrorFallback/ErrorFallback.js.map +1 -1
  13. package/dist/components/ErrorFallback/ErrorFallback.mjs +4 -1
  14. package/dist/components/ErrorFallback/ErrorFallback.mjs.map +1 -1
  15. package/dist/components/IconButton/IconButton.d.ts.map +1 -1
  16. package/dist/components/IconButton/IconButton.js +6 -1
  17. package/dist/components/IconButton/IconButton.js.map +1 -1
  18. package/dist/components/IconButton/IconButton.mjs +6 -1
  19. package/dist/components/IconButton/IconButton.mjs.map +1 -1
  20. package/dist/components/InfoSprinkle/InfoSprinkle.d.ts.map +1 -1
  21. package/dist/components/InfoSprinkle/InfoSprinkle.js +1 -1
  22. package/dist/components/InfoSprinkle/InfoSprinkle.js.map +1 -1
  23. package/dist/components/InfoSprinkle/InfoSprinkle.mjs +1 -1
  24. package/dist/components/InfoSprinkle/InfoSprinkle.mjs.map +1 -1
  25. package/dist/components/Loaders/PageLoader.d.ts.map +1 -1
  26. package/dist/components/Loaders/PageLoader.js +4 -1
  27. package/dist/components/Loaders/PageLoader.js.map +1 -1
  28. package/dist/components/Loaders/PageLoader.mjs +4 -1
  29. package/dist/components/Loaders/PageLoader.mjs.map +1 -1
  30. package/dist/components/Loaders/SectionLoader.d.ts.map +1 -1
  31. package/dist/components/Loaders/SectionLoader.js +1 -1
  32. package/dist/components/Loaders/SectionLoader.js.map +1 -1
  33. package/dist/components/Loaders/SectionLoader.mjs +1 -1
  34. package/dist/components/Loaders/SectionLoader.mjs.map +1 -1
  35. package/dist/components/Menu/MenuList.js +2 -2
  36. package/dist/components/Menu/MenuList.js.map +1 -1
  37. package/dist/components/Menu/MenuList.mjs +2 -2
  38. package/dist/components/Menu/MenuList.mjs.map +1 -1
  39. package/dist/components/Popover/PopoverContent.js +2 -2
  40. package/dist/components/Popover/PopoverContent.js.map +1 -1
  41. package/dist/components/Popover/PopoverContent.mjs +2 -2
  42. package/dist/components/Popover/PopoverContent.mjs.map +1 -1
  43. package/dist/components/Select/Combobox.js +1 -1
  44. package/dist/components/Select/Combobox.js.map +1 -1
  45. package/dist/components/Select/Combobox.mjs +1 -1
  46. package/dist/components/Select/Combobox.mjs.map +1 -1
  47. package/dist/components/Select/Select.js +1 -1
  48. package/dist/components/Select/Select.js.map +1 -1
  49. package/dist/components/Select/Select.mjs +1 -1
  50. package/dist/components/Select/Select.mjs.map +1 -1
  51. package/dist/components/Tooltip/Tooltip.d.ts.map +1 -1
  52. package/dist/components/Tooltip/Tooltip.js +10 -1
  53. package/dist/components/Tooltip/Tooltip.js.map +1 -1
  54. package/dist/components/Tooltip/Tooltip.mjs +10 -1
  55. package/dist/components/Tooltip/Tooltip.mjs.map +1 -1
  56. package/dist/components/Workflow/canvas/GraphErrorBanner/GraphErrorBanner.d.ts.map +1 -1
  57. package/dist/components/Workflow/canvas/GraphErrorBanner/GraphErrorBanner.js +1 -1
  58. package/dist/components/Workflow/canvas/GraphErrorBanner/GraphErrorBanner.js.map +1 -1
  59. package/dist/components/Workflow/canvas/GraphErrorBanner/GraphErrorBanner.mjs +1 -1
  60. package/dist/components/Workflow/canvas/GraphErrorBanner/GraphErrorBanner.mjs.map +1 -1
  61. package/dist/theme/index.d.ts.map +1 -1
  62. package/dist/theme/index.js +2 -0
  63. package/dist/theme/index.js.map +1 -1
  64. package/dist/theme/index.mjs +2 -0
  65. package/dist/theme/index.mjs.map +1 -1
  66. package/dist/theme/motion.d.ts +74 -2
  67. package/dist/theme/motion.d.ts.map +1 -1
  68. package/dist/theme/motion.js +41 -8
  69. package/dist/theme/motion.js.map +1 -1
  70. package/dist/theme/motion.mjs +41 -9
  71. package/dist/theme/motion.mjs.map +1 -1
  72. package/dist/theme/recipes/accordion.d.ts +12 -0
  73. package/dist/theme/recipes/accordion.d.ts.map +1 -0
  74. package/dist/theme/recipes/accordion.js +25 -0
  75. package/dist/theme/recipes/accordion.js.map +1 -0
  76. package/dist/theme/recipes/accordion.mjs +23 -0
  77. package/dist/theme/recipes/accordion.mjs.map +1 -0
  78. package/dist/utils/focusRing.d.ts +7 -6
  79. package/dist/utils/focusRing.d.ts.map +1 -1
  80. package/dist/utils/focusRing.js +7 -4
  81. package/dist/utils/focusRing.js.map +1 -1
  82. package/dist/utils/focusRing.mjs +7 -4
  83. package/dist/utils/focusRing.mjs.map +1 -1
  84. package/package.json +1 -1
  85. package/src/components/Accordion/Accordion.presence.test.tsx +54 -0
  86. package/src/components/Button/Button.styles.ts +6 -2
  87. package/src/components/Checkbox/Checkbox.styles.ts +1 -1
  88. package/src/components/Collapsible/Collapsible.presence.test.tsx +2 -2
  89. package/src/components/ErrorFallback/ErrorFallback.tsx +3 -1
  90. package/src/components/IconButton/IconButton.tsx +7 -0
  91. package/src/components/InfoSprinkle/InfoSprinkle.tsx +0 -1
  92. package/src/components/Loaders/PageLoader.tsx +5 -2
  93. package/src/components/Loaders/SectionLoader.tsx +2 -1
  94. package/src/components/Menu/MenuList.tsx +2 -2
  95. package/src/components/Popover/PopoverContent.tsx +2 -2
  96. package/src/components/Select/Combobox.tsx +1 -1
  97. package/src/components/Select/Select.tsx +1 -1
  98. package/src/components/Tooltip/Tooltip.instant.test.tsx +43 -0
  99. package/src/components/Tooltip/Tooltip.tsx +11 -2
  100. package/src/components/Workflow/canvas/GraphErrorBanner/GraphErrorBanner.tsx +6 -3
  101. package/src/theme/Motion.stories.tsx +16 -10
  102. package/src/theme/animationStyles.test.ts +25 -9
  103. package/src/theme/claude.md +4 -2
  104. package/src/theme/index.ts +2 -0
  105. package/src/theme/motion.ts +43 -9
  106. package/src/theme/motionGuards.test.ts +2 -0
  107. package/src/theme/recipes/accordion.ts +20 -0
  108. package/src/utils/focusRing.ts +7 -4
@@ -59,9 +59,9 @@ export const MenuList = forwardRef<HTMLDivElement, MenuListProps>(
59
59
  p="1.5"
60
60
  // Chakra's recipe opens the menu in 150ms and closes it in 100 —
61
61
  // nearly symmetric, and shorter than everything else that appears
62
- // over the page. `presence` puts it on the house enter/exit ratio;
62
+ // over the page. `presence-anchored` puts it on the house enter/exit ratio;
63
63
  // the slide-from-the-trigger movement is the recipe's and stays.
64
- animationStyle="presence"
64
+ animationStyle="presence-anchored"
65
65
  {...rest}
66
66
  >
67
67
  <ScaledContext fontSize={baseFontSize}>{rows}</ScaledContext>
@@ -18,8 +18,8 @@ export const PopoverContent = forwardRef<HTMLDivElement, PopoverContentProps>(
18
18
  borderWidth="1px"
19
19
  borderColor="border.subtle"
20
20
  // Scales from its arrow's origin on the recipe's keyframes, on our
21
- // clock — see `presence`.
22
- animationStyle="presence"
21
+ // clock — see `presence-anchored`.
22
+ animationStyle="presence-anchored"
23
23
  {...props}
24
24
  >
25
25
  <ScaledContext fontSize={baseFontSize}>{children}</ScaledContext>
@@ -27,7 +27,7 @@ const Content = forwardRef<HTMLDivElement, ChakraCombobox.ContentProps>(
27
27
  ref={ref}
28
28
  // Chakra gives this one `animationDuration: 0s` on close — the list
29
29
  // vanishes mid-keystroke with no exit at all. Same policy as Select.
30
- animationStyle="presence"
30
+ animationStyle="presence-anchored"
31
31
  {...props}
32
32
  css={mergeCss(contentStyles, css)}
33
33
  />
@@ -29,7 +29,7 @@ const Content = forwardRef<HTMLDivElement, ChakraSelect.ContentProps>(
29
29
  ref={ref}
30
30
  // Chakra closes this one over `fastest` (50ms), which is fast enough to
31
31
  // read as the list being cut off rather than dismissed.
32
- animationStyle="presence"
32
+ animationStyle="presence-anchored"
33
33
  {...props}
34
34
  css={mergeCss(contentStyles, css)}
35
35
  />
@@ -0,0 +1,43 @@
1
+ import { ChakraProvider } from '@chakra-ui/react';
2
+ import { render } from '@testing-library/react';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { system } from '../../theme';
6
+ import { Tooltip } from './Tooltip';
7
+
8
+ /**
9
+ * zag marks a tooltip `data-instant` when the pointer moves to it from another
10
+ * open one. It skips the open delay there by itself; the animation is ours to
11
+ * skip. The rule has to outrank the presence clock's `:is([data-state=open])`,
12
+ * so this checks the emitted selector rather than trusting the cascade.
13
+ */
14
+ describe('Tooltip instant mode', () => {
15
+ it('drops the enter/exit animation when zag marks the content instant', () => {
16
+ const { getByRole } = render(
17
+ <ChakraProvider value={system}>
18
+ <Tooltip content="hint" open>
19
+ <button type="button">trigger</button>
20
+ </Tooltip>
21
+ </ChakraProvider>
22
+ );
23
+ const content = getByRole('tooltip');
24
+ const css = Array.from(document.querySelectorAll('style'))
25
+ .map((s) => s.textContent ?? '')
26
+ .join('\n');
27
+ const classes = content.className
28
+ .split(' ')
29
+ .filter((c) => c.startsWith('css-'));
30
+ const rule = classes
31
+ .flatMap((c) => [
32
+ ...css.matchAll(
33
+ new RegExp(
34
+ `\\.${c}\\[data-instant\\]\\[data-state\\]\\{([^}]*)\\}`,
35
+ 'g'
36
+ )
37
+ ),
38
+ ])
39
+ .map((m) => m[1])
40
+ .join(';');
41
+ expect(rule).toContain('animation-duration:0ms');
42
+ });
43
+ });
@@ -53,10 +53,19 @@ export const Tooltip = forwardRef(
53
53
  // ways — which is why a tooltip used to feel like it was being
54
54
  // peeled off the trigger. It has the shortest dwell of anything
55
55
  // here, so the exit being half the enter matters most.
56
- animationStyle="presence"
56
+ animationStyle="presence-anchored"
57
57
  {...contentProps}
58
58
  css={mergeCss(
59
- { '--tooltip-bg': 'var(--chakra-colors-gray-1200)' },
59
+ {
60
+ '--tooltip-bg': 'var(--chakra-colors-gray-1200)',
61
+ // zag sets `data-instant` when the pointer moves from one
62
+ // open tooltip to the next. It already skips the delay
63
+ // there; skip the enter/exit too, so sweeping a toolbar
64
+ // doesn't replay the animation on every button. The extra
65
+ // `[data-state]` outranks the presence clock's
66
+ // `:is([data-state=open])`.
67
+ '&[data-instant][data-state]': { animationDuration: '0ms' },
68
+ },
60
69
  contentProps?.css
61
70
  )}
62
71
  >
@@ -201,7 +201,8 @@ export function GraphErrorBanner({
201
201
  border="none"
202
202
  cursor="pointer"
203
203
  opacity={showValidating ? 0.6 : 1}
204
- transition="opacity 0.15s"
204
+ animationStyle="feedback"
205
+ transitionProperty="opacity"
205
206
  onClick={() => setOpen((o) => !o)}
206
207
  >
207
208
  <Text fontSize="sm" fontWeight="semibold" color="slate.1300">
@@ -211,7 +212,8 @@ export function GraphErrorBanner({
211
212
  boxSize="xs"
212
213
  color="slate.900"
213
214
  transform={open ? 'rotate(180deg)' : 'none'}
214
- transition="transform 0.15s"
215
+ animationStyle="feedback"
216
+ transitionProperty="transform"
215
217
  />
216
218
  </Box>
217
219
  {open ? (
@@ -221,7 +223,8 @@ export function GraphErrorBanner({
221
223
  maxH="80"
222
224
  overflowY="auto"
223
225
  opacity={showValidating ? 0.6 : 1}
224
- transition="opacity 0.15s"
226
+ animationStyle="feedback"
227
+ transitionProperty="opacity"
225
228
  borderTopWidth="1px"
226
229
  borderColor="slate.200"
227
230
  >
@@ -178,6 +178,11 @@ const VOCAB = [
178
178
  name: 'presence',
179
179
  line: 'animationStyle="presence"',
180
180
  },
181
+ {
182
+ when: '트리거에 붙은 팝업이 열리고 닫힌다',
183
+ name: 'presence-anchored',
184
+ line: 'animationStyle="presence-anchored"',
185
+ },
181
186
  {
182
187
  when: '목록이 차례로 도착한다',
183
188
  name: 'stagger',
@@ -733,7 +738,7 @@ export const Choose: Story = {
733
738
 
734
739
  <Preset
735
740
  name="press"
736
- timing="120ms · standard"
741
+ timing="120ms · emphasized"
737
742
  lead="포인터가 눌리는 순간의 접촉감. 유일하게 property를 안 적어도 되는 프리셋입니다 — 프레스는 언제나 scale이라 기본값이 들어 있습니다."
738
743
  code={`<Box
739
744
  scale="1"
@@ -805,7 +810,7 @@ export const Choose: Story = {
805
810
  <Preset
806
811
  name="presence"
807
812
  timing="진입 300ms · emphasized / 퇴장 150ms · standard"
808
- lead="열리고 닫히는 모든 것. 진입은 읽혀야 하고 퇴장은 이미 결정된 일이라 절반입니다. Menu · Popover · Tooltip · Select · Collapsible · Modal 여섯이 이 하나를 나눠 씁니다."
813
+ lead="열리고 닫히는 모든 것. 진입은 읽혀야 하고 퇴장은 이미 결정된 일이라 절반입니다. Modal · BottomSheet · Collapsible 은 300 / 150, 트리거에 붙은 Menu · Select · Combobox · Popover · Tooltip 은 presence-anchored 로 200 / 100 입니다."
809
814
  code={`<Menu.List animationStyle="presence" />
810
815
 
811
816
  // 무엇이 움직이는지는 각 recipe의 것이고, presence는 시계만 바꿉니다 —
@@ -819,10 +824,10 @@ export const Choose: Story = {
819
824
  <Stack gap={4}>
820
825
  <Stack gap={1}>
821
826
  {[
822
- ['Menu', '150 / 100 → 300 / 150'],
823
- ['Popover', '150 / 100 → 300 / 150'],
824
- ['Tooltip', '150 / 150 → 300 / 150'],
825
- ['Select', '150 / 50 → 300 / 150'],
827
+ ['Menu', '150 / 100 → 200 / 100 (anchored)'],
828
+ ['Popover', '150 / 100 → 200 / 100 (anchored)'],
829
+ ['Tooltip', '150 / 150 → 200 / 100 (anchored)'],
830
+ ['Select', '150 / 50 → 200 / 100 (anchored)'],
826
831
  ['Collapsible', '200 / 200 → 300 / 150'],
827
832
  ['Modal', '자체 keyframe + presence 시계'],
828
833
  ].map(([name, change]) => (
@@ -956,7 +961,7 @@ export const Choose: Story = {
956
961
  lead="한 요소 안에서 속성마다 다른 속도가 필요할 때만. Button은 프레스가 120ms인데 색은 150ms라 duration 하나로 표현이 안 됩니다. composite가 주는 것은 동작 줄이기 가드 하나뿐이고, 값은 반드시 토큰 var()로 적으세요."
957
962
  code={`// Button.styles.ts
958
963
  export const buttonTransition = [
959
- 'scale var(--chakra-durations-motion-press) var(--chakra-easings-standard)',
964
+ 'scale var(--chakra-durations-motion-press) var(--chakra-easings-emphasized)',
960
965
  'background-color var(--chakra-durations-fast) var(--chakra-easings-standard)',
961
966
  ].join(', ');
962
967
 
@@ -1087,7 +1092,8 @@ const OURS = [
1087
1092
 
1088
1093
  const CHAKRAS = [
1089
1094
  { name: 'fast', ms: 150, use: 'feedback 프리셋 · presence 퇴장' },
1090
- { name: 'moderate', ms: 200, use: '200ms가 필요하면 이걸 쓰세요' },
1095
+ { name: 'faster', ms: 100, use: 'presence-anchored 퇴장' },
1096
+ { name: 'moderate', ms: 200, use: 'presence-anchored 진입' },
1091
1097
  { name: 'slow', ms: 300, use: '⚠️ 우리 motion.slow(500ms)와 다른 값' },
1092
1098
  { name: 'slower', ms: 400, use: '⚠️ 우리 motion.slower(700ms)와 다른 값' },
1093
1099
  ];
@@ -1097,13 +1103,13 @@ const CURVES = [
1097
1103
  name: 'standard',
1098
1104
  d: 'M0,100 C40,100 20,0 100,0',
1099
1105
  half: '50%',
1100
- use: 'press · feedback · presence 퇴장',
1106
+ use: 'feedback',
1101
1107
  },
1102
1108
  {
1103
1109
  name: 'emphasized',
1104
1110
  d: 'M0,100 C22,0 36,0 100,0',
1105
1111
  half: '96%',
1106
- use: 'travel · presence 진입',
1112
+ use: 'press · travel · presence 진입·퇴장',
1107
1113
  },
1108
1114
  {
1109
1115
  name: 'overshoot',
@@ -106,7 +106,7 @@ describe('motion vocabulary', () => {
106
106
  const d = (n: string) => `var(--chakra-durations-${n})`;
107
107
  const e = (n: string) => `var(--chakra-easings-${n})`;
108
108
 
109
- expect(timing('press')).toEqual([d('motion-press'), e('standard')]);
109
+ expect(timing('press')).toEqual([d('motion-press'), e('emphasized')]);
110
110
  expect(timing('feedback')).toEqual([d('fast'), e('standard')]);
111
111
  expect(timing('travel')).toEqual([d('motion-base'), e('emphasized')]);
112
112
  expect(timing('spring')).toEqual([d('motion-base'), e('overshoot')]);
@@ -133,11 +133,12 @@ describe('the enter/exit policy', () => {
133
133
  // A guard on the guard: if the condition Chakra emits for `_open` ever
134
134
  // changes spelling, `withOpen` silently empties and every assertion below
135
135
  // passes over nothing.
136
- // `presence` is the only global preset with an open state — the Modal now
137
- // carries its own two keyframe names and borrows this clock. The walk stays
138
- // a walk rather than an assertion about `presence` alone, so the next
139
- // preset with an `_open` branch is covered the day it is written.
140
- expect(withOpen).toEqual(['presence']);
136
+ // `presence` and its shorter anchored twin are the only global presets
137
+ // with an open state — the Modal carries its own two keyframe names and
138
+ // borrows the `presence` clock. The walk stays a walk rather than an
139
+ // assertion about these two alone, so the next preset with an `_open`
140
+ // branch is covered the day it is written.
141
+ expect(withOpen).toEqual(['presence', 'presence-anchored']);
141
142
  });
142
143
 
143
144
  it('never declares an enter without the matching exit', () => {
@@ -162,8 +163,23 @@ describe('the enter/exit policy', () => {
162
163
  // The reason one preset can sit on a menu that slides, a popover that
163
164
  // scales and a collapsible that interpolates a measured height. A name here
164
165
  // would flatten all three into whatever it named.
165
- const presence = applied('presence') as Css;
166
- expect(presence[OPEN].animationName).toBeUndefined();
167
- expect(presence[CLOSED].animationName).toBeUndefined();
166
+ withOpen.forEach((name) => {
167
+ const css = applied(name) as Css;
168
+ expect(css[OPEN].animationName, name).toBeUndefined();
169
+ expect(css[CLOSED].animationName, name).toBeUndefined();
170
+ });
171
+ });
172
+
173
+ it('opens anchored popups faster than dialogs, on the same curves', () => {
174
+ const full = applied('presence') as Css;
175
+ const anchored = applied('presence-anchored') as Css;
176
+ expect(DURATION_MS[anchored[OPEN].animationDuration]).toBe(200);
177
+ expect(DURATION_MS[anchored[CLOSED].animationDuration]).toBe(100);
178
+ expect(anchored[OPEN].animationTimingFunction).toBe(
179
+ full[OPEN].animationTimingFunction
180
+ );
181
+ expect(anchored[CLOSED].animationTimingFunction).toBe(
182
+ full[CLOSED].animationTimingFunction
183
+ );
168
184
  });
169
185
  });
@@ -54,10 +54,11 @@ it never knows *what* moves, so `transitionProperty` stays at the call site.
54
54
  | `travel` | Something moving to a new position or size |
55
55
  | `spring` | A physical flip, or two things crossing |
56
56
  | `presence` | A part with **both** an open and a closed state |
57
+ | `presence-anchored` | The same, for a popup anchored to a trigger (Menu, Select, Combobox, Popover, Tooltip) |
57
58
  | `stagger` | Siblings arriving in sequence (with `staggerProps(index)`) |
58
59
  | `composite` | Escape hatch — one element needing two different clocks |
59
60
 
60
- **Seven is the whole list and that is policy.** A motion with one caller belongs
61
+ **Eight is the whole list and that is policy.** A motion with one caller belongs
61
62
  next to that caller, not here (see `Spinner.styles.ts`, `Checkbox.styles.ts`).
62
63
  Adding an eighth needs two real call sites that the existing seven cannot
63
64
  express.
@@ -71,7 +72,8 @@ them:
71
72
  default fails invisibly.
72
73
  - **`prefers-reduced-motion`** is handled. Loops are the exception and slow down
73
74
  rather than stopping — a frozen spinner reads as a dead request.
74
- - **Enter is 300ms, exit is 150ms** for `presence`, enforced by a test.
75
+ - **Enter is 300ms, exit is 150ms** for `presence` (Modal, BottomSheet,
76
+ Collapsible) and **200ms / 100ms** for `presence-anchored`, enforced by a test.
75
77
 
76
78
  Three things `animationStyle` accepts and should never be given — all three
77
79
  compile, and all three are caught by lint instead of by types, because Chakra
@@ -9,6 +9,7 @@ import {
9
9
  import { colors, semanticTokens } from './colors';
10
10
  import { globalCss } from './global';
11
11
  import { animationStyles, durations, easings, keyframes } from './motion';
12
+ import { accordionSlotRecipe } from './recipes/accordion';
12
13
  import { tableSlotRecipe } from './recipes/table';
13
14
 
14
15
  /**
@@ -333,6 +334,7 @@ const config = defineConfig({
333
334
  textarea: formControlLgOverride,
334
335
  },
335
336
  slotRecipes: {
337
+ accordion: accordionSlotRecipe,
336
338
  table: tableSlotRecipe,
337
339
  select: selectSlotRecipe,
338
340
  },
@@ -175,7 +175,8 @@ const REDUCED = { _motionReduce: { transitionDuration: 'motion.instant' } };
175
175
  * most of the distance early and settles. An exit has already been decided:
176
176
  * the reader dismissed the thing and is looking at what is behind it, so every
177
177
  * millisecond the leaving element still owns the screen is a millisecond of
178
- * waiting. Half the time, on the neutral curve.
178
+ * waiting. Half the time, on the same ease-out — a symmetric curve would
179
+ * start the exit slowly, which is the one place a dismissal must not lag.
179
180
  *
180
181
  * Only the *clock* is here, never `animation-name`. Each Chakra recipe already
181
182
  * names the right movement for its part — the menu slides from its trigger,
@@ -192,14 +193,14 @@ const REDUCED = { _motionReduce: { transitionDuration: 'motion.instant' } };
192
193
  * at all reads as a page swap. The movement is what has to go, not the fact
193
194
  * that something changed.
194
195
  */
195
- const PRESENCE_TIMING = {
196
+ export const PRESENCE_TIMING = {
196
197
  _open: {
197
198
  animationDuration: 'motion.base',
198
199
  animationTimingFunction: 'emphasized',
199
200
  },
200
201
  _closed: {
201
202
  animationDuration: 'fast',
202
- animationTimingFunction: 'standard',
203
+ animationTimingFunction: 'emphasized',
203
204
  },
204
205
  _motionReduce: {
205
206
  _open: { animationName: 'fade-in' },
@@ -207,6 +208,27 @@ const PRESENCE_TIMING = {
207
208
  },
208
209
  } as const;
209
210
 
211
+ /**
212
+ * The same policy, on a shorter clock, for parts anchored to a trigger —
213
+ * Menu, Select, Combobox, Popover, Tooltip.
214
+ *
215
+ * These open tens of times a day, under a pointer that is already on its way
216
+ * to the first item, so the enter has much less to be read than a dialog does:
217
+ * the reader asked for it and knows where it will appear. 300ms there is twice
218
+ * Chakra's stock 150ms and feels like waiting; 200ms keeps a visible slide from
219
+ * the trigger and finishes before the pointer arrives. The exit keeps the
220
+ * half-the-enter ratio. Curves and the reduced-motion fade come from
221
+ * {@link PRESENCE_TIMING}, so the two cannot drift apart.
222
+ *
223
+ * Modal, BottomSheet and Collapsible stay on the full `presence` clock — they
224
+ * move more of the screen and are not anchored to anything.
225
+ */
226
+ const ANCHORED_PRESENCE_TIMING = {
227
+ ...PRESENCE_TIMING,
228
+ _open: { ...PRESENCE_TIMING._open, animationDuration: 'moderate' },
229
+ _closed: { ...PRESENCE_TIMING._closed, animationDuration: 'faster' },
230
+ } as const;
231
+
210
232
  /**
211
233
  * Dash length for the `checkmark-draw` keyframe. Declared once so the pattern
212
234
  * the tick is hidden behind and the offset the keyframe animates away are the
@@ -239,7 +261,9 @@ export const animationStyles = {
239
261
  // The one preset with a real default: a press is a scale, always.
240
262
  transitionProperty: 'scale',
241
263
  transitionDuration: 'motion.press',
242
- transitionTimingFunction: 'standard',
264
+ // Ease-out, not `standard`: `standard` starts slowly, so the first frames
265
+ // of the press lag behind the finger.
266
+ transitionTimingFunction: 'emphasized',
243
267
  ...REDUCED,
244
268
  },
245
269
  },
@@ -300,6 +324,15 @@ export const animationStyles = {
300
324
  value: { ...PRESENCE_TIMING },
301
325
  },
302
326
 
327
+ /**
328
+ * `presence` for a part anchored to its trigger — menu, select, combobox,
329
+ * popover and tooltip content. 200ms in, 100ms out; see
330
+ * {@link ANCHORED_PRESENCE_TIMING}.
331
+ */
332
+ 'presence-anchored': {
333
+ value: { ...ANCHORED_PRESENCE_TIMING },
334
+ },
335
+
303
336
  /**
304
337
  * List items arriving one after another instead of all at once.
305
338
  *
@@ -365,12 +398,13 @@ export const keyframes = {
365
398
  from: { opacity: '1', scale: '1' },
366
399
  to: { opacity: '0', scale: '0.97' },
367
400
  },
368
- // The dot only mounts on check, so it grows from nothing. `0.4` is where
369
- // Chakra's radiomark recipe rests it; ending anywhere else would make the dot
370
- // jump the moment the animation hands back.
401
+ // The dot only mounts on check. It starts small and transparent rather than
402
+ // at `scale: 0` — nothing real appears from nothing, and a dot growing from a
403
+ // point reads as a glitch. `0.4` is where Chakra's radiomark recipe rests it;
404
+ // ending anywhere else would make the dot jump when the animation hands back.
371
405
  'dot-pop': {
372
- from: { scale: '0' },
373
- to: { scale: '0.4' },
406
+ from: { scale: '0.25', opacity: '0' },
407
+ to: { scale: '0.4', opacity: '1' },
374
408
  },
375
409
  'checkmark-draw': {
376
410
  // Negative, not positive. Chakra's polyline runs `20 6 → 9 17 → 4 12`, so
@@ -37,6 +37,7 @@ const CASES = {
37
37
  // The two presets that carry their own scope and must not be asked for one.
38
38
  pressNeedsNoScope: `<div animationStyle="press" />`,
39
39
  presenceNeedsNoScope: `<div animationStyle="presence" />`,
40
+ anchoredNeedsNoScope: `<div animationStyle="presence-anchored" />`,
40
41
  };
41
42
 
42
43
  type Case = keyof typeof CASES;
@@ -126,6 +127,7 @@ describe('the scope guard', () => {
126
127
  it.each([
127
128
  ['press', 'pressNeedsNoScope'],
128
129
  ['presence', 'presenceNeedsNoScope'],
130
+ ['presence-anchored', 'anchoredNeedsNoScope'],
129
131
  ] as const)('leaves %s alone, since it carries its own scope', (_n, key) => {
130
132
  // press defaults to `scale`; presence is an `animation-*` preset and its
131
133
  // keyframe already names what moves. Asking either for a property would
@@ -0,0 +1,20 @@
1
+ import { defineSlotRecipe } from '@chakra-ui/react';
2
+
3
+ import { PRESENCE_TIMING } from '../motion';
4
+
5
+ /**
6
+ * Logician overrides for Chakra's `accordion` slot recipe.
7
+ *
8
+ * Only the clock. Chakra opens and closes the item content over a symmetric
9
+ * 200 / 200, while `Collapsible` — the same height animation — already runs on
10
+ * the house `presence` policy (300 in, 150 out). Consumers render
11
+ * `Accordion.ItemContent` straight from Chakra, so a component prop cannot
12
+ * reach it; the recipe can. The recipe's own `expand-height` /
13
+ * `collapse-height` names are untouched and still decide what moves.
14
+ */
15
+ export const accordionSlotRecipe = defineSlotRecipe({
16
+ slots: ['itemContent'],
17
+ base: {
18
+ itemContent: { ...PRESENCE_TIMING },
19
+ },
20
+ });
@@ -2,8 +2,13 @@
2
2
  * Standard focus ring styles for interactive components.
3
3
  *
4
4
  * - _focus: suppresses the browser's default outline on all focus events (including mouse)
5
- * - _focusVisible: shows the ring only on keyboard focus (CSS :focus-visible),
6
- * with a 0.15s ease-out transition on box-shadow
5
+ * - _focusVisible: shows the ring only on keyboard focus (CSS :focus-visible)
6
+ *
7
+ * No transition, on purpose. The ring answers a key press, and keyboard
8
+ * actions should land instantly — Tab is pressed hundreds of times a day. It
9
+ * also used to be a `transition` *shorthand* under `:focus-visible`, which
10
+ * replaced the element's whole transition list while focused, so a
11
+ * keyboard-focused Switch or Checkbox snapped its fill instead of fading it.
7
12
  */
8
13
  export const focusRing = {
9
14
  _focus: { outline: 'none' },
@@ -11,7 +16,5 @@ export const focusRing = {
11
16
  outline: 'none',
12
17
  boxShadow:
13
18
  'rgb(255, 255, 255) 0px 0px 0px 2px, var(--chakra-colors-primary-main) 0px 0px 0px 4px',
14
- transition: 'box-shadow 0.15s ease-out',
15
- _motionReduce: { transition: 'none' },
16
19
  },
17
20
  } as const;