@parallelworks/ui 0.56.0 → 0.58.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 (41) hide show
  1. package/README.md +36 -13
  2. package/dist/access/PrincipalPicker.js +2 -1
  3. package/dist/ai/ai.css +38 -0
  4. package/dist/components/Avatar.d.ts +5 -1
  5. package/dist/components/Avatar.js +4 -2
  6. package/dist/components/Markdown.d.ts +3 -0
  7. package/dist/components/Markdown.js +2 -1
  8. package/dist/components/MarkdownImpl.d.ts +1 -1
  9. package/dist/components/MarkdownImpl.js +5 -3
  10. package/dist/components/Provider.d.ts +10 -0
  11. package/dist/components/Provider.js +9 -0
  12. package/dist/components/Table.d.ts +7 -5
  13. package/dist/components/Table.js +6 -2
  14. package/dist/components/ThemeEditor.js +1 -1
  15. package/dist/components/category.d.ts +9 -0
  16. package/dist/components/category.js +30 -0
  17. package/dist/components/fieldBox.js +3 -3
  18. package/dist/components/httpsImages.d.ts +12 -0
  19. package/dist/components/httpsImages.js +27 -0
  20. package/dist/components/stickyTop.d.ts +10 -0
  21. package/dist/components/stickyTop.js +88 -0
  22. package/dist/file-explorer/FileExplorer.js +21 -15
  23. package/dist/graph/DependencyGraph.d.ts +14 -1
  24. package/dist/graph/DependencyGraph.js +169 -77
  25. package/dist/graph/JobSummary.js +13 -3
  26. package/dist/graph/StepSummary.d.ts +4 -0
  27. package/dist/graph/StepSummary.js +31 -0
  28. package/dist/graph/index.d.ts +1 -0
  29. package/dist/graph/index.js +2 -1
  30. package/dist/graph/types.d.ts +3 -0
  31. package/dist/graph/util.d.ts +1 -1
  32. package/dist/icons.d.ts +3 -0
  33. package/dist/icons.js +23 -2
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.js +2 -1
  36. package/dist/logviewer/LogViewer.js +3 -3
  37. package/dist/styles/theme.css +17 -0
  38. package/dist/styles.css +1 -1
  39. package/dist/theme/derive.d.ts +2 -1
  40. package/dist/theme/derive.js +52 -1
  41. package/package.json +1 -1
package/README.md CHANGED
@@ -84,7 +84,7 @@ Each need has one component. Reach for it before styling markup by hand.
84
84
  | How full something is, how far a task has come | `Meter`, `ProgressBar` |
85
85
  | Pages of a long list | `Pagination` |
86
86
  | A grouped page of settings | `SettingsCard` with `SettingRow`s |
87
- | A person | `Avatar` |
87
+ | A person | `Avatar`, colored by name without a picture |
88
88
  | A status in words | `StatusBadge`, or `StatusDot` beside text that says it |
89
89
  | A run's, job's or step's status beside its name | `RunStatusIcon`; `StatusBadge` with `runStatusTone` to say it in words |
90
90
  | A copy action | `CopyButton`; `CopyCodeBlock` for a command or snippet; `useCopy` for a menu row or a control of your own |
@@ -273,18 +273,24 @@ until it reaches its target against every surface it sits on.
273
273
  | Placeholders | 3 / 3 / 4.5 | 45 / 50 / 65 |
274
274
  | Focus ring, status icons, dots, borders and bars | 3 / 3 / 4.5 | |
275
275
  | Control edges | 3 / 3 / 4.5 | |
276
+ | Text field edges | 1.3 / 1.5 / 3 | |
276
277
  | Text on the accent, white text on a solid status fill | 4.5 | |
277
278
 
278
- Lines come in two tokens. `--theme-border` is decorative: card outlines,
279
+ Lines come in three tokens. `--theme-border` is decorative: card outlines,
279
280
  dividers and table lines, which stay quiet. `--theme-border-strong` is a
280
- control's edge: the box of an input, select or checkbox, the ring of a radio,
281
- and the off track of a switch or slider. It is often all that shows where the
282
- control is, so it is solved to WCAG 1.4.11's 3:1 on the page, the panels and the
283
- input fill. A checked control's edge, an on switch's track and a slider's
284
- filled part take `--theme-focus`: the accent as picked when it reaches 3:1, and
285
- otherwise the accent solved to secondary text's contrast, so on always reads
286
- heavier than the off track. A host that sets `--theme-border-strong` itself
287
- sets it to a color that keeps 3:1 against its surfaces.
281
+ control's edge: the box of a checkbox, the ring of a radio, and the off track of
282
+ a switch or slider. It is all that shows where the control is, so it is solved
283
+ to WCAG 1.4.11's 3:1 on the page, the panels and the input fill. A checked
284
+ control's edge, an on switch's track and a slider's filled part take
285
+ `--theme-focus`: the accent as picked when it reaches 3:1, and otherwise the
286
+ accent solved to secondary text's contrast, so on always reads heavier than the
287
+ off track. `--theme-border-field` is a text field's resting edge: inputs,
288
+ selects, textareas and pickers. A field is also found by its label, its size and
289
+ the text inside it, and a form of 3:1 boxes reads heavy, so its edge sits
290
+ between the other two and reaches 3:1 at contrast 100. Pointing at a field
291
+ raises its edge to `--theme-border-strong`. A host that sets
292
+ `--theme-border-strong` itself sets it to a color that keeps 3:1 against its
293
+ surfaces.
288
294
 
