@graview/primitives 0.1.16 → 0.1.18

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 (151) hide show
  1. package/README.md +47 -2
  2. package/dist/app-bar.d.ts +87 -19
  3. package/dist/app-bar.d.ts.map +1 -1
  4. package/dist/app-bar.js +379 -141
  5. package/dist/app-bar.js.map +1 -1
  6. package/dist/bar-panes-door.d.ts +8 -0
  7. package/dist/bar-panes-door.d.ts.map +1 -0
  8. package/dist/bar-panes-door.js +10 -0
  9. package/dist/bar-panes-door.js.map +1 -0
  10. package/dist/bar-panes.d.ts.map +1 -1
  11. package/dist/bar-panes.js +3 -27
  12. package/dist/bar-panes.js.map +1 -1
  13. package/dist/chat.js +1 -1
  14. package/dist/chat.js.map +1 -1
  15. package/dist/choice.d.ts +9 -0
  16. package/dist/choice.d.ts.map +1 -0
  17. package/dist/choice.js +21 -0
  18. package/dist/choice.js.map +1 -0
  19. package/dist/companion.d.ts +0 -15
  20. package/dist/companion.d.ts.map +1 -1
  21. package/dist/companion.js +19 -150
  22. package/dist/companion.js.map +1 -1
  23. package/dist/connections.d.ts +7 -1
  24. package/dist/connections.d.ts.map +1 -1
  25. package/dist/connections.js +13 -13
  26. package/dist/connections.js.map +1 -1
  27. package/dist/declared-lens-doors.d.ts +3 -3
  28. package/dist/declared-lens-doors.d.ts.map +1 -1
  29. package/dist/declared-lens-doors.js +45 -25
  30. package/dist/declared-lens-doors.js.map +1 -1
  31. package/dist/default-view.d.ts +2 -0
  32. package/dist/default-view.d.ts.map +1 -1
  33. package/dist/default-view.js +10 -4
  34. package/dist/default-view.js.map +1 -1
  35. package/dist/default-views.d.ts.map +1 -1
  36. package/dist/default-views.js +195 -42
  37. package/dist/default-views.js.map +1 -1
  38. package/dist/editable.d.ts +31 -0
  39. package/dist/editable.d.ts.map +1 -1
  40. package/dist/editable.js +86 -17
  41. package/dist/editable.js.map +1 -1
  42. package/dist/figure.js +1 -1
  43. package/dist/figure.js.map +1 -1
  44. package/dist/find.d.ts.map +1 -1
  45. package/dist/find.js +23 -6
  46. package/dist/find.js.map +1 -1
  47. package/dist/frame.d.ts +3 -2
  48. package/dist/frame.d.ts.map +1 -1
  49. package/dist/frame.js +2 -1
  50. package/dist/frame.js.map +1 -1
  51. package/dist/graview-mark.d.ts +21 -0
  52. package/dist/graview-mark.d.ts.map +1 -0
  53. package/dist/graview-mark.js +24 -0
  54. package/dist/graview-mark.js.map +1 -0
  55. package/dist/index.d.ts +14 -8
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +10 -6
  58. package/dist/index.js.map +1 -1
  59. package/dist/ladder.d.ts.map +1 -1
  60. package/dist/ladder.js +4 -10
  61. package/dist/ladder.js.map +1 -1
  62. package/dist/lens/calendar-dates.d.ts +2 -0
  63. package/dist/lens/calendar-dates.d.ts.map +1 -1
  64. package/dist/lens/calendar-dates.js +2 -0
  65. package/dist/lens/calendar-dates.js.map +1 -1
  66. package/dist/lens/calendar-drawing.d.ts +2 -2
  67. package/dist/lens/calendar-drawing.d.ts.map +1 -1
  68. package/dist/lens/calendar-drawing.js +1 -2
  69. package/dist/lens/calendar-drawing.js.map +1 -1
  70. package/dist/lens/calendar-spans.d.ts.map +1 -1
  71. package/dist/lens/calendar-spans.js +1 -2
  72. package/dist/lens/calendar-spans.js.map +1 -1
  73. package/dist/lens/calendar-view.d.ts +2 -1
  74. package/dist/lens/calendar-view.d.ts.map +1 -1
  75. package/dist/lens/calendar-view.js +2 -1
  76. package/dist/lens/calendar-view.js.map +1 -1
  77. package/dist/notice-place.d.ts.map +1 -1
  78. package/dist/notice-place.js +2 -0
  79. package/dist/notice-place.js.map +1 -1
  80. package/dist/pages.d.ts +5 -3
  81. package/dist/pages.d.ts.map +1 -1
  82. package/dist/pages.js +5 -2
  83. package/dist/pages.js.map +1 -1
  84. package/dist/places.js +2 -2
  85. package/dist/places.js.map +1 -1
  86. package/dist/profile.d.ts.map +1 -1
  87. package/dist/profile.js +34 -9
  88. package/dist/profile.js.map +1 -1
  89. package/dist/prose-editor.d.ts +9 -0
  90. package/dist/prose-editor.d.ts.map +1 -0
  91. package/dist/prose-editor.js +64 -0
  92. package/dist/prose-editor.js.map +1 -0
  93. package/dist/record-head.d.ts +28 -0
  94. package/dist/record-head.d.ts.map +1 -0
  95. package/dist/record-head.js +3 -0
  96. package/dist/record-head.js.map +1 -0
  97. package/dist/relation-key.d.ts.map +1 -1
  98. package/dist/relation-key.js +2 -2
  99. package/dist/relation-key.js.map +1 -1
  100. package/dist/scene-css.d.ts.map +1 -1
  101. package/dist/scene-css.js +15 -2
  102. package/dist/scene-css.js.map +1 -1
  103. package/dist/scene-places.d.ts +37 -0
  104. package/dist/scene-places.d.ts.map +1 -0
  105. package/dist/scene-places.js +77 -0
  106. package/dist/scene-places.js.map +1 -0
  107. package/dist/scene.d.ts +0 -1
  108. package/dist/scene.d.ts.map +1 -1
  109. package/dist/scene.js +0 -1
  110. package/dist/scene.js.map +1 -1
  111. package/dist/seats.d.ts.map +1 -1
  112. package/dist/seats.js +2 -11
  113. package/dist/seats.js.map +1 -1
  114. package/dist/shell.d.ts.map +1 -1
  115. package/dist/shell.js +1 -2
  116. package/dist/shell.js.map +1 -1
  117. package/dist/spec-views.d.ts +17 -2
  118. package/dist/spec-views.d.ts.map +1 -1
  119. package/dist/spec-views.js +69 -7
  120. package/dist/spec-views.js.map +1 -1
  121. package/dist/subject.d.ts +17 -0
  122. package/dist/subject.d.ts.map +1 -0
  123. package/dist/subject.js +155 -0
  124. package/dist/subject.js.map +1 -0
  125. package/dist/text-body.d.ts +28 -0
  126. package/dist/text-body.d.ts.map +1 -0
  127. package/dist/text-body.js +64 -0
  128. package/dist/text-body.js.map +1 -0
  129. package/dist/theme.d.ts.map +1 -1
  130. package/dist/theme.js +13 -7
  131. package/dist/theme.js.map +1 -1
  132. package/dist/view-doors.d.ts +5 -1
  133. package/dist/view-doors.d.ts.map +1 -1
  134. package/dist/view-doors.js +28 -14
  135. package/dist/view-doors.js.map +1 -1
  136. package/dist/workbench/answer-args.d.ts.map +1 -1
  137. package/dist/workbench/answer-args.js +26 -2
  138. package/dist/workbench/answer-args.js.map +1 -1
  139. package/dist/workbench/inspector.js +1 -1
  140. package/dist/workbench/inspector.js.map +1 -1
  141. package/dist/workbench/standing.d.ts.map +1 -1
  142. package/dist/workbench/standing.js +4 -3
  143. package/dist/workbench/standing.js.map +1 -1
  144. package/fonts/OFL-Montserrat.txt +93 -0
  145. package/fonts/montserrat-latin.woff2 +0 -0
  146. package/fonts/montserrat.css +23 -0
  147. package/package.json +13 -8
  148. package/dist/home-landing.d.ts +0 -18
  149. package/dist/home-landing.d.ts.map +0 -1
  150. package/dist/home-landing.js +0 -59
  151. package/dist/home-landing.js.map +0 -1
