@wtfalch/design 0.3.2 → 0.5.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.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * A box that scrolls, and says so.
3
+ *
4
+ * **A list that overflows with no mark at its edge reads as a list that ended.**
5
+ * That is the bug this exists to stop. A thread list showing eleven of four
6
+ * hundred conversations looks exactly like a mailbox with eleven conversations
7
+ * in it: the content runs to the bottom edge of its box and stops, and on a
8
+ * trackpad there is no scrollbar to contradict it. People do not scroll things
9
+ * they have no reason to believe continue.
10
+ *
11
+ * So the edges are drawn. A soft fade appears at whichever end has more behind
12
+ * it and goes when that end is reached, which means the affordance is *absent*
13
+ * exactly when the list really has ended -- the one case where a permanent
14
+ * gradient would lie.
15
+ *
16
+ * **The scrollbar is the theme's, not the platform's.** Same argument as
17
+ * `Select`: an overlay scrollbar drawn by macOS in the platform's grey, over a
18
+ * Night panel, is the one part of the surface the design does not reach. It is
19
+ * also 15px of white on Windows, which is the widest single element in a mail
20
+ * sidebar. `scrollbar-color` covers Firefox and `::-webkit-scrollbar` the rest;
21
+ * both are in `scrollarea.css`.
22
+ *
23
+ * **`overscroll-behavior: contain`**, because a scroll that reaches the end of
24
+ * a pane and carries on into the page behind it is how a thread list scrolls
25
+ * the whole application away. In a split view that is disorienting rather than
26
+ * merely untidy.
27
+ *
28
+ * **Pad the content, not the box.** Padding on the scroller itself insets the
29
+ * fade and the scrollbar along with everything else, so the gradient stops
30
+ * short of the sides and the bar floats in from the edge; and vertical padding
31
+ * on a box whose rows already have their own reads as extra space above the
32
+ * first item, because it is. Put the inset on the child.
33
+ *
34
+ * Not a virtualiser. Ten thousand rows still cost ten thousand nodes; this
35
+ * decides how the box behaves, not how much is in it.
36
+ */
37
+ export interface Props {
38
+ /** Which way it scrolls. `y` is the common case and the default; `both` is
39
+ * for a table too wide as well as too tall. */
40
+ axis?: 'x' | 'y' | 'both';
41
+ /**
42
+ * Draw the fade at a vertical edge with more behind it. On by default.
43
+ *
44
+ * Vertical only: the misreading is "the list ended", which is a claim about
45
+ * the bottom edge. A word sliced in half at the right edge is already its
46
+ * own affordance, so `axis="x"` draws no fade whatever this says.
47
+ *
48
+ * Turn it off where the content ends in something solid -- a sticky footer,
49
+ * cards on a coloured ground -- because the gradient is mixed towards
50
+ * `--panel` and over anything else it reads as a smudge.
51
+ */
52
+ fade?: boolean;
53
+ /** Removes the visible scrollbar and keeps every other behaviour, for a
54
+ * strip that is scrolled by a control beside it rather than by dragging. */
55
+ hideBar?: boolean;
56
+ className?: string;
57
+ children: React.ReactNode;
58
+ /** The scrolling element, for a caller that has to drive it -- scrolling a
59
+ * newly selected row into view, or restoring a position. */
60
+ ref?: React.Ref<HTMLDivElement>;
61
+ /** Names the region when it is one a screen reader should be able to reach
62
+ * directly. A scroll box that is keyboard-focusable owes an accessible
63
+ * name; one that is not owes nothing, so this is optional on purpose. */
64
+ label?: string;
65
+ }
66
+ export default function ScrollArea({ axis, fade, hideBar, className, children, ref, label, }: Props): import("react").JSX.Element;
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef } from 'react';
3
+ export default function ScrollArea({ axis = 'y', fade = true, hideBar = false, className, children, ref, label, }) {
4
+ const own = useRef(null);
5
+ /* The edge state lives in data attributes rather than React state.
6
+
7
+ This runs on every scroll frame. Setting state there re-renders the
8
+ subtree -- which, for the thing this is usually wrapped around, is the
9
+ whole list -- sixty times a second while a finger is on the trackpad.
10
+ Writing two attributes touches one element and never re-renders, and CSS
11
+ reads them. */
12
+ const measure = useCallback(() => {
13
+ const el = own.current;
14
+ if (!el)
15
+ return;
16
+ const room = 1;
17
+ el.dataset.top = String(el.scrollTop > room);
18
+ el.dataset.bottom = String(el.scrollTop + el.clientHeight < el.scrollHeight - room);
19
+ el.dataset.left = String(el.scrollLeft > room);
20
+ el.dataset.right = String(el.scrollLeft + el.clientWidth < el.scrollWidth - room);
21
+ }, []);
22
+ useEffect(() => {
23
+ const el = own.current;
24
+ if (!el)
25
+ return;
26
+ measure();
27
+ el.addEventListener('scroll', measure, { passive: true });
28
+ /* Content arriving is the case a scroll listener alone misses: a mailbox
29
+ that loads its second page grows the box without anyone scrolling, and
30
+ the bottom fade has to appear for content nobody has touched. */
31
+ const resize = new ResizeObserver(measure);
32
+ resize.observe(el);
33
+ for (const child of Array.from(el.children))
34
+ resize.observe(child);
35
+ return () => {
36
+ el.removeEventListener('scroll', measure);
37
+ resize.disconnect();
38
+ };
39
+ }, [measure]);
40
+ return (_jsx("div", { ref: (node) => {
41
+ own.current = node;
42
+ if (typeof ref === 'function')
43
+ ref(node);
44
+ else if (ref)
45
+ ref.current = node;
46
+ }, className: `scroller axis-${axis}${fade ? ' faded' : ''}${hideBar ? ' barless' : ''}${className ? ` ${className}` : ''}`, ...(label ? { tabIndex: 0, role: 'region', 'aria-label': label } : {}), children: children }));
47
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Two panes and a handle between them.
3
+ *
4
+ * A mail client is a list beside a message, a forum is threads beside a
5
+ * thread, and in both the right width depends on the reader rather than on us:
6
+ * a wide list to scan senders, a narrow one to give the message room. So it is
7
+ * theirs to set, and it has to survive a reload or setting it was a waste of
8
+ * their time.
9
+ *
10
+ * **The handle is a control, not a decoration, and that is the part everybody
11
+ * skips.** Nearly every split view on the web is a `<div>` with a mousedown
12
+ * listener: no role, no tab stop, no way to move it without a pointer. The
13
+ * ARIA pattern for this is `separator` with a value, which makes it a real
14
+ * widget -- focusable, announced as "splitter, 30 percent", and moved with the
15
+ * arrow keys. Home and End take it to its limits, and Enter collapses the
16
+ * first pane and restores it, which is the thing a mouse does by dragging to
17
+ * the edge.
18
+ *
19
+ * **The size is a percentage of the container**, not pixels, because the
20
+ * window is resized more often than the split is. A pixel width chosen on a
21
+ * wide monitor is most of a laptop screen.
22
+ *
23
+ * **The panes are `min-width: 0`.** Without it a flex child refuses to shrink
24
+ * below the intrinsic width of its content, so one long unbroken subject line
25
+ * silently pins the list open and the handle stops halfway with no explanation.
26
+ * That is in `splitpane.css` and it is the reason the file exists.
27
+ */
28
+ export interface Props {
29
+ /** Exactly two: the first pane and the second. */
30
+ children: [React.ReactNode, React.ReactNode];
31
+ /** `row` puts them side by side with a vertical handle. `column` stacks
32
+ * them. */
33
+ direction?: 'row' | 'column';
34
+ /** The first pane's share, as a percentage, before anyone moves it. */
35
+ defaultSize?: number;
36
+ /** How small and how large the first pane may get, as percentages. The
37
+ * limits are the design's: a list narrower than its own row content is not
38
+ * a smaller list, it is a broken one. */
39
+ min?: number;
40
+ max?: number;
41
+ /**
42
+ * Remember the size under this key, per browser.
43
+ *
44
+ * Without it the handle resets on every reload, which makes moving it feel
45
+ * like it did not work. Storage can throw -- a private window, a browser set
46
+ * to block site data -- so a failure to remember is silent and the default
47
+ * stands.
48
+ */
49
+ storageKey?: string;
50
+ /** Names the handle. "Sidebar width", not "splitter": the name is announced
51
+ * and should say what moving it does. */
52
+ label: string;
53
+ /** Called as the handle moves, with the first pane's percentage. */
54
+ onResize?: (size: number) => void;
55
+ className?: string;
56
+ }
57
+ export default function SplitPane({ children, direction, defaultSize, min, max, storageKey, label, onResize, className, }: Props): import("react").JSX.Element;
@@ -0,0 +1,103 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useId, useRef, useState } from 'react';
3
+ function clamp(value, min, max) {
4
+ return Math.min(max, Math.max(min, value));
5
+ }
6
+ function remembered(key, fallback) {
7
+ if (!key)
8
+ return fallback;
9
+ try {
10
+ const stored = window.localStorage.getItem(`design.split.${key}`);
11
+ const parsed = stored === null ? Number.NaN : Number.parseFloat(stored);
12
+ return Number.isFinite(parsed) ? parsed : fallback;
13
+ }
14
+ catch {
15
+ return fallback;
16
+ }
17
+ }
18
+ export default function SplitPane({ children, direction = 'row', defaultSize = 30, min = 15, max = 70, storageKey, label, onResize, className, }) {
19
+ const [first, second] = children;
20
+ const frame = useRef(null);
21
+ const firstPaneId = useId();
22
+ const [size, setSize] = useState(() => clamp(remembered(storageKey, defaultSize), min, max));
23
+ /* Where the pane was before Enter collapsed it, so Enter puts it back where
24
+ it was rather than at the default. Collapsing and restoring should be the
25
+ same gesture undone, not a reset. */
26
+ const restore = useRef(size);
27
+ const move = useCallback((next) => {
28
+ const bounded = clamp(next, min, max);
29
+ setSize(bounded);
30
+ onResize?.(bounded);
31
+ if (!storageKey)
32
+ return;
33
+ try {
34
+ window.localStorage.setItem(`design.split.${storageKey}`, String(bounded));
35
+ }
36
+ catch {
37
+ // A browser that will not remember is not a reason to refuse to resize.
38
+ }
39
+ }, [min, max, onResize, storageKey]);
40
+ /* Pointer events rather than mouse events, so a pen and a touch drag work
41
+ with the same code; and pointer capture, so a drag that leaves the window
42
+ -- which is what dragging to the edge *is* -- keeps sending moves instead
43
+ of freezing the handle where the cursor left. */
44
+ const onPointerDown = (event) => {
45
+ if (event.button !== 0)
46
+ return;
47
+ const box = frame.current?.getBoundingClientRect();
48
+ if (!box)
49
+ return;
50
+ const handle = event.currentTarget;
51
+ handle.setPointerCapture(event.pointerId);
52
+ const onMove = (e) => {
53
+ const along = direction === 'row' ? e.clientX - box.left : e.clientY - box.top;
54
+ const total = direction === 'row' ? box.width : box.height;
55
+ if (total > 0)
56
+ move((along / total) * 100);
57
+ };
58
+ const onUp = (e) => {
59
+ handle.releasePointerCapture(e.pointerId);
60
+ handle.removeEventListener('pointermove', onMove);
61
+ handle.removeEventListener('pointerup', onUp);
62
+ handle.removeEventListener('pointercancel', onUp);
63
+ document.body.classList.remove('splitting');
64
+ };
65
+ handle.addEventListener('pointermove', onMove);
66
+ handle.addEventListener('pointerup', onUp);
67
+ handle.addEventListener('pointercancel', onUp);
68
+ /* The cursor is set on the body for the duration, because a drag that
69
+ passes over a text pane otherwise flickers to an I-beam the whole way
70
+ across. */
71
+ document.body.classList.add('splitting');
72
+ };
73
+ useEffect(() => () => document.body.classList.remove('splitting'), []);
74
+ const onKeyDown = (event) => {
75
+ const back = direction === 'row' ? 'ArrowLeft' : 'ArrowUp';
76
+ const forward = direction === 'row' ? 'ArrowRight' : 'ArrowDown';
77
+ const step = event.shiftKey ? 10 : 2;
78
+ if (event.key === back)
79
+ move(size - step);
80
+ else if (event.key === forward)
81
+ move(size + step);
82
+ else if (event.key === 'Home')
83
+ move(min);
84
+ else if (event.key === 'End')
85
+ move(max);
86
+ else if (event.key === 'Enter') {
87
+ if (size <= min)
88
+ move(restore.current);
89
+ else {
90
+ restore.current = size;
91
+ move(min);
92
+ }
93
+ }
94
+ else
95
+ return;
96
+ event.preventDefault();
97
+ };
98
+ return (_jsxs("div", { ref: frame, className: `split dir-${direction}${className ? ` ${className}` : ''}`, style: { '--split': `${size}%` }, children: [_jsx("div", { className: "split-pane split-first", id: firstPaneId, children: first }), _jsx("div", { role: "separator", tabIndex: 0, "aria-label": label, "aria-orientation": direction === 'row' ? 'vertical' : 'horizontal', "aria-controls": firstPaneId, "aria-valuenow": Math.round(size), "aria-valuemin": min, "aria-valuemax": max, className: "split-handle", onPointerDown: onPointerDown, onKeyDown: onKeyDown,
99
+ /* Back to where it started. A drag has no undo, and the size is
100
+ remembered, so without this a mis-drag is permanent until you get it
101
+ right by hand. */
102
+ onDoubleClick: () => move(defaultSize), children: _jsx("span", { className: "split-grip", "aria-hidden": "true" }) }), _jsx("div", { className: "split-pane split-second", children: second })] }));
103
+ }
@@ -65,6 +65,16 @@ export default function Toggle({ label, hint, checked, onChange, disabled, said,
65
65
  is stopped at the track a tap on the knob no longer reaches the input by
66
66
  itself (measured, in `keyboard.spec.ts`).
67
67
 
68
+ **The knob goes where the pointer is, not where the pointer has been.**
69
+ This measured the drag as a delta from the knob's resting side, which made
70
+ a click that drifted a few pixels answer the opposite of the click: press
71
+ the far side of an off switch, wander seven pixels, and the delta is about
72
+ zero, so it resolves to off -- it refuses the very thing you pressed. And
73
+ it refuses *silently*, because the knob is back where it started and
74
+ nothing on screen says a gesture was even seen. Reading the pointer's
75
+ position on the track instead means a press and a press-with-a-wobble give
76
+ the same answer, which is the one the pointer is over.
77
+
68
78
  `onChange` is called once per gesture, or not at all if the knob was put
69
79
  back where it started -- a consumer that saves on change must not see a
70
80
  drag as two saves. The keyboard is untouched: the input is still the
@@ -126,9 +136,16 @@ export default function Toggle({ label, hint, checked, onChange, disabled, said,
126
136
  const [held, setHeld] = useState(false);
127
137
  const gesture = useRef(null);
128
138
  const swallowClick = useRef(false);
129
- const position = (track, g, x) => {
130
- const travel = track.clientWidth - KNOB[size] - 4;
131
- return Math.min(1, Math.max(0, g.from + (x - g.startX) / travel));
139
+ /** Where the knob would sit, 0 to 1, if its centre were under `x`. The
140
+ * 2px and the knob's width are the inset `toggle.css` draws it at:
141
+ * `left: calc(2px + var(--knob-x) * var(--knob-travel))`. */
142
+ const position = (track, x) => {
143
+ const box = track.getBoundingClientRect();
144
+ const knobWidth = KNOB[size];
145
+ const travel = box.width - knobWidth - 4;
146
+ if (travel <= 0)
147
+ return 0;
148
+ return Math.min(1, Math.max(0, (x - box.left - 2 - knobWidth / 2) / travel));
132
149
  };
133
150
  return (_jsxs(Switch, { ref: rowRef, className: `switch-row switch-${size}${className ? ` ${className}` : ''}`, isSelected: shown, onChange: commit, isDisabled: disabled,
134
151
  /* Read-only, not disabled, while a request is out: focus stays where it
@@ -143,7 +160,6 @@ export default function Toggle({ label, hint, checked, onChange, disabled, said,
143
160
  id: e.pointerId,
144
161
  startX: e.clientX,
145
162
  startY: e.clientY,
146
- from: shown ? 1 : 0,
147
163
  moved: false,
148
164
  };
149
165
  setHeld(true);
@@ -154,16 +170,15 @@ export default function Toggle({ label, hint, checked, onChange, disabled, said,
154
170
  if (!g.moved) {
155
171
  const dx = e.clientX - g.startX;
156
172
  const dy = e.clientY - g.startY;
157
- /* Mostly sideways and past the slop, or it is still a press. A
158
- drag begins where the threshold was crossed, not where the
159
- pointer first landed, so the knob starts from rest instead of
160
- jumping the slop's width the moment it engages. */
173
+ /* Mostly sideways and past the slop, or it is still a press. The
174
+ slop is what keeps a click a click: under it nothing is drawn
175
+ and nothing moves, so a hand that is not quite still does not
176
+ turn a press into a drag. */
161
177
  if (Math.abs(dx) < SLOP || Math.abs(dx) <= Math.abs(dy))
162
178
  return;
163
179
  g.moved = true;
164
- g.startX = e.clientX;
165
180
  }
166
- setKnob(position(e.currentTarget, g, e.clientX));
181
+ setKnob(position(e.currentTarget, e.clientX));
167
182
  }, onPointerUp: (e) => {
168
183
  const g = gesture.current;
169
184
  if (!g || e.pointerId !== g.id)
@@ -172,7 +187,7 @@ export default function Toggle({ label, hint, checked, onChange, disabled, said,
172
187
  setHeld(false);
173
188
  setKnob(null);
174
189
  swallowClick.current = true;
175
- const on = g.moved ? position(e.currentTarget, g, e.clientX) > 0.5 : !shown;
190
+ const on = g.moved ? position(e.currentTarget, e.clientX) > 0.5 : !shown;
176
191
  if (on !== shown)
177
192
  commit(on);
178
193
  }, onPointerCancel: () => {
@@ -14,5 +14,5 @@
14
14
  * derived from this, so the type, the glyph table and the gallery cannot
15
15
  * disagree.
16
16
  */
17
- export declare const ICON_NAMES: readonly ["chat", "settings", "refresh", "minimize", "code", "wifi", "wifi-off", "check", "close", "info", "warning", "error", "expand", "download", "image", "bolt", "speaker", "mic", "back", "spinner", "folder", "book", "eye", "eye-off", "palette", "stars", "wrench", "cloud", "file"];
17
+ export declare const ICON_NAMES: readonly ["chat", "settings", "refresh", "minimize", "code", "wifi", "wifi-off", "check", "close", "info", "warning", "error", "expand", "download", "image", "bolt", "speaker", "mic", "back", "spinner", "folder", "book", "eye", "eye-off", "palette", "stars", "wrench", "cloud", "file", "mail", "mail-open", "pen", "send", "trash", "paperclip", "star", "star-filled", "search", "more", "menu", "reply", "forward"];
18
18
  export type IconName = (typeof ICON_NAMES)[number];
@@ -57,4 +57,29 @@ export const ICON_NAMES = [
57
57
  // is the wrong noun: you are handing over one file, not opening a place
58
58
  // that holds several.
59
59
  'file',
60
+ // A mail client's vocabulary, added for `@wtfalch/email`'s mailbox. The set
61
+ // had no noun for a message, a folder to file one in, or any of the four
62
+ // things you do to one, so the mailbox was drawing Inbox with `chat`, Sent
63
+ // with `download` and Deleted Items with `close` -- three right shapes for
64
+ // three wrong words. Measured the same way as the rest; see the `Glyph`
65
+ // docstring in `Icon.tsx`.
66
+ 'mail',
67
+ 'mail-open',
68
+ 'pen',
69
+ 'send',
70
+ 'trash',
71
+ 'paperclip',
72
+ // `star` is a rating or a flagged message; `stars` above it is the
73
+ // sparkles, and means generated. One letter apart and unrelated, which is
74
+ // worth the comment: reach for the wrong one and it still compiles.
75
+ 'star',
76
+ 'star-filled',
77
+ 'search',
78
+ 'more',
79
+ 'menu',
80
+ // Diagonal arrows, which is what every minimal set draws reply and forward
81
+ // as. Pepicons has no curved corner arrow, and a horizontal `arrow-left`
82
+ // reads as going back rather than answering.
83
+ 'reply',
84
+ 'forward',
60
85
  ];
@@ -250,4 +250,101 @@ export const ICONS = {
250
250
  'M11 3h-1a4 4 0 0 0-3.874 3H6a4 4 0 1 0 0 8h8a4 4 0 0 0 .899-7.899A4 4 0 0 0 11 3M6.901 7l.193-.75A3 3 0 0 1 10 4h1c1.405 0 2.614.975 2.924 2.325l.14.61l.61.141A3.001 3.001 0 0 1 14 13H6a3 3 0 1 1 0-6z',
251
251
  ],
252
252
  },
253
+ /* The mail set. Every one of these is a Pepicons Pencil glyph measured the
254
+ way the paragraph above describes: bounding box from `getBBox()`,
255
+ re-centred, grown to 78% fill, magnification clamped to 0.85x-1.45x of
256
+ its own 20-unit grid. `reply` and `forward` sit on the upper clamp, as
257
+ `close` does -- small drawings that would otherwise come out at twice
258
+ the ink weight of their neighbours. */
259
+ mail: {
260
+ view: '0.38 0.38 19.23 19.23',
261
+ d: [
262
+ 'M17 4H3a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h14a.5.5 0 0 0 .5-.5v-11A.5.5 0 0 0 17 4M3.5 15V5h13v10z',
263
+ 'm17.324 4.88l-7.045 6a.5.5 0 0 1-.65-.001l-6.956-6A.5.5 0 0 1 3 4h14a.5.5 0 0 1 .324.88M15.642 5H4.345l5.612 4.841z',
264
+ ],
265
+ },
266
+ 'mail-open': {
267
+ view: '-0.26 -0.26 20.51 20.51',
268
+ d: [
269
+ 'M2.5 8a.5.5 0 0 1 .5.5V17h14V8.5a.5.5 0 0 1 1 0v9a.5.5 0 0 1-.5.5h-15a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5',
270
+ 'M3 5.5a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 .5.5v4.67a.5.5 0 0 1-.223.416l-6.5 4.33a.5.5 0 0 1-.554 0l-6.5-4.33A.5.5 0 0 1 3 10.17zM4 6v3.902l6 3.997l6-3.997V6z',
271
+ 'M9.723 2.084a.5.5 0 0 1 .554 0l4.5 3l-.554.832L10 3.101L5.777 5.916l-.554-.832zm7.131 5.062l1 1l-.708.708l-1-1zm-13 .708l-1 1l-.708-.708l1-1zM6.75 8A.25.25 0 0 1 7 7.75h6a.25.25 0 1 1 0 .5H7A.25.25 0 0 1 6.75 8m.5 2a.25.25 0 0 1 .25-.25h5a.25.25 0 1 1 0 .5h-5a.25.25 0 0 1-.25-.25',
272
+ ],
273
+ },
274
+ pen: {
275
+ view: '1.02 -0.92 20.09 20.09',
276
+ d: [
277
+ 'M3.944 11.79a.5.5 0 0 1 .141-.277L14.163 1.435a.5.5 0 0 1 .707 0l3.89 3.89a.5.5 0 0 1 0 .706L8.68 16.11a.5.5 0 0 1-.277.14l-4.595.706a.5.5 0 0 1-.57-.57zm.964.314l-.577 3.76l3.759-.578l9.609-9.608l-3.183-3.182z',
278
+ 'm15.472 8.173l-3.537-3.53l.707-.708l3.536 3.53z',
279
+ ],
280
+ },
281
+ send: {
282
+ view: '-2.09 -1.79 21.9 21.9',
283
+ d: [
284
+ 'M.874 7.454L8.697 9.5l2.803 7.868a.5.5 0 0 0 .95-.026l4.746-16.085a.5.5 0 0 0-.655-.61L.826 6.502a.5.5 0 0 0 .048.952m1.783-.567l13.296-4.954l-4.027 13.652l-2.376-6.67a.5.5 0 0 0-.344-.315z',
285
+ 'm16 1.293l.707.707L9 9.707L8.293 9z',
286
+ ],
287
+ },
288
+ trash: {
289
+ view: '0.74 1.01 18.52 18.52',
290
+ d: [
291
+ 'M8.5 14.999a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 1 0zm2 0a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 1 0zm2 0a.5.5 0 1 1-1 0v-6a.5.5 0 0 1 1 0zm-1-10.5h-3a1.501 1.501 0 0 1 3-.001',
292
+ 'M4.5 4.999a.5.5 0 1 1 0-1h11a.5.5 0 0 1 0 1z',
293
+ 'M14.5 5.5h-9A.5.5 0 0 0 5 6v11a.5.5 0 0 0 .5.5h9a.5.5 0 0 0 .5-.5V6a.5.5 0 0 0-.5-.5M6 16.5v-10h8v10z',
294
+ ],
295
+ },
296
+ paperclip: {
297
+ view: '-0.99 -0.41 21.47 21.47',
298
+ d: [
299
+ 'M9.455 3.188a4 4 0 0 1 5.79 5.521l-5.349 5.608a2.25 2.25 0 0 1-3.258-3.106l4.485-4.706a.5.5 0 1 1 .724.69L7.362 11.9a1.25 1.25 0 0 0 1.81 1.726l5.349-5.608a3 3 0 1 0-4.342-4.141L5.348 8.944a.5.5 0 1 1-.724-.69z',
300
+ 'M4.463 16.391c-.439-.419-.824-1.056-1.145-1.758c-.71-1.552-.17-3.383 1.097-4.712L8.8 5.325a.5.5 0 0 0-.724-.69L3.692 9.231c-1.455 1.526-2.214 3.783-1.284 5.818c.342.747.791 1.518 1.365 2.066c.566.54 1.342.947 2.094 1.251c2.095.849 4.337-.039 5.791-1.593l5.692-6.081a.5.5 0 1 0-.73-.684l-5.692 6.082c-1.267 1.354-3.089 1.996-4.686 1.35c-.704-.286-1.346-.636-1.779-1.049',
301
+ ],
302
+ },
303
+ star: {
304
+ view: '-0.25 -0.51 20.51 20.51',
305
+ d: [
306
+ 'M10 2a.5.5 0 0 1 .435.253l2.425 4.274l4.745 1.023a.5.5 0 0 1 .27.82l-3.253 3.667l.51 4.911a.5.5 0 0 1-.703.507L10 15.448l-4.429 2.007a.5.5 0 0 1-.704-.507l.51-4.91l-3.25-3.668a.5.5 0 0 1 .269-.82L7.14 6.527l2.425-4.274A.5.5 0 0 1 10 2m0 1.513L7.9 7.215a.5.5 0 0 1-.33.242l-4.128.89l2.83 3.191a.5.5 0 0 1 .123.384l-.443 4.263l3.842-1.74a.5.5 0 0 1 .412 0l3.842 1.74l-.443-4.263a.5.5 0 0 1 .123-.384l2.83-3.191l-4.128-.89a.5.5 0 0 1-.33-.242z',
307
+ ],
308
+ },
309
+ 'star-filled': {
310
+ view: '-1.03 -1.3 22.07 22.07',
311
+ d: [
312
+ 'm10 15.97l-4.295 1.915a1 1 0 0 1-1.402-1.018l.494-4.677L1.65 8.698a1 1 0 0 1 .535-1.647l4.6-.976L9.134 2a1 1 0 0 1 1.732 0l2.35 4.074l4.6.976a1 1 0 0 1 .535 1.647l-3.148 3.494l.494 4.676a1 1 0 0 1-1.402 1.018z',
313
+ ],
314
+ },
315
+ search: {
316
+ view: '1.62 1.66 15.87 15.87',
317
+ d: [
318
+ 'M4.828 4.828A5 5 0 1 0 11.9 11.9a5 5 0 0 0-7.07-7.07m6.364 6.364a4 4 0 1 1-5.656-5.657a4 4 0 0 1 5.656 5.657',
319
+ 'M11.192 12.627a1 1 0 0 1 1.415-1.414l2.828 2.829a1 1 0 1 1-1.414 1.414z',
320
+ ],
321
+ },
322
+ more: {
323
+ view: '2.24 2.24 16.03 16.03',
324
+ d: [
325
+ 'M14 10.25a1.25 1.25 0 1 1 2.5 0a1.25 1.25 0 0 1-2.5 0m-5 0a1.25 1.25 0 1 1 2.5 0a1.25 1.25 0 0 1-2.5 0m-5 0a1.249 1.249 0 1 1 2.5 0a1.25 1.25 0 1 1-2.5 0',
326
+ ],
327
+ },
328
+ menu: {
329
+ view: '-0.26 0.24 20.51 20.51',
330
+ d: [
331
+ 'M2 8.5a.5.5 0 0 1 .5-.5h11.308a.5.5 0 0 1 0 1H2.5a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h15a.5.5 0 0 1 0 1h-15a.5.5 0 0 1-.5-.5m0 8a.5.5 0 0 1 .5-.5h15a.5.5 0 0 1 0 1h-15a.5.5 0 0 1-.5-.5m0 4a.5.5 0 0 1 .5-.5h11.308a.5.5 0 0 1 0 1H2.5a.5.5 0 0 1-.5-.5',
332
+ ],
333
+ },
334
+ reply: {
335
+ view: '2.87 2.87 13.79 13.79',
336
+ d: [
337
+ 'M5.948 12.145a.5.5 0 0 1-.453-.543l.471-5.186a.5.5 0 0 1 .996.09l-.471 5.186a.5.5 0 0 1-.543.453',
338
+ 'M12.148 5.945a.5.5 0 0 1-.453.543L6.51 6.96a.5.5 0 0 1-.09-.996l5.185-.472a.5.5 0 0 1 .543.453',
339
+ 'M6.647 6.643a.5.5 0 0 1 .707 0l6.535 6.536a.5.5 0 1 1-.707.707L6.646 7.351a.5.5 0 0 1 0-.708',
340
+ ],
341
+ },
342
+ forward: {
343
+ view: '3.34 2.87 13.79 13.79',
344
+ d: [
345
+ 'M7.852 5.952a.5.5 0 0 1 .543-.453l5.186.472a.5.5 0 0 1-.09.996l-5.186-.472a.5.5 0 0 1-.453-.543',
346
+ 'M14.052 12.152a.5.5 0 0 1-.543-.453l-.472-5.185a.5.5 0 0 1 .996-.09l.472 5.185a.5.5 0 0 1-.453.543',
347
+ 'M13.354 6.65a.5.5 0 0 1 0 .708l-6.536 6.535a.5.5 0 0 1-.707-.707l6.535-6.536a.5.5 0 0 1 .707 0',
348
+ ],
349
+ },
253
350
  };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * How an identity disc is drawn: its two letters and its hue.
3
+ *
4
+ * **Its own module so `Identity.tsx` stays a Fast Refresh boundary.** A
5
+ * component module that exports a value beside its component loses the
6
+ * boundary, and editing it re-runs every importer instead of swapping the
7
+ * component in place. `iconNames.ts` and `tourMarker.ts` exist for the same
8
+ * reason.
9
+ *
10
+ * Exported from the package as well, because anything else colouring by
11
+ * sender -- a thread list's left rule, a chart of who writes most -- has to
12
+ * agree with the discs or the second cue contradicts the first.
13
+ */
14
+ /**
15
+ * Up to two initials.
16
+ *
17
+ * From the name's first and last word, which is right for "Ada Lovelace" and
18
+ * for "Ada Byron King Lovelace"; from the address's local part when there is
19
+ * no name, which is most machine senders. Split on whitespace only --
20
+ * splitting on punctuation turns "O'Brien" into "OB" and "Smith-Jones" into
21
+ * "SJ", which are worse than the single letter they replace.
22
+ */
23
+ export declare function initialsOf(name: string | null | undefined, address: string): string;
24
+ /**
25
+ * A hue from the address.
26
+ *
27
+ * A cheap, stable string hash. Not a cryptographic one and not trying to be:
28
+ * the requirement is that the same address gives the same number everywhere
29
+ * and on every machine, which any deterministic function satisfies, and that
30
+ * neighbouring addresses do not land on the same hue, which multiplying by an
31
+ * odd prime handles well enough.
32
+ *
33
+ * Case- and whitespace-insensitive, because `Ada@Example.com ` and
34
+ * `ada@example.com` are one person and two colours would say they were two.
35
+ */
36
+ export declare function hueOf(address: string): number;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * How an identity disc is drawn: its two letters and its hue.
3
+ *
4
+ * **Its own module so `Identity.tsx` stays a Fast Refresh boundary.** A
5
+ * component module that exports a value beside its component loses the
6
+ * boundary, and editing it re-runs every importer instead of swapping the
7
+ * component in place. `iconNames.ts` and `tourMarker.ts` exist for the same
8
+ * reason.
9
+ *
10
+ * Exported from the package as well, because anything else colouring by
11
+ * sender -- a thread list's left rule, a chart of who writes most -- has to
12
+ * agree with the discs or the second cue contradicts the first.
13
+ */
14
+ /**
15
+ * Up to two initials.
16
+ *
17
+ * From the name's first and last word, which is right for "Ada Lovelace" and
18
+ * for "Ada Byron King Lovelace"; from the address's local part when there is
19
+ * no name, which is most machine senders. Split on whitespace only --
20
+ * splitting on punctuation turns "O'Brien" into "OB" and "Smith-Jones" into
21
+ * "SJ", which are worse than the single letter they replace.
22
+ */
23
+ export function initialsOf(name, address) {
24
+ const words = (name ?? '').trim().split(/\s+/).filter(Boolean);
25
+ if (words.length > 0) {
26
+ const first = words[0]?.[0] ?? '';
27
+ const last = words.length > 1 ? (words[words.length - 1]?.[0] ?? '') : '';
28
+ return (first + last).toUpperCase();
29
+ }
30
+ const local = address.split('@')[0] ?? address;
31
+ return (local[0] ?? '?').toUpperCase();
32
+ }
33
+ /**
34
+ * A hue from the address.
35
+ *
36
+ * A cheap, stable string hash. Not a cryptographic one and not trying to be:
37
+ * the requirement is that the same address gives the same number everywhere
38
+ * and on every machine, which any deterministic function satisfies, and that
39
+ * neighbouring addresses do not land on the same hue, which multiplying by an
40
+ * odd prime handles well enough.
41
+ *
42
+ * Case- and whitespace-insensitive, because `Ada@Example.com ` and
43
+ * `ada@example.com` are one person and two colours would say they were two.
44
+ */
45
+ export function hueOf(address) {
46
+ let hash = 0;
47
+ for (const character of address.trim().toLowerCase()) {
48
+ hash = (hash * 31 + (character.codePointAt(0) ?? 0)) % 360;
49
+ }
50
+ return hash;
51
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Which page buttons a pager draws.
3
+ *
4
+ * **Its own module so `Pagination.tsx` stays a Fast Refresh boundary**, the
5
+ * same rule `iconNames.ts` and `initials.ts` exist under.
6
+ *
7
+ * Never more than seven slots, with the first and last always present and a
8
+ * gap standing in for the run that is elided. A pager that lists twenty-six
9
+ * pages is wider than the table above it; one that lists only the current
10
+ * page's neighbours loses the jump to the end, which is the second most
11
+ * common thing anybody does with one.
12
+ *
13
+ * The width is held at seven even near an end, so the row does not change size
14
+ * as you move through it -- otherwise the button under the cursor shifts
15
+ * between clicks, which is how somebody lands two pages from where they meant.
16
+ *
17
+ * The gap is where the pager puts its "go to page" field, because that is
18
+ * exactly where the pages you cannot see are. What the gap *does* is the
19
+ * component's business; this only says where one goes.
20
+ */
21
+ export declare function pageWindow(current: number, pages: number): (number | 'gap')[];
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Which page buttons a pager draws.
3
+ *
4
+ * **Its own module so `Pagination.tsx` stays a Fast Refresh boundary**, the
5
+ * same rule `iconNames.ts` and `initials.ts` exist under.
6
+ *
7
+ * Never more than seven slots, with the first and last always present and a
8
+ * gap standing in for the run that is elided. A pager that lists twenty-six
9
+ * pages is wider than the table above it; one that lists only the current
10
+ * page's neighbours loses the jump to the end, which is the second most
11
+ * common thing anybody does with one.
12
+ *
13
+ * The width is held at seven even near an end, so the row does not change size
14
+ * as you move through it -- otherwise the button under the cursor shifts
15
+ * between clicks, which is how somebody lands two pages from where they meant.
16
+ *
17
+ * The gap is where the pager puts its "go to page" field, because that is
18
+ * exactly where the pages you cannot see are. What the gap *does* is the
19
+ * component's business; this only says where one goes.
20
+ */
21
+ export function pageWindow(current, pages) {
22
+ if (pages <= 7)
23
+ return Array.from({ length: pages }, (_, i) => i + 1);
24
+ const near = [current - 1, current, current + 1].filter((p) => p > 1 && p < pages);
25
+ const slots = new Set([1, ...near, pages]);
26
+ /* Near an end there is only one gap instead of two, so the run has to be one
27
+ longer to keep the row at seven. Four rather than three: 1 2 3 4 5 … 26,
28
+ not 1 2 3 4 … 26, which is six slots and a row that changes width as you
29
+ leave the first page. */
30
+ if (current <= 3)
31
+ for (const p of [2, 3, 4, 5])
32
+ slots.add(p);
33
+ if (current >= pages - 2) {
34
+ for (const p of [pages - 4, pages - 3, pages - 2, pages - 1])
35
+ slots.add(p);
36
+ }
37
+ const out = [];
38
+ let previous = 0;
39
+ for (const page of [...slots].filter((p) => p >= 1 && p <= pages).sort((a, b) => a - b)) {
40
+ if (previous && page - previous > 1)
41
+ out.push('gap');
42
+ out.push(page);
43
+ previous = page;
44
+ }
45
+ return out;
46
+ }