@gjermundgaraba-designs/pixel-paper 0.13.0 → 0.14.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.
Files changed (130) hide show
  1. package/DESIGN.md +29 -22
  2. package/dist/src/announcer/announcer.d.ts +34 -26
  3. package/dist/src/announcer/announcer.d.ts.map +1 -1
  4. package/dist/src/announcer/announcer.js +42 -32
  5. package/dist/src/announcer/index.d.ts +1 -1
  6. package/dist/src/announcer/index.d.ts.map +1 -1
  7. package/dist/src/announcer/index.js +1 -1
  8. package/dist/src/banner/banner.d.ts +1 -1
  9. package/dist/src/banner/banner.js +1 -1
  10. package/dist/src/barChart/barChart.js +1 -1
  11. package/dist/src/board/board.d.ts +1 -4
  12. package/dist/src/board/board.d.ts.map +1 -1
  13. package/dist/src/board/board.js +8 -71
  14. package/dist/src/button/button.d.ts +1 -1
  15. package/dist/src/button/button.js +1 -1
  16. package/dist/src/card/card.d.ts +35 -9
  17. package/dist/src/card/card.d.ts.map +1 -1
  18. package/dist/src/card/card.js +46 -8
  19. package/dist/src/card/index.d.ts +2 -2
  20. package/dist/src/card/index.d.ts.map +1 -1
  21. package/dist/src/card/index.js +1 -1
  22. package/dist/src/commandPalette/commandPalette.d.ts +4 -0
  23. package/dist/src/commandPalette/commandPalette.d.ts.map +1 -1
  24. package/dist/src/confirmDialog/confirmDialog.d.ts +18 -26
  25. package/dist/src/confirmDialog/confirmDialog.d.ts.map +1 -1
  26. package/dist/src/confirmDialog/confirmDialog.js +21 -19
  27. package/dist/src/confirmDialog/index.d.ts +1 -1
  28. package/dist/src/confirmDialog/index.d.ts.map +1 -1
  29. package/dist/src/confirmDialog/index.js +1 -1
  30. package/dist/src/contextMenu/contextMenu.d.ts +6 -6
  31. package/dist/src/dialog/dialog.d.ts +36 -7
  32. package/dist/src/dialog/dialog.d.ts.map +1 -1
  33. package/dist/src/dialog/dialog.js +32 -12
  34. package/dist/src/dialog/index.d.ts +1 -1
  35. package/dist/src/dialog/index.d.ts.map +1 -1
  36. package/dist/src/dialog/index.js +1 -1
  37. package/dist/src/drag/drag.d.ts +40 -0
  38. package/dist/src/drag/drag.d.ts.map +1 -0
  39. package/dist/src/drag/drag.js +134 -0
  40. package/dist/src/emptyState/emptyState.d.ts +6 -4
  41. package/dist/src/emptyState/emptyState.d.ts.map +1 -1
  42. package/dist/src/emptyState/emptyState.js +1 -1
  43. package/dist/src/errorSummary/errorSummary.d.ts +1 -1
  44. package/dist/src/errorSummary/errorSummary.js +1 -1
  45. package/dist/src/index.d.ts +3 -0
  46. package/dist/src/index.d.ts.map +1 -1
  47. package/dist/src/index.js +3 -0
  48. package/dist/src/keyHelp/keyHelp.d.ts +4 -0
  49. package/dist/src/keyHelp/keyHelp.d.ts.map +1 -1
  50. package/dist/src/lineChart/lineChart.js +1 -1
  51. package/dist/src/liveRegion/index.d.ts +3 -0
  52. package/dist/src/liveRegion/index.d.ts.map +1 -0
  53. package/dist/src/liveRegion/index.js +1 -0
  54. package/dist/src/liveRegion/liveRegion.d.ts +42 -0
  55. package/dist/src/liveRegion/liveRegion.d.ts.map +1 -0
  56. package/dist/src/liveRegion/liveRegion.js +35 -0
  57. package/dist/src/loading/loading.d.ts +8 -3
  58. package/dist/src/loading/loading.d.ts.map +1 -1
  59. package/dist/src/loading/loading.js +4 -3
  60. package/dist/src/menu/menu.d.ts +6 -6
  61. package/dist/src/meter/meter.d.ts +3 -0
  62. package/dist/src/meter/meter.d.ts.map +1 -1
  63. package/dist/src/meter/meter.js +6 -3
  64. package/dist/src/remote/index.d.ts +3 -0
  65. package/dist/src/remote/index.d.ts.map +1 -0
  66. package/dist/src/remote/index.js +1 -0
  67. package/dist/src/remote/remote.d.ts +29 -0
  68. package/dist/src/remote/remote.d.ts.map +1 -0
  69. package/dist/src/remote/remote.js +41 -0
  70. package/dist/src/skipLink/index.d.ts +1 -1
  71. package/dist/src/skipLink/index.d.ts.map +1 -1
  72. package/dist/src/skipLink/index.js +1 -1
  73. package/dist/src/skipLink/skipLink.d.ts +24 -8
  74. package/dist/src/skipLink/skipLink.d.ts.map +1 -1
  75. package/dist/src/skipLink/skipLink.js +27 -8
  76. package/dist/src/sortable/index.d.ts +3 -0
  77. package/dist/src/sortable/index.d.ts.map +1 -0
  78. package/dist/src/sortable/index.js +1 -0
  79. package/dist/src/sortable/sortable.d.ts +1144 -0
  80. package/dist/src/sortable/sortable.d.ts.map +1 -0
  81. package/dist/src/sortable/sortable.js +504 -0
  82. package/dist/src/toast/index.d.ts +1 -1
  83. package/dist/src/toast/index.d.ts.map +1 -1
  84. package/dist/src/toast/index.js +1 -1
  85. package/dist/src/toast/toast.d.ts +32 -20
  86. package/dist/src/toast/toast.d.ts.map +1 -1
  87. package/dist/src/toast/toast.js +8 -3
  88. package/dist/src/topBar/barHeight.d.ts +6 -0
  89. package/dist/src/topBar/barHeight.d.ts.map +1 -1
  90. package/dist/src/topBar/barHeight.js +4 -0
  91. package/dist/src/topBar/index.d.ts +1 -1
  92. package/dist/src/topBar/index.d.ts.map +1 -1
  93. package/dist/src/topBar/index.js +1 -1
  94. package/dist/src/topBar/topBar.d.ts +1 -2
  95. package/dist/src/topBar/topBar.d.ts.map +1 -1
  96. package/package.json +1 -1
  97. package/src/announcer/announcer.ts +48 -32
  98. package/src/announcer/index.ts +1 -1
  99. package/src/banner/banner.ts +1 -1
  100. package/src/barChart/barChart.ts +1 -1
  101. package/src/board/board.ts +18 -87
  102. package/src/button/button.ts +1 -1
  103. package/src/card/card.ts +120 -21
  104. package/src/card/index.ts +2 -2
  105. package/src/confirmDialog/confirmDialog.ts +25 -32
  106. package/src/confirmDialog/index.ts +2 -0
  107. package/src/dialog/dialog.ts +39 -13
  108. package/src/dialog/index.ts +3 -1
  109. package/src/drag/drag.ts +207 -0
  110. package/src/emptyState/emptyState.ts +7 -5
  111. package/src/errorSummary/errorSummary.ts +1 -1
  112. package/src/index.ts +3 -0
  113. package/src/lineChart/lineChart.ts +1 -1
  114. package/src/liveRegion/index.ts +2 -0
  115. package/src/liveRegion/liveRegion.ts +75 -0
  116. package/src/loading/loading.ts +11 -5
  117. package/src/meter/meter.ts +17 -3
  118. package/src/pixel-paper.css +182 -27
  119. package/src/remote/index.ts +2 -0
  120. package/src/remote/remote.ts +76 -0
  121. package/src/skipLink/index.ts +1 -1
  122. package/src/skipLink/skipLink.ts +38 -9
  123. package/src/sortable/index.ts +14 -0
  124. package/src/sortable/sortable.ts +857 -0
  125. package/src/toast/index.ts +1 -0
  126. package/src/toast/toast.ts +9 -3
  127. package/src/tokens.css +13 -11
  128. package/src/topBar/barHeight.ts +6 -0
  129. package/src/topBar/index.ts +1 -1
  130. package/src/topBar/topBar.ts +1 -2