package/dist/app-bar.js CHANGED
@@ -1,17 +1,21 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { OVERVIEW_PATH, OVERVIEW_SLUG, orderKinds, overviewTitle, placeSlug } from "@graview/core";
2
+ import { hueFor, orderKinds, OVERVIEW_SLUG, placeSlug } from "@graview/core";
3
3
  import { POPOVER_STYLE, usePopover } from "@graview/react/provider";
4
4
  import { createContext, useCallback, useContext, useLayoutEffect, useRef, useState } from "react";
5
5
  import { AppMark } from "./app-title.js";
6
- /** The key the scene's place has on the bar. */
7
- export const OVERVIEW_KEY = OVERVIEW_SLUG;
6
+ /** The key the home's place has (FR-136): the app's front page, first in the list, at the app's own address. */
7
+ export const HOME_KEY = "home";
8
+ /** The home's address on the routed face: the app's own. */
9
+ export const HOME_PATH = "/";
10
+ /** The key of the scene's first place (FR-144): the whole thing, no picture in view. */
11
+ export const WHOLE_KEY = "scene:whole";
8
12
  const upper = (word) => word.charAt(0).toUpperCase() + word.slice(1);
9
13
  /**
10
- * THE APP'S PLACES, IN THE BAR'S ORDER: the overview (when the face can draw
11
- * the scene), each named picture, each kind's list in the declaration's
12
- * order, and how the kinds connect, when they do. What a seat may not see
13
- * is not offered: a kind kept from it, a disabled module's kind, a picture
14
- * over either.
14
+ * THE APP'S PLACES, IN THE LIST'S ORDER (FR-138): the home; each kind's list
15
+ * in the declaration's order; each named picture, then how the kinds
16
+ * connect, when they do. What a seat may not see is not offered: a kind
17
+ * kept from it, a disabled module's kind, a picture over either. The scene
18
+ * is not a place here: it is the switch's (FR-137).
15
19
  */
