@startsimpli/ui 0.4.160 → 0.4.161
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/components/collection/ReviewDrawer.tsx +4 -11
- package/src/components/collection/__tests__/review-drawer-chips.test.tsx +88 -0
- package/src/components/document-editor/DocumentEditor.tsx +11 -1
- package/src/components/document-editor/__tests__/DocumentEditor.test.tsx +16 -2
- package/src/components/history/RecordHistoryPanel.tsx +21 -1
- package/src/components/history/__tests__/record-history-panel.test.tsx +31 -0
- package/src/components/history/stale-save-dialog-default-class-names.ts +3 -1
- package/src/components/navigation/__tests__/grouped-nav.test.tsx +9 -0
- package/src/components/navigation/grouped-nav.tsx +3 -1
- package/src/components/track-changes/ChangesRail.tsx +2 -2
- package/src/components/track-changes/TrackedTextEditor.tsx +3 -1
- package/src/components/track-changes/__tests__/rails.test.tsx +17 -0
- package/src/components/track-changes/__tests__/tracked-text-editor.test.tsx +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.161",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -152,8 +152,8 @@
|
|
|
152
152
|
"tailwindcss": "^3.4.19",
|
|
153
153
|
"tsup": "^8.5.1",
|
|
154
154
|
"typescript": "^6.0.3",
|
|
155
|
-
"@startsimpli/
|
|
156
|
-
"@startsimpli/
|
|
155
|
+
"@startsimpli/i18n": "0.1.3",
|
|
156
|
+
"@startsimpli/realtime": "0.0.2"
|
|
157
157
|
},
|
|
158
158
|
"keywords": [
|
|
159
159
|
"ui",
|
|
@@ -299,7 +299,9 @@ function ReviewDrawerInner({
|
|
|
299
299
|
{status.replace(/_/g, ' ')}
|
|
300
300
|
</span>
|
|
301
301
|
) : null
|
|
302
|
-
) : cfg.statusChoices.length > 0 ? (
|
|
302
|
+
) : mode !== 'edit' && cfg.statusChoices.length > 0 ? (
|
|
303
|
+
// Not while editing fields: the form carries the same status field,
|
|
304
|
+
// and two controls for one value can be set apart (bd startsim-uc9fz).
|
|
303
305
|
<Select value={status || undefined} onValueChange={overrideStatus}>
|
|
304
306
|
<SelectTrigger className="h-7 w-auto gap-1 border-border text-xs capitalize">
|
|
305
307
|
<SelectValue placeholder="State…" />
|
|
@@ -379,7 +381,7 @@ function ReviewDrawerInner({
|
|
|
379
381
|
<h2 className="text-lg font-semibold leading-snug text-foreground">{title}</h2>
|
|
380
382
|
|
|
381
383
|
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
|
|
382
|
-
{rank ? (
|
|
384
|
+
{rank && Number(rank) !== 0 ? (
|
|
383
385
|
<span className="rounded-full bg-primary/10 px-2 py-0.5 font-semibold text-primary">
|
|
384
386
|
rank {rank}
|
|
385
387
|
</span>
|
|
@@ -514,19 +516,10 @@ function DecisionButton({
|
|
|
514
516
|
<Icon className="h-4 w-4" />
|
|
515
517
|
{busy ? 'Saving…' : decision.label}
|
|
516
518
|
</span>
|
|
517
|
-
<span className={cn('text-[10px] font-normal', decision.tone === 'good' ? 'text-white/80' : 'opacity-70')}>
|
|
518
|
-
{decisionHint(decision)}
|
|
519
|
-
</span>
|
|
520
519
|
</button>
|
|
521
520
|
);
|
|
522
521
|
}
|
|
523
522
|
|
|
524
|
-
/** The tiny "→ ready · good" outcome hint under a decision. */
|
|
525
|
-
function decisionHint(d: ReviewDecision): string {
|
|
526
|
-
const parts = [d.status ? `→ ${d.status}` : null, d.verdict].filter(Boolean);
|
|
527
|
-
return parts.join(' · ');
|
|
528
|
-
}
|
|
529
|
-
|
|
530
523
|
/** Source chips: host + optional tier badge, linked when a url is present. */
|
|
531
524
|
function ReviewSources({
|
|
532
525
|
data,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The topic drawer's header chips and decision buttons read as plain words
|
|
3
|
+
* (bd startsim-piff1): a rank of 0 is no rank, and the buttons no longer carry
|
|
4
|
+
* the raw "→ ready · good" status/verdict hint.
|
|
5
|
+
*/
|
|
6
|
+
import * as React from 'react';
|
|
7
|
+
import { render, screen } from '@testing-library/react';
|
|
8
|
+
import userEvent from '@testing-library/user-event';
|
|
9
|
+
|
|
10
|
+
import { ReviewDrawer } from '../ReviewDrawer';
|
|
11
|
+
import type { CollectionClient, EntityRecord, EntityTypeDef, Paginated } from '../types';
|
|
12
|
+
|
|
13
|
+
const TOPIC_TYPE: EntityTypeDef = {
|
|
14
|
+
id: 1,
|
|
15
|
+
key: 'topic',
|
|
16
|
+
label: 'Topic',
|
|
17
|
+
attributes: [
|
|
18
|
+
{ id: 'status', name: 'status', dataType: 'enum', required: false, config: { choices: ['suggested', 'ready', 'written', 'rejected'] } },
|
|
19
|
+
{ id: 'ai_rank', name: 'ai_rank', dataType: 'number', required: false, config: {} },
|
|
20
|
+
{ id: 'team_verdict', name: 'team_verdict', dataType: 'text', required: false, config: {} },
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function topic(rank: unknown): EntityRecord {
|
|
25
|
+
return {
|
|
26
|
+
id: 1,
|
|
27
|
+
entityType: 'topic',
|
|
28
|
+
externalId: null,
|
|
29
|
+
name: 'Topic 1',
|
|
30
|
+
data: { status: 'suggested', ai_rank: rank },
|
|
31
|
+
createdAt: '2026-09-01T00:00:00Z',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function page<T>(results: T[]): Paginated<T> {
|
|
36
|
+
return { count: results.length, next: null, previous: null, results };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function client(): CollectionClient {
|
|
40
|
+
return {
|
|
41
|
+
listTypes: jest.fn(async () => page([TOPIC_TYPE])),
|
|
42
|
+
listAllEntities: jest.fn(async () => []),
|
|
43
|
+
updateEntity: jest.fn(),
|
|
44
|
+
} as unknown as CollectionClient;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderDrawer(rank: unknown) {
|
|
48
|
+
const r = topic(rank);
|
|
49
|
+
render(
|
|
50
|
+
<ReviewDrawer
|
|
51
|
+
client={client()}
|
|
52
|
+
type={TOPIC_TYPE}
|
|
53
|
+
records={[r]}
|
|
54
|
+
record={r}
|
|
55
|
+
onClose={jest.fn()}
|
|
56
|
+
onNavigate={jest.fn()}
|
|
57
|
+
renderEditFields={() => <p>the form</p>}
|
|
58
|
+
/>,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('ReviewDrawer header chips', () => {
|
|
63
|
+
it('hides a rank of 0', () => {
|
|
64
|
+
renderDrawer(0);
|
|
65
|
+
expect(screen.queryByText(/rank 0/)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('still shows a real rank', () => {
|
|
69
|
+
renderDrawer(7);
|
|
70
|
+
expect(screen.getByText('rank 7')).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('decision buttons carry no raw status/verdict hint', () => {
|
|
74
|
+
renderDrawer(7);
|
|
75
|
+
expect(screen.queryByText(/→/)).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('ReviewDrawer edit mode (bd startsim-uc9fz)', () => {
|
|
80
|
+
it('drops the header State select while the field form is open', async () => {
|
|
81
|
+
const user = userEvent.setup();
|
|
82
|
+
renderDrawer(7);
|
|
83
|
+
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
|
84
|
+
await user.click(screen.getByRole('button', { name: /Edit fields/ }));
|
|
85
|
+
expect(screen.getByText('the form')).toBeInTheDocument();
|
|
86
|
+
expect(screen.queryByRole('combobox')).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -352,6 +352,16 @@ function TextSection({
|
|
|
352
352
|
);
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
+
/** "primaryKeyword" / "meta_description" → "Primary keyword" / "Meta description". */
|
|
356
|
+
function structuredFieldLabel(key: string): string {
|
|
357
|
+
const spaced = key
|
|
358
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
359
|
+
.replace(/[_-]+/g, ' ')
|
|
360
|
+
.trim()
|
|
361
|
+
.toLowerCase();
|
|
362
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
363
|
+
}
|
|
364
|
+
|
|
355
365
|
function StructuredSection({
|
|
356
366
|
section,
|
|
357
367
|
readOnly,
|
|
@@ -377,7 +387,7 @@ function StructuredSection({
|
|
|
377
387
|
return (
|
|
378
388
|
<div key={field} className="grid gap-1 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-start sm:gap-3">
|
|
379
389
|
<label htmlFor={`${section.key}-${field}`} className="pt-1.5 text-xs font-medium text-muted-foreground">
|
|
380
|
-
{field
|
|
390
|
+
{structuredFieldLabel(field)}
|
|
381
391
|
</label>
|
|
382
392
|
<div>
|
|
383
393
|
{readOnly ? (
|
|
@@ -73,12 +73,12 @@ describe('DocumentEditor', () => {
|
|
|
73
73
|
|
|
74
74
|
it('structured section renders a field input and an SEO char meter on meta_description', () => {
|
|
75
75
|
render(<Harness />);
|
|
76
|
-
const metaInput = screen.getByLabelText('
|
|
76
|
+
const metaInput = screen.getByLabelText('Meta description') as HTMLInputElement;
|
|
77
77
|
expect(metaInput.value).toBe('short');
|
|
78
78
|
// the meta meter surfaces the aim band
|
|
79
79
|
expect(screen.getByText(/aim 150/)).toBeInTheDocument();
|
|
80
80
|
fireEvent.change(metaInput, { target: { value: 'new description' } });
|
|
81
|
-
expect((screen.getByLabelText('
|
|
81
|
+
expect((screen.getByLabelText('Meta description') as HTMLInputElement).value).toBe('new description');
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
it('list section adds, edits and removes items', () => {
|
|
@@ -123,3 +123,17 @@ describe('DocumentEditor', () => {
|
|
|
123
123
|
expect(onSave).not.toHaveBeenCalled();
|
|
124
124
|
});
|
|
125
125
|
});
|
|
126
|
+
|
|
127
|
+
describe('DocumentEditor structured labels (bd startsim-5w5p6)', () => {
|
|
128
|
+
it('labels camelCase and snake_case keys in plain words', () => {
|
|
129
|
+
render(
|
|
130
|
+
<DocumentEditor
|
|
131
|
+
sections={[{ key: 'seo', label: 'SEO', kind: 'structured', value: { primaryKeyword: 'kw', meta_description: 'm', tags: 't' } }]}
|
|
132
|
+
onChange={() => {}}
|
|
133
|
+
/>,
|
|
134
|
+
);
|
|
135
|
+
expect(screen.getByText('Primary keyword')).toBeInTheDocument();
|
|
136
|
+
expect(screen.getByText('Meta description')).toBeInTheDocument();
|
|
137
|
+
expect(screen.getByText('Tags')).toBeInTheDocument();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -169,6 +169,9 @@ export interface RecordHistoryPanelProps {
|
|
|
169
169
|
headerAside?: ReactNode;
|
|
170
170
|
/** Map a wire field name to a reader-facing label. */
|
|
171
171
|
fieldLabel?: (field: string) => string;
|
|
172
|
+
/** Leave a field out of every entry — its row, and the entry's field count —
|
|
173
|
+
* e.g. a host's system bookkeeping keys (`_run_id`). Nothing hidden by default. */
|
|
174
|
+
hideField?: (field: string) => boolean;
|
|
172
175
|
/** Group entries under day headings. On by default — a record's history is
|
|
173
176
|
* read by date far more often than as a flat list. */
|
|
174
177
|
groupByDay?: boolean;
|
|
@@ -226,6 +229,19 @@ export interface RecordHistoryPanelProps {
|
|
|
226
229
|
classNames?: RecordHistoryPanelClassNames;
|
|
227
230
|
}
|
|
228
231
|
|
|
232
|
+
/** A sitting with the host's hidden fields taken out, so the headline's count
|
|
233
|
+
* and the rows under it agree. */
|
|
234
|
+
function withoutFields(sitting: RevisionSitting, hide: (field: string) => boolean): RevisionSitting {
|
|
235
|
+
const keep = (f: string) => !hide(f);
|
|
236
|
+
return {
|
|
237
|
+
...sitting,
|
|
238
|
+
changed: Object.fromEntries(Object.entries(sitting.changed).filter(([f]) => keep(f))),
|
|
239
|
+
preserved: sitting.preserved.filter(keep),
|
|
240
|
+
regenerate: sitting.regenerate.filter(keep),
|
|
241
|
+
redacted: sitting.redacted.filter(keep),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
229
245
|
export function RecordHistoryPanel({
|
|
230
246
|
client,
|
|
231
247
|
queryKey = ['record-history'],
|
|
@@ -236,6 +252,7 @@ export function RecordHistoryPanel({
|
|
|
236
252
|
description = 'Who changed which field, from what to what, newest first.',
|
|
237
253
|
headerAside,
|
|
238
254
|
fieldLabel = (field) => field,
|
|
255
|
+
hideField,
|
|
239
256
|
groupByDay = true,
|
|
240
257
|
emptyTitle = 'No edits recorded for this record',
|
|
241
258
|
// NOT "this record has never changed" — see sentence 3 in the header.
|
|
@@ -427,7 +444,10 @@ export function RecordHistoryPanel({
|
|
|
427
444
|
}, []);
|
|
428
445
|
|
|
429
446
|
// THE FOLD RUNS OVER THE ACCUMULATION, never per page. See the header.
|
|
430
|
-
const sittings = useMemo(() =>
|
|
447
|
+
const sittings = useMemo(() => {
|
|
448
|
+
const folded = foldRevisions(revisions, windowMs);
|
|
449
|
+
return hideField ? folded.map((s) => withoutFields(s, hideField)) : folded;
|
|
450
|
+
}, [revisions, windowMs, hideField]);
|
|
431
451
|
|
|
432
452
|
const items = useMemo<ActivityTimelineItem[]>(
|
|
433
453
|
() =>
|
|
@@ -458,3 +458,34 @@ describe('RecordHistoryPanel — the unrecorded-kind sentence is scoped (bd star
|
|
|
458
458
|
expect(note.textContent).toMatch(/for the rest/i)
|
|
459
459
|
})
|
|
460
460
|
})
|
|
461
|
+
|
|
462
|
+
describe('RecordHistoryPanel — hideField (bd startsim-mpjbi, startsim-l1ya5)', () => {
|
|
463
|
+
it('leaves hidden fields out of the rows AND the entry count', async () => {
|
|
464
|
+
mount(
|
|
465
|
+
<RecordHistoryPanel
|
|
466
|
+
hideField={(f) => f.startsWith('_')}
|
|
467
|
+
fieldLabel={(f) => (f === 'team_verdict' ? 'Team verdict' : f)}
|
|
468
|
+
client={clientFor(
|
|
469
|
+
page([
|
|
470
|
+
rev({
|
|
471
|
+
version: 1,
|
|
472
|
+
action: 'created',
|
|
473
|
+
metadata: {
|
|
474
|
+
changed: {
|
|
475
|
+
_run_id: { before: null, after: 'r1' },
|
|
476
|
+
_origin: { before: null, after: 'n8n' },
|
|
477
|
+
blog: { before: null, after: 'x' },
|
|
478
|
+
team_verdict: { before: null, after: 'good' },
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
}),
|
|
482
|
+
]),
|
|
483
|
+
)}
|
|
484
|
+
/>,
|
|
485
|
+
)
|
|
486
|
+
await screen.findByTestId('record-history-panel')
|
|
487
|
+
expect(await screen.findByText(/Created the record with 2 fields/)).toBeInTheDocument()
|
|
488
|
+
expect(screen.queryByText('_run_id')).toBeNull()
|
|
489
|
+
expect(screen.queryByText('_origin')).toBeNull()
|
|
490
|
+
})
|
|
491
|
+
})
|
|
@@ -41,5 +41,7 @@ export const STALE_SAVE_DIALOG_DEFAULTS: Required<StaleSaveDialogClassNames> = {
|
|
|
41
41
|
// Underplayed ON PURPOSE. It is the only control here that destroys the text
|
|
42
42
|
// this dialog exists to protect, so it must never be the thing a hurried
|
|
43
43
|
// reviewer clicks by muscle memory.
|
|
44
|
-
|
|
44
|
+
// Red so it does not read as a help link, and pushed to the far left, apart
|
|
45
|
+
// from the two safe buttons.
|
|
46
|
+
destructiveAction: 'me-auto text-xs text-destructive underline underline-offset-2 disabled:opacity-50',
|
|
45
47
|
};
|
|
@@ -67,3 +67,12 @@ describe('GroupedNav', () => {
|
|
|
67
67
|
expect(link).toHaveAttribute('data-router-link', '1')
|
|
68
68
|
})
|
|
69
69
|
})
|
|
70
|
+
|
|
71
|
+
describe('GroupedNav: a group holding the current page (bd startsim-m7fdm.39)', () => {
|
|
72
|
+
it('draws its header as a plain heading, not a collapse button that does nothing', () => {
|
|
73
|
+
render(<GroupedNav items={items} activeHref="/t/general" />)
|
|
74
|
+
expect(screen.queryByRole('button', { name: /Content/ })).toBeNull()
|
|
75
|
+
expect(screen.getByText('Content')).toBeInTheDocument()
|
|
76
|
+
expect(screen.getByText('General')).toBeInTheDocument()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -170,7 +170,9 @@ function NavGroup({
|
|
|
170
170
|
|
|
171
171
|
return (
|
|
172
172
|
<div className="pt-3 first:pt-0">
|
|
173
|
-
{
|
|
173
|
+
{/* A group holding the current page cannot close, so its header is not
|
|
174
|
+
drawn as a control that does nothing (bd startsim-m7fdm.39). */}
|
|
175
|
+
{collapsible && !hasActiveChild ? (
|
|
174
176
|
<button
|
|
175
177
|
type="button"
|
|
176
178
|
onClick={() => setOpen((o) => !o)}
|
|
@@ -184,12 +184,12 @@ export function ChangesRail(props: ChangesRailProps) {
|
|
|
184
184
|
</h3>
|
|
185
185
|
<div className="flex flex-wrap gap-1">
|
|
186
186
|
{onPrevChange && (
|
|
187
|
-
<button type="button" className={btn} onClick={onPrevChange} title="Shift+F8 in the text">
|
|
187
|
+
<button type="button" className={btn} onClick={onPrevChange} disabled={open.length === 0} title="Shift+F8 in the text">
|
|
188
188
|
Previous
|
|
189
189
|
</button>
|
|
190
190
|
)}
|
|
191
191
|
{onNextChange && (
|
|
192
|
-
<button type="button" className={btn} onClick={onNextChange} title="F8 in the text">
|
|
192
|
+
<button type="button" className={btn} onClick={onNextChange} disabled={open.length === 0} title="F8 in the text">
|
|
193
193
|
Next change
|
|
194
194
|
</button>
|
|
195
195
|
)}
|
|
@@ -531,7 +531,9 @@ export const TrackedTextEditor = forwardRef<TrackedTextEditorHandle, TrackedText
|
|
|
531
531
|
: undefined;
|
|
532
532
|
|
|
533
533
|
let proposalLine: string | null = null;
|
|
534
|
-
|
|
534
|
+
// Nothing typed and nothing in flight: no line. "0 changes" read as a count
|
|
535
|
+
// of saved suggestions sitting right above the rail's own count.
|
|
536
|
+
if (mode === 'suggesting' && canSuggest && (localCount > 0 || proposal.state !== 'idle')) {
|
|
535
537
|
const n = `${localCount} ${localCount === 1 ? 'change' : 'changes'}`;
|
|
536
538
|
proposalLine =
|
|
537
539
|
proposal.state === 'error'
|
|
@@ -624,3 +624,20 @@ describe('TrackedField', () => {
|
|
|
624
624
|
)
|
|
625
625
|
})
|
|
626
626
|
})
|
|
627
|
+
|
|
628
|
+
describe('ChangesRail: nothing to step to (bd startsim-5378r)', () => {
|
|
629
|
+
it('disables Previous and Next change when there are no open items', () => {
|
|
630
|
+
render(
|
|
631
|
+
<ChangesRail fieldLabel="Blog" currentActorSub="ada" suggestions={[]} threads={[]} onPrevChange={jest.fn()} onNextChange={jest.fn()} />,
|
|
632
|
+
)
|
|
633
|
+
expect(screen.getByRole('button', { name: 'Previous' })).toBeDisabled()
|
|
634
|
+
expect(screen.getByRole('button', { name: 'Next change' })).toBeDisabled()
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
it('enables them when there is something to step to', () => {
|
|
638
|
+
render(
|
|
639
|
+
<ChangesRail fieldLabel="Blog" currentActorSub="ada" suggestions={[sugg('s1', 'Hello', 'Hi')]} threads={[]} onPrevChange={jest.fn()} onNextChange={jest.fn()} />,
|
|
640
|
+
)
|
|
641
|
+
expect(screen.getByRole('button', { name: 'Next change' })).toBeEnabled()
|
|
642
|
+
})
|
|
643
|
+
})
|
|
@@ -82,6 +82,13 @@ describe('suggesting mode', () => {
|
|
|
82
82
|
return { ...utils, ref, onChange, onPropose }
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
it('shows no "0 changes" line before anything is typed (bd startsim-9is5h)', () => {
|
|
86
|
+
const { ref } = mountSuggesting()
|
|
87
|
+
expect(screen.queryByTestId('proposal-state')).toBeNull()
|
|
88
|
+
typeAt(ref.current!, 6, 'big ')
|
|
89
|
+
expect(screen.getByTestId('proposal-state')).toHaveTextContent(/not sent yet/)
|
|
90
|
+
})
|
|
91
|
+
|
|
85
92
|
it('never reports an edit to the host, and posts the whole proposed text after a 5 s pause', async () => {
|
|
86
93
|
const { ref, onChange, onPropose } = mountSuggesting()
|
|
87
94
|
typeAt(ref.current!, 6, 'big ')
|