@astryxdesign/core 0.6.3-canary.ea2f048 → 0.6.3

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.
@@ -1,510 +0,0 @@
1
- // Copyright (c) Meta Platforms, Inc. and affiliates.
2
-
3
- /**
4
- * @file ChatLayoutScrollButton.a11y.chromium.spec.ts
5
- * @input Uses the ChatLayout scroll-affordance story and a built Storybook
6
- * @output Keyboard and pixel evidence for the hidden/visible/re-hidden scroll
7
- * affordance, and for the pill tracking the theme's element-size token
8
- * @position Real-Chromium evidence for the 2026-09-23 ChatLayout audit. jsdom
9
- * has no cascade and no sequential focus model, so neither the hidden state's
10
- * removal from the tab order nor the pill's containment of its own Button is
11
- * observable in the unit lane.
12
- *
13
- * Two claims are proven here, both against the shipped public API:
14
- *
15
- * 1. The default affordance is hidden at rest, becomes visible when the reader
16
- * scrolls away from the newest message, and hides again on activation. It is
17
- * keyboard reachable exactly while it is visible, because focus landing on
18
- * something that paints nothing has no visible indicator (WCAG 2.2 SC 2.4.7).
19
- * 2. The pill's height follows `--size-element-md`, so a theme that retunes the
20
- * element scale cannot make it clip the Button it wraps. Butter resolves that
21
- * token to 40px where Neutral resolves it to 32px, so the two shipped themes
22
- * are the natural before/after — no hand-built arm is needed for the token
23
- * claim. The regression arm below restores the old literal to show what the
24
- * fix prevents, and declares its exact delta.
25
- */
26
-
27
- import {createHash} from 'node:crypto';
28
- import * as fs from 'node:fs';
29
- import * as path from 'node:path';
30
- import {expect, test, type Locator, type Page} from '@playwright/test';
31
- import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
32
- import {
33
- DEFAULT_STORYBOOK_DIR,
34
- serveStorybook,
35
- type StaticServer,
36
- } from '@astryxdesign/a11y-spec/storybook';
37
-
38
- const OUTPUT = path.resolve('test-results/chat-layout-audit-evidence');
39
- const STORY_ID = 'core-chatlayout--scroll-affordance-states';
40
-
41
- /** The pill wrapper: the element whose visibility and height are the claims. */
42
- const PILL = '.astryx-chat-layout-scroll-button > div';
43
-
44
- /** One observed tab stop, identified structurally rather than by its text. */
45
- type FocusStop = {
46
- tag: string;
47
- astryxClass: string | null;
48
- role: string | null;
49
- tabIndex: string | null;
50
- accessibleName: string | null;
51
- isLayoutRoot: boolean;
52
- };
53
-
54
- type Shot = {
55
- file: string;
56
- sha256: string;
57
- width: number;
58
- height: number;
59
- };
60
-
61
- type Receipt = {
62
- /** What this frame is evidence of. */
63
- state: string;
64
- storyId: string;
65
- theme: string;
66
- colorMode: string;
67
- direction: string;
68
- viewport: {width: number; height: number};
69
- devicePixelRatio: number;
70
- /** Semantic state that uniquely identifies the intended render. */
71
- rendered: Record<string, unknown>;
72
- /** Visible subject geometry, so a zero-box decoy cannot pass as the subject. */
73
- geometry: {selectorCount: number; width: number; height: number};
74
- settled: {fontsReady: boolean; pageErrors: number};
75
- image: Shot;
76
- };
77
-
78
- const receipts: Receipt[] = [];
79
- const pageErrors: string[] = [];
80
- let storybook: StaticServer;
81
- let browserVersion = 'unknown';
82
-
83
- test.beforeAll(async () => {
84
- // Do NOT wipe: Playwright restarts the worker after a failing test, which
85
- // re-runs this hook. Wiping here would delete the frames the earlier tests
86
- // already banked, which is exactly when the evidence matters most.
87
- fs.mkdirSync(OUTPUT, {recursive: true});
88
- storybook = await serveStorybook(
89
- process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
90
- );
91
- });
92
-
93
- test.afterAll(async () => {
94
- const manifestPath = path.join(OUTPUT, 'manifest.json');
95
- const previous = fs.existsSync(manifestPath)
96
- ? (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).frames ?? [])
97
- : [];
98
- const merged = [...previous, ...receipts].filter(
99
- (frame, index, all) =>
100
- all.findIndex(other => other.state === frame.state) === index,
101
- );
102
- fs.writeFileSync(
103
- manifestPath,
104
- `${JSON.stringify(
105
- {
106
- version: 1,
107
- component: 'core/ChatLayout',
108
- headSha:
109
- process.env.ASTRYX_HEAD_SHA ??
110
- process.env.GITHUB_SHA ??
111
- 'local-working-copy',
112
- checkoutSha: process.env.GITHUB_SHA ?? 'local-working-copy',
113
- browser: browserVersion,
114
- frames: merged,
115
- },
116
- null,
117
- 2,
118
- )}\n`,
119
- );
120
- await storybook?.close();
121
- });
122
-
123
- async function openStory(
124
- page: Page,
125
- {theme = 'neutral', colorMode = 'light'} = {},
126
- ): Promise<Locator> {
127
- page.on('pageerror', error => pageErrors.push(String(error)));
128
- browserVersion = page.context().browser()?.version() ?? 'unknown';
129
- await page.goto(
130
- `${storybook.origin}/iframe.html?id=${STORY_ID}&viewMode=story` +
131
- `&globals=colorMode:${colorMode};astryxTheme:${theme}`,
132
- );
133
- const root = page.locator('.astryx-chat-layout');
134
- await root.waitFor({state: 'visible'});
135
- await holdMotionStill(page);
136
- await page.evaluate(async () => document.fonts.ready);
137
- return root;
138
- }
139
-
140
- /** The scroll container settles at the bottom on first fill. */
141
- async function distanceFromBottom(root: Locator): Promise<number> {
142
- return root.evaluate(
143
- element => element.scrollHeight - element.clientHeight - element.scrollTop,
144
- );
145
- }
146
-
147
- /** How far this fixture can scroll at all. The states below need real range. */
148
- async function scrollRange(root: Locator): Promise<number> {
149
- return root.evaluate(element => element.scrollHeight - element.clientHeight);
150
- }
151
-
152
- /** Scroll to the top, which is the furthest the reader can get from newest. */
153
- async function scrollToTop(root: Locator): Promise<void> {
154
- await root.evaluate(element => {
155
- element.scrollTop = 0;
156
- element.dispatchEvent(new Event('scroll'));
157
- });
158
- }
159
-
160
- /** Computed visibility of the pill — the state every claim below turns on. */
161
- async function pillVisibility(page: Page): Promise<string> {
162
- return page
163
- .locator(PILL)
164
- .evaluate(element => getComputedStyle(element).visibility);
165
- }
166
-
167
- /**
168
- * Can the affordance take focus at all? This is the mechanism under test:
169
- * `visibility: hidden` removes an element from sequential focus navigation AND
170
- * refuses programmatic focus, which `opacity: 0` does neither of. A CSS locator
171
- * is used rather than a role query because a hidden button is not in the
172
- * accessibility tree and a role query would not resolve it.
173
- */
174
- async function affordanceAcceptsFocus(page: Page): Promise<boolean> {
175
- return page
176
- .locator('.astryx-chat-layout-scroll-button button')
177
- .evaluate(element => {
178
- // preventScroll: focusing must not move the scroll position, or the
179
- // probe would change the very state the next assertion reads.
180
- (element as HTMLElement).focus({preventScroll: true});
181
- return document.activeElement === element;
182
- });
183
- }
184
-
185
- /**
186
- * Tab forward from `from` and report where focus actually lands, by identity.
187
- *
188
- * Each stop records the element's tag, its `astryx-*` class, explicit role,
189
- * explicit tabindex, accessible name, and whether it IS the layout root — not
190
- * a text-content guess. Several nested elements share the transcript's text,
191
- * so a label built from `textContent` cannot tell the scrolling layout root
192
- * from the `role="log"` message list inside it.
193
- *
194
- * The sweep MUST stop at the layout boundary. Sequential focus navigation
195
- * scrolls each stop into view, and this affordance's visibility is a function
196
- * of scroll position, so a sweep that runs past the last stop wraps around the
197
- * document, re-enters the layout, and can flip the state it is measuring.
198
- */
199
- /** Render a sweep as a readable trail for a failure message. */
200
- function describeStops(stops: FocusStop[]): string {
201
- return stops
202
- .map(stop => {
203
- const parts = [stop.tag];
204
- if (stop.astryxClass != null) {
205
- parts.push(`.${stop.astryxClass}`);
206
- }
207
- if (stop.role != null) {
208
- parts.push(`[role=${stop.role}]`);
209
- }
210
- if (stop.isLayoutRoot) {
211
- parts.push('(layout root)');
212
- }
213
- return parts.join('');
214
- })
215
- .join(' -> ');
216
- }
217
-
218
- async function tabWithinLayout(
219
- page: Page,
220
- from: Locator,
221
- maxPresses: number,
222
- ): Promise<{reached: boolean; sequence: FocusStop[]}> {
223
- await from.focus();
224
- const sequence: FocusStop[] = [];
225
- for (let index = 0; index < maxPresses; index += 1) {
226
- await page.keyboard.press('Tab');
227
- const stop = await page.evaluate(() => {
228
- const active = document.activeElement as HTMLElement | null;
229
- const layout = document.querySelector('.astryx-chat-layout');
230
- if (active == null || active === document.body) {
231
- return {
232
- tag: 'body',
233
- astryxClass: null,
234
- role: null,
235
- tabIndex: null,
236
- accessibleName: null,
237
- isLayoutRoot: false,
238
- inside: false,
239
- onAffordance: false,
240
- };
241
- }
242
- return {
243
- tag: active.tagName.toLowerCase(),
244
- astryxClass:
245
- [...active.classList].find(name => name.startsWith('astryx-')) ??
246
- null,
247
- role: active.getAttribute('role'),
248
- tabIndex: active.getAttribute('tabindex'),
249
- accessibleName:
250
- active.getAttribute('aria-label') ??
251
- active.getAttribute('aria-labelledby') ??
252
- null,
253
- isLayoutRoot: active === layout,
254
- inside: layout != null && layout.contains(active),
255
- onAffordance:
256
- active.closest('.astryx-chat-layout-scroll-button') != null,
257
- };
258
- });
259
- const {inside, onAffordance, ...identity} = stop;
260
- sequence.push(identity);
261
- if (onAffordance) {
262
- return {reached: true, sequence};
263
- }
264
- if (!inside) {
265
- break;
266
- }
267
- }
268
- return {reached: false, sequence};
269
- }
270
-
271
- /**
272
- * Read a PNG's real pixel dimensions out of its IHDR header.
273
- *
274
- * These frames are ELEMENT screenshots, so their size is the subject's box,
275
- * not the viewport's. An earlier revision recorded the viewport here and
276
- * published receipts claiming 1280x720 for images that are 1216x420 — the
277
- * pixels were genuine, the stated dimensions were not. The viewport stays in
278
- * its own sensor row, where it belongs.
279
- */
280
- function pngShot(file: string, bytes: Buffer): Shot {
281
- return {
282
- file,
283
- sha256: createHash('sha256').update(bytes).digest('hex'),
284
- width: bytes.readUInt32BE(16),
285
- height: bytes.readUInt32BE(20),
286
- };
287
- }
288
-
289
- async function capture(
290
- page: Page,
291
- state: string,
292
- rendered: Record<string, unknown>,
293
- ) {
294
- const pill = page.locator(PILL);
295
- const box = await pill.boundingBox();
296
- if (box == null) {
297
- throw new Error(`${state}: the pill has no layout box`);
298
- }
299
- const bytes = await page.locator('.astryx-chat-layout').screenshot({
300
- animations: 'disabled',
301
- });
302
- const file = `chat-layout-${state}.png`;
303
- fs.writeFileSync(path.join(OUTPUT, file), bytes);
304
- const dimensions = await page.evaluate(() => ({
305
- width: window.innerWidth,
306
- height: window.innerHeight,
307
- dpr: window.devicePixelRatio,
308
- }));
309
- const receipt: Receipt = {
310
- state,
311
- storyId: STORY_ID,
312
- theme: await page.evaluate(
313
- () =>
314
- document
315
- .querySelector('[data-astryx-theme]')
316
- ?.getAttribute('data-astryx-theme') ?? 'neutral',
317
- ),
318
- colorMode: await page.evaluate(
319
- () => getComputedStyle(document.documentElement).colorScheme,
320
- ),
321
- direction: await page
322
- .locator('.astryx-chat-layout')
323
- .evaluate(element => getComputedStyle(element).direction),
324
- viewport: {width: dimensions.width, height: dimensions.height},
325
- devicePixelRatio: dimensions.dpr,
326
- rendered,
327
- geometry: {
328
- selectorCount: await page.locator(PILL).count(),
329
- width: Math.round(box.width),
330
- height: Math.round(box.height),
331
- },
332
- settled: {fontsReady: true, pageErrors: pageErrors.length},
333
- image: pngShot(file, bytes),
334
- };
335
- receipts.push(receipt);
336
- return receipt;
337
- }
338
-
339
- test('the scroll affordance is keyboard reachable exactly while it is visible', async ({
340
- page,
341
- }) => {
342
- const root = await openStory(page);
343
- const before = page.getByRole('button', {name: 'Before chat', exact: true});
344
- const pill = page.locator(PILL);
345
- const SWEEP = 6;
346
-
347
- // Order matters in every block below: assert the state, probe focus with
348
- // preventScroll, capture the frame, and only THEN walk Tab. Sequential focus
349
- // navigation scrolls each stop into view, and this affordance's visibility is
350
- // a function of scroll position — measured, not assumed: an earlier revision
351
- // swept first and photographed a "hidden" state that Tab had already scrolled
352
- // 174px away from the bottom and made visible. The sweep is recorded as
353
- // evidence of the real tab order; the assertion is the focus probe, because a
354
- // button that refuses focus() is not in the sequential tab order.
355
-
356
- // ---- hidden: the resting state, where the defect lived -------------------
357
- // Precondition: the fixture must actually overflow, or "hidden at rest" and
358
- // "visible when scrolled up" would both pass without proving anything.
359
- expect(
360
- await scrollRange(root),
361
- 'the fixture must overflow by more than the 100px button threshold',
362
- ).toBeGreaterThan(150);
363
- expect(await distanceFromBottom(root)).toBeLessThan(2);
364
- await expect(pill).toHaveCSS('visibility', 'hidden');
365
- expect(
366
- await affordanceAcceptsFocus(page),
367
- 'a control that paints nothing must refuse focus (WCAG 2.2 SC 2.4.7)',
368
- ).toBe(false);
369
- const restFrame = await capture(page, 'rest-hidden', {
370
- pillVisibility: await pillVisibility(page),
371
- affordanceAcceptsFocus: false,
372
- distanceFromBottomPx: await distanceFromBottom(root),
373
- });
374
- restFrame.rendered.tabOrderObserved = (
375
- await tabWithinLayout(page, before, SWEEP)
376
- ).sequence;
377
-
378
- // ---- visible: the reader scrolls away from the newest message ------------
379
- await scrollToTop(root);
380
- await expect(pill).toHaveCSS('visibility', 'visible');
381
- expect(await distanceFromBottom(root)).toBeGreaterThan(100);
382
- expect(
383
- await affordanceAcceptsFocus(page),
384
- 'the visible affordance must keep its keyboard access',
385
- ).toBe(true);
386
- await expect(
387
- page.getByRole('button', {name: 'Scroll to bottom'}),
388
- ).toHaveAccessibleName('Scroll to bottom');
389
- const visibleFrame = await capture(page, 'scrolled-up-visible', {
390
- pillVisibility: await pillVisibility(page),
391
- affordanceAcceptsFocus: true,
392
- accessibleName: 'Scroll to bottom',
393
- distanceFromBottomPx: await distanceFromBottom(root),
394
- });
395
- const visibleSweep = await tabWithinLayout(page, before, SWEEP);
396
- visibleFrame.rendered.tabOrderObserved = visibleSweep.sequence;
397
- visibleFrame.rendered.reachedByTabAfterPresses = visibleSweep.sequence.length;
398
- expect(
399
- visibleSweep.reached,
400
- `Tab never reached the visible affordance: ${describeStops(visibleSweep.sequence)}`,
401
- ).toBe(true);
402
-
403
- // ---- re-hidden: activating it returns to the bottom ----------------------
404
- // The sweep above left focus on the affordance, so Enter activates it. Wait
405
- // for the scroll spring to SETTLE at the bottom rather than merely for the
406
- // pill to hide: hiding happens at the 100px threshold, while the animation
407
- // is still running, and anything that nudges the scroller in that window
408
- // flips the affordance back.
409
- await page.keyboard.press('Enter');
410
- await expect
411
- .poll(async () => distanceFromBottom(root), {timeout: 5000})
412
- .toBeLessThan(2);
413
- await expect(pill).toHaveCSS('visibility', 'hidden');
414
- expect(
415
- await affordanceAcceptsFocus(page),
416
- 'after returning to the bottom the affordance must refuse focus again',
417
- ).toBe(false);
418
- const returnedFrame = await capture(page, 'returned-hidden', {
419
- pillVisibility: await pillVisibility(page),
420
- affordanceAcceptsFocus: false,
421
- activatedWith: 'Enter',
422
- distanceFromBottomPx: await distanceFromBottom(root),
423
- });
424
- returnedFrame.rendered.tabOrderObserved = (
425
- await tabWithinLayout(page, before, SWEEP)
426
- ).sequence;
427
-
428
- expect(pageErrors).toEqual([]);
429
- });
430
-
431
- test('the pill tracks the theme element-size token instead of a fixed 32px', async ({
432
- page,
433
- }) => {
434
- // Butter resolves --size-element-md to 40px; Neutral resolves it to 32px.
435
- // Both are shipped themes, so this is a real theme pair, not a mutated arm.
436
- const sizes: Record<string, {token: string; pill: number; button: number}> =
437
- {};
438
-
439
- for (const theme of ['neutral', 'butter']) {
440
- const root = await openStory(page, {theme});
441
- await scrollToTop(root);
442
- const pill = page.locator(PILL);
443
- await expect(pill).toHaveCSS('visibility', 'visible');
444
-
445
- const token = await pill.evaluate(element =>
446
- getComputedStyle(element).getPropertyValue('--size-element-md').trim(),
447
- );
448
- const pillHeight = (await pill.boundingBox())?.height ?? 0;
449
- const buttonHeight =
450
- (await page.getByRole('button', {name: 'Scroll to bottom'}).boundingBox())
451
- ?.height ?? 0;
452
-
453
- sizes[theme] = {
454
- token,
455
- pill: Math.round(pillHeight),
456
- button: Math.round(buttonHeight),
457
- };
458
-
459
- // The pill clips its own content (`overflow: hidden`), so it must be at
460
- // least as tall as the Button it wraps in every theme. Neutral also shrinks
461
- // its md Button to `calc(--size-element-md - 8px)`, so the two are equal
462
- // only where a theme leaves the Button at the full token height.
463
- expect(
464
- sizes[theme].pill,
465
- `${theme}: the pill must not be shorter than the Button it clips`,
466
- ).toBeGreaterThanOrEqual(sizes[theme].button);
467
- await capture(page, `token-size-${theme}`, {
468
- theme,
469
- sizeElementMd: token,
470
- pillHeightPx: sizes[theme].pill,
471
- buttonHeightPx: sizes[theme].button,
472
- clippedPx: Math.max(0, sizes[theme].button - sizes[theme].pill),
473
- });
474
- }
475
-
476
- // The themes must actually disagree, or the pair proves nothing. Butter
477
- // resolves the token to 40px and leaves its md Button at the full token
478
- // height; Neutral resolves it to 32px and shrinks the Button by 8px.
479
- expect(sizes.butter.token).not.toBe(sizes.neutral.token);
480
- expect(sizes.butter.pill).toBeGreaterThan(sizes.neutral.pill);
481
- expect(sizes.butter.button).toBeGreaterThan(sizes.neutral.button);
482
-
483
- // ---- regression arm -----------------------------------------------------
484
- // ARM: pre-fix literal
485
- // BASE: this head
486
- // DELTA: one injected rule restoring the removed literal on the pill —
487
- // `height: 32px; max-width: 32px` — and nothing else.
488
- // UNRELATED DELTA: none
489
- const root = await openStory(page, {theme: 'butter'});
490
- await scrollToTop(root);
491
- await page.addStyleTag({
492
- content: `${PILL} { height: 32px !important; max-width: 32px !important; }`,
493
- });
494
- const clippedPill = (await page.locator(PILL).boundingBox())?.height ?? 0;
495
- const clippedButton =
496
- (await page.getByRole('button', {name: 'Scroll to bottom'}).boundingBox())
497
- ?.height ?? 0;
498
- await capture(page, 'token-size-butter-prefix-literal', {
499
- theme: 'butter',
500
- arm: 'pre-fix literal (height: 32px; max-width: 32px)',
501
- pillHeightPx: Math.round(clippedPill),
502
- buttonHeightPx: Math.round(clippedButton),
503
- clippedPx: Math.round(clippedButton - clippedPill),
504
- });
505
-
506
- // What the fix prevents: under Butter the old literal left the Button taller
507
- // than the pill that clips it.
508
- expect(Math.round(clippedButton)).toBeGreaterThan(Math.round(clippedPill));
509
- expect(pageErrors).toEqual([]);
510
- });
@@ -1,183 +0,0 @@
1
- // Copyright (c) Meta Platforms, Inc. and affiliates.
2
-
3
- /** @type {import('@astryxdesign/cli/authoring').ComponentAnatomyElement[]} */
4
- const anatomy = [
5
- {
6
- name: 'Elapsed time',
7
- required: true,
8
- description:
9
- 'Semantic time element containing a standardized elapsed duration.',
10
- },
11
- ];
12
-
13
- /** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */
14
- export const docs = {
15
- name: 'Timer',
16
- displayName: 'Timer',
17
- category: 'Content',
18
- keywords: [
19
- 'timer',
20
- 'elapsed',
21
- 'duration',
22
- 'seconds',
23
- 'minutes',
24
- 'hours',
25
- 'stopwatch',
26
- 'waiting',
27
- 'loading',
28
- 'processing',
29
- ],
30
- props: [
31
- {
32
- name: 'startTime',
33
- type: 'number',
34
- description:
35
- "Unix time in milliseconds when the measured operation began. Omit it to start from this Timer's mount.",
36
- },
37
- {
38
- name: 'format',
39
- type: "'elapsed' | 'clock'",
40
- description:
41
- 'Standard duration representation. Elapsed uses compact units and drops seconds after one hour; clock uses m:ss or h:mm:ss.',
42
- default: "'elapsed'",
43
- },
44
- {
45
- name: 'type',
46
- type: "'body' | 'large' | 'label' | 'supporting' | 'code' | 'display-1' | 'display-2' | 'display-3' | 'inherit'",
47
- description:
48
- 'Semantic text type. Uses the same typography behavior as Timestamp.',
49
- default: "'supporting'",
50
- },
51
- {
52
- name: 'size',
53
- type: "'4xs' | '3xs' | '2xs' | 'xsm' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'",
54
- description: 'Explicit font size override. Overrides the size from type.',
55
- },
56
- {
57
- name: 'color',
58
- type: "'primary' | 'secondary' | 'disabled' | 'placeholder' | 'accent' | 'inherit'",
59
- description: 'Text color.',
60
- default: "'secondary'",
61
- },
62
- {
63
- name: 'weight',
64
- type: "'normal' | 'medium' | 'semibold' | 'bold'",
65
- description: 'Font weight override.',
66
- },
67
- {
68
- name: 'xstyle',
69
- type: 'StyleXStyles',
70
- description:
71
- 'StyleX styles for the Text wrapper. Must be a stylex.create() value.',
72
- },
73
- {
74
- name: 'className',
75
- type: 'string',
76
- description:
77
- 'CSS class name for the Text wrapper. Prefer xstyle for styling.',
78
- },
79
- {
80
- name: 'style',
81
- type: 'CSSProperties',
82
- description:
83
- 'Inline styles for the Text wrapper. Prefer xstyle for styling.',
84
- },
85
- ],
86
- examples: [
87
- {
88
- label: 'Elapsed duration',
89
- code: '<Timer />',
90
- },
91
- {
92
- label: 'Stopwatch clock',
93
- code: '<Timer format="clock" />',
94
- },
95
- {
96
- label: 'Operation that started before mount',
97
- code: '<Timer startTime={operationStartedAt} />',
98
- },
99
- {
100
- label: 'Match surrounding text',
101
- code: `<Text>
102
- Processing for <Timer type="inherit" color="inherit" />
103
- </Text>`,
104
- },
105
- {
106
- label: 'Prominent elapsed time',
107
- code: '<Timer type="body" size="lg" color="primary" weight="semibold" />',
108
- },
109
- ],
110
- theming: {
111
- targets: [{className: 'astryx-timer'}],
112
- },
113
- usage: {
114
- anatomy,
115
- description:
116
- 'Displays a standardized elapsed duration for active work without scheduling a React render on every tick. Elapsed format updates by second below one hour and by minute after one hour; clock format remains second-precise.',
117
- bestPractices: [
118
- {
119
- guidance: true,
120
- description:
121
- 'Use elapsed for compact duration text that may span seconds, minutes, or hours.',
122
- },
123
- {
124
- guidance: true,
125
- description:
126
- 'Use clock for stopwatch-like surfaces where seconds remain meaningful after an hour.',
127
- },
128
- {
129
- guidance: true,
130
- description:
131
- 'Pass startTime when the operation began before Timer mounted so the display reflects the complete wait.',
132
- },
133
- {
134
- guidance: false,
135
- description:
136
- 'Do not use Timer for dates, time zones, or relative calendar language; use Timestamp instead.',
137
- },
138
- {
139
- guidance: false,
140
- description:
141
- 'Do not add aria-live unless hearing an announcement every tick is appropriate for the specific task.',
142
- },
143
- ],
144
- },
145
- };
146
-
147
- /** @type {import('@astryxdesign/cli/authoring').ComponentTranslationDoc} */
148
- export const docsDense = {
149
- description:
150
- 'Standardized elapsed or stopwatch duration with clock-derived, non-rendering DOM updates.',
151
- propDescriptions: {
152
- startTime:
153
- "operation start as Unix milliseconds; omit to count from Timer's mount",
154
- format: 'elapsed compact units or clock stopwatch notation',
155
- type: 'semantic text type; defaults to supporting like Timestamp',
156
- size: 'explicit font size override',
157
- color: 'text color; defaults to secondary like Timestamp',
158
- weight: 'font weight override',
159
- xstyle: 'StyleX styles for the Text wrapper',
160
- className: 'CSS class for the Text wrapper',
161
- style: 'inline styles for the Text wrapper',
162
- },
163
- usage: {
164
- anatomy,
165
- description:
166
- 'Use for active-operation elapsed time when periodic React renders would add avoidable work.',
167
- bestPractices: [
168
- {
169
- guidance: true,
170
- description:
171
- 'Use elapsed for compact durations and clock for stopwatch UI.',
172
- },
173
- {
174
- guidance: true,
175
- description: 'Pass startTime for work that began before mount.',
176
- },
177
- {
178
- guidance: false,
179
- description: 'Use Timestamp for dates and relative calendar language.',
180
- },
181
- ],
182
- },
183
- };