16
20
  export function barPlaces(input) {
17
21
  const { store, principal, views } = input;
@@ -19,27 +23,26 @@ export function barPlaces(input) {
19
23
  const live = store.schema.kinds.filter((kind) => !store.modules.disabledKinds.has(kind) && !kept.has(kind));
20
24
  const arrangement = views?.arrangement?.();
21
25
  const plural = (kind) => store.schema.tryDefinition(kind)?.plural ?? `${kind}s`;
22
- const out = [];
23
- if (input.overview)
24
- out.push({ key: OVERVIEW_KEY, label: overviewTitle(arrangement), path: OVERVIEW_PATH });
26
+ const out = [{ key: HOME_KEY, label: "Home", path: HOME_PATH, group: "home" }];
27
+ for (const kind of orderKinds(live, arrangement?.order))
28
+ out.push({ key: `kind:${kind}`, label: upper(plural(kind)), path: `/${placeSlug(plural(kind))}`, group: "lists", kind });
25
29
  const all = views?.places() ?? [];
26
30
  for (const place of all) {
31
+ // A place at the scene's address is not offered: the address is the scene's (FR-132).
27
32
  if (!live.includes(place.kind) || place.as === OVERVIEW_SLUG)
28
33
  continue;
29
34
  // Two kinds' pictures of one name say whose with `?of=` (as the routed face addresses them).
30
35
  const shared = all.some((other) => other.as === place.as && other.kind !== place.kind);
31
36
  const path = `/places/${encodeURIComponent(place.as)}${shared ? `?of=${placeSlug(plural(place.kind))}` : ""}`;
32
37
  if (!out.some((held) => held.path === path))
33
- out.push({ key: `place:${place.kind}:${place.as}`, label: place.title, path });
38
+ out.push({ key: `place:${place.kind}:${place.as}`, label: place.title, path, group: "pictures", kind: place.kind });
34
39
  }
35
- for (const kind of orderKinds(live, arrangement?.order))
36
- out.push({ key: `kind:${kind}`, label: upper(plural(kind)), path: `/${placeSlug(plural(kind))}` });
37
40
  const connects = live.some((kind) => Object.keys((store.schema.tryDefinition(kind)?.edges ?? {})).length > 0);
38
41
  if (connects)
39
- out.push({ key: "connections", label: "Connections", path: "/map" });
42
+ out.push({ key: "connections", label: "Connections", path: "/map", group: "pictures" });
40
43
  return out;
41
44
  }
42
- /** Which of the places a path on the routed face is: its own, a record's kind's, or none (the home, Find, the problems). */
45
+ /** Which of the places a path on the routed face is: its own, a record's kind's, or none (Find, the problems). */
43
46
  export function barPlaceAt(places, path) {
44
47
  const [pathname = "/", search = ""] = path.split("?");
45
48
  const exact = places.find((place) => place.path === (search ? `${pathname}?${search}` : pathname)) ?? places.find((place) => place.path === pathname);
@@ -47,43 +50,124 @@ export function barPlaceAt(places, path) {
47
50
  return exact.key;
48
51
  const first = `/${pathname.split("/").filter(Boolean)[0] ?? ""}`;
49
52
  // A record's page is under its kind's list.
50
- return places.find((place) => place.key.startsWith("kind:") && place.path === first)?.key ?? null;
53
+ return places.find((place) => place.group === "lists" && place.path === first)?.key ?? null;
51
54
  }
52
55
  export const BarFindContext = createContext(null);
53
56
  /** The bar's place for a Find box, when a bar above this face has one. */
54
57
  export const useBarFind = () => useContext(BarFindContext);
55
58
  /** One size for every tool on the bar (FR-131). */
56
59
  export const TOOL = 30;
60
+ /** The bar's one row, its rule under it included, on a desk and a phone alike (FR-138). */
61
+ export const BAR_HEIGHT = 48;
62
+ /** Below this width of its own the bar is a phone's: the place is the page's first line, and Find a magnifier that opens the box over the row. */
63
+ export const BAR_PHONE = 640;
64
+ /** The room between two places standing on the row (FR-145). */
65
+ export const PLACE_GAP = 4;
66
+ /** The fewest places that stand on the row: fewer, and the one control says the same in less room. */
67
+ export const FEWEST_STANDING = 2;
68
+ /**
69
+ * WHICH PLACES STAND ON THE ROW (FR-145), from their widths and the room:
70
+ * every one when all fit; else the first ones in their order, the current
71
+ * one always among them (in its own place in the order, the last of the
72
+ * first ones giving way to it), with "More" after them; else none — null,
73
+ * the one control. Fewer than `FEWEST_STANDING` is none. A width that is
74
+ * not measured (nothing laid out) is none too.
75
+ */
76
+ export function placesThatStand(input) {
77
+ const { widths, current, room, more, gap = PLACE_GAP } = input;
78
+ if (widths.length === 0 || !(room > 0) || !(more > 0) || widths.some((width) => !(width > 0)))
79
+ return null;
80
+ const span = (set, folded) => set.reduce((sum, at) => sum + widths[at], 0) + gap * Math.max(0, set.length - 1) + (folded ? gap + more : 0);
81
+ const all = widths.map((_, at) => at);
82
+ if (span(all, false) <= room)
83
+ return all;
84
+ for (let count = widths.length - 1; count >= FEWEST_STANDING; count--) {
85
+ const set = current < count ? all.slice(0, count) : [...all.slice(0, count - 1), current];
86
+ if (span(set, true) <= room)
87
+ return set;
88
+ }
89
+ return null;
90
+ }
91
+ /*
92
+ * THE BAR FITS ITS BOX. Every rule here reads the bar's own width (it is
93
+ * its own container), never the screen's: an embed in a 650 px box on a
94
+ * 1440 desk is a narrow bar, not a desk's bar squeezed. When the row is
95
+ * short, Find gives first (a small box that says its shortcut, drawn wide
96
+ * while it is used), then the place down to 9em of its name (whole in its
97
+ * title), then the app's name, which wraps between its words onto a second
98
+ * line and never inside one; the switch keeps its size, and draws its marks
99
+ * alone when its words do not fit (`roomForWords`).
100
+ *
101
+ * Where the places stand on the row (FR-145), Find gives first — down to
102
+ * its least — then the places fold into "More", then into the one control,
103
+ * which gives as above. The places are weighed with the switch's words
104
+ * kept: the words are worth more than a third place on the row.
105
+ *
106
+ * The order is the shrink factors: Find 600, the place 100, the app 1. A
107
+ * factor under 1 would not do — the free space is scaled by the factors
108
+ * left when the others have reached their least, so an app at 0.001 would
109
+ * give 0.1% of what is still needed and the row would run out of its box.
110
+ * At 1 the app gives a fraction of a pixel while the others still can,
111
+ * which would wrap its last word; its 2 px spacer (`::after`) takes that.
112
+ * The tools (`display:contents`) stand on the row themselves, so Find's
113
+ * box is what gives, not a column around it.
114
+ */
57
115
  const BAR_CSS = `
58
- .graview-bar{container-type:inline-size;flex:0 0 auto;position:relative;background:var(--graview-bar);border-bottom:1px solid var(--graview-edge);color:var(--graview-ink);font-family:var(--graview-font-body,system-ui)}
59
- .graview-bar-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;grid-template-areas:"app places tools";align-items:stretch;column-gap:20px;padding:0 16px;min-height:48px}
60
- .graview-bar-app{grid-area:app;display:flex;align-items:center;min-width:0}
61
- .graview-bar-name{margin:0;min-width:0;display:flex;font-family:var(--graview-font-display,var(--graview-font-body,system-ui));font-size:.9375rem;font-weight:600;line-height:1.3;letter-spacing:-.005em}
62
- .graview-bar-home{display:inline-flex;align-items:center;gap:8px;min-width:0;min-height:30px;color:var(--graview-ink);text-decoration:none;font:inherit;letter-spacing:inherit;overflow-wrap:anywhere;margin:0;padding:0;border:0;background:none;box-shadow:none;cursor:pointer;text-align:left}
63
- .graview-bar-more{display:grid;min-width:200px;max-width:calc(100vw - 32px);margin:0;padding:4px;list-style:none;border-radius:10px;border:1px solid var(--graview-edge);background:var(--graview-float);box-shadow:var(--graview-lift-high)}
64
- .graview-bar-more[hidden]{display:none}
65
- .graview-bar-item{display:flex;align-items:center;width:100%;min-height:32px;padding:0 10px;border:0;border-left:2px solid transparent;border-radius:7px;background:none;box-shadow:none;font:inherit;font-size:.875rem;text-align:left;text-decoration:none;color:var(--graview-ink);cursor:pointer}
66
- .graview-bar-item[aria-current="page"]{font-weight:600;border-left-color:var(--graview-accent)}
67
- .graview-bar-places{grid-area:places;display:flex;align-items:stretch;min-width:0}
68
- .graview-bar-tools{grid-area:tools;display:flex;align-items:center;gap:8px;min-width:0}
69
- .graview-bar-find{display:flex;align-items:center;width:15rem;min-width:0}
70
- .graview-bar-find-open{display:none;align-items:center;justify-content:center}
71
- .graview-bar-tab{display:inline-flex;align-items:center;gap:4px;padding:0 10px;margin:0;border:0;border-bottom:2px solid transparent;border-radius:0;background:none;box-shadow:none;font:inherit;font-size:.875rem;line-height:1.2;white-space:nowrap;color:var(--graview-ink-muted);text-decoration:none;cursor:pointer;flex:0 0 auto}
72
- .graview-bar-tab:hover{color:var(--graview-ink)}
73
- .graview-bar-tab[aria-current="page"]{color:var(--graview-ink);border-bottom-color:var(--graview-accent);font-weight:600}
74
- .graview-bar-tab:focus-visible{outline:2px solid var(--graview-accent);outline-offset:-4px}
75
- .graview-bar-measure{position:absolute;visibility:hidden;pointer-events:none;display:flex;white-space:nowrap;height:0;overflow:hidden}
76
- @container (max-width: 639px){
77
- .graview-bar-row{grid-template-columns:minmax(0,1fr) auto;grid-template-areas:"app tools" "places places";padding:0 12px;min-height:0;column-gap:12px}
78
- .graview-bar-app,.graview-bar-tools{min-height:48px}
79
- .graview-bar-places{border-top:1px solid var(--graview-edge);margin:0 -12px;padding:0 2px;min-height:40px}
80
- .graview-bar-find{display:none;width:auto;flex:1 1 auto}
116
+ .graview-bar{display:block;margin:0;padding:0;container:graview-bar/inline-size;flex:0 0 auto;position:relative;background:var(--graview-bar);border-bottom:1px solid var(--graview-edge);color:var(--graview-ink);font-family:var(--graview-font-body,system-ui)}
117
+ .graview-bar-row{display:flex;flex-wrap:nowrap;align-items:center;gap:16px;height:${BAR_HEIGHT - 1}px;margin:0;padding:0 16px}
118
+ .graview-bar-home,.graview-bar-face,.graview-bar-place,.graview-bar-item,.graview-bar-at{display:inline-flex;align-items:center;box-sizing:border-box;min-width:0;margin:0;border:0;background:none;box-shadow:none;font:inherit;letter-spacing:normal;text-transform:none;text-decoration:none;color:var(--graview-ink);cursor:pointer;text-align:left}
119
+ .graview-bar-app{display:flex;flex:0 1 auto;max-width:50%}
120
+ .graview-bar-app::after{content:"";flex:0 100000 auto;width:2px;min-width:0}
121
+ .graview-bar-name{margin:0;display:flex;font-family:var(--graview-font-display,var(--graview-font-body,system-ui));font-size:.9375rem;font-weight:600;line-height:1.25}
122
+ .graview-bar-home{gap:8px;min-width:auto;min-height:30px;padding:0;letter-spacing:-.005em}
123
+ .graview-bar-app-name{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;text-overflow:ellipsis;overflow-wrap:normal;word-break:normal}
124
+ .graview-bar-faces{display:inline-flex;flex:0 0 auto;box-sizing:border-box;height:30px;margin:0;padding:2px;gap:2px;border:1px solid var(--graview-edge);border-radius:8px}
125
+ .graview-bar-face{gap:6px;padding:0 10px;border-radius:6px;font-size:.8125rem;white-space:nowrap;color:var(--graview-ink-muted)}
126
+ .graview-bar-face span{min-width:0;max-width:11em;overflow:hidden;text-overflow:ellipsis}
127
+ .graview-bar-face[aria-pressed=true]{color:var(--graview-ink);font-weight:600;background:color-mix(in srgb,var(--graview-accent) 16%,transparent)}
128
+ [data-switch-drawn=icons] .graview-bar-face{padding:0 8px}
129
+ [data-switch-drawn=icons] .graview-bar-face span{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
130
+ .graview-bar-mid{display:flex;flex:1 100 0;min-width:0}
131
+ .graview-bar-mid[data-place]{min-width:9em;flex-basis:auto}
132
+ .graview-bar-place-at{position:relative;display:inline-flex;min-width:0;max-width:100%}
133
+ .graview-bar-place{gap:6px;height:30px;max-width:100%;padding:0 8px;border:1px solid transparent;border-radius:8px;font-size:.875rem;font-weight:600}
134
+ .graview-bar-face:hover,.graview-bar-place:hover{border-color:var(--graview-edge);color:var(--graview-ink)}
135
+ .graview-bar-place-words{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
136
+ .graview-bar-mid[data-places=standing]{flex:0 0 auto}
137
+ .graview-bar-places{display:flex;align-items:center;gap:${PLACE_GAP}px;min-width:0;margin:0;padding:0}
138
+ .graview-bar-at{gap:6px;flex:0 0 auto;height:30px;padding:0 8px;border-block:2px solid transparent;border-radius:0;font-size:.875rem;font-weight:500;white-space:nowrap;color:var(--graview-ink-muted)}
139
+ .graview-bar-at:is(:hover,[aria-current],[aria-expanded=true]){color:var(--graview-ink)}
140
+ .graview-bar-at[aria-current]{font-weight:600;border-bottom-color:var(--graview-accent)}
141
+ .graview-bar-ruler{position:absolute;left:0;top:0;display:flex;width:0;height:0;overflow:hidden;visibility:hidden;pointer-events:none}
142
+ .graview-bar-ruler .graview-bar-at{font-weight:600}
143
+ .graview-bar-list{display:grid;align-content:start;width:min(320px,calc(100vw - 24px));height:auto;max-height:min(70vh,560px);overflow:auto;margin:0;padding:6px;border-radius:10px;border:1px solid var(--graview-edge);background:var(--graview-float);box-shadow:var(--graview-lift-high);color:var(--graview-ink)}
144
+ .graview-bar-list[hidden]{display:none}
145
+ .graview-bar-list p{margin:8px 8px 2px;font-size:.6875rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--graview-ink-muted)}
146
+ .graview-bar-list ul,.graview-bar-list li{display:grid;margin:0;padding:0;list-style:none}
147
+ .graview-bar-item{align-items:flex-start;gap:10px;width:100%;padding:7px 8px;border-radius:7px;font-size:.875rem;line-height:1.3;overflow-wrap:anywhere}
148
+ .graview-bar-item:hover{background:color-mix(in srgb,var(--graview-ink) 7%,transparent)}
149
+ .graview-bar-item[aria-current]{font-weight:600;background:color-mix(in srgb,var(--graview-accent) 14%,transparent)}
150
+ .graview-bar-mark{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:14px;height:1.3em;color:var(--graview-ink-muted)}
151
+ .graview-bar :focus-visible{outline:2px solid var(--graview-accent);outline-offset:1px}
152
+ .graview-bar-tools{display:contents}
153
+ .graview-bar-tools>*{margin-left:-8px}
154
+ .graview-bar-tools>:is(.graview-bar-find,.graview-bar-find-open){margin-left:auto}
155
+ .graview-bar-find{position:relative;display:flex;min-width:7.5rem;flex:0 600 9rem;--graview-bar-find-end:3.25em}
156
+ .graview-bar-find-slot{display:flex;flex:1;min-width:0}
157
+ .graview-bar-find-keys{position:absolute;right:10px;line-height:30px;pointer-events:none;font-size:.75rem;color:var(--graview-ink-faint)}
158
+ .graview-bar-find:is(:focus-within,:has(input:not(:placeholder-shown))){flex-basis:16rem;--graview-bar-find-end:10px}
159
+ :is(:focus-within,:has(input:not(:placeholder-shown)))>.graview-bar-find-keys,.graview-bar-find-open{display:none}
160
+ .graview-bar-line{display:flex;flex:0 0 auto;padding:6px 8px;background:var(--graview-ground);border-bottom:1px solid var(--graview-edge);font-family:var(--graview-font-body,system-ui)}
161
+ .graview-bar-line .graview-bar-place{height:auto;min-height:32px}
162
+ .graview-bar-line .graview-bar-place-words{white-space:normal}
163
+ @media (pointer:coarse){.graview-bar-find-keys{display:none}}
164
+ @container graview-bar (max-width: ${BAR_PHONE - 1}px){
165
+ .graview-bar-row{gap:10px;padding:0 12px}
166
+ .graview-bar-app{max-width:none;flex:1 1 auto}
167
+ .graview-bar-mid,.graview-bar-find{display:none}
81
168
  .graview-bar-find-open{display:inline-flex}
82
- .graview-bar[data-finding] .graview-bar-app{display:none}
83
- .graview-bar[data-finding] .graview-bar-row{grid-template-areas:"tools tools" "places places"}
84
- .graview-bar[data-finding] .graview-bar-tools{flex:1 1 auto}
85
- .graview-bar[data-finding] .graview-bar-find{display:flex}
86
- .graview-bar[data-finding] .graview-bar-find-open{display:none}
169
+ .graview-bar[data-finding] :is(.graview-bar-app,.graview-bar-faces,.graview-bar-find-open){display:none}
170
+ .graview-bar[data-finding] .graview-bar-find{display:flex;flex:1 1 auto;min-width:0}
87
171
  }`;
88
172
  /** A tool's own box: one size, square, its name in words for whoever cannot see the mark. */
89
173
  export const toolStyle = {
@@ -106,27 +190,67 @@ export const toolStyle = {
106
190
  };
107
191
  /**
108
192
  * THE BAR. The app at the left — its mark, its name as the page's heading,
109
- * the way home — its places in the middle, and the tools at the right: the
110
- * Find a face puts in it, then `tools` (the standing and the person). One
111
- * row at a desk; on a phone the places take a second row of their own.
193
+ * the way home — then the switch between the scene and the pages (FR-137),
194
+ * then, on Pages, the place you are on (FR-138), and the tools at the
195
+ * right: the Find a face puts in it, then `tools` (the standing and the
196
+ * person). One row of one height on a desk and a phone; on a phone the
197
+ * place is the page's first line, under the bar.
112
198
  */
113
- export function AppBar({ brand, name, heading = 1, home, places, current, reach, tools, find = true, description, findBox, onFind, }) {
199
+ export function AppBar({ brand, name, heading = 1, home, faces, places, current, reach, tools, find = true, description, findBox, onFind, switch: switchForm = "words", scenePlaces, }) {
114
200
  const bar = useRef(null);
115
201
  const [slot, setSlot] = useState(null);
116
202
  const [compact, setCompact] = useState(false);
117
203
  const [finding, setFinding] = useState(false);
204
+ // Whether the switch's words fit beside everything else the row holds (`roomForWords`).
205
+ const [roomy, setRoomy] = useState(true);
206
+ // Which places stand on the row, by their place in the list ("0,1,2"); null, the one control (FR-145).
207
+ const [standing, setStanding] = useState(null);
208
+ // The places on the face drawn: the routed face's, or the scene's (FR-144).
209
+ const onPages = !faces || faces.pages.current;
210
+ const shown = onPages ? { places, current, reach } : scenePlaces;
211
+ const list = shown && shown.places.length > 0 ? shown : null;
212
+ const currentAt = list ? list.places.findIndex((place) => place.key === list.current) : -1;
213
+ const wordsAsked = Boolean(faces) && switchForm === "words";
214
+ const weigh = useCallback(() => {
215
+ const element = bar.current;
216
+ if (!element)
217
+ return;
218
+ const stand = standsIn(element, currentAt, wordsAsked);
219
+ if (stand !== undefined)
220
+ setStanding(stand);
221
+ // Standing places were weighed with the switch's words kept.
222
+ const room = stand ? true : roomForWords(element);
223
+ if (room !== null)
224
+ setRoomy(room);
225
+ }, [currentAt, wordsAsked]);
118
226
  useLayoutEffect(() => {
119
227
  const element = bar.current;
120
228
  if (!element)
121
229
  return;
122
- const read = () => setCompact(element.getBoundingClientRect().width < 640);
230
+ const read = () => {
231
+ const width = element.getBoundingClientRect().width;
232
+ setCompact(width < BAR_PHONE);
233
+ // What hangs from the bar as wide as it (a phone's Find sheet) is told the bar's width, not the screen's.
234
+ element.style.setProperty("--graview-bar-width", `${Math.round(width)}px`);
235
+ weigh();
236
+ };
123
237
  read();
238
+ // The brand's face, once it has come, is wider or narrower than the one the row was first weighed in.
239
+ const fonts = typeof document === "undefined" ? undefined : document.fonts;
240
+ void fonts?.ready.then(weigh);
241
+ fonts?.addEventListener?.("loadingdone", weigh);
124
242
  if (typeof ResizeObserver === "undefined")
125
- return;
243
+ return () => fonts?.removeEventListener?.("loadingdone", weigh);
126
244
  const watch = new ResizeObserver(read);
127
245
  watch.observe(element);
128
- return () => watch.disconnect();
129
- }, []);
246
+ // What stands beside the places — the tools, a count on the standing — changes their room without changing the bar's width.
247
+ for (const tool of element.querySelectorAll(".graview-bar-tools > *"))
248
+ watch.observe(tool);
249
+ return () => {
250
+ watch.disconnect();
251
+ fonts?.removeEventListener?.("loadingdone", weigh);
252
+ };
253
+ }, [weigh]);
130
254
  const told = useRef(onFind);
131
255
  told.current = onFind;
132
256
  useLayoutEffect(() => {
@@ -151,31 +275,108 @@ export function AppBar({ brand, name, heading = 1, home, places, current, reach,
151
275
  requestAnimationFrame(() => opener.current?.focus());
152
276
  }, []);
153
277
  const Heading = heading === false ? "span" : `h${heading}`;
278
+ const Home = (home.href !== undefined ? "a" : "button");
154
279
  const press = (go) => (event) => {
155
280
  if (!go || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
156
281
  return;
157
282
  event.preventDefault();
158
283
  go();
159
284
  };
160
- return (_jsxs("header", { ref: bar, className: "graview-bar", "data-testid": "app-bar", "data-graview-app-bar": "", ...(finding && compact ? { "data-finding": "" } : {}), children: [_jsx("style", { children: BAR_CSS }), _jsxs("div", { className: "graview-bar-row", children: [_jsx("div", { className: "graview-bar-app", children: _jsx(Heading, { className: "graview-bar-name", children: home.href !== undefined ? (_jsxs("a", { href: home.href, onClick: press(home.go), "data-testid": "app-home", ...(home.current ? { "aria-current": "page" } : {}), title: description ? `${name} — ${description}` : `${name} — home`, className: "graview-bar-home", children: [_jsx(AppMark, { brand: brand, size: 20 }), _jsx("span", { "data-testid": "app-name", children: name })] })) : (_jsxs("button", { type: "button", onClick: home.go, "data-testid": "app-home", ...(home.current ? { "aria-current": "page" } : {}), title: description ? `${name} — ${description}` : `${name} — home`, className: "graview-bar-home", children: [_jsx(AppMark, { brand: brand, size: 20 }), _jsx("span", { "data-testid": "app-name", children: name })] })) }) }), _jsx(BarPlaces, { places: places, current: current, reach: reach }), _jsxs("div", { className: "graview-bar-tools", children: [find ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "graview-bar-find-open", ref: opener, "data-testid": "app-find-open", "aria-label": "Find", title: "Find", onClick: openFind, style: { ...toolStyle, display: undefined }, children: _jsx(FindMark, {}) }), _jsx("div", { ref: setSlot, className: "graview-bar-find", "data-testid": "app-find", onBlur: (event) => {
161
- // Put away on a phone when the keyboard leaves it with nothing typed.
162
- const next = event.relatedTarget;
163
- if (next instanceof Node && event.currentTarget.contains(next))
164
- return;
165
- if (!event.currentTarget.querySelector("input")?.value)
166
- putAway();
167
- }, onKeyDownCapture: (event) => {
168
- if (event.key === "Escape" && compact)
169
- escaping.current = true;
170
- }, onKeyDown: (event) => {
171
- if (event.key !== "Escape" || !compact)
172
- return;
173
- // The scene's Find takes Escape for its words first; an empty box is put away.
174
- if (!event.defaultPrevented || !event.currentTarget.querySelector("input")?.value)
175
- putAway();
176
- else
177
- escaping.current = false;
178
- }, children: findBox })] })) : null, tools] })] })] }));
285
+ // The place control: in the bar on a desk, as the page's first line on a phone; the places themselves on the row when they fit (FR-145).
286
+ const scene = !onPages;
287
+ const stands = list && !compact && standing !== null ? standing.split(",").map(Number).filter((at) => at < list.places.length) : null;
288
+ const placeControl = list ? _jsx(Places, { places: list.places, current: list.current, reach: list.reach, scene: scene, stands: stands }) : null;
289
+ const said = list ? list.places.map((place) => place.label).join("\n") : "";
290
+ // What the row holds changed — the name, the places, the face, the brand — so the places and the words may fit now, or no longer.
291
+ useLayoutEffect(weigh, [weigh, name, brand, said, list?.current, onPages, compact, switchForm, find]);
292
+ // The marks alone on a phone's bar, when asked, or when the words do not fit.
293
+ const drawn = switchForm === "icons" || !roomy || compact ? "icons" : "words";
294
+ return (_jsxs(_Fragment, { children: [_jsxs("header", { ref: bar, className: "graview-bar", "data-testid": "app-bar", "data-graview-app-bar": "", ...(finding && compact ? { "data-finding": "" } : {}), children: [_jsx("style", { children: BAR_CSS }), _jsxs("div", { className: "graview-bar-row", children: [_jsx("div", { className: "graview-bar-app", children: _jsx(Heading, { className: "graview-bar-name", children: _jsxs(Home, { ...(home.href !== undefined ? { href: home.href, onClick: press(home.go) } : { type: "button", onClick: home.go }), "data-testid": "app-home", ...(home.current ? { "aria-current": "page" } : {}), title: description ? `${name} — ${description}` : `${name} — home`, className: "graview-bar-home", children: [_jsx(AppMark, { brand: brand, size: 20 }), _jsx("span", { className: "graview-bar-app-name", "data-testid": "app-name", children: name })] }) }) }), faces ? _jsx(FaceSwitch, { faces: faces, form: switchForm, drawn: drawn }) : null, _jsx("div", { className: "graview-bar-mid", ...(stands ? { "data-places": "standing" } : !compact && placeControl ? { "data-place": "" } : {}), children: compact ? null : placeControl }), !compact && list && list.places.length > 1 ? (_jsxs("span", { className: "graview-bar-ruler", "aria-hidden": "true", "data-graview-bar-ruler": "", children: [list.places.map((place) => (_jsx("span", { className: "graview-bar-at", children: place.label }, place.key))), _jsxs("span", { className: "graview-bar-at", children: [MORE, _jsx(Chevron, {})] })] })) : null, _jsxs("div", { className: "graview-bar-tools", children: [find ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "graview-bar-find-open", ref: opener, "data-testid": "app-find-open", "aria-label": "Find", title: "Find", onClick: openFind, style: { ...toolStyle, display: undefined }, children: _jsx(FindMark, {}) }), _jsxs("div", { className: "graview-bar-find", "data-testid": "app-find", onBlur: (event) => {
295
+ // Put away on a phone when the keyboard leaves it with nothing typed.
296
+ const next = event.relatedTarget;
297
+ if (next instanceof Node && event.currentTarget.contains(next))
298
+ return;
299
+ if (!event.currentTarget.querySelector("input")?.value)
300
+ putAway();
301
+ }, onKeyDownCapture: (event) => {
302
+ if (event.key === "Escape" && compact)
303
+ escaping.current = true;
304
+ }, onKeyDown: (event) => {
305
+ if (event.key !== "Escape" || !compact)
306
+ return;
307
+ // The scene's Find takes Escape for its words first; an empty box is put away.
308
+ if (!event.defaultPrevented || !event.currentTarget.querySelector("input")?.value)
309
+ putAway();
310
+ else
311
+ escaping.current = false;
312
+ }, children: [_jsx("div", { ref: setSlot, className: "graview-bar-find-slot", children: findBox }), _jsx("span", { className: "graview-bar-find-keys", "data-testid": "app-find-keys", "aria-hidden": "true", children: findKeys() })] })] })) : null, tools] })] })] }), compact && placeControl ? (_jsx("div", { className: "graview-bar-line", "data-graview-place-line": "", "data-testid": "app-place-line", children: placeControl })) : null] }));
313
+ }
314
+ /**
315
+ * THE SWITCH (FR-137): the scene and the pages, side by side, an icon and a
316
+ * word each — the words the accessible names alone on a phone — the one
317
+ * drawn pressed. Choosing the scene draws it under the bar; choosing the
318
+ * pages goes back to the page you were on.
319
+ */
320
+ function FaceSwitch({ faces, form, drawn }) {
321
+ const face = (which) => {
322
+ const one = faces[which];
323
+ return (_jsxs("button", { type: "button", className: "graview-bar-face", "data-testid": `app-face-${which}`, "aria-pressed": one.current, title: one.label, onClick: () => {
324
+ if (!one.current)
325
+ one.go();
326
+ }, children: [which === "scene" ? _jsx(SceneMark, {}) : _jsx(PagesMark, {}), _jsx("span", { children: one.label })] }));
327
+ };
328
+ return (_jsxs("div", { className: "graview-bar-faces", role: "group", "aria-label": `${faces.scene.label} or ${faces.pages.label}`, "data-testid": "app-faces", "data-switch": form, "data-switch-drawn": drawn, children: [face("scene"), face("pages")] }));
329
+ }
330
+ /**
331
+ * WHETHER THE SWITCH'S WORDS FIT (the switch left to say its words where
332
+ * there is room). The row is weighed as it is drawn: the room the middle
333
+ * has past what the place wants (its words, up to 14em; none on the
334
+ * scene), less what Find has given up; none when the app's name has
335
+ * wrapped. The words fit when that room holds them — the words
336
+ * drawn already, or their width to come when the marks are drawn alone —
337
+ * so the switch never flips back and forth at one width. Not weighed while
338
+ * Find is in use (it is drawn wide then), on a phone's bar (the marks
339
+ * alone), or where nothing is laid out (null).
340
+ */
341
+ function roomForWords(header) {
342
+ const faces = header.querySelector(".graview-bar-faces");
343
+ const mid = header.querySelector(".graview-bar-mid");
344
+ const find = header.querySelector(".graview-bar-find");
345
+ if (!faces || !mid || find?.matches(":focus-within"))
346
+ return null;
347
+ const row = header.getBoundingClientRect().width;
348
+ if (row < BAR_PHONE)
349
+ return null;
350
+ // The words to come, beside each mark: the word, the gap before it, and the button's wider padding.
351
+ const words = wordsOf(faces);
352
+ const place = mid.querySelector(".graview-bar-place-words");
353
+ const em = place ? Number.parseFloat(getComputedStyle(place).fontSize) || 14 : 0;
354
+ // The place's words, its chevron, its gap, padding and frame: up to 14em of them.
355
+ const wanted = place ? Math.min(place.scrollWidth + 34, 14 * em) : 0;
356
+ let spare = mid.getBoundingClientRect().width - wanted;
357
+ if (find && find.getBoundingClientRect().width > 0) {
358
+ const basis = Number.parseFloat(getComputedStyle(find).flexBasis);
359
+ if (Number.isFinite(basis))
360
+ spare -= Math.max(0, basis - find.getBoundingClientRect().width);
361
+ }
362
+ // The app's name on two lines has given its room already: no room for the words.
363
+ const name = header.querySelector(".graview-bar-app-name");
364
+ if (name && name.getBoundingClientRect().height > Number.parseFloat(getComputedStyle(name).lineHeight) * 1.5)
365
+ return false;
366
+ return faces.getAttribute("data-switch-drawn") === "icons" ? spare >= words + 2 : spare >= -1;
367
+ }
368
+ /** Find's shortcut as the keyboard says it: ⌘K on a Mac (and an iPad's keyboard), Ctrl K elsewhere. */
369
+ function findKeys() {
370
+ const platform = typeof navigator === "undefined" ? "" : (navigator.platform ?? "");
371
+ return /Mac|iPhone|iPad|iPod/.test(platform) ? "⌘K" : "Ctrl K";
372
+ }
373
+ /** The scene's mark: a plot of the city, seen from above at an angle. */
374
+ function SceneMark() {
375
+ return (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 14 14", "aria-hidden": "true", focusable: "false", style: { flex: "0 0 auto" }, children: [_jsx("path", { d: "M7 1.5 L12.5 4.5 L7 7.5 L1.5 4.5 Z", fill: "none", stroke: "currentColor", strokeWidth: "1.3", strokeLinejoin: "round" }), _jsx("path", { d: "M1.5 7.5 L7 10.5 L12.5 7.5", fill: "none", stroke: "currentColor", strokeWidth: "1.3", strokeLinejoin: "round", strokeLinecap: "round" })] }));
376
+ }
377
+ /** The pages' mark: a page of lines. */
378
+ function PagesMark() {
379
+ return (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 14 14", "aria-hidden": "true", focusable: "false", style: { flex: "0 0 auto" }, children: [_jsx("rect", { x: "2.25", y: "1.75", width: "9.5", height: "10.5", rx: "1.5", fill: "none", stroke: "currentColor", strokeWidth: "1.3" }), _jsx("path", { d: "M4.5 5 H9.5 M4.5 7.25 H9.5 M4.5 9.5 H7.5", stroke: "currentColor", strokeWidth: "1.2", strokeLinecap: "round" })] }));
179
380
  }
180
381
  /** The way a menu opens: a chevron drawn, not a glyph a face may draw as a dot. */
181
382
  function Chevron() {
@@ -185,77 +386,114 @@ function Chevron() {
185
386
  function FindMark() {
186
387
  return (_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 16 16", "aria-hidden": "true", focusable: "false", children: [_jsx("circle", { cx: "7", cy: "7", r: "4.75", fill: "none", stroke: "currentColor", strokeWidth: "1.5" }), _jsx("path", { d: "M10.5 10.5 L14 14", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" })] }));