289
295
  Status colors (success, error, warning, info, favorite) and terminal colors keep
290
296
  fixed hues, so red means the same thing in every theme.
@@ -306,6 +312,16 @@ icon beside an error message may keep the message's text shade through
306
312
  from the accent's hue when they come within 30° of it, so a keyword never looks
307
313
  like a link.
308
314
 
315
+ Eight category colors tell things apart without ranking them:
316
+ `--theme-category-1` to `--theme-category-8` are soft fills at hues 45° apart,
317
+ all at one lightness and chroma so none reads louder, and each
318
+ `--theme-category-N-text` is solved to secondary text's targets on its fill. An
319
+ `Avatar` without a picture takes one, picked by an FNV-1a hash of its name (or
320
+ its `colorKey`), so the same name has the same color on every page; a nameless
321
+ one keeps the accent. `categoryOf(key)` gives the same pick, 1–8, and
322
+ `categoryClasses(key)` its fill and text classes, for anything else drawn
323
+ beside a name, such as a group's icon.
324
+
309
325
  Every color is mapped into sRGB with the CSS Color 4 method, which lowers
310
326
  chroma at fixed lightness and hue. It is mapped into display-p3 too: where the
311
327
  wider color differs from sRGB by more than one just-noticeable difference
@@ -560,13 +576,20 @@ root:
560
576
  | ------------------- | ----------------------------------------------------------------------- |
561
577
  | `--app-inset-top` | The height of a fixed top bar. Drawers and toasts start below it. |
562
578
  | `--app-inset-start` | The width of a fixed sidebar on the start side. Toasts center over the page beside it, and a drawer at its widest leaves that strip, and never less than 2.5rem. Update it when the sidebar collapses. |
563
- | `--table-header-top` | How far below the top of its scrolling box a table's sticky header row stops (`Table stickyHeader`, which list tables use). Set it on the scrolling box, or an ancestor, when something else sticks at that box's top, such as a page header in a page that scrolls as a whole. Inside `ListPage` the rows scroll in their own box below the header and toolbar, so it stays 0. |
564
579
 
565
580
  ```ts
566
581
  document.documentElement.style.setProperty('--app-inset-top', '56px')
567
582
  document.documentElement.style.setProperty('--app-inset-start', '220px')
568
583
  ```
569
584
 
585
+ A table's sticky header row (`Table stickyHeader`, which list tables use) needs
586
+ no variable. It stops just below whatever its scrolling box already pins at the
587
+ top, such as a page header, a toolbar and tabs: it finds each element there with
588
+ `position: sticky` and a `top` whose parent also holds the table, so it stays
589
+ pinned while the table passes, and follows it as it wraps to a new height or
590
+ comes and goes. In a box that scrolls on its own, such as a fixed-height panel
591
+ or `ListPage`'s rows, it pins at the box's top.
592
+
570
593
  Everything the package lays over the page stacks on one scale, set on the
571
594
  document root by `base.css`. Give the host's own fixed elements a place on it,
572
595
  such as `z-index: 100` for a top bar, rather than a z-index chosen to win:
@@ -617,8 +640,8 @@ meaning across the apps.
617
640
  `DisableUserIcon` turns off their account. `ChangesRequestedIcon` asks for
618
641
  changes to something sent, while `FlaggedIcon` marks an item for attention.
619
642
  `ProjectIcon` is a project, while `FolderIcon` is a folder of files.
620
- `LibraryIcon` is a collection of write-ups to browse, while `NotebookIcon`
621
- is one notebook.
643
+ `LibraryIcon` is a collection of write-ups to browse, `ArticleIcon` one
644
+ write-up to read, such as a case study, and `NotebookIcon` one notebook.
622
645
  `PendingIcon` is a requirement not met yet and `NotRunningIcon` a run not
623
646
  started; they share the empty circle, so keep them out of the same list.
