@weasel-js/labkit 1.4.3 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/_dts/{CanvasStackContext-Dde8_1my.d.ts → CanvasStackContext-kTEZvEgE.d.ts} +1 -1
  2. package/dist/_dts/{frac-B5mB8LsJ.d.ts → frac-z7ker2Vx.d.ts} +4 -3
  3. package/dist/_dts/{index-DelFfeV0.d.ts → index-Blv9uQzu.d.ts} +27 -2
  4. package/dist/canvas/index.d.ts +4 -4
  5. package/dist/chrome/index.d.ts +2 -2
  6. package/dist/chrome/index.js +4 -4
  7. package/dist/{chunk-5TROQVQ2.js → chunk-2P6PP5N4.js} +10 -4
  8. package/dist/chunk-2P6PP5N4.js.map +1 -0
  9. package/dist/{chunk-C42T6DJT.js → chunk-64ZCN3DA.js} +5 -5
  10. package/dist/{chunk-C42T6DJT.js.map → chunk-64ZCN3DA.js.map} +1 -1
  11. package/dist/{chunk-ISCOFAUO.js → chunk-BKFVHKJH.js} +3 -3
  12. package/dist/{chunk-ISCOFAUO.js.map → chunk-BKFVHKJH.js.map} +1 -1
  13. package/dist/{chunk-QAGAHJKZ.js → chunk-MOM3GOVY.js} +3 -3
  14. package/dist/{chunk-QAGAHJKZ.js.map → chunk-MOM3GOVY.js.map} +1 -1
  15. package/dist/{chunk-2RQPGOQF.js → chunk-SMHP6XZ4.js} +3 -3
  16. package/dist/{chunk-2RQPGOQF.js.map → chunk-SMHP6XZ4.js.map} +1 -1
  17. package/dist/{chunk-ASWKLRKJ.js → chunk-U3IYHIAE.js} +11 -11
  18. package/dist/chunk-U3IYHIAE.js.map +1 -0
  19. package/dist/{chunk-MHAGC6VD.js → chunk-W3ECWC2K.js} +199 -184
  20. package/dist/chunk-W3ECWC2K.js.map +1 -0
  21. package/dist/controls/index.d.ts +1 -1
  22. package/dist/controls/index.js +2 -2
  23. package/dist/dragdrop/index.d.ts +1 -1
  24. package/dist/index.d.ts +55 -9
  25. package/dist/index.js +128 -45
  26. package/dist/index.js.map +1 -1
  27. package/dist/layers/index.d.ts +2 -2
  28. package/dist/layers/index.js +2 -2
  29. package/dist/loupe/index.d.ts +3 -3
  30. package/dist/passthrough/weasel-ui.d.ts +10 -0
  31. package/dist/passthrough/weasel-ui.js +1 -1
  32. package/dist/primitives/index.js +3 -3
  33. package/dist/state/index.js +2 -2
  34. package/dist/styles.css +81 -14
  35. package/dist/ui/layers/index.js +1 -1
  36. package/dist/undo/index.d.ts +1 -1
  37. package/package.json +7 -7
  38. package/src/annotations/frac.test.ts +4 -4
  39. package/src/annotations/frac.ts +3 -2
  40. package/src/annotations/index.ts +1 -1
  41. package/src/annotations/store.ts +2 -2
  42. package/src/annotations/svgNodes.ts +1 -2
  43. package/src/index.ts +1 -1
  44. package/src/lab/LabShell.tsx +14 -2
  45. package/src/lab/LabSwitcher.less +85 -0
  46. package/src/lab/LabSwitcher.test.tsx +96 -0
  47. package/src/lab/LabSwitcher.tsx +112 -0
  48. package/src/lab/index.ts +2 -0
  49. package/src/state/helpers.test.ts +20 -0
  50. package/src/state/helpers.ts +16 -0
  51. package/src/state/store.ts +2 -2
  52. package/src/styles.less +1 -0
  53. package/src/theme/base.less +4 -4
  54. package/src/trial/trialOps.ts +3 -2
  55. package/dist/chunk-5TROQVQ2.js.map +0 -1
  56. package/dist/chunk-ASWKLRKJ.js.map +0 -1
  57. package/dist/chunk-MHAGC6VD.js.map +0 -1
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { fracContains, fracIntersects, fracToWorld, roundFrac, worldToFrac } from './frac';
2
+ import { fracContains, fracEncloses, fracToWorld, roundFrac, worldToFrac } from './frac';
3
3
  import type { FracRect } from './types';