187
388
  }
389
+ /** A place's mark in the list: the home's house, a kind's plot in its own hue. */
390
+ function PlaceMark({ place }) {
391
+ // The scene's whole thing: the scene's own mark, as on the switch (FR-144).
392
+ if (place.key === WHOLE_KEY)
393
+ return _jsx(SceneMark, {});
394
+ if (place.group === "home") {
395
+ return (_jsx("svg", { width: "13", height: "13", viewBox: "0 0 14 14", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M2 6.5 L7 2 L12 6.5 V12 H2 Z", fill: "none", stroke: "currentColor", strokeWidth: "1.3", strokeLinejoin: "round" }) }));
396
+ }
397
+ // A kind's plot, as the scene draws it from altitude (the mark FR-117 gave a kind with no drawing); a picture of it wears its outline; how the kinds connect, the ink's.
398
+ const hue = place.kind ? `hsl(${Math.round(hueFor(place.kind))} 55% 52%` : "rgb(128 128 128";
399
+ return (_jsx("span", { "aria-hidden": "true", style: {
400
+ display: "inline-block",
401
+ width: 12,
402
+ height: 8,
403
+ clipPath: "polygon(50% 0, 100% 50%, 50% 100%, 0 50%)",
404
+ background: place.group === "lists" ? `${hue})` : `linear-gradient(${hue}), ${hue})) center / 6px 4px no-repeat, ${hue} / 0.35)`,
405
+ } }));
406
+ }
407
+ const GROUPS = [{ group: "home" }, { group: "lists", heading: "Lists" }, { group: "pictures", heading: "Pictures" }];
408
+ /** The word the places that do not stand on the row fold under (FR-145). */
409
+ const MORE = "More";
188
410
  /**
189
- * THE PLACES AS PLAIN TABS. As many as the row holds, in order; the rest
190
- * under "More". The tab you are on is marked, and always on the row — in
191
- * the last tab's room when the row could not otherwise hold it, and alone
192
- * beside "More" when the row holds none — so where you are is never hidden.
411
+ * ONE PLACE, AS THE BAR OFFERS IT — in the list, or standing on the row: a
412
+ * link where it has an address, a press where the bar decides; marked
413
+ * `aria-current` where the reader is. Whichever way it is drawn it is
414
+ * `app-place-<key>` and says its path (`data-place-path`), once in the bar.
193
415
  */
194
- function BarPlaces({ places, current, reach }) {
195
- const row = useRef(null);
196
- const measure = useRef(null);
197
- const [fits, setFits] = useState(places.length);
416
+ function PlaceEntry({ place, current, reach, standing, done }) {
417
+ const at = place.key === current;
418
+ const href = reach.href?.(place.path);
419
+ const Entry = (href !== undefined ? "a" : "button");
420
+ return (_jsxs(Entry, { className: standing ? "graview-bar-at" : "graview-bar-item", "data-testid": `app-place-${place.key}`, "data-place-path": place.path, ...(at ? { "aria-current": "page" } : {}), ...(href !== undefined ? { href } : { type: "button" }), onClick: (event) => {
421
+ done();
422
+ if (!reach.go)
423
+ return;
424
+ if (href !== undefined && (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey))
425
+ return;
426
+ event.preventDefault();
427
+ reach.go(place);
428
+ }, children: [standing ? null : (_jsx("span", { className: "graview-bar-mark", children: _jsx(PlaceMark, { place: place }) })), standing && at ? _jsx("span", { "data-testid": "app-place-current", children: place.label }) : _jsx("span", { children: place.label })] }));
429
+ }
430
+ /** The places of a list, grouped — the home (or the whole thing), the Lists, the Pictures — each group with its heading. */
431
+ function PlaceGroups({ places, current, reach, id, done }) {
432
+ return (_jsx(_Fragment, { children: GROUPS.map(({ group, heading }) => {
433
+ const listed = places.filter((place) => place.group === group);
434
+ if (listed.length === 0)
435
+ return null;
436
+ const named = `${id}-${group}`;
437
+ return (_jsxs("div", { role: "group", ...(heading ? { "aria-labelledby": named } : { "aria-label": listed[0].label }), "data-place-group": group, children: [heading ? _jsx("p", { id: named, children: heading }) : null, _jsx("ul", { children: listed.map((place) => (_jsx("li", { children: _jsx(PlaceEntry, { place: place, current: current, reach: reach, standing: false, done: done }) }, place.key))) })] }, group));
438
+ }) }));
439
+ }
440
+ /**
441
+ * THE PLACE YOU ARE ON, AND EVERY OTHER (FR-138): one control, the place's
442
+ * name and a chevron, that opens the app's places — the home first, then
443
+ * the Lists and the Pictures, each with its mark, a long name wrapped
444
+ * rather than cut. Every place is two presses away, and the bar is one row
445
+ * of one height however many there are. On the scene, what is in view and
446
+ * every picture it can show (FR-144).
447
+ *
448
+ * WHERE THEY FIT, THE PLACES THEMSELVES (FR-145): the ones that stand, as
449
+ * words in their order, the one you are on underlined; then "More", which
450
+ * opens the rest, grouped as the one control's list is — no "More" when
451
+ * every place stands. "More" is `app-places-open` and its list
452
+ * `app-places`, as the one control and its list are, so a place that does
453
+ * not stand is reached by the same two presses either way.
454
+ */
455
+ function Places({ places, current, reach, scene, stands }) {
198
456
  const popover = usePopover("places");
199
- const signature = places.map((place) => place.label).join("|");
200
- const currentAt = places.findIndex((place) => place.key === current);
201
- useLayoutEffect(() => {
202
- const element = row.current;
203
- const ruler = measure.current;
204
- if (!element || !ruler)
205
- return;
206
- const fit = () => {
207
- const room = element.getBoundingClientRect().width + 0.5;
208
- const widths = [...ruler.children].map((child) => child.getBoundingClientRect().width);
209
- const more = widths.pop() ?? 0;
210
- const sum = (list) => list.reduce((total, width) => total + width, 0);
211
- let count = widths.length;
212
- if (sum(widths) > room) {
213
- // As many as fit beside "More" — the place you are on among them, in the last one's room when it is further along.
214
- count = widths.length - 1;
215
- while (count > 0) {
216
- const seen = currentAt >= count ? [...widths.slice(0, count - 1), widths[currentAt]] : widths.slice(0, count);
217
- if (sum(seen) + more <= room)
218
- break;
219
- count -= 1;
220
- }
221
- }
222
- setFits((was) => (was === count ? was : count));
223
- };
224
- fit();
225
- if (typeof ResizeObserver === "undefined")
226
- return;
227
- const watch = new ResizeObserver(fit);
228
- watch.observe(element);
229
- // The ruler too: a brand's web font arriving after the first measure widens every tab.
230
- watch.observe(ruler);
231
- return () => watch.disconnect();
232
- }, [signature, currentAt]);
233
- if (places.length === 0)
234
- return _jsx("div", { className: "graview-bar-places", "data-testid": "app-places", ref: row });
235
- // The place you are on is on the row: in the last tab's room when it is one the row could not otherwise hold, alone when it holds none.
236
- const shown = currentAt >= fits ? [...places.slice(0, Math.max(0, fits - 1)), places[currentAt]] : places.slice(0, fits);
237
- const rest = places.filter((place) => !shown.includes(place));
238
- const tab = (place, inMenu) => {
239
- const here = place.key === current;
240
- const href = reach.href?.(place.path);
241
- const common = {
242
- className: inMenu ? "graview-bar-item" : "graview-bar-tab",
243
- "data-testid": `app-place-${place.key}`,
244
- "data-place-path": place.path,
245
- ...(here ? { "aria-current": "page" } : {}),
246
- onClick: (event) => {
247
- if (inMenu)
248
- popover.setOpen(false);
249
- if (!reach.go)
250
- return;
251
- if (href !== undefined && (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey))
252
- return;
253
- event.preventDefault();
254
- reach.go(place);
255
- },
256
- };
257
- return href !== undefined ? (_jsx("a", { ...common, href: href, children: place.label }, place.key)) : (_jsx("button", { ...common, type: "button", children: place.label }, place.key));
258
- };
259
- return (_jsxs("nav", { ref: row, className: "graview-bar-places", "aria-label": "The app\u2019s places", "data-testid": "app-places", style: { position: "relative" }, children: [_jsxs("div", { ref: measure, className: "graview-bar-measure", "aria-hidden": "true", children: [places.map((place) => (_jsx("span", { className: "graview-bar-tab", style: { fontWeight: 600 }, children: place.label }, place.key))), _jsxs("span", { className: "graview-bar-tab", children: ["More", _jsx(Chevron, {})] })] }), shown.map((place) => tab(place, false)), rest.length > 0 ? (_jsxs("span", { style: { position: "relative", display: "inline-flex" }, children: [_jsxs("button", { type: "button", className: "graview-bar-tab", "data-testid": "app-places-more", ...popover.trigger, onClick: popover.toggle, "aria-label": "More places", children: ["More", _jsx(Chevron, {})] }), _jsx("ul", { ...popover.pane, "data-testid": "app-places-more-list", "aria-label": "More places", hidden: !popover.open, className: "graview-bar-more", style: POPOVER_STYLE, children: rest.map((place) => (_jsx("li", { children: tab(place, true) }, place.key))) })] })) : null] }));
457
+ const said = places.find((place) => place.key === current)?.label ?? "Places";
458
+ const rest = stands ? places.filter((_, at) => !stands.includes(at)) : places;
459
+ return (_jsxs("nav", { className: stands ? "graview-bar-places" : "graview-bar-place-at", "aria-label": scene ? "What the scene shows" : "The app’s places", ...(stands ? { "data-testid": "app-places-standing" } : {}), children: [stands?.map((at) => _jsx(PlaceEntry, { place: places[at], current: current, reach: reach, standing: true, done: () => undefined }, places[at].key)), rest.length > 0 ? (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", className: stands ? "graview-bar-at" : "graview-bar-place", "data-testid": "app-places-open", ...popover.trigger, onClick: popover.toggle, "aria-label": stands ? `${MORE} — ${rest.length} more ${scene ? "pictures" : "places"}` : `${said} — ${scene ? "everything the scene shows" : "every place"}`, title: stands ? rest.map((place) => place.label).join(", ") : said, children: [stands ? (MORE) : (_jsx("span", { className: "graview-bar-place-words", "data-testid": "app-place-current", children: said })), _jsx(Chevron, {})] }), _jsx("div", { ...popover.pane, "data-testid": "app-places", hidden: !popover.open, className: "graview-bar-list", style: POPOVER_STYLE, children: _jsx(PlaceGroups, { places: rest, current: current, reach: reach, id: popover.pane.id, done: () => popover.setOpen(false) }) })] })) : null] }));
460
+ }
461
+ /**
462
+ * WHICH PLACES STAND ON THE ROW, WEIGHED FROM THE ROW AS DRAWN (FR-145):
463
+ * the places' widths read off the ruler (each at its weight as the place
464
+ * you are on, so marking one never moves the others), and the room from
465
+ * where the places begin to where Find ends, less Find at its least, less
466
+ * the switch's words when they are asked for. The same answer whichever way
467
+ * the places are drawn, so the row never flips between the two at one
468
+ * width. Not weighed while Find is in use (`undefined`); none on a phone's
469
+ * bar, when the app's name has wrapped, or where nothing is laid out (null).
470
+ */
471
+ function standsIn(header, current, wordsAsked) {
472
+ const ruler = header.querySelector(".graview-bar-ruler");
473
+ const mid = header.querySelector(".graview-bar-mid");
474
+ if (!ruler || !mid)
475
+ return null;
476
+ const find = header.querySelector(".graview-bar-find");
477
+ if (find?.matches(":focus-within"))
478
+ return undefined;
479
+ if (header.getBoundingClientRect().width < BAR_PHONE)
480
+ return null;
481
+ const name = header.querySelector(".graview-bar-app-name");
482
+ if (name && name.getBoundingClientRect().height > Number.parseFloat(getComputedStyle(name).lineHeight) * 1.5)
483
+ return null;
484
+ const widths = [...ruler.children].map((one) => one.getBoundingClientRect().width);
485
+ const more = widths.pop() ?? 0;
486
+ const faces = header.querySelector(".graview-bar-faces");
487
+ const words = faces && wordsAsked ? wordsOf(faces) : 0;
488
+ const drawnWords = faces?.getAttribute("data-switch-drawn") === "words" ? words : 0;
489
+ const from = mid.getBoundingClientRect().left;
490
+ const end = find?.getBoundingClientRect();
491
+ // To Find's end, less Find at its least and the room between them (the row's gap, less the tools' pull); with no Find, the middle's own room.
492
+ const room = end && end.width > 0 ? end.right - from - (Number.parseFloat(getComputedStyle(find).minWidth) || 0) - 8 : mid.getBoundingClientRect().width;
493
+ return placesThatStand({ widths, current, room: Math.floor(room + drawnWords - words), more })?.join(",") ?? null;
494
+ }
495
+ /** The switch's words, beside each mark: the word, the gap before it, and the button's wider padding. */
496
+ function wordsOf(faces) {
497
+ return [...faces.querySelectorAll(".graview-bar-face span")].reduce((sum, span) => sum + span.scrollWidth + 10, 0);
260
498
  }
261
499
  //# sourceMappingURL=app-bar.js.map