@@ -7,10 +7,12 @@ import type { Html, HtmlBuilder } from "foldkit/html";
7
7
  export type ViewConfig = Readonly<{
8
8
  /** Pixel caps: what is missing (`"Nothing queued"`). */
9
9
  title: string;
10
- /** Makes the title a heading at this level, for an empty state that is the page's or the
11
- * section's main content (a gate, a missing page); a `strong` when not given. It looks the
12
- * same either way. */
13
- headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
10
+ /** The title's element, which looks the same either way. A heading at this level where the
11
+ * empty state is the page's or a section's main content: `3` inside a Panel, whose title is
12
+ * an `h2`; `1` for a gate or a missing page. `"Strong"` where it is not (a chart with no
13
+ * data, a picture of the state). Required, because the two look alike and only the outline
14
+ * tells them apart. */
15
+ headingLevel: 1 | 2 | 3 | 4 | 5 | 6 | "Strong";
14
16
  /** One sentence: how it gets filled. */
15
17
  body?: ReadonlyArray<Html | string>;
16
18
  /** What fills it: a button under the sentence. */
@@ -24,7 +26,7 @@ export const view = <Message>(config: ViewConfig, h: HtmlBuilder<Message>): Html
24
26
  return h.div(
25
27
  [h.Class("empty")],
26
28
  [
27
- headingLevel === undefined
29
+ headingLevel === "Strong"
28
30
  ? h.strong([h.Class("empty-title")], [title])
29
31
  : h[`h${headingLevel}`]([h.Class("empty-title")], [title]),
30
32
  ...Array.match(body, { onEmpty: () => [], onNonEmpty: (sentence) => [h.p([], sentence)] }),
@@ -39,7 +39,7 @@ const headline = (count: number): string =>
39
39
  * busy) is a failed submit like any other: render the summary from it and focus it.
40
40
  *
41
41
  * Each link is a fragment link to its field; a Foldkit application's fragment handler moves
42
- * focus there (see `SkipLink.focusFragment`).
42
+ * focus there (`SkipLink.ScrollToFragment`).
43
43
  */
44
44
  export const view = <Message>(config: ViewConfig, h: HtmlBuilder<Message>): Html => {
45
45
  const { id, errors } = config;
package/src/index.ts CHANGED
@@ -46,6 +46,7 @@ export * as LineChart from "./lineChart/index.ts";
46
46
  export * as LineDiff from "./lineDiff/index.ts";
47
47
  export * as LinkButton from "./linkButton/index.ts";
48
48
  export * as LinkState from "./linkState/index.ts";
49
+ export * as LiveRegion from "./liveRegion/index.ts";
49
50
  export * as Loading from "./loading/index.ts";
50
51
  export * as Mark from "./mark/index.ts";
51
52
  export * as Media from "./media/index.ts";
@@ -64,6 +65,7 @@ export * as Prose from "./prose/index.ts";
64
65
  export * as QueryChip from "./queryChip/index.ts";
65
66
  export * as RadioChoices from "./radioChoices/index.ts";
66
67
  export * as Rail from "./rail/index.ts";
68
+ export * as Remote from "./remote/index.ts";
67
69
  export * as Rows from "./rows/index.ts";
68
70
  export * as Scheme from "./scheme/index.ts";
69
71
  export * as Secret from "./secret/index.ts";
@@ -72,6 +74,7 @@ export * as SelectAll from "./selectAll/index.ts";
72
74
  export * as SelectField from "./selectField/index.ts";
73
75
  export * as Sheet from "./sheet/index.ts";
74
76
  export * as SkipLink from "./skipLink/index.ts";
77
+ export * as Sortable from "./sortable/index.ts";
75
78
  export * as Stack from "./stack/index.ts";
76
79
  export * as Stage from "./stage/index.ts";
77
80
  export * as Stat from "./stat/index.ts";
@@ -221,6 +221,6 @@ const chartView = <Message>(
221
221
  */
222
222
  export const view = <Message>(config: ViewConfig, h: HtmlBuilder<Message>): Html =>
223
223
  Array.match(config.labels, {
224
- onEmpty: () => EmptyState.view({ title: "nothing to chart" }, h),
224
+ onEmpty: () => EmptyState.view({ title: "nothing to chart", headingLevel: "Strong" }, h),
225
225
  onNonEmpty: (labels) => chartView(config, labels, h),
226
226
  });
@@ -0,0 +1,2 @@
1
+ export { place, view } from "./liveRegion.ts";
2
+ export type { DialogEntry, Placement, ViewConfig } from "./liveRegion.ts";
@@ -0,0 +1,75 @@
1
+ import { Array, Option, pipe } from "effect";
2
+ import type { Html, HtmlBuilder } from "foldkit/html";
3
+
4
+ import * as Announcer from "../announcer/announcer.ts";
5
+
6
+ // PLACE
7
+
8
+ /** A dialog that can host the page's live parts: its name in the application, and whether it
9
+ * is open now. */
10
+ export type DialogEntry<Host extends string> = readonly [host: Host, isOpen: boolean];
11
+
12
+ /** Where the page's live parts go on this render. */
13
+ export type Placement<Host extends string> = Readonly<{
14
+ /** The `toLiveRegionView` to give the dialog `host`: the parts while it is the topmost open
15
+ * dialog, `undefined` while it is not. */
16
+ toDialogView: (host: Host) => (() => Html) | undefined;
17
+ /** The parts for the end of the body while no dialog is open, nothing while one is. */
18
+ toPageView: () => ReadonlyArray<Html>;
19
+ }>;
20
+
21
+ /**
22
+ * Places the page's live parts by the hosting rule: an open Dialog makes the page outside it
23
+ * inert and hidden from screen readers, so they render inside the topmost open dialog, and at
24
+ * the end of the body while none is open, exactly once either way. Neither renders them
25
+ * until called, so a Submodel among the parts (the toast stack) is built once. `dialogs` lists every dialog
26
+ * that can host them from the bottom of the stack to the top: one that opens from another
27
+ * (a confirm from a form) comes after it, so of the open ones the last is the topmost. Call it
28
+ * on every render, from the application's Model, so the parts move as dialogs open and close.
29
+ *
30
+ * ```ts
31
+ * const placement = LiveRegion.place([
32
+ * ["Rename", Dialog.isOpen(model.rename)],
33
+ * ["Delete", ConfirmDialog.isOpen(model.deleteConfirm)],
34
+ * ], () => LiveRegion.view({ toasts: toastsView(model, h) }, h));
35
+ * // the rename dialog's view inputs: toLiveRegionView: placement.toDialogView("Rename")
36
+ * // the body: [...content, ...placement.toPageView()]
37
+ * ```
38
+ */
39
+ export const place = <Host extends string>(
40
+ dialogs: ReadonlyArray<DialogEntry<Host>>,
41
+ toView: () => Html,
42
+ ): Placement<Host> => {
43
+ const maybeTopmost = pipe(
44
+ Array.findLast(dialogs, ([, isOpen]) => isOpen),
45
+ Option.map(([host]) => host),
46
+ );
47
+
48
+ return {
49
+ toDialogView: (host) =>
50
+ Option.exists(maybeTopmost, (topmost) => topmost === host) ? toView : undefined,
51
+ toPageView: () => (Option.isNone(maybeTopmost) ? [toView()] : []),
52
+ };
53
+ };
54
+
55
+ // VIEW
56
+
57
+ /** Configuration for rendering the page's live parts with `view`. */
58
+ export type ViewConfig = Readonly<{
59
+ /** The page's Toast stack (its `h.submodel`), when it has one. */
60
+ toasts?: Html;
61
+ /** The Banner in force, when one is: it stays in reach while a dialog is open. */
62
+ banner?: Html;
63
+ }>;
64
+
65
+ /** The page's live parts together, for `place`: the Announcer's region, then the toast stack
66
+ * and the banner the application gives. */
67
+ export const view = <Message>(config: ViewConfig, h: HtmlBuilder<Message>): Html =>
68
+ h.div(
69
+ [],
70
+ [
71
+ Announcer.view(h),
72
+ ...(config.toasts === undefined ? [] : [config.toasts]),
73
+ ...(config.banner === undefined ? [] : [config.banner]),
74
+ ],
75
+ );
@@ -1,25 +1,31 @@
1
1
  import type { Html, HtmlBuilder } from "foldkit/html";
2
2
 
3
+ import type { Attributes } from "../attributes/attributes.ts";
4
+
3
5
  // VIEW
4
6
 
5
7
  /** Configuration for rendering a loading line with `view`. */
6
- export type ViewConfig = Readonly<{
8
+ export type ViewConfig<Message> = Readonly<{
7
9
  /** Whether the content is on its way. Done, the line empties and leaves the flow, so it
8
10
  * takes no room and no gap, but stays in place, so the next wait is heard. */
9
11
  isLoading: boolean;
10
12
  /** What is on its way, in pixel caps (`"loading the items"`). `"loading"` by default. */
11
13
  label?: string;
14
+ /** Attributes for the line, such as the id and `tabindex="-1"` that make it a place to
15
+ * send focus. */
16
+ attributes?: Attributes<Message>;
12
17
  }>;
13
18
 
14
19
  /** What a panel says while its content is on the way, centred where the content will be, with
15
20
  * the working cursor. A polite status that stays in place with its words swapped, as a live
16
21
  * region must be for a screen reader to hear it: render it before the content, from before
17
- * the wait starts, and set `isLoading`. */
18
- export const view = <Message>(config: ViewConfig, h: HtmlBuilder<Message>): Html => {
19
- const { isLoading, label = "loading" } = config;
22
+ * the wait starts, and set `isLoading`. It is not the page's live region (the Announcer's,
23
+ * marked `data-pixel-paper-announcer`), so a test that counts live regions counts that. */
24
+ export const view = <Message>(config: ViewConfig<Message>, h: HtmlBuilder<Message>): Html => {
25
+ const { isLoading, label = "loading", attributes = [] } = config;
20
26
 
21
27
  return h.div(
22
- [h.Role("status"), h.Class(isLoading ? "loading" : "loading-idle")],
28
+ [...attributes, h.Role("status"), h.Class(isLoading ? "loading" : "loading-idle")],
23
29
  isLoading ? [label, h.span([h.Class("cursor"), h.AriaHidden(true)])] : [],
24
30
  );
25
31
  };
@@ -76,6 +76,9 @@ export type MeterConfig = Readonly<{
76
76
  /** Replaces the printed numbers and what screen readers hear:
77
77
  * `"41,200 of 50,000 requests"`. */
78
78
  valueText?: string;
79
+ /** Prints the numbers line (the default). Leave it out where it would repeat the
80
+ * percentage (`valueText: "62% used"`); screen readers still hear `valueText`. */
81
+ hasNumbers?: boolean;
79
82
  /** In a red chip once the value reaches `max`. The percentage by default. */
80
83
  full?: string;
81
84
  /** The last reading failed: the cells print muted, the numbers are the last known, and
@@ -117,7 +120,16 @@ const percentView = <Message>(
117
120
  };
118
121
 
119
122
  const meterView = <Message>(config: MeterConfig, h: HtmlBuilder<Message>): Html => {
120
- const { id, label, value, max, detail = [], foot = [], isStale = false } = config;
123
+ const {
124
+ id,
125
+ label,
126
+ value,
127
+ max,
128
+ detail = [],
129
+ foot = [],
130
+ isStale = false,
131
+ hasNumbers = true,
132
+ } = config;
121
133
  const share = max > 0 ? Math.min(1, Math.max(0, value / max)) : 0;
122
134
  const isFull = value >= max;
123
135
  const numbers = config.valueText ?? numbersText(value, max, config.unit);
@@ -152,7 +164,7 @@ const meterView = <Message>(config: MeterConfig, h: HtmlBuilder<Message>): Html
152
164
  cellRowView(METER_CELLS, scaled(share, METER_CELLS, isFull), h),
153
165
  ),
154
166
  percentView(config, scaled(share, PERCENT, isFull), isFull, h),
155
- h.span([h.Class("meter-num tnum")], [numbers]),
167
+ ...(hasNumbers ? [h.span([h.Class("meter-num tnum")], [numbers])] : []),
156
168
  ],
157
169
  ),
158
170
  ...slotView("div", "meter-foot", foot, h),
@@ -177,6 +189,8 @@ export type ListViewConfig = Readonly<{
177
189
  */
178
190
  export const listView = <Message>(config: ListViewConfig, h: HtmlBuilder<Message>): Html =>
179
191
  h.ul(
180
- [h.Class("meters")],
192
+ // NOTE: Safari takes list semantics from a list printed without markers, so the list
193
+ // says it is one
194
+ [h.Role("list"), h.Class("meters")],
181
195
  Array.map(config.meters, (meter) => meterView(meter, h)),
182
196
  );
@@ -32,8 +32,7 @@ body {
32
32
  line-height: 1.45;
33
33
  color: var(--ink);
34
34
  caret-color: var(--ink);
35
- background-color: var(--ground);
36
- background-image: var(--dither);
35
+ background: var(--dither);
37
36
  }
38
37
 
39
38
  fieldset {
@@ -380,16 +379,20 @@ button.tag > span {
380
379
  }
381
380
 
382
381
  /* the right-hand tools wrap as one group, never one control per line */
382
+ /* a bar of tools wider than the window wraps onto lines of its own, at the right */
383
383
  .topbar-tools {
384
384
  display: flex;
385
+ flex-wrap: wrap;
386
+ justify-content: flex-end;
385
387
  align-items: center;
386
- gap: 16px;
388
+ gap: 8px 16px;
389
+ min-width: 0;
387
390
  margin-left: auto;
388
391
  }
389
392
 
390
393
  @media (max-width: 600px) {
391
394
  .topbar-tools {
392
- gap: 12px;
395
+ gap: 8px 12px;
393
396
  }
394
397
 
395
398
  .topbar-tools .btn {
@@ -515,7 +518,7 @@ a.textbtn {
515
518
 
516
519
  /* the phone bar: wordmark and tools on one line, the page nav as a full-width strip
517
520
  under them. Where the two do not fit side by side the tools wrap under the wordmark,
518
- which never clips. Sign out and the connection word move to the palette; offline stays */
521
+ which never clips. Every tool stays: a session action may have nowhere else to live */
519
522
  .topbar {
520
523
  gap: 8px 12px;
521
524
  padding: 8px 12px;
@@ -532,11 +535,6 @@ a.textbtn {
532
535
  text-align: center;
533
536
  }
534
537
 
535
- .topbar .topbar-tools .textbtn,
536
- .topbar .link-state:not(.offline, .reconnecting) {
537
- display: none;
538
- }
539
-
540
538
  .deck {
541
539
  padding: 20px 12px 64px;
542
540
  }
@@ -2634,8 +2632,7 @@ dialog.veil:focus {
2634
2632
  .veil-backdrop {
2635
2633
  position: fixed;
2636
2634
  inset: 0;
2637
- background-color: var(--veil);
2638
- background-image: var(--veil-grain);
2635
+ background: var(--veil-grain), var(--veil);
2639
2636
  animation: veilIn var(--motion-veil) steps(3, end);
2640
2637
  }
2641
2638
 
@@ -3329,6 +3326,143 @@ dialog.veil:focus {
3329
3326
  flex: 1;
3330
3327
  }
3331
3328
 
3329
+ /* ---------- sortable: items the reader puts in order, as rows or tiles ---------- */
3330
+
3331
+ .sortable-items {
3332
+ list-style: none;
3333
+ }
3334
+
3335
+ /* a row: the grip, then the item, between 1px rules, edge to edge in a flush panel */
3336
+ .sortable-item {
3337
+ display: flex;
3338
+ align-items: center;
3339
+ gap: 10px;
3340
+ min-width: 0;
3341
+ padding: 8px var(--pad) 8px 10px;
3342
+ position: relative;
3343
+ background: var(--paper);
3344
+ border-bottom: var(--hair) solid var(--rule);
3345
+ }
3346
+
3347
+ .sortable-items > .sortable-item:last-child {
3348
+ border-bottom: 0;
3349
+ }
3350
+
3351
+ .sortable-body {
3352
+ flex: 1;
3353
+ min-width: 0;
3354
+ }
3355
+
3356
+ .sortable-item.movable {
3357
+ cursor: grab;
3358
+ }
3359
+
3360
+ .sortable-item [data-sortable-grip] {
3361
+ flex: none;
3362
+ }
3363
+
3364
+ .sortable-item.movable [data-sortable-grip] {
3365
+ cursor: grab;
3366
+ /* the grip is where a finger drags; the rest of the item scrolls the page */
3367
+ touch-action: none;
3368
+ }
3369
+
3370
+ .sortable-item.movable:hover {
3371
+ background: var(--head);
3372
+ }
3373
+
3374
+ .sortable-item.drag-dim {
3375
+ opacity: 0.4;
3376
+ }
3377
+
3378
+ /* where a pointer would drop the held item: a 2px dashed ink line beside the item it lands
3379
+ next to, drawn over the layout so nothing moves under the pointer */
3380
+ .sortable-item:is(.drop-before, .drop-after)::before {
3381
+ content: "";
3382
+ position: absolute;
3383
+ left: 0;
3384
+ right: 0;
3385
+ border-top: var(--line) dashed var(--ink);
3386
+ pointer-events: none;
3387
+ }
3388
+
3389
+ .sortable-item.drop-before::before {
3390
+ top: -1px;
3391
+ }
3392
+
3393
+ .sortable-item.drop-after::before {
3394
+ bottom: -1px;
3395
+ }
3396
+
3397
+ .sortable.grid .sortable-item:is(.drop-before, .drop-after)::before {
3398
+ top: -2px;
3399
+ bottom: -2px;
3400
+ left: auto;
3401
+ right: auto;
3402
+ border-top: 0;
3403
+ border-left: var(--line) dashed var(--ink);
3404
+ }
3405
+
3406
+ .sortable.grid .sortable-item.drop-before::before {
3407
+ left: -9px;
3408
+ }
3409
+
3410
+ .sortable.grid .sortable-item.drop-after::before {
3411
+ right: -9px;
3412
+ }
3413
+
3414
+ /* the held copy, following the pointer or where the keyboard would drop it: lifted onto the
3415
+ card shadow and tinted, as a board's is; the held item keeps its own print */
3416
+ .sortable-item.lifted {
3417
+ border: var(--line) solid var(--ink);
3418
+ background: var(--head);
3419
+ box-shadow: var(--shadow-card);
3420
+ cursor: grabbing;
3421
+ }
3422
+
3423
+ .sortable-slot {
3424
+ list-style: none;
3425
+ padding: 6px;
3426
+ }
3427
+
3428
+ .sortable-lift {
3429
+ width: var(--lift-width, 320px);
3430
+ translate: var(--lift-x, 0) var(--lift-y, 0);
3431
+ }
3432
+
3433
+ /* tiles: paper slips on the 3px print shadow that wrap, 180px and up */
3434
+ .sortable.grid .sortable-items {
3435
+ display: grid;
3436
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
3437
+ gap: 12px;
3438
+ padding: 12px;
3439
+ }
3440
+
3441
+ .sortable.grid .sortable-item {
3442
+ align-items: flex-start;
3443
+ height: 100%;
3444
+ padding: 9px 11px 9px 6px;
3445
+ border: var(--line) solid var(--ink);
3446
+ box-shadow: var(--shadow-button);
3447
+ }
3448
+
3449
+ .sortable.grid .sortable-item.lifted {
3450
+ box-shadow: var(--shadow-card);
3451
+ }
3452
+
3453
+ .sortable.grid .sortable-slot {
3454
+ padding: 0;
3455
+ }
3456
+
3457
+ /* while a pointer carries an item, the page grabs and selects nothing */
3458
+ :root[data-pixel-paper-dragging] {
3459
+ user-select: none;
3460
+ }
3461
+
3462
+ :root[data-pixel-paper-dragging] * {
3463
+ cursor: grabbing !important;
3464
+ }
3465
+
3332
3466
  /* ---------- cells: a reading printed as ink squares in a box ---------- */
3333
3467
 
3334
3468
  .cells {
@@ -3355,15 +3489,19 @@ dialog.veil:focus {
3355
3489
  height: 8px;
3356
3490
  }
3357
3491
 
3492
+ /* meters in a list step down by their column instead (below) */
3358
3493
  @media (max-width: 600px) {
3359
- .cells:not(.short) .cell {
3494
+ .cells:not(.short, .meters *) .cell {
3360
3495
  width: 8px;
3361
3496
  }
3362
3497
  }
3363
3498
 
3364
3499
  /* ---------- meters: a named reading, its cells, the numbers and a foot line ---------- */
3365
3500
 
3501
+ /* the list is the container its rows measure: they stack and their cells narrow by the
3502
+ column they are placed in, not by the window */
3366
3503
  .meters {
3504
+ container: meters / inline-size;
3367
3505
  list-style: none;
3368
3506
  }
3369
3507
 
@@ -3393,7 +3531,7 @@ dialog.veil:focus {
3393
3531
  align-items: center;
3394
3532
  }
3395
3533
 
3396
- @media (max-width: 700px) {
3534
+ @container meters (width < 560px) {
3397
3535
  .meter-row {
3398
3536
  grid-template-columns: minmax(0, 1fr);
3399
3537
  }
@@ -3401,12 +3539,38 @@ dialog.veil:focus {
3401
3539
  .meter-head {
3402
3540
  grid-row: auto;
3403
3541
  flex-direction: row;
3542
+ flex-wrap: wrap;
3404
3543
  align-items: baseline;
3405
- gap: 12px;
3544
+ gap: 2px 12px;
3545
+ }
3546
+
3547
+ .meter-num {
3548
+ width: 100%;
3549
+ }
3550
+ }
3551
+
3552
+ /* twenty cells take 246px at 10px each; in a narrower column they step down whole pixels,
3553
+ never to a fraction, so a meter fits a column of 160px and up */
3554
+ @container meters (width < 280px) {
3555
+ .meters .cells .cell {
3556
+ width: 8px;
3557
+ }
3558
+ }
3559
+
3560
+ @container meters (width < 240px) {
3561
+ .meters .cells .cell {
3562
+ width: 6px;
3563
+ }
3564
+ }
3565
+
3566
+ @container meters (width < 200px) {
3567
+ .meters .cells .cell {
3568
+ width: 4px;
3406
3569
  }
3407
3570
  }
3408
3571
 
3409
3572
  .meter-name {
3573
+ overflow-wrap: anywhere;
3410
3574
  font-family: var(--font-px);
3411
3575
  font-size: var(--type-name);
3412
3576
  text-transform: uppercase;
@@ -3462,12 +3626,6 @@ dialog.veil:focus {
3462
3626
  color: var(--red);
3463
3627
  }
3464
3628
 
3465
- @media (max-width: 600px) {
3466
- .meter-num {
3467
- width: 100%;
3468
- }
3469
- }
3470
-
3471
3629
  /* a history list of checks: when, what was checked, what it read */
3472
3630
  .history-list.checks {
3473
3631
  border-top: 0;
@@ -4760,16 +4918,14 @@ dialog.veil:focus {
4760
4918
  height: 100dvh;
4761
4919
  max-height: none;
4762
4920
  padding: 12px 16px var(--gap) 12px;
4763
- background-color: var(--ground);
4764
- background-image: var(--dither);
4921
+ background: var(--dither);
4765
4922
  border-right: var(--line) solid var(--ink);
4766
4923
  box-shadow: var(--shadow-card);
4767
4924
  animation: railIn var(--motion-card) steps(3, end);
4768
4925
  }
4769
4926
 
4770
4927
  .rail[popover]::backdrop {
4771
- background-color: var(--veil);
4772
- background-image: var(--veil-grain);
4928
+ background: var(--veil-grain), var(--veil);
4773
4929
  }
4774
4930
 
4775
4931
  .rail-head {
@@ -4840,8 +4996,7 @@ dialog.veil:focus {
4840
4996
  display: grid;
4841
4997
  place-items: center;
4842
4998
  padding: 24px;
4843
- background-color: var(--veil);
4844
- background-image: var(--veil-grain);
4999
+ background: var(--veil-grain), var(--veil);
4845
5000
  }
4846
5001
 
4847
5002
  .stage-paused > .notice {
@@ -0,0 +1,2 @@
1
+ export { view } from "./remote.ts";
2
+ export type { ViewConfig } from "./remote.ts";
@@ -0,0 +1,76 @@
1
+ import { AsyncData } from "foldkit";
2
+ import type { Html, HtmlBuilder } from "foldkit/html";
3
+
4
+ import * as Button from "../button/button.ts";
5
+ import * as Loading from "../loading/loading.ts";
6
+ import * as Notice from "../notice/notice.ts";
7
+
8
+ // VIEW
9
+
10
+ /** Configuration for rendering data that loads with `view`. */
11
+ export type ViewConfig<Data, Error, Message> = Readonly<{
12
+ /** The loading line's id, unique in the document. Retry takes its button away with the
13
+ * notice, so return `Focus.Focus({ id })` with the retry: focus lands on the line, which
14
+ * says a first load and stays quiet over a refresh. */
15
+ id: string;
16
+ data: AsyncData.AsyncData<Data, Error>;
17
+ /** What loads, as a noun in the words around it: `"usage"` reads "loading usage", "Usage
18
+ * not loaded" and "Usage not refreshed". */
19
+ label: string;
20
+ /** Why a load failed, in a sentence, under the notice's title. */
21
+ toErrorText: (error: Error) => string;
22
+ /** Loads it again: the notice's retry button, on a failure and on stale data alike. */
23
+ onRetry: Message;
24
+ /** The data, loaded or the last loaded (an EmptyState for none is the caller's). */
25
+ toDataView: (data: Data) => ReadonlyArray<Html>;
26
+ }>;
27
+
28
+ const capitalised = (label: string): string => `${label.charAt(0).toUpperCase()}${label.slice(1)}`;
29
+
30
+ /**
31
+ * Data that loads, in each of Foldkit's `AsyncData` states, with the retry built in: a Loading
32
+ * line while it first loads (in place in every state, so its status is heard), an error
33
+ * Notice with retry when it failed with nothing to show, a warn Notice with retry above the
34
+ * last data when a refresh failed (stale), and the data itself once loaded or while it
35
+ * refreshes, quietly, so a page that polls is not heard each time. Spread it where the
36
+ * content goes.
37
+ */
38
+ export const view = <Data, Error, Message>(
39
+ config: ViewConfig<Data, Error, Message>,
40
+ h: HtmlBuilder<Message>,
41
+ ): ReadonlyArray<Html> => {
42
+ const { id, data, label, toErrorText, onRetry, toDataView } = config;
43
+
44
+ const noticeView = (tone: "Error" | "Warn", title: string, error: Error): Html =>
45
+ Notice.view(
46
+ {
47
+ tone,
48
+ title,
49
+ body: [toErrorText(error)],
50
+ actions: [Button.view({ label: "retry", isSmall: true, onClick: onRetry }, h)],
51
+ },
52
+ h,
53
+ );
54
+
55
+ return [
56
+ Loading.view(
57
+ {
58
+ isLoading: AsyncData.isLoading(data),
59
+ label: `loading ${label}`,
60
+ attributes: [h.Id(id), h.Tabindex(-1)],
61
+ },
62
+ h,
63
+ ),
64
+ ...AsyncData.match(data, {
65
+ onIdle: () => [],
66
+ onLoading: () => [],
67
+ onFailure: (error) => [noticeView("Error", `${capitalised(label)} not loaded`, error)],
68
+ onStale: ({ error, data: lastData }) => [
69
+ noticeView("Warn", `${capitalised(label)} not refreshed`, error),
70
+ ...toDataView(lastData),
71
+ ],
72
+ onRefreshing: toDataView,
73
+ onSuccess: toDataView,
74
+ }),
75
+ ];
76
+ };
@@ -1 +1 @@
1
- export { focusFragment, view } from "./skipLink.ts";
1
+ export { ScrollToFragment, view } from "./skipLink.ts";