@cssints/headless 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +190 -2
- package/dist/index.d.mts +768 -236
- package/dist/index.mjs +1417 -507
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,8 +65,8 @@ A primitive that has a seam honours an optional `prefs` entry (a `Partial<Prefs>
|
|
|
65
65
|
- **menu / menubar** — `typeaheadDelayMs` (the typeahead buffer timeout).
|
|
66
66
|
- **tooltip** — `tooltipShowDelayMs` / `tooltipHideDelayMs`, exposed as `showDelayMs()` / `hideDelayMs()` for the host to wire its `setTimeout` from (the spec keeps timers as events).
|
|
67
67
|
|
|
68
|
-
Everything else **ignores** prefs: accordion, alert, alertdialog, breadcrumb, button, carousel, checkbox, checkbox-group, combobox, confirm, dialog-modal, disclosure, feed, field, grid, link, listbox, meter, player, popover, radio, resizable-panels, scrollspy, slider, slider-multithumb, spinbutton, switch, table, tabs, timeline, toolbar, treegrid, treeview, windowsplitter — they expose no timing / motion / live-region / camera seam. `listbox` / `treeview` / `timeline` typeahead timeouts are host-driven via their reset methods, so they take no delay here either.
|
|
69
|
-
Everything else **ignores** prefs: accordion, alert, alertdialog, breadcrumb, button, carousel, checkbox, checkbox-group, combobox, confirm, dialog-modal, disclosure, feed, field, grid, link, listbox, meter, panzoom, player, popover, radio, resizable-panels, slider, slider-multithumb, spinbutton, switch, table, tabs, timeline, toolbar, treegrid, treeview, windowsplitter — they expose no timing / motion / live-region / camera seam. `listbox` / `treeview` / `timeline` typeahead timeouts are host-driven via their reset methods, so they take no delay here either.
|
|
68
|
+
Everything else **ignores** prefs: accordion, alert, alertdialog, breadcrumb, button, carousel, checkbox, checkbox-group, combobox, command-palette, confirm, dialog-modal, disclosure, feed, field, grid, link, listbox, meter, player, popover, radio, resizable-panels, scrollspy, slider, slider-multithumb, spinbutton, switch, table, tabs, timeline, toolbar, treegrid, treeview, windowsplitter — they expose no timing / motion / live-region / camera seam. `listbox` / `treeview` / `timeline` typeahead timeouts are host-driven via their reset methods, so they take no delay here either.
|
|
69
|
+
Everything else **ignores** prefs: accordion, alert, alertdialog, breadcrumb, button, carousel, checkbox, checkbox-group, combobox, command-palette, confirm, dialog-modal, disclosure, feed, field, grid, link, listbox, meter, panzoom, player, popover, radio, resizable-panels, slider, slider-multithumb, spinbutton, switch, table, tabs, timeline, toolbar, treegrid, treeview, windowsplitter — they expose no timing / motion / live-region / camera seam. `listbox` / `treeview` / `timeline` typeahead timeouts are host-driven via their reset methods, so they take no delay here either.
|
|
70
70
|
|
|
71
71
|
`motionDurationMs` is **not consumed**: canvas tweens are tick-driven, not millisecond-driven, and the look layer owns durations. It stays in `Prefs` only so a host's preference record fits the type.
|
|
72
72
|
|
|
@@ -125,6 +125,30 @@ const panzoom = createPanZoom({
|
|
|
125
125
|
|
|
126
126
|
Controlled is `scale` + `onScaleChange`; the offset has no controlled prop, so a host that ignores a scale request also gets the offset resolved for the scale it refused.
|
|
127
127
|
|
|
128
|
+
## Knob
|
|
129
|
+
|
|
130
|
+
`createKnob` is a virtual knob after GarageBand's ([How Apple Designs a Virtual Knob](https://jherrm.github.io/knobs/)): one focusable control you spin around its centre or slide vertically / horizontally, with **one gesture locked per drag**. **There is no APG pattern and no ARIA role for a dial**, so it is a `role=slider` with `aria-valuenow` / `aria-valuetext`, and `test/knob.browser.test.tsx` (real Chromium) is its oracle — there is no Quint spec.
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
const knob = createKnob({ labels: { knob: "Volume" }, format: (value) => `${value}%` });
|
|
134
|
+
const turn = () => knob.percentOf(knob.value());
|
|
135
|
+
|
|
136
|
+
<div {...knob.knobProps} class="knob">
|
|
137
|
+
<span class="indicator" style={{ transform: `rotate(${turn() * 360}deg)` }} />
|
|
138
|
+
</div>
|
|
139
|
+
<span {...knob.valueProps} class="visually-hidden">
|
|
140
|
+
{knob.value()}
|
|
141
|
+
</span>;
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`percentOf` is the fraction of a full turn the skin rotates by; the look is the host's. `valueProps` must be rendered (hide it visually, never with `hidden`): it is the live announcement.
|
|
145
|
+
|
|
146
|
+
**A dial is never the only control.** The keyboard contract stands in for every gesture — arrows step, PageUp/PageDown by ten steps, Home/End to the bounds — but a numeric field (`createSpinbutton`) still belongs next to it: a slider role is operable but does not make a range easy to discover or type.
|
|
147
|
+
|
|
148
|
+
**Gestures** (`gesture`, default `"both"`). A pointerdown records the centre, the start angle and the side. On the first movement past 6 px the drag picks a mode once and keeps it for the rest of the drag: movement that stays on one axis (the other under 35% of it) is a slide, anything else is a turn around the centre. The reference's direction rule holds: down on the right half turns clockwise (up in value), down on the left half counter-clockwise, and a horizontal slide uses the same left/right side. A spin's value per degree shrinks as the pointer moves away from the centre — the same angle is a longer arc, so farther is finer. `gesture: "spin"` / `"slide"` disables the other. The wheel steps once per event and `preventDefault`s, so hovering the dial stops the page from scrolling.
|
|
149
|
+
|
|
150
|
+
**`wrap`** makes the dial continuous: past `max` comes back at `min`, so a full turn is a full cycle instead of the clamped end of the range. `setValue` / `stepBy` are requests like a key, so a controlled `value` still owns the result.
|
|
151
|
+
|
|
128
152
|
## Resizable panels
|
|
129
153
|
|
|
130
154
|
`createResizablePanels` is a group of N panes and N-1 separators: react-resizable-panels' layout on the APG window splitter's keyboard. Its behaviour is `packages/apg-specs/specs/patterns/resizable-panels.qnt` — not an APG pattern, so it lives under `patterns/` — and the DOM is checked against it by the differential walk. The spec is the three-pane instance (10/80 bounds, 10% steps, pane 0 collapsible at 0); the primitive takes N panes and any bounds.
|
|
@@ -206,6 +230,36 @@ const email = createTextInput({ type: "email", autoComplete: "email", inputMode:
|
|
|
206
230
|
- **`disabled` vs `readOnly`.** Disabled is out of the tab order and not submitted; readOnly is focusable, selectable and submitted. A field the user must read before signing is readOnly, not disabled.
|
|
207
231
|
- `createTextArea` adds `rows`, `wrap` and `resize` (`resize` is a style, since CSS has no attribute for it; `none` is what a layout that owns its own height wants).
|
|
208
232
|
|
|
233
|
+
## Drop zone
|
|
234
|
+
|
|
235
|
+
`createDropZone` is a file drop target on a **hidden native `<input type="file">`**: the picker, the `accept` filter and `multiple` are the platform's, and the primitive adds a drag target, the validation and a status line. **There is no APG pattern for a drop zone**, so there is no Quint spec: `test/dropzone.browser.test.tsx` is its oracle. The same primitive replaces the bare file inputs of cms-admin's image field and kocharin's dashboard upload.
|
|
236
|
+
|
|
237
|
+
```tsx
|
|
238
|
+
const upload = createDropZone({
|
|
239
|
+
accept: "image/*",
|
|
240
|
+
multiple: true,
|
|
241
|
+
maxSize: 5 * 1024 * 1024,
|
|
242
|
+
labels: { zone: "Images", browse: "Choose images" },
|
|
243
|
+
onFiles: (files) => void add(files),
|
|
244
|
+
onReject: ({ file, reason }) => toast(`${file.name}: ${reason}`),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
<div {...upload.zoneProps}>
|
|
248
|
+
<p>Drop images here{upload.dragging() ? " (release to add)" : ""}</p>
|
|
249
|
+
<input {...upload.inputProps} />
|
|
250
|
+
<button {...upload.browseProps}>{upload.message() ?? "Choose images"}</button>
|
|
251
|
+
<p {...upload.statusProps}>{upload.message()}</p>
|
|
252
|
+
</div>;
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
- **The zone is never drag-only.** `browseProps` goes on a real `<button type="button">` that clicks the hidden input, so the picker is the keyboard path (Enter and Space are the platform's) and a pointer user can click as well as drag. The browse button's text is the host's; `labels.browse` is its accessible name when that text is an icon.
|
|
256
|
+
- **Render the hidden input and the status region always.** `inputProps` carries `hidden`, so it is out of the tab order and the accessibility tree but still the thing the button clicks; the `role="status"` region announces a change of text, which only happens if it was already mounted.
|
|
257
|
+
- **Validation happens once per file**, type then size: `accept` is the HTML grammar (`.png`, `image/png`, `image/*`), `maxSize` is bytes. A refused file reaches `onReject` with `reason: "type" | "size"`; the accepted ones reach `onFiles` in one batch, and a batch with nothing accepted calls neither.
|
|
258
|
+
- **`message()` is a default sentence**, "2 files, 1 rejected: too large", for the status region; the words are the host's, the primitive only tracks the counts of the last batch. It is `undefined` before the first file.
|
|
259
|
+
- **A folder or an empty `DataTransfer` is a no-op**: no callback, no rejection, and the previous announcement stays.
|
|
260
|
+
- **`dragleave` counts.** A child's `dragleave` bubbles to the zone, so the state is a depth counter: `dragging()` clears only when the drag leaves the zone itself, or on a drop. `dragging()` is the styling hook — the primitive writes no `data-*`.
|
|
261
|
+
- **`disabled`** marks the zone `aria-disabled`, disables the input natively and the browse button, and drops every drag before it becomes a batch. Options are read when an attribute is read, so a host may flip it on a mounted zone.
|
|
262
|
+
|
|
209
263
|
## Pagination
|
|
210
264
|
|
|
211
265
|
`createPagination` is a navigation landmark over a page list. **No APG pattern exists**; the requirements are TetraLogical's (accessible-components-library v1.0.0), and `test/pagination.browser.test.tsx` is the oracle.
|
|
@@ -289,6 +343,89 @@ const table = createTable({
|
|
|
289
343
|
- **`numeric` is a layout hint** for the host's skin (right alignment, tabular figures): HTML has no attribute for it, so the primitive carries nothing.
|
|
290
344
|
- Solid 2 arrives through delegated events; a host that reads the DOM right after a click waits a tick.
|
|
291
345
|
|
|
346
|
+
## Timeline
|
|
347
|
+
|
|
348
|
+
`createTimeline` has two modes over one axis. Without `rows` it renders the package's demo tree of five fixed spans (collapsing, a zoom window, the differential walk against `specs/apg/timeline.qnt`); with `rows` it takes real span data and lays it out as a trace waterfall.
|
|
349
|
+
|
|
350
|
+
```tsx
|
|
351
|
+
const timeline = createTimeline({
|
|
352
|
+
rows: spans, // { id, label, start, end, depth?, status? }, in the host's display order
|
|
353
|
+
axis: { unit: "ms" },
|
|
354
|
+
selected: selected(), // controlled; omit it and the primitive keeps the value
|
|
355
|
+
onSelect: (id) => setSelected(id),
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
<ul {...timeline.listProps}>
|
|
359
|
+
{spans.map((span) => (
|
|
360
|
+
<li {...timeline.rowProps(span.id)}>
|
|
361
|
+
<span {...timeline.labelProps(span.id)}>{span.label}</span>
|
|
362
|
+
<div class="track">
|
|
363
|
+
<div {...timeline.barProps(span.id)} />
|
|
364
|
+
</div>
|
|
365
|
+
</li>
|
|
366
|
+
))}
|
|
367
|
+
</ul>
|
|
368
|
+
<div {...{ role: timeline.rulerProps.role }}>
|
|
369
|
+
{timeline.rulerProps.ticks.map((tick) => (
|
|
370
|
+
<span style={{ left: `${tick.left}%` }}>{tick.label}</span>
|
|
371
|
+
))}
|
|
372
|
+
</div>;
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
- **A waterfall is a list, not an APG widget.** There is no trace/waterfall pattern, and the rows are neither a tree (nothing collapses) nor a grid (nothing is edited). The rows mode is `role="list"` / `role="listitem"` (see `listProps` / `rowProps`) with `aria-level` = `depth + 1`, so the nesting a flat DOM cannot show is still announced; the selected row is `aria-current="true"`, which every element may carry — `aria-selected` is not allowed on a `listitem` and a waterfall is not a selection control. The demo tree keeps `role="tree"` / `treeitem`, because it does collapse.
|
|
376
|
+
- **The extent is the rows'.** `extent()` is the smallest `start` and the largest `end`, so a nested span is positioned and sized as a percentage of the whole trace, not of its parent. `barProps(id).style` is `left` / `width` in percent of that extent. A zero-length span (a marker, `start === end`) still paints: every bar has a **0.5% floor** (2 px on a 400 px track), the floor the o11y dashboard hand-rolled before this mode existed.
|
|
377
|
+
- **Depth is a custom property**, `--depth` on `barProps` and `labelProps`, so a narrow track keeps its indentation: `padding-inline-start: calc(var(--depth) * 0.75rem)`. Nesting the label's DOM would steal width from the bar.
|
|
378
|
+
- **The ruler is derived.** `rulerProps.ticks` is the extent stepped by 1, 2 or 5 times a power of ten (about five ticks), each `{ value, left, label }` with the label in the `axis.unit` (default `ms`). The ruler itself is decorative (`role="presentation"`, `aria-hidden="true"`); render the ticks as children.
|
|
379
|
+
- **Selection and keys.** A row click selects it and moves the tab stop; ArrowUp / ArrowDown / Home / End move a roving tab stop, and Enter or Space selects the row that has DOM focus — the host moves the real focus after an arrow key, as the demo fixture does. `selected` + `onSelect` make it controlled (`selected` is a request the host may ignore); `select(id)` is the programmatic request.
|
|
380
|
+
- `status` is carried as `data-status` on the row for the host's colour, and `label` never leaves the host's data: the primitive does not invent the row's text.
|
|
381
|
+
|
|
382
|
+
## Tabs
|
|
383
|
+
|
|
384
|
+
`createTabs` is the APG tablist: roving tabindex, wrapping arrows, Home/End, automatic or manual activation, disabled tabs, and a selection that is a request (`value` + `onValueChange`) or uncontrolled (`defaultSelected`). The plain widget is checked against `specs/apg/tabs.qnt` by the scenario bridge and the differential walk in `test/tabs.browser.test.tsx`.
|
|
385
|
+
|
|
386
|
+
Its accessibility requirements — one row per requirement, each naming the Quint invariant, browser test or manual AT row that holds it, and the gaps still open — are `docs/requirements/tabs.md`, written on the model in `docs/requirements/README.md`: a behaviour goes to the spec and its walk, a DOM attribute or a layout value to a browser test, and what a screen reader says to the assistive-technology table.
|
|
387
|
+
|
|
388
|
+
The docs playground's file tabs add and close files and rename them, so `createTabs` also takes `closable` and `renamable` — both off by default, so the plain widget is unchanged. **The primitive never takes the list:** it asks (`onClose`, `onRename`), the host removes or renames. `label(index)` is the tab's name: it names the close and rename buttons and seeds the rename draft, and it is read lazily.
|
|
389
|
+
|
|
390
|
+
```tsx
|
|
391
|
+
const tabs = createTabs({
|
|
392
|
+
get items() {
|
|
393
|
+
return files().length;
|
|
394
|
+
}, // the host's list; it shrinks after a close
|
|
395
|
+
get closable() {
|
|
396
|
+
return true;
|
|
397
|
+
},
|
|
398
|
+
get renamable() {
|
|
399
|
+
return true;
|
|
400
|
+
},
|
|
401
|
+
label: (index) => files()[index], // "Close index.ts", the rename draft
|
|
402
|
+
onClose: (index) => setFiles((all) => all.filter((_, at) => at !== index)),
|
|
403
|
+
onRename: (index, label) =>
|
|
404
|
+
setFiles((all) => all.map((name, at) => (at === index ? label : name))),
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
<div {...tabs.tablistProps}>
|
|
408
|
+
{files().map((name, index) => (
|
|
409
|
+
<div {...tabs.tabProps(index)} onDblClick={tabs.renameProps(index).onDblClick}>
|
|
410
|
+
{tabs.renameProps(index).editing() ? (
|
|
411
|
+
<input
|
|
412
|
+
{...tabs.renameProps(index).inputProps}
|
|
413
|
+
ref={(element) => queueMicrotask(() => element.focus())}
|
|
414
|
+
/>
|
|
415
|
+
) : (
|
|
416
|
+
name
|
|
417
|
+
)}
|
|
418
|
+
</div>
|
|
419
|
+
))}
|
|
420
|
+
{/* ... the close button of each tab, and the panels */}
|
|
421
|
+
</div>;
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
- **Close.** `closeProps(index)` is the tab's close button: `aria-label` "Close <label>" and `tabindex=-1`, because a tablist's tab stops are its tabs; the keyboard path is Delete on the focused tab. Focus after a close goes to the tab that slid into the closed one's place, or to the previous tab when the last one closed: `focusAfterClose(index)` is that index in the shortened list, and the host focuses it after its removal lands. Closing the last remaining tab is refused, as the spec refuses an empty tablist; a closed tab that was selected asks for the tab that takes the focus.
|
|
425
|
+
- **Rename.** A double click begins; the tab renders `inputProps` instead of its label. Enter commits, Escape reverts, blur commits; a label that trims to nothing cancels. An `<input>` cannot live in a `<button>`, so the tab becomes a `role="tab"` element while it is editing. While editing the input owns the keys: the tablist's roving does not move, and an arrow key leaves the caret alone. `begin(index)` is the programmatic start (a tab the host has just added: append it, then `focusTab(index)` and `begin(index)`).
|
|
426
|
+
- **State.** `editing()` is the tab being renamed, `null` when none. `items` is read from the option on every read, so the bounds, the roving tab stop and `firstEnabled` follow a host that shrinks its list.
|
|
427
|
+
- The rename and the two callbacks are the extension's own: the spec models a `deletable` flavour (Delete, focus to the next tab, the last refused) but no rename and no callbacks, so `test/tabs-close.browser.test.tsx` is the oracle for all of it.
|
|
428
|
+
|
|
292
429
|
## Confirm
|
|
293
430
|
|
|
294
431
|
`createConfirm` is a promise-based confirmation over `createDialogModal({ role: "alertdialog" })`. Like the field it is not checked against a spec (the dialog is; `test/confirm.browser.test.tsx` covers the promise around it, in Chromium).
|
|
@@ -486,6 +623,57 @@ onSettled(() => toc.attach()); // returns the teardown
|
|
|
486
623
|
- **Smooth scrolling is the host's.** The links are plain `#id` targets, the primitive never moves the scrollport, so `scroll-behavior: smooth` (and any `prefers-reduced-motion` override) belongs to the page's CSS; there is no scroll helper to reduce.
|
|
487
624
|
- **"On this page" is English.** Spread `navProps` first and override it after: `<nav {...toc.navProps} aria-label={t("onThisPage")}>`.
|
|
488
625
|
|
|
626
|
+
## Command palette
|
|
627
|
+
|
|
628
|
+
The first **composition** on the primitives — no new widget, no new spec: `createCommandPalette` is `createDialogModal` (a modal dialog) + `createTextInput` (the field) + `createStatus` (the count), wired into the shape [docs/research/compositions-2026-10.md](docs/research/compositions-2026-10.md) settles.
|
|
629
|
+
|
|
630
|
+
```tsx
|
|
631
|
+
const palette = createCommandPalette({
|
|
632
|
+
commands, // ReadonlyArray<{ id, label, group?, keywords?, run(): void | Promise<void> }>
|
|
633
|
+
hotkey: "mod+k", // what attach() binds; this is the default
|
|
634
|
+
labels: { title: t("palette"), placeholder: t("search"), empty: t("empty") },
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
onSettled(() => palette.attach()); // one document hotkey; returns the teardown
|
|
638
|
+
|
|
639
|
+
const groups = () => [...new Set(palette.results().map((result) => result.group ?? ""))];
|
|
640
|
+
|
|
641
|
+
<button
|
|
642
|
+
aria-haspopup="dialog"
|
|
643
|
+
aria-expanded={palette.open()}
|
|
644
|
+
onClick={() => palette.openPalette()}
|
|
645
|
+
>
|
|
646
|
+
Commands
|
|
647
|
+
</button>
|
|
648
|
+
<div {...palette.backdropProps} />
|
|
649
|
+
<div {...palette.dialogProps}>
|
|
650
|
+
<h2 {...palette.titleProps}>{palette.labels.title}</h2>
|
|
651
|
+
<input {...palette.inputProps} />
|
|
652
|
+
<div {...palette.listProps}>
|
|
653
|
+
<For each={groups()}>
|
|
654
|
+
{(group) => (
|
|
655
|
+
<section>
|
|
656
|
+
<h3>{group}</h3>
|
|
657
|
+
<For each={palette.results().filter((result) => result.group === group)}>
|
|
658
|
+
{(result) => <div {...palette.optionProps(result.id)}>{result.label}</div>}
|
|
659
|
+
</For>
|
|
660
|
+
</section>
|
|
661
|
+
)}
|
|
662
|
+
</For>
|
|
663
|
+
</div>
|
|
664
|
+
<p {...palette.emptyProps}>{palette.labels.empty}</p>
|
|
665
|
+
<div {...palette.statusProps}>{palette.status()}</div>
|
|
666
|
+
</div>;
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
- **A dialog, not a combobox.** Choosing a row runs a command; it does not complete the field. So the field is `input[type=text][role=searchbox]` with no `aria-autocomplete` and no `aria-haspopup`, the container is `role=dialog` (a trigger says `aria-haspopup="dialog"`), and the list is a `role=listbox` named by the title. Nothing is changed by typing.
|
|
670
|
+
- **Focus stays in the field.** `aria-activedescendant` names the highlighted option; ↓ past the last and ↑ before the first put the highlight back on the field (the attribute is absent), and ↓/↑ from there re-enter the list at the first/last row. Home/End move the caret while the field has text and are list keys (first/last) only while it is empty. Enter runs the highlighted command; Escape closes through the dialog; Tab is not intercepted, so the dialog's own focus policy applies.
|
|
671
|
+
- **Filtering is a plain case-insensitive substring match** on `label` and `keywords`. No fuzzy scoring: a palette holds tens of commands, and ranking (recents, scopes, weighted keywords) is a policy the host owns — a typo hiding a command is worse than an extra row. `results()` keeps host order.
|
|
672
|
+
- **Groups, empty state, one announcement.** `result.group` (or `undefined`) is what the host groups by; it renders the headings and the CSS. `emptyProps.hidden` is false when nothing matches, and `labels.empty` is the text to show. The count lands in one polite region (`statusProps`, `status()`), written once per query change — moving the highlight does not re-announce it.
|
|
673
|
+
- **Reset on close**: the query and highlight are cleared on close, so the next open starts at the full list (unlike a combobox, which keeps its value). Focus returns to the element that had it when the palette opened.
|
|
674
|
+
- **The host's**: rendering, grouping and CSS; async commands (`run` may return a promise, and moving the highlight to the next command is the host's call); closing on anything but Escape or a command; every visible string through `labels`; and the dialog's title element, which carries `titleProps` (`aria-labelledby` points at it).
|
|
675
|
+
- **Not in this PR**: the filterable menu (`filter: true` on `createMenu`, with the IME guard and the arrow loop to the field) and the date picker are later compositions on the same research; this one is the palette only.
|
|
676
|
+
|
|
489
677
|
## Commands
|
|
490
678
|
|
|
491
679
|
```sh
|