4
4
 
5
5
  const CONTENT = { w: 256, h: 170 };
@@ -51,9 +51,9 @@ describe('fraction hit geometry', () => {
51
51
  expect(fracContains(box, { x: 0.18, y: 0.3 }, 0.05)).toBe(true);
52
52
  });
53
53
 
54
- it('intersects only a box that wholly contains it', () => {
55
- expect(fracIntersects({ x: 0, y: 0, w: 1, h: 1 }, box)).toBe(true);
54
+ it('encloses only a box that lies wholly inside', () => {
55
+ expect(fracEncloses({ x: 0, y: 0, w: 1, h: 1 }, box)).toBe(true);
56
56
  // Overlapping is not containing: a marquee takes what it encloses.
57
- expect(fracIntersects({ x: 0.4, y: 0.4, w: 0.4, h: 0.4 }, box)).toBe(false);
57
+ expect(fracEncloses({ x: 0.4, y: 0.4, w: 0.4, h: 0.4 }, box)).toBe(false);
58
58
  });
59
59
  });
@@ -55,8 +55,9 @@ export function fracContains(box: FracRect, pt: FracPoint, tol = 0): boolean {
55
55
  }
56
56
 
57
57
  /** Whether `outer` wholly encloses `inner`. A marquee takes what it encloses,
58
- * not what it grazes — brushing selection is a different gesture. */
59
- export function fracIntersects(outer: FracRect, inner: FracRect): boolean {
58
+ * not what it grazes — brushing selection is a different gesture, and this
59
+ * answers false for two rects that merely overlap. */
60
+ export function fracEncloses(outer: FracRect, inner: FracRect): boolean {
60
61
  return (
61
62
  inner.x >= outer.x &&
62
63
  inner.y >= outer.y &&
@@ -4,7 +4,7 @@ export type { MarkDrawOptions } from './drawOne';
4
4
  export { createMarkDrawOne, resolveMarkStyle } from './drawOne';
5
5
  export { ExportMenu } from './ExportMenu';
6
6
  export type { WorldRect } from './frac';
7
- export { fracContains, fracIntersects, fracToWorld, roundFrac, worldToFrac } from './frac';
7
+ export { fracContains, fracEncloses, fracToWorld, roundFrac, worldToFrac } from './frac';
8
8
  export type { HistoryScene } from './history';
9
9
  export { MarkHistory } from './history';
10
10
  export type { MarkListProps } from './MarkList';
@@ -7,7 +7,7 @@ import {
7
7
  } from '@weasel-js/core';
8
8
  import { captureTarget } from './capture';
9
9
  import type { WorldRect } from './frac';
10
- import { fracContains, fracIntersects, fracToWorld, roundFrac, worldToFrac } from './frac';
10
+ import { fracContains, fracEncloses, fracToWorld, roundFrac, worldToFrac } from './frac';
11
11
  import { MarkHistory } from './history';
12
12
  import { isStale as isStaleAgainst, seenFrom } from './staleness';
13
13
  import type {
@@ -197,7 +197,7 @@ export function createAnnotationStore(opts: AnnotationStoreOptions): Annotations
197
197
  },
198
198
 
199
199
  within(target, box: FracRect) {
200
- return marksOn(target).filter((a) => fracIntersects(box, a.frac));
200
+ return marksOn(target).filter((a) => fracEncloses(box, a.frac));
201
201
  },
202
202
 
203
203
  isStale(a, config) {
@@ -1,5 +1,4 @@
1
1
  import type { DrawCommand, FillStyle, Stroke } from '@weasel-js/core';
2
- import { resolveStrokeWidth } from '@weasel-js/core';
3
2
  import type { SvgNode, SvgPaint, SvgStroke } from '@weasel-js/svg';
4
3
  import { type MarkStyle, markCommands, type PaintableMark } from './paint';
5
4
 
@@ -23,7 +22,7 @@ function toSvgPaint(paint: FillStyle | undefined): SvgPaint {
23
22
  function toSvgStroke(stroke: Stroke): SvgStroke {
24
23
  return {
25
24
  paint: toSvgPaint(stroke.paint),
26
- width: resolveStrokeWidth(stroke.width ?? 1, 1),
25
+ width: stroke.width ?? 1,
27
26
  ...(stroke.cap ? { cap: stroke.cap } : {}),
28
27
  ...(stroke.join ? { join: stroke.join } : {}),
29
28
  ...(stroke.dash ? { dash: [...stroke.dash] } : {}),
package/src/index.ts CHANGED
@@ -76,7 +76,7 @@ export {
76
76
  createAnnotationScene,
77
77
  createAnnotationStore,
78
78
  fracContains,
79
- fracIntersects,
79
+ fracEncloses,
80
80
  fracToWorld,
81
81
  isStale,
82
82
  roundFrac,
@@ -2,6 +2,8 @@ import { ThemeProvider, useThemeOptional } from '@weasel-js/theme/react';
2
2
  import type { ReactNode } from 'react';
3
3
  import type { LabMode } from '../state/types';
4
4
  import { interstellarTheme } from '../theme/interstellar';
5
+ import type { LabPage } from './LabSwitcher';
6
+ import { LabSwitcher } from './LabSwitcher';
5
7
  import { useResolvedMode } from './useSystemMode';
6
8
 
7
9
  /** Props for `<LabShell>`. */
@@ -14,12 +16,19 @@ export interface LabShellProps {
14
16
  footer?: ReactNode;
15
17
  /** Color mode. "auto" (default) follows prefers-color-scheme. */
16
18
  mode?: LabMode;
19
+ /** The project's other labs. Given two or more, the title becomes the way
20
+ * to reach them; given none, it stays a plain heading. */
21
+ pages?: readonly LabPage[];
22
+ /** The path the switcher reads to mark the open page. Defaults to the
23
+ * current location. */
24
+ path?: string;
17
25
  }
18
26
 
19
27
  /** Page frame for a lab: a titled header, a body, and an optional footer,
20
28
  * themed for the resolved color mode. Presentational only — use `<Lab>` when
21
29
  * the trial runtime is wanted too. */
22
- export function LabShell({ title, children, header, footer, mode = 'auto' }: LabShellProps) {
30
+ export function LabShell({ title, children, header, footer, mode = 'auto',
31
+ pages, path }: LabShellProps) {
23
32
  const resolved = useResolvedMode(mode);
24
33
  const outer = useThemeOptional();
25
34
 
@@ -28,7 +37,10 @@ export function LabShell({ title, children, header, footer, mode = 'auto' }: Lab
28
37
  // carries the element defaults a lab's bare markup is styled by.
29
38
  <div className="lk-root lk-shell" data-wzl-portal-host="">
30
39
  <header className="lk-shell-header">
31
- <h1 className="lk-shell-title">{title}</h1>
40
+ {pages && pages.length > 0
41
+ ? <LabSwitcher title={title} pages={pages} path={path}
42
+ className="lk-shell-title" />
43
+ : <h1 className="lk-shell-title">{title}</h1>}
32
44
  {header && <div className="lk-shell-header-actions">{header}</div>}
33
45
  </header>
34
46
  <main className="lk-shell-body">{children}</main>
@@ -0,0 +1,85 @@
1
+ .lk-switcher {
2
+ position: relative;
3
+ display: inline-block;
4
+ }
5
+
6
+ .lk-switcher-title {
7
+ margin: 0;
8
+ font-size: var(--wzl-font-size-xl);
9
+ line-height: var(--wzl-line-height-tight, 1.2);
10
+ }
11
+
12
+ .lk-switcher-trigger {
13
+ display: inline-flex;
14
+ align-items: baseline;
15
+ gap: var(--wzl-space-xs);
16
+ // The title IS the control, so it keeps the title's type and gives up every
17
+ // other button affordance until it is pointed at.
18
+ font: inherit;
19
+ color: inherit;
20
+ background: none;
21
+ border: 0;
22
+ padding: 0;
23
+ cursor: pointer;
24
+
25
+ &:hover { color: var(--wzl-text-accent, var(--wzl-text)); }
26
+
27
+ &:focus-visible {
28
+ outline: 2px solid var(--wzl-focus-ring, currentColor);
29
+ outline-offset: 2px;
30
+ border-radius: var(--wzl-radius-sm, 2px);
31
+ }
32
+ }
33
+
34
+ .lk-switcher-caret {
35
+ // Sized in em so it tracks the title rather than needing a second rule per
36
+ // heading size.
37
+ width: 0.36em;
38
+ height: 0.36em;
39
+ border-right: 0.09em solid currentColor;
40
+ border-bottom: 0.09em solid currentColor;
41
+ transform: translateY(-0.18em) rotate(45deg);
42
+ opacity: 0.6;
43
+ }
44
+
45
+ .lk-switcher-trigger[aria-expanded='true'] .lk-switcher-caret {
46
+ transform: translateY(0.06em) rotate(-135deg);
47
+ }
48
+
49
+ .lk-switcher-menu {
50
+ position: absolute;
51
+ top: calc(100% + var(--wzl-space-xs));
52
+ left: 0;
53
+ z-index: var(--wzl-z-popover, 60);
54
+ min-width: max(100%, 11rem);
55
+ display: flex;
56
+ flex-direction: column;
57
+ padding: var(--wzl-space-xs);
58
+ background: var(--wzl-surface-raised);
59
+ border: 1px solid var(--wzl-border);
60
+ border-radius: var(--wzl-radius-md, 6px);
61
+ box-shadow: var(--wzl-shadow-md, 0 6px 20px rgb(0 0 0 / 35%));
62
+ }
63
+
64
+ .lk-switcher-item {
65
+ padding: var(--wzl-space-xs) var(--wzl-space-sm);
66
+ border-radius: var(--wzl-radius-sm, 3px);
67
+ color: var(--wzl-text);
68
+ font-size: var(--wzl-font-size-md);
69
+ text-decoration: none;
70
+ white-space: nowrap;
71
+
72
+ &:hover { background: var(--wzl-surface-hover, var(--wzl-surface)); }
73
+
74
+ &:focus-visible {
75
+ outline: 2px solid var(--wzl-focus-ring, currentColor);
76
+ outline-offset: -2px;
77
+ }
78
+ }
79
+
80
+ // The open page stays in the list rather than being filtered out: a menu that
81
+ // drops its current entry shifts every other one as you move between pages.
82
+ .lk-switcher-item[aria-current='page'] {
83
+ color: var(--wzl-text-strong, var(--wzl-text));
84
+ font-weight: var(--wzl-font-weight-semibold, 600);
85
+ }
@@ -0,0 +1,96 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { currentPage, LabSwitcher } from './LabSwitcher';
4
+ import { LabShell } from './LabShell';
5
+
6
+ const PAGES = [
7
+ { href: '/corpus', label: 'Wall' },
8
+ { href: '/stats', label: 'Dashboard' },
9
+ { href: '/ingest', label: 'Ingestion' },
10
+ ];
11
+
12
+ const open = () => fireEvent.click(screen.getByRole('button', { name: /corpus stats/ }));
13
+
14
+ describe('currentPage', () => {
15
+ it('matches a bare path', () => {
16
+ expect(currentPage('/stats', PAGES)).toBe(1);
17
+ });
18
+
19
+ it('survives a query string, a trailing slash and a leftover extension', () => {
20
+ // A dev server maps `/stats` to `stats.html` and serves both spellings, so
21
+ // an old bookmark must still resolve to the same page.
22
+ for (const path of ['/stats?kind=base', '/stats/', '/stats.html',
23
+ '/lab/stats.html#top']) {
24
+ expect(currentPage(path, PAGES)).toBe(1);
25
+ }
26
+ });
27
+
28
+ it('reports -1 for a page that is not one of them', () => {
29
+ expect(currentPage('/bench', PAGES)).toBe(-1);
30
+ });
31
+ });
32
+
33
+ describe('<LabSwitcher>', () => {
34
+ it('shows the title and nothing else until it is opened', () => {
35
+ render(<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />);
36
+ expect(screen.getByRole('button', { name: /corpus stats/ })).toHaveAttribute(
37
+ 'aria-expanded', 'false');
38
+ expect(screen.queryByRole('menu')).toBeNull();
39
+ });
40
+
41
+ it('opens a menu of every lab, the open one included', () => {
42
+ // Filtering the current page out shifts every other entry as you move
43
+ // between pages, so it stays and is marked instead.
44
+ render(<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />);
45
+ open();
46
+ const items = screen.getAllByRole('menuitem');
47
+ expect(items.map((i) => i.textContent)).toEqual(['Wall', 'Dashboard', 'Ingestion']);
48
+ expect(items[1]).toHaveAttribute('aria-current', 'page');
49
+ });
50
+
51
+ it('offers real links, so cmd-click and the back button keep working', () => {
52
+ // These are separate documents. A <select> would fire a change event and
53
+ // get none of the three for free.
54
+ render(<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />);
55
+ open();
56
+ expect(screen.getByRole('menuitem', { name: 'Wall' }).tagName).toBe('A');
57
+ expect(screen.getByRole('menuitem', { name: 'Wall' })).toHaveAttribute(
58
+ 'href', '/corpus');
59
+ });
60
+
61
+ it('closes on Escape and on a press outside it', () => {
62
+ render(
63
+ <div>
64
+ <LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />
65
+ <button type="button">elsewhere</button>
66
+ </div>);
67
+ open();
68
+ fireEvent.keyDown(document, { key: 'Escape' });
69
+ expect(screen.queryByRole('menu')).toBeNull();
70
+
71
+ open();
72
+ fireEvent.pointerDown(screen.getByRole('button', { name: 'elsewhere' }));
73
+ expect(screen.queryByRole('menu')).toBeNull();
74
+ });
75
+
76
+ it('stays a plain heading when there is nowhere to switch to', () => {
77
+ // A disclosure arrow promising a menu of the page you are already on is
78
+ // worse than no control.
79
+ render(<LabSwitcher title="corpus stats" pages={[PAGES[1]!]} path="/stats" />);
80
+ expect(screen.getByRole('heading', { name: 'corpus stats' })).toBeInTheDocument();
81
+ expect(screen.queryByRole('button')).toBeNull();
82
+ });
83
+ });
84
+
85
+ describe('<LabShell> with pages', () => {
86
+ it('turns its title into the switcher', () => {
87
+ render(<LabShell title="corpus stats" pages={PAGES} path="/stats">body</LabShell>);
88
+ expect(screen.getByRole('button', { name: /corpus stats/ })).toBeInTheDocument();
89
+ });
90
+
91
+ it('leaves the title alone when no pages are given', () => {
92
+ render(<LabShell title="corpus stats">body</LabShell>);
93
+ expect(screen.getByRole('heading', { name: 'corpus stats' })).toBeInTheDocument();
94
+ expect(screen.queryByRole('button', { name: /corpus stats/ })).toBeNull();
95
+ });
96
+ });
@@ -0,0 +1,112 @@
1
+ import { useCallback, useEffect, useId, useRef, useState } from 'react';
2
+
3
+ /** One page in a project's set of labs. */
4
+ export interface LabPage {
5
+ /** The page's own URL, and how the switcher knows which page is open. */
6
+ href: string;
7
+ label: string;
8
+ }
9
+
10
+ /** Props for `<LabSwitcher>`. */
11
+ export interface LabSwitcherProps {
12
+ /** The open page's title, shown as the control's own label. */
13
+ title: string;
14
+ pages: readonly LabPage[];
15
+ /** Defaults to the current location. Pass it in tests, or to render a
16
+ * switcher for a page other than the one being viewed. */
17
+ path?: string;
18
+ className?: string;
19
+ }
20
+
21
+ /** Which of `pages` a path is on, or -1.
22
+ *
23
+ * Matched on the end of the path so a query string, a trailing slash or a
24
+ * leftover `.html` cannot lose it: a project whose dev server maps `/stats`
25
+ * to `stats.html` serves both spellings, and a bookmark from before the URLs
26
+ * lost their extension still resolves to the same page. */
27
+ export function currentPage(path: string, pages: readonly LabPage[]): number {
28
+ const here = path.split('?')[0]!.split('#')[0]!
29
+ .replace(/\/$/, '').replace(/\.html$/, '');
30
+ return pages.findIndex((p) => here.endsWith(p.href.replace(/\.html$/, '')));
31
+ }
32
+
33
+ /** A project's lab title, doubling as the way to reach its other labs.
34
+ *
35
+ * The menu holds real anchors, not a `<select>` and not buttons. These are
36
+ * separate documents, so an `href` is what gets middle-click, cmd-click and
37
+ * the back button for free; a select fires a change event and can do none of
38
+ * the three. The trigger is a button because it opens something rather than
39
+ * going anywhere.
40
+ *
41
+ * Pages accumulate -- a project grows a wall, a dashboard, an ingest page, a
42
+ * bench -- and a tab strip is the layout that stops working first. */
43
+ export function LabSwitcher({ title, pages, path, className }: LabSwitcherProps) {
44
+ const [open, setOpen] = useState(false);
45
+ const root = useRef<HTMLDivElement>(null);
46
+ const menuId = useId();
47
+ const here = currentPage(path ?? (typeof window === 'undefined'
48
+ ? '' : window.location.pathname), pages);
49
+
50
+ const close = useCallback(() => setOpen(false), []);
51
+
52
+ useEffect(() => {
53
+ if (!open) return;
54
+ const onKey = (e: KeyboardEvent) => {
55
+ if (e.key === 'Escape') {
56
+ close();
57
+ root.current?.querySelector('button')?.focus();
58
+ }
59
+ };
60
+ // Pointer, not click: a press that starts outside should dismiss before
61
+ // whatever it lands on reacts, the way every other menu on the page does.
62
+ const onDown = (e: PointerEvent) => {
63
+ if (!root.current?.contains(e.target as Node)) close();
64
+ };
65
+ document.addEventListener('keydown', onKey);
66
+ document.addEventListener('pointerdown', onDown);
67
+ return () => {
68
+ document.removeEventListener('keydown', onKey);
69
+ document.removeEventListener('pointerdown', onDown);
70
+ };
71
+ }, [open, close]);
72
+
73
+ // One lab is not a set to choose from, and a disclosure arrow promising a
74
+ // menu of itself is worse than no control.
75
+ if (pages.length < 2) {
76
+ return <h1 className={`lk-switcher-title${className ? ` ${className}` : ''}`}>{title}</h1>;
77
+ }
78
+
79
+ return (
80
+ <div className={`lk-switcher${className ? ` ${className}` : ''}`} ref={root}>
81
+ <h1 className="lk-switcher-title">
82
+ <button
83
+ type="button"
84
+ className="lk-switcher-trigger"
85
+ aria-expanded={open}
86
+ aria-haspopup="menu"
87
+ aria-controls={open ? menuId : undefined}
88
+ onClick={() => setOpen((was) => !was)}
89
+ >
90
+ {title}
91
+ <span className="lk-switcher-caret" aria-hidden="true" />
92
+ </button>
93
+ </h1>
94
+ {open && (
95
+ <div className="lk-switcher-menu" id={menuId} role="menu">
96
+ {pages.map((page, i) => (
97
+ <a
98
+ key={page.href}
99
+ href={page.href}
100
+ role="menuitem"
101
+ className="lk-switcher-item"
102
+ aria-current={i === here ? 'page' : undefined}
103
+ onClick={close}
104
+ >
105
+ {page.label}
106
+ </a>
107
+ ))}
108
+ </div>
109
+ )}
110
+ </div>
111
+ );
112
+ }
package/src/lab/index.ts CHANGED
@@ -6,5 +6,7 @@ export type { LabPaletteProps } from './LabPalette';
6
6
  export { LabPalette } from './LabPalette';
7
7
  export type { LabShellProps } from './LabShell';
8
8
  export { LabShell } from './LabShell';
9
+ export type { LabPage, LabSwitcherProps } from './LabSwitcher';
10
+ export { currentPage, LabSwitcher } from './LabSwitcher';
9
11
  export type { PanelDescriptor, TrialLayout, WorkspaceProps } from './Workspace';
10
12
  export { Workspace } from './Workspace';
@@ -4,6 +4,7 @@ import {
4
4
  deserializeTrials,
5
5
  emptyUndoStack,
6
6
  encodeUrlHash,
7
+ newId,
7
8
  labStorageKey,
8
9
  serializeTrials,
9
10
  } from './helpers';
@@ -114,4 +115,23 @@ describe('deserializeTrials', () => {
114
115
  it('returns an empty list when given something that is not an array', () => {
115
116
  expect(deserializeTrials(undefined as never, {})).toEqual([]);
116
117
  });
118
+
119
+ describe('newId', () => {
120
+ it('mints distinct ids', () => {
121
+ expect(newId()).not.toBe(newId());
122
+ });
123
+
124
+ it('falls back where crypto.randomUUID is absent', () => {
125
+ // What a lab reached by LAN IP gets: not a secure context, so the
126
+ // function is not there. It used to throw on the first render and show a
127
+ // blank page.
128
+ const real = globalThis.crypto;
129
+ Object.defineProperty(globalThis, 'crypto', { value: {}, configurable: true });
130
+ try {
131
+ expect(newId()).not.toBe(newId());
132
+ } finally {
133
+ Object.defineProperty(globalThis, 'crypto', { value: real, configurable: true });
134
+ }
135
+ });
136
+ });
117
137
  });
@@ -25,6 +25,22 @@ export function decodeUrlHash(hash: string): string | null {
25
25
  }
26
26
  }
27
27
 
28
+ /**
29
+ * An id for a trial or a snapshot.
30
+ *
31
+ * `crypto.randomUUID` exists only in a secure context, and a LAN address is
32
+ * not one — so a lab opened on a phone or a tablet by IP throws on its first
33
+ * render and shows a blank page with nothing in reach to say why. The counter
34
+ * is per page load, which is all these ids have to survive: they name records
35
+ * inside one session's store, and a saved snapshot carries its own name.
36
+ */
37
+ let idCounter = 0;
38
+ export function newId(): string {
39
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
40
+ if (c && typeof c.randomUUID === 'function') return c.randomUUID();
41
+ return `lk-${Date.now().toString(36)}-${idCounter++}`;
42
+ }
43
+
28
44
  /** A fresh, empty undo history. */
29
45
  export function emptyUndoStack(): UndoStack {
30
46
  return { past: [], future: [] };
@@ -11,7 +11,7 @@ import {
11
11
  readLegacyDocument,
12
12
  runMigrations,
13
13
  } from './document';
14
- import { deserializeTrials, emptyUndoStack, serializeTrials } from './helpers';
14
+ import { deserializeTrials, emptyUndoStack, newId, serializeTrials } from './helpers';
15
15
  import type {
16
16
  CreateLabStoreOptions,
17
17
  LabDocument,
@@ -221,7 +221,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
221
221
  const lastAt = get().savedSnapshots.reduce((m, sn) => (sn.savedAt > m ? sn.savedAt : m), 0);
222
222
  const savedAt = Math.max(Date.now(), lastAt + 1);
223
223
  const snapshot: SavedSnapshot = {
224
- id: crypto.randomUUID(),
224
+ id: newId(),
225
225
  name,
226
226
  trialId,
227
227
  instrumentName: trial.instrumentName,
package/src/styles.less CHANGED
@@ -2,6 +2,7 @@
2
2
  @import './theme/base.less';
3
3
  @import './lab/Workspace.less';
4
4
  @import './lab/LabShell.less';
5
+ @import './lab/LabSwitcher.less';
5
6
  @import './primitives/Toolbar.less';
6
7
  @import './primitives/Sidebar.less';
7
8
  @import './primitives/ZoomControl.less';
@@ -79,13 +79,13 @@
79
79
  &::-webkit-slider-runnable-track {
80
80
  height: var(--wzl-slider-track-h);
81
81
  border-radius: var(--wzl-radius-sm);
82
- background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-tint), transparent);
82
+ background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-mix), transparent);
83
83
  }
84
84
 
85
85
  &::-moz-range-track {
86
86
  height: var(--wzl-slider-track-h);
87
87
  border-radius: var(--wzl-radius-sm);
88
- background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-tint), transparent);
88
+ background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-mix), transparent);
89
89
  }
90
90
 
91
91
  &::-webkit-slider-thumb {
@@ -95,7 +95,7 @@
95
95
  height: var(--wzl-slider-thumb-size);
96
96
  border: none;
97
97
  border-radius: 50%;
98
- background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-tint), transparent);
98
+ background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-mix), transparent);
99
99
  margin-top: calc((var(--wzl-slider-track-h) - var(--wzl-slider-thumb-size)) / 2);
100
100
  }
101
101
 
@@ -104,7 +104,7 @@
104
104
  height: var(--wzl-slider-thumb-size);
105
105
  border: none;
106
106
  border-radius: 50%;
107
- background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-tint), transparent);
107
+ background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-mix), transparent);
108
108
  }
109
109
  }
110
110
  }
@@ -1,6 +1,7 @@
1
1
  import { fillConfigDefaults } from '../config/path';
2
2
  import type { Instrument, InstrumentList } from '../instrument/types';
3
3
  import type { TrialRecord } from '../state/types';
4
+ import { newId } from '../state/helpers';
4
5
 
5
6
  const DEFAULT_VIEW = { zoom: 1, pan: { x: 0, y: 0 } } as const;
6
7
 
@@ -47,7 +48,7 @@ export function addTrial(
47
48
  const config = seedConfig(instrument.defaultConfig(), options.config);
48
49
  const state = instrument.initialState(config);
49
50
  const record: TrialRecord = {
50
- id: crypto.randomUUID(),
51
+ id: newId(),
51
52
  instrumentName,
52
53
  config,
53
54
  state,
@@ -66,7 +67,7 @@ export function cloneTrial(trials: TrialRecord[], id: string): TrialRecord[] {
66
67
  if (!source) return trials;
67
68
  const clone: TrialRecord = {
68
69
  ...source,
69
- id: crypto.randomUUID(),
70
+ id: newId(),
70
71
  config: structuredClone(source.config),
71
72
  state: structuredClone(source.state),
72
73
  view: structuredClone(source.view),