624
647
  - **Adding an icon:** pick the [Lucide glyph](https://lucide.dev/icons) and add
@@ -1,6 +1,7 @@
1
1
  import { cx } from "../cx.js";
2
2
  import { OrganizationIcon, RefreshIcon, UsersIcon } from "../icons.js";
3
3
  import { useQueryMatch } from "../components/search.js";
4
+ import { categoryClasses } from "../components/category.js";
4
5
  import { Avatar } from "../components/Avatar.js";
5
6
  import { Callout } from "../components/Callout.js";
6
7
  import { Combobox } from "../components/Combobox.js";
@@ -35,7 +36,7 @@ function PrincipalTile({ principal, compact = false, className }) {
35
36
  const Icon = everyone ? OrganizationIcon : UsersIcon;
36
37
  return /* @__PURE__ */ jsx("span", {
37
38
  "aria-hidden": "true",
38
- className: cx("flex size-8 shrink-0 items-center justify-center rounded-md [&_svg]:size-4", compact && "size-6 [&_svg]:size-3.5", everyone ? "bg-(--theme-info-muted) text-(--theme-info-text)" : "bg-(--theme-muted-panel-bg) text-(--theme-muted-text-color)", className),
39
+ className: cx("flex size-8 shrink-0 items-center justify-center rounded-md [&_svg]:size-4", compact && "size-6 [&_svg]:size-3.5", everyone ? "bg-(--theme-info-muted) text-(--theme-info-text)" : categoryClasses(principal.name), className),
39
40
  children: /* @__PURE__ */ jsx(Icon, {})
40
41
  });
41
42
  }
package/dist/ai/ai.css CHANGED
@@ -182,6 +182,44 @@
182
182
  .markdown [data-streamdown="table"] tbody tr:last-child {
183
183
  border-bottom: 0;
184
184
  }
185
+ /* Markdown set inside other UI, such as a step's summary, reads at the page's
186
+ * text size, its headings a step above it rather than at a document's sizes. */
187
+ .markdown-compact {
188
+ font-size: 0.875rem;
189
+ line-height: 1.5;
190
+ }
191
+ .markdown-compact :is(h1, h2, h3, h4, h5, h6) {
192
+ font-size: 0.875rem;
193
+ line-height: 1.25rem;
194
+ font-weight: 600;
195
+ }
196
+ .markdown-compact :is(h1, h2) {
197
+ font-size: 1rem;
198
+ line-height: 1.5rem;
199
+ }
200
+ .markdown-compact [data-streamdown="table-wrapper"] table,
201
+ .markdown-compact [data-streamdown="table"] :is(th, td) {
202
+ font-size: 0.875rem;
203
+ line-height: 1.25rem;
204
+ }
205
+ /* A summary's tables are mostly a few short columns of counts: they hug their
206
+ * content so each number sits by its label, and a phone fits them unscrolled. */
207
+ .markdown-compact table[data-streamdown="table"] {
208
+ width: auto;
209
+ min-width: min(100%, 20rem);
210
+ }
211
+ .markdown-compact [data-streamdown="table"] :is(th, td) {
212
+ min-width: 0;
213
+ padding-inline-end: 2rem;
214
+ font-variant-numeric: tabular-nums;
215
+ }
216
+ .markdown-compact [data-streamdown="table"] th {
217
+ font-weight: 500;
218
+ color: var(--theme-muted-text-color);
219
+ }
220
+ .markdown-compact :not(pre) > code {
221
+ font-size: 0.8125rem;
222
+ }
185
223
  @media (prefers-reduced-motion: reduce) {
186
224
  .markdown [data-streamdown="table-wrapper"] > div:first-child {
187
225
  transition: none;
@@ -4,6 +4,10 @@ export interface AvatarProps {
4
4
  /** Image URL. Until it loads, or when it fails, the initials of `name` show. */
5
5
  src?: string | null | undefined;
6
6
  name?: string | undefined;
7
+ /** Picks the fallback's color from the theme's categorical palette, so the same key gets the
8
+ * same color everywhere. Defaults to `name`; pass a stable id when names can repeat or
9
+ * change, or `null` for the accent fill whatever the name. Without either, the accent fills it. */
10
+ colorKey?: string | null | undefined;
7
11
  size?: AvatarSize | undefined;
8
12
  status?: AvatarStatus | undefined;
9
13
  className?: string | undefined;
@@ -13,4 +17,4 @@ export declare function avatarInitials(name: string | undefined): string;
13
17
  /** A person's picture, or their initials while it loads or when it fails. It is
14
18
  * one image to assistive technology, named for the person, whichever shows,
15
19
  * and their presence when it has a status dot. */
16
- export declare function Avatar({ src, name, size, status, className }: AvatarProps): import("react").JSX.Element;
20
+ export declare function Avatar({ src, name, colorKey, size, status, className }: AvatarProps): import("react").JSX.Element;
@@ -1,5 +1,6 @@
1
1
  import { cx } from "../cx.js";
2
2
  import { useStrings } from "./Provider.js";
3
+ import { categoryClasses } from "./category.js";
3
4
  import { jsx, jsxs } from "react/jsx-runtime";
4
5
  import { Avatar } from "@base-ui/react/avatar";
5
6
  //#region src/components/Avatar.tsx
@@ -28,9 +29,10 @@ function avatarInitials(name) {
28
29
  /** A person's picture, or their initials while it loads or when it fails. It is
29
30
  * one image to assistive technology, named for the person, whichever shows,
30
31
  * and their presence when it has a status dot. */
31
- function Avatar$1({ src, name, size = "md", status, className }) {
32
+ function Avatar$1({ src, name, colorKey, size = "md", status, className }) {
32
33
  const { common } = useStrings();
33
34
  const who = name || common.userAvatar;
35
+ const colorBy = colorKey === void 0 ? name?.trim() : colorKey;
34
36
  return /* @__PURE__ */ jsxs("span", {
35
37
  role: "img",
36
38
  "aria-label": status ? common.withPresence(who, common.presence[status]) : who,
@@ -45,7 +47,7 @@ function Avatar$1({ src, name, size = "md", status, className }) {
45
47
  className: "absolute inset-0 size-full object-cover data-error:hidden data-loading:invisible"
46
48
  }, src), /* @__PURE__ */ jsx(Avatar.Fallback, {
47
49
  "aria-hidden": "true",
48
- className: "flex size-full items-center justify-center bg-gradient-to-br from-[var(--gradient-start,var(--theme-accent))] to-[var(--gradient-mid,var(--theme-link))] text-(--theme-accent-text)",
50
+ className: cx("flex size-full items-center justify-center", colorBy ? categoryClasses(colorBy) : "bg-gradient-to-br from-[var(--gradient-start,var(--theme-accent))] to-[var(--gradient-mid,var(--theme-link))] text-(--theme-accent-text)"),
49
51
  children: size === "xs" ? avatarInitials(name).charAt(0) : avatarInitials(name)
50
52
  })]
51
53
  }), status && /* @__PURE__ */ jsx("span", {
@@ -7,6 +7,9 @@ export interface MarkdownProps {
7
7
  controls?: ControlsConfig;
8
8
  /** Per-tag overrides of the rendered elements. */
9
9
  components?: Components;
10
+ httpsImagesOnly?: boolean;
11
+ /** Sets it at the page's text size, for Markdown inside other UI rather than a document of its own. */
12
+ compact?: boolean;
10
13
  }
11
14
  /** Markdown, with highlighted code, math and mermaid diagrams. Links leave in a
12
15
  * new tab, and inline code breaks after path separators. */
@@ -1,3 +1,4 @@
1
+ import { cx } from "../cx.js";
1
2
  import { jsx } from "react/jsx-runtime";
2
3
  import { Suspense, lazy } from "react";
3
4
  //#region src/components/Markdown.tsx
@@ -7,7 +8,7 @@ var MarkdownImpl = lazy(() => import("./MarkdownImpl.js"));
7
8
  function Markdown(props) {
8
9
  return /* @__PURE__ */ jsx(Suspense, {
9
10
  fallback: /* @__PURE__ */ jsx("div", {
10
- className: "markdown whitespace-pre-wrap text-start w-full",
11
+ className: cx("markdown whitespace-pre-wrap text-start w-full", props.compact && "markdown-compact"),
11
12
  children: props.children
12
13
  }),
13
14
  children: /* @__PURE__ */ jsx(MarkdownImpl, { ...props })
@@ -1,3 +1,3 @@
1
1
  import 'katex/dist/katex.min.css';
2
2
  import type { MarkdownProps } from './Markdown.js';
3
- export default function MarkdownImpl({ children, isStreaming, controls, components, }: MarkdownProps): import("react").JSX.Element;
3
+ export default function MarkdownImpl({ children, isStreaming, controls, components, httpsImagesOnly, compact, }: MarkdownProps): import("react").JSX.Element;
@@ -2,6 +2,7 @@ import { cx } from "../cx.js";
2
2
  import { ExternalLink } from "./ExternalLink.js";
3
3
  import { readIsDark } from "./useCssIsDark.js";
4
4
  import { CODE_THEMES } from "./codeTheme.js";
5
+ import { rehypeHttpsImagesOnly } from "./httpsImages.js";
5
6
  import { rehypeCodeBreaks } from "./markdownCodeBreaks.js";
6
7
  import { jsx } from "react/jsx-runtime";
7
8
  import { useEffect, useState } from "react";
@@ -33,7 +34,8 @@ function loadMermaid() {
33
34
  return mermaidLoad;
34
35
  }
35
36
  var rehypePlugins = [...Object.values(defaultRehypePlugins), rehypeCodeBreaks];
36
- function MarkdownImpl({ children, isStreaming, controls, components }) {
37
+ var httpsImagesRehypePlugins = [...Object.entries(defaultRehypePlugins).flatMap(([name, plugin]) => name === "harden" ? [rehypeHttpsImagesOnly, plugin] : [plugin]), rehypeCodeBreaks];
38
+ function MarkdownImpl({ children, isStreaming, controls, components, httpsImagesOnly, compact }) {
37
39
  const needsMermaid = !!children && children.includes("```mermaid");
38
40
  const [, setMermaidReady] = useState(() => !!pluginsWithMermaid);
39
41
  useEffect(() => {
@@ -48,7 +50,7 @@ function MarkdownImpl({ children, isStreaming, controls, components }) {
48
50
  }, [needsMermaid]);
49
51
  const plugins = needsMermaid && pluginsWithMermaid ? pluginsWithMermaid : basePlugins;
50
52
  return /* @__PURE__ */ jsx("div", {
51
- className: "markdown whitespace-normal text-start w-full",
53
+ className: cx("markdown whitespace-normal text-start w-full", compact && "markdown-compact"),
52
54
  children: /* @__PURE__ */ jsx(Streamdown, {
53
55
  mode: isStreaming ? "streaming" : "static",
54
56
  isAnimating: isStreaming ?? false,
@@ -58,7 +60,7 @@ function MarkdownImpl({ children, isStreaming, controls, components }) {
58
60
  } : {},
59
61
  ...controls ? { controls } : {},
60
62
  plugins,
61
- rehypePlugins,
63
+ rehypePlugins: httpsImagesOnly ? httpsImagesRehypePlugins : rehypePlugins,
62
64
  shikiTheme: CODE_THEMES,
63
65
  components: {
64
66
  a: ({ node, ...props }) => /* @__PURE__ */ jsx(ExternalLink, {
@@ -227,6 +227,14 @@ export interface UIStrings {
227
227
  loadingLogs: string;
228
228
  noLogFound: string;
229
229
  logLoadFailed: string;
230
+ loadingSummary: string;
231
+ hasSummary: string;
232
+ stepOf: (position: number, count: number) => string;
233
+ summaryLoadFailed: string;
234
+ summaryTab: string;
235
+ logsTab: string;
236
+ scriptTab: string;
237
+ openSubworkflow: string;
230
238
  statusLabel: (status: string) => string;
231
239
  statusReason: (reason: string | undefined | null) => string | undefined;
232
240
  /** A run's general log, beside its summary. */
@@ -277,6 +285,8 @@ export interface UIStrings {
277
285
  openFolder: string;
278
286
  /** A path's page in the app, as in "Copy link". */
279
287
  link: string;
288
+ /** An object's key within its storage, as in "Copy path". */
289
+ path: string;
280
290
  detailsTab: string;
281
291
  previewTab: string;
282
292
  preview: string;
@@ -236,6 +236,14 @@ var DEFAULTS = {
236
236
  loadingLogs: "Loading logs",
237
237
  noLogFound: "No log found",
238
238
  logLoadFailed: "Log could not be loaded",
239
+ loadingSummary: "Loading summary",
240
+ hasSummary: "Has a summary",
241
+ stepOf: (position, count) => `Step ${position} of ${count}`,
242
+ summaryLoadFailed: "Summary could not be loaded",
243
+ summaryTab: "Summary",
244
+ logsTab: "Logs",
245
+ scriptTab: "Script",
246
+ openSubworkflow: "Open Subworkflow",
239
247
  statusLabel: (status) => status,
240
248
  statusReason: () => void 0,
241
249
  generalLog: "General log",
@@ -289,6 +297,7 @@ var DEFAULTS = {
289
297
  viewDetails: "View details",
290
298
  openFolder: "Open folder",
291
299
  link: "link",
300
+ path: "path",
292
301
  detailsTab: "Details",
293
302
  previewTab: "Preview",
294
303
  preview: "Preview",
@@ -37,11 +37,13 @@ interface TableProps {
37
37
  roundedBottom?: boolean;
38
38
  isLoading?: boolean;
39
39
  /** The header row stays in view while the rows scroll under it. It sticks
40
- * to the nearest scrolling ancestor, `--table-header-top` below its top
41
- * edge (0 unless an ancestor sets it), so the table no longer scrolls or
42
- * clips on its own: a box that scrolls itself is all a sticky header can
43
- * stick to. Its ancestor scrolls it sideways as well as down. Rows pass
44
- * under the header, so a `theadClassName` of your own needs a background. */
40
+ * to the nearest scrolling ancestor, just below whatever that ancestor
41
+ * already pins at its top, such as a page header, a toolbar or tabs, which
42
+ * it finds and follows by itself; in a box that scrolls on its own it pins
43
+ * at the box's top. So the table no longer scrolls or clips on its own: a
44
+ * box that scrolls itself is all a sticky header can stick to. Its ancestor
45
+ * scrolls it sideways as well as down. Rows pass under the header, so a
46
+ * `theadClassName` of your own needs a background. */
45
47
  stickyHeader?: boolean | undefined;
46
48
  /** Forwarded to the `<table>`, for attributes that must sit on the element owning
47
49
  * the rows — `role='grid'`, aria-activedescendant, aria-rowcount. `className` is
@@ -1,8 +1,9 @@
1
1
  import { cx } from "../cx.js";
2
2
  import { SortDownIcon, SortUnsortedIcon, SortUpIcon } from "../icons.js";
3
3
  import { Loader } from "./Loader.js";
4
+ import { useStickyTop } from "./stickyTop.js";
4
5
  import { jsx, jsxs } from "react/jsx-runtime";
5
- import React, { createContext, useContext } from "react";
6
+ import React, { createContext, useContext, useRef } from "react";
6
7
  //#region src/components/Table.tsx
7
8
  var DensityContext = createContext("comfortable");
8
9
  var cellX = "px-3";
@@ -47,6 +48,8 @@ var isHeaderChild = (child) => {
47
48
  * the children, then `<tr>`s of `Table.Item`s. */
48
49
  function Table({ density = "comfortable", borderless = false, panel = true, className, wrapperClassName = "", tbodyClassName = "divide-y", theadClassName = "theme-muted-panel", children, roundedTop = false, rounded = false, roundedBottom = false, isLoading = false, stickyHeader = false, tableProps }) {
49
50
  const childList = React.Children.toArray(children);
51
+ const thead = useRef(null);
52
+ useStickyTop(thead, stickyHeader && !isLoading);
50
53
  const hasOverflow = wrapperClassName.split(" ").some((cls) => cls.startsWith("overflow-"));
51
54
  return /* @__PURE__ */ jsx(DensityContext, {
52
55
  value: density,
@@ -62,7 +65,8 @@ function Table({ density = "comfortable", borderless = false, panel = true, clas
62
65
  className: cx("min-w-full divide-y theme-border", className),
63
66
  ...tableProps,
64
67
  children: [/* @__PURE__ */ jsx("thead", {
65
- className: cx(stickyHeader && "sticky top-(--table-header-top,0px) z-(--layer-sticky) border-b-0 shadow-[inset_0_-1px_0_var(--theme-border)]", theadClassName),
68
+ ref: thead,
69
+ className: cx(stickyHeader && "sticky top-0 z-(--layer-sticky) border-b-0 shadow-[inset_0_-1px_0_var(--theme-border)]", theadClassName),
66
70
  children: /* @__PURE__ */ jsx("tr", { children: childList.filter(isHeaderChild) })
67
71
  }), /* @__PURE__ */ jsx("tbody", {
68
72
  className: tbodyClassName,
@@ -347,7 +347,7 @@ function ThemePreview({ theme, mode }) {
347
347
  /* @__PURE__ */ jsxs("div", {
348
348
  className: "flex items-center gap-2",
349
349
  children: [/* @__PURE__ */ jsx("span", {
350
- className: cx(fieldBox({ size: "sm" }), "flex flex-1 cursor-default items-center text-(--theme-placeholder) border-(--theme-border-strong)"),
350
+ className: cx(fieldBox({ size: "sm" }), "flex flex-1 cursor-default items-center text-(--theme-placeholder)"),
351
351
  children: t.previewSearch
352
352
  }), /* @__PURE__ */ jsx("span", {
353
353
  className: "rounded-md bg-(--theme-accent) px-3 py-1.5 text-xs font-medium text-(--theme-accent-text)",
@@ -0,0 +1,9 @@
1
+ /** The fill and text of each palette entry, written out so the stylesheet build sees them. */
2
+ export declare const CATEGORY_CLASSES: readonly ['bg-(--theme-category-1) text-(--theme-category-1-text)', 'bg-(--theme-category-2) text-(--theme-category-2-text)', 'bg-(--theme-category-3) text-(--theme-category-3-text)', 'bg-(--theme-category-4) text-(--theme-category-4-text)', 'bg-(--theme-category-5) text-(--theme-category-5-text)', 'bg-(--theme-category-6) text-(--theme-category-6-text)', 'bg-(--theme-category-7) text-(--theme-category-7-text)', 'bg-(--theme-category-8) text-(--theme-category-8-text)'];
3
+ /** 32-bit FNV-1a over a string's UTF-8 bytes: cheap, well spread, and the same in any language. */
4
+ export declare function fnv1a(text: string): number;
5
+ /** The palette entry, 1–8, for a key. Case, surrounding space and Unicode composition don't
6
+ * change it, so "Ada Lovelace" and " ada lovelace" match. */
7
+ export declare function categoryOf(key: string): number;
8
+ /** The fill and text classes for a key. */
9
+ export declare function categoryClasses(key: string): string;
@@ -0,0 +1,30 @@
1
+ //#region src/components/category.ts
2
+ /** The fill and text of each palette entry, written out so the stylesheet build sees them. */
3
+ var CATEGORY_CLASSES = [
4
+ "bg-(--theme-category-1) text-(--theme-category-1-text)",
5
+ "bg-(--theme-category-2) text-(--theme-category-2-text)",
6
+ "bg-(--theme-category-3) text-(--theme-category-3-text)",
7
+ "bg-(--theme-category-4) text-(--theme-category-4-text)",
8
+ "bg-(--theme-category-5) text-(--theme-category-5-text)",
9
+ "bg-(--theme-category-6) text-(--theme-category-6-text)",
10
+ "bg-(--theme-category-7) text-(--theme-category-7-text)",
11
+ "bg-(--theme-category-8) text-(--theme-category-8-text)"
12
+ ];
13
+ var encoder = new TextEncoder();
14
+ /** 32-bit FNV-1a over a string's UTF-8 bytes: cheap, well spread, and the same in any language. */
15
+ function fnv1a(text) {
16
+ let hash = 2166136261;
17
+ for (const byte of encoder.encode(text)) hash = Math.imul(hash ^ byte, 16777619);
18
+ return hash >>> 0;
19
+ }
20
+ /** The palette entry, 1–8, for a key. Case, surrounding space and Unicode composition don't
21
+ * change it, so "Ada Lovelace" and " ada lovelace" match. */
22
+ function categoryOf(key) {
23
+ return fnv1a(key.trim().normalize("NFC").toLowerCase()) % CATEGORY_CLASSES.length + 1;
24
+ }
25
+ /** The fill and text classes for a key. */
26
+ function categoryClasses(key) {
27
+ return CATEGORY_CLASSES[categoryOf(key) - 1];
28
+ }
29
+ //#endregion
30
+ export { CATEGORY_CLASSES, categoryClasses, categoryOf, fnv1a };
@@ -20,9 +20,9 @@ var sizes = {
20
20
  * thumb: an outline in the theme's focus color, which is solved for 3:1 on every surface a
21
21
  * control sits on, so it shows on a checked control as well as an empty one. */
22
22
  var focusOutline = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--theme-focus)";
23
- var base = "w-full min-w-0 rounded-md border border-(--theme-border-strong) bg-(--theme-input-bg) text-(--theme-app) outline-none transition-colors placeholder:text-(--theme-placeholder)";
24
- var own = cx("focus-visible:border-(--theme-focus) focus-visible:ring-1 focus-visible:ring-(--theme-focus)", "aria-invalid:border-(--theme-error) aria-invalid:focus-visible:ring-(--theme-error)", "disabled:cursor-not-allowed disabled:bg-(--theme-input-disabled-bg) disabled:text-(--theme-placeholder)", "data-disabled:cursor-not-allowed data-disabled:bg-(--theme-input-disabled-bg) data-disabled:text-(--theme-placeholder)");
25
- var inside = cx("has-[:focus-visible]:border-(--theme-focus) has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-(--theme-focus)", "has-[[aria-invalid=true]]:border-(--theme-error) has-[[aria-invalid=true]:focus-visible]:ring-(--theme-error)", "has-[input:disabled]:cursor-not-allowed has-[input:disabled]:bg-(--theme-input-disabled-bg) has-[input:disabled]:text-(--theme-placeholder)", "data-disabled:cursor-not-allowed data-disabled:bg-(--theme-input-disabled-bg) data-disabled:text-(--theme-placeholder)");
23
+ var base = "w-full min-w-0 rounded-md border border-(--theme-border-field) bg-(--theme-input-bg) text-(--theme-app) outline-none transition-colors placeholder:text-(--theme-placeholder) hover:border-(--theme-border-strong)";
24
+ var own = cx("focus-visible:border-(--theme-focus) focus-visible:ring-1 focus-visible:ring-(--theme-focus)", "aria-invalid:border-(--theme-error) aria-invalid:focus-visible:ring-(--theme-error)", "disabled:cursor-not-allowed disabled:hover:border-(--theme-border-field) disabled:bg-(--theme-input-disabled-bg) disabled:text-(--theme-placeholder)", "data-disabled:cursor-not-allowed data-disabled:hover:border-(--theme-border-field) data-disabled:bg-(--theme-input-disabled-bg) data-disabled:text-(--theme-placeholder)");
25
+ var inside = cx("has-[:focus-visible]:border-(--theme-focus) has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-(--theme-focus)", "has-[[aria-invalid=true]]:border-(--theme-error) has-[[aria-invalid=true]:focus-visible]:ring-(--theme-error)", "has-[input:disabled]:cursor-not-allowed has-[input:disabled]:hover:border-(--theme-border-field) has-[input:disabled]:bg-(--theme-input-disabled-bg) has-[input:disabled]:text-(--theme-placeholder)", "data-disabled:cursor-not-allowed data-disabled:hover:border-(--theme-border-field) data-disabled:bg-(--theme-input-disabled-bg) data-disabled:text-(--theme-placeholder)");
26
26
  /** The box every input-like control draws: text inputs, textareas, and the triggers of
27
27
  * pickers. Invalid follows `aria-invalid`, disabled follows `disabled` or `data-disabled`. */
28
28
  function fieldBox({ size = "md", multiline = false, padded = true, within = false } = {}) {
@@ -0,0 +1,12 @@
1
+ interface HastElement {
2
+ type: 'element';
3
+ tagName: string;
4
+ properties?: Record<string, unknown>;
5
+ children: HastNode[];
6
+ }
7
+ type HastNode = HastElement | {
8
+ type: string;
9
+ children?: HastNode[];
10
+ };
11
+ export declare function rehypeHttpsImagesOnly(): (tree: HastNode) => void;
12
+ export {};
@@ -0,0 +1,27 @@
1
+ //#region src/components/httpsImages.ts
2
+ function isHttpsUrl(url) {
3
+ if (typeof url !== "string") return false;
4
+ try {
5
+ return new URL(url).protocol === "https:";
6
+ } catch {
7
+ return false;
8
+ }
9
+ }
10
+ function isHttpsSrcSet(srcSet) {
11
+ return (Array.isArray(srcSet) ? srcSet : [srcSet]).every((entry) => typeof entry === "string" && entry.split(",").every((candidate) => isHttpsUrl(candidate.trim().split(/\s+/)[0])));
12
+ }
13
+ function walk(node) {
14
+ if (node.type === "element") {
15
+ const { tagName, properties } = node;
16
+ if (properties && tagName === "img" && !isHttpsUrl(properties["src"])) delete properties["src"];
17
+ if (properties && (tagName === "img" || tagName === "source") && !isHttpsSrcSet(properties["srcSet"])) delete properties["srcSet"];
18
+ }
19
+ for (const child of node.children ?? []) walk(child);
20
+ }
21
+ function rehypeHttpsImagesOnly() {
22
+ return (tree) => {
23
+ walk(tree);
24
+ };
25
+ }
26
+ //#endregion
27
+ export { rehypeHttpsImagesOnly };
@@ -0,0 +1,10 @@
1
+ import { type RefObject } from 'react';
2
+ /**
3
+ * Pins a `sticky` element just below whatever its scroller already pins at
4
+ * the top, such as a page header, a toolbar and a tab row, rather than at
5
+ * `top: 0` behind them. It finds them in the page, so neither they nor the
6
+ * host declare anything, and it follows them as they change height (a toolbar
7
+ * wrapping on a narrow screen) or come and go, before the browser paints.
8
+ * In a box that scrolls on its own, nothing sits above, so it pins at 0.
9
+ */
10
+ export declare function useStickyTop(ref: RefObject<HTMLElement | null>, enabled: boolean): void;
@@ -0,0 +1,88 @@
1
+ import { useLayoutEffect } from "react";
2
+ //#region src/components/stickyTop.ts
3
+ var scrolls = (style) => /auto|scroll|hidden|overlay/.test(`${style.overflow} ${style.overflowX} ${style.overflowY}`);
4
+ /** The box a sticky element sticks to: its nearest ancestor that scrolls, or
5
+ * the document's when none does. */
6
+ function scrollContainer(el) {
7
+ const root = el.ownerDocument.documentElement;
8
+ for (let node = el.parentElement; node && node !== root; node = node.parentElement) if (scrolls(getComputedStyle(node))) return node;
9
+ return root;
10
+ }
11
+ /** Elements laid out as children of `parent`'s box: its children, with those
12
+ * of a `display: contents` child (such as a tab row's root) in its place. */
13
+ function boxChildren(nodes, into) {
14
+ for (const node of nodes) {
15
+ into.push(node);
16
+ if (getComputedStyle(node).display === "contents") boxChildren(node.children, into);
17
+ }
18
+ }
19
+ function chromeAbove(el, scroller) {
20
+ const above = {
21
+ chrome: [],
22
+ parents: [],
23
+ siblings: []
24
+ };
25
+ for (let node = el; node !== scroller && node.parentElement;) {
26
+ const parent = node.parentElement;
27
+ const earlier = [];
28
+ for (let sib = node.previousElementSibling; sib; sib = sib.previousElementSibling) earlier.push(sib);
29
+ const laidOut = [];
30
+ boxChildren(earlier, laidOut);
31
+ for (const sib of laidOut) {
32
+ above.siblings.push(sib);
33
+ const style = getComputedStyle(sib);
34
+ if (style.display === "contents") above.parents.push(sib);
35
+ if (style.position === "sticky" && style.top !== "auto" && sib instanceof HTMLElement) above.chrome.push(sib);
36
+ }
37
+ above.parents.push(parent);
38
+ node = parent;
39
+ }
40
+ return above;
41
+ }
42
+ /** Where `chrome`'s bottom edge sits, below the scroller's top, while it's pinned. */
43
+ function pinnedBottom(chrome) {
44
+ const height = chrome.getBoundingClientRect().height;
45
+ return height > 0 ? (Number.parseFloat(getComputedStyle(chrome).top) || 0) + height : 0;
46
+ }
47
+ /**
48
+ * Pins a `sticky` element just below whatever its scroller already pins at
49
+ * the top, such as a page header, a toolbar and a tab row, rather than at
50
+ * `top: 0` behind them. It finds them in the page, so neither they nor the
51
+ * host declare anything, and it follows them as they change height (a toolbar
52
+ * wrapping on a narrow screen) or come and go, before the browser paints.
53
+ * In a box that scrolls on its own, nothing sits above, so it pins at 0.
54
+ */
55
+ function useStickyTop(ref, enabled) {
56
+ useLayoutEffect(() => {
57
+ const el = ref.current;
58
+ if (!enabled || !el) return;
59
+ let chrome = [];
60
+ const place = () => {
61
+ el.style.top = `${Math.max(0, ...chrome.map(pinnedBottom))}px`;
62
+ };
63
+ const resizes = new ResizeObserver(place);
64
+ const changes = new MutationObserver(gather);
65
+ function gather() {
66
+ if (!el) return;
67
+ const above = chromeAbove(el, scrollContainer(el));
68
+ chrome = above.chrome;
69
+ resizes.disconnect();
70
+ changes.disconnect();
71
+ for (const node of chrome) resizes.observe(node);
72
+ for (const parent of above.parents) changes.observe(parent, { childList: true });
73
+ for (const sib of above.siblings) changes.observe(sib, {
74
+ attributes: true,
75
+ attributeFilter: ["class", "style"]
76
+ });
77
+ place();
78
+ }
79
+ gather();
80
+ return () => {
81
+ resizes.disconnect();
82
+ changes.disconnect();
83
+ el.style.top = "";
84
+ };
85
+ }, [ref, enabled]);
86
+ }
87
+ //#endregion
88
+ export { useStickyTop };
@@ -1,6 +1,6 @@
1
1
  import { cx } from "../cx.js";
2
2
  import { buttonClasses } from "../components/buttonClasses.js";
3
- import { AccessIcon, ChevronDownIcon, ChevronRightIcon, CollapseIcon, DownloadIcon, FileIcon, FileTextIcon, FolderIcon, LinkIcon, PreviewIcon, ShareIcon, TrashIcon, UploadIcon } from "../icons.js";
3
+ import { AccessIcon, ChevronDownIcon, ChevronRightIcon, CollapseIcon, DownloadIcon, FileIcon, FileTextIcon, FolderIcon, FolderTreeIcon, LinkIcon, PreviewIcon, ShareIcon, TrashIcon, UploadIcon } from "../icons.js";
4
4
  import { MenuItem, MenuPopup, MenuSeparator } from "../components/Menu.js";
5
5
  import { safeUrl } from "../safeUrl.js";
6
6
  import { Loader } from "../components/Loader.js";
@@ -676,24 +676,30 @@ function FileExplorer({ storages, onRefresh, refreshKey, onOpenFile, showUserHie
676
676
  ...open,
677
677
  onClick: () => handleSelectTreeItem(node.path, node)
678
678
  }];
679
- const uriExtras = uris.map((value) => ({
680
- label: `${getUriScheme(value).toUpperCase()} URI`,
681
- value,
682
- icon: /* @__PURE__ */ jsx(LinkIcon, {})
683
- }));
684
- const pageUrl = absoluteUrl(href);
685
- if (pageUrl) uriExtras.unshift({
686
- label: t.preview.link,
687
- value: pageUrl,
688
- icon: /* @__PURE__ */ jsx(LinkIcon, {})
679
+ const copy = copySubmenu({
680
+ name: isFile ? node.name : void 0,
681
+ extra: [
682
+ {
683
+ label: t.preview.path,
684
+ value: node.root ? void 0 : getObjectKeyFromNode(node),
685
+ icon: /* @__PURE__ */ jsx(FolderTreeIcon, {})
686
+ },
687
+ {
688
+ label: t.preview.link,
689
+ value: absoluteUrl(href),
690
+ icon: /* @__PURE__ */ jsx(LinkIcon, {})
691
+ },
692
+ ...uris.map((value) => ({
693
+ label: `${getUriScheme(value).toUpperCase()} URI`,
694
+ value,
695
+ icon: /* @__PURE__ */ jsx(LinkIcon, {})
696
+ }))
697
+ ]
689
698
  });
690
699
  return assembleRowMenu({
691
700
  lead,
692
701
  actions,
693
- copy: uriExtras.length > 0 ? copySubmenu({
694
- name: isFile ? node.name : void 0,
695
- extra: uriExtras
696
- }) : void 0
702
+ copy: copy.items.length > 0 ? copy : void 0
697
703
  });
698
704
  };
699
705
  const openNodeContextMenu = (e, node) => {