diff --git a/README.md b/README.md
index a86143c1..943ec881 100644
--- a/README.md
+++ b/README.md
@@ -46,8 +46,64 @@ to new users.
Volunteers are always welcome!
## Documentation
-- [Visual Language](https://solidos.github.io/solid-panes/Documentation/VisualLanguage.html)
-- [Conventions](./Documentation/conventions.md)
+- [Visual Language](https://solidos.github.io/solid-panes/docs/VisualLanguage.html)
+- [Conventions](./docs/conventions.md)
+- [Navigation design](./docs/navigation-design.md)
+
+### Profile heading
+
+The `solid-panes-profile-heading` component accepts presentation data through
+`profileData`. Its boolean `compact` property (also available as an attribute)
+halves the responsive avatar size and shows only the photo, name, and job title.
+The page controller uses the full heading on the profile pane and compact mode
+on every other pane where the heading is visible. Owner editing remains exclusive
+to the profile pane. On storage panes, the heading is shown only when the active
+storage URI matches the viewed profile's navbar entry. Opening the logged-in
+user's storage from the header menu hides the heading, even when the URL still
+points to someone else's profile. Reloading a WebID URL always shows its profile
+with Profile selected in the navbar. On a container or resource URL (not a WebID) the page
+shows the resource as usual with the navbar visible and nothing selected; choosing Storage
+in the navbar replaces that view with the whole storage, with the container or resource
+from the URL opened inside it. Any other pane on a page inside someone else's pod
+(for example their container) shows the compact heading of that pod's owner; it is
+hidden inside the viewer's own pod and for account-menu views. Pressing Enter on the current URL,
+or using the browser's back and forward buttons, re-renders the page without a reload, and
+like a reload shows what the URL is: the profile on a WebID (selected in the navbar), otherwise
+the container or resource itself, whatever the history entry last displayed through the navbar
+or the account menu. Solid-panes locally presents the heading data and checks
+viewer edit access; the component owns the edit interaction and opens the local
+heading editor. The page controller refreshes the heading after a successful save.
+After a successful save the component also dispatches a bubbling, composed
+`profile-heading-saved` event with `detail.subjectUri`, which profile-pane uses to
+rerender sections such as More contacts.
+The heading editor includes its own dialog and responsive styles.
+On mobile, the edit icon sits closer to the heading's top-right edge while
+preserving its full touch target.
+
+On the profile pane, logged-in visitors viewing someone else's profile also see
+an "Add as friend" action to the left of Edit (when available). The page controller
+sets `friendSubject` only in that context. The action loads the viewer's profile,
+checks `foaf:knows`, and saves the new friendship in the viewer's profile document.
+Existing friends have a disabled "Already a friend" button (hidden on mobile).
+Success and error messages are centered below the button, announced and focused,
+and dismissed after 10 seconds; failed operations can be
+retried. Anonymous visitors, owners, and other panes do not show this action.
+On mobile, friend actions sit in the top-right corner beside the name, which
+wraps as needed.
+
+Navigation reuses the mounted heading, with a 300ms CSS transition for the avatar,
+padding, and detail rows in both directions. Transitions are disabled when the
+user prefers reduced motion.
+When navigating to a pane without a heading, the entire heading fades and
+collapses (including its outer spacing) over 300ms before removal. Navigating
+back during that animation cancels it and reuses the existing component.
+The reverse navigation fades and expands the full or compact heading and its
+outer spacing over 300ms, after the component has rendered. Reduced-motion users
+get immediate appearance and removal instead.
+
+The navbar is hidden when the loaded URL is the logged-in user's WebID or its
+fragment-free profile document URL. Other URLs in the same pod and other users'
+profiles keep the navbar. Account-menu navigation continues to hide it.
## Development
@@ -65,6 +121,15 @@ npm start
3. You can change the `subject` in the sandbox to determine which pane gets rendered. For example, the default subject loads the `profile-pane`.
+### Using a local profile-pane
+
+When working inside the SolidOS monorepo, `profile-pane` (and the other local
+workspaces) are symlinked by `npx lerna bootstrap --force-local` (run from the
+monorepo root, as `npm run watch-pivot` does), even if the version in
+`package.json` is not published to npm. Use a plain version in `package.json`
+(not a `file:` path, which lerna does not link) and avoid a bare `npm install`
+here, which fails for unpublished versions and removes the links.
+
## Contributing panes
When you created a pane, you can either add it as an npm dependency
of this repo (for instance meeting-pane, issue-pane, chat-pane are all
diff --git a/docs/navigation-design.md b/docs/navigation-design.md
new file mode 100644
index 00000000..5a2f3b57
--- /dev/null
+++ b/docs/navigation-design.md
@@ -0,0 +1,316 @@
+# Navigation in the databrowser
+
+This document describes how navigation works in `solid-panes` as implemented: what decides what the
+page shows, where that logic lives, the rules it follows, and its known limits.
+
+## What decides what is shown
+
+What the user sees depends on several things at once:
+
+- the page URL
+- the pane that was last opened, and whether the account menu or the navbar opened it
+- who is logged in
+- whose pod the URL is in
+
+Each of these is read in one place (below), and the rules that combine them are pure functions, so
+the heading, the navbar, the outline and the file explorer header all apply the same decision.
+
+## Where the logic lives
+
+| Concern | Where |
+|---|---|
+| Reading and writing what a history entry displayed (`paneName`, `paneUri`, `viaAccountMenu`) | `navigation/state.ts`; the one write is in `GotoSubject` (`outline/manager.js`) |
+| Rules for the heading, navbar and file explorer header | `navigation/chrome.ts` (pure functions) |
+| Whose pod the page is in, the viewer's storages | `navigation/podContext.ts` (cached lookups) |
+| Displaying a view and the follow-up effects | `navigation/controller.ts`, wired to the real navbar and heading in `mainPage/navigation.ts` |
+| Telling the page that a view was displayed | `navigation/events.ts` (`solid-panes:navigated`, dispatched by `GotoSubject`) |
+| Who asked for a view (`GotoSubject`'s seventh argument) | `navigation/options.ts` |
+| Showing or hiding the navbar | `navigation/navbarVisibility.ts` |
+| Page load and reload | `initMainPage` (`mainPage/index.ts`) |
+| Layout and theme changes | `refreshUI` (`mainPage/index.ts`), called by mashlib |
+| Enter on the current URL, back and forward | `navigation/browserEvents.ts` |
+| Account menu | `mainPage/header.ts` (maps a choice to an intent) |
+| Navbar | `mainPage/navbar.ts` (builds items from the pod context, maps a choice to an intent) |
+| Profile heading | `mainPage/profileHeading.ts` (applies `deriveHeading` to the pod context) |
+| What is displayed for a pane | `GotoSubject` (displayed subject, open-inside-storage), `FileExplorerProvider` (applies the header rule) |
+| Other callers of `GotoSubject` | links in the outline, `solid-ui` buttons, `tabbedPane`, mashlib |
+
+## Paths into the page
+
+Every way of changing what is displayed ends in `GotoSubject`. Most of them go through the
+navigation controller, which always applies the same follow-up effects.
+
+| Path | Resolves the pane | Navbar | Heading |
+|---|---|---|---|
+| Page load (`initMainPage`) | controller (`restore`) | built once from the pod context | refreshed by the controller |
+| Account menu (`header.ts`) | controller | hidden | refreshed by the controller |
+| Navbar click (`navbar.ts`) | controller | selection kept by the navbar, then `selectNavbarPane` | refreshed by the controller |
+| Enter, back and forward | controller (`restore`, after clearing the saved pane) | `selectNavbarPane` | refreshed by the controller |
+| Layout or theme change | controller (`restore`, keeps the saved pane and the account-menu marker) | `selectNavbarPane`, or hidden | refreshed by the controller |
+| Links and buttons in `solid-ui`, `GotoSubject` calls from mashlib | none (default pane); not through the controller | rebuilt on a pod change, via the `navigated` event | refreshed via the `navigated` event |
+
+`GotoSubject` is called directly, with `solo` true, by `solid-ui` (`buttons.ts`, `forms.js`) and
+mashlib. Those callers rely on it to push the history entry and the URL, so it is the one place that
+writes history, and it announces what it did.
+
+## Rules
+
+### URL and history
+- The page URL is never changed by the account menu or the navbar. They display something else while
+ the URL keeps pointing at what the user opened.
+- A history entry records the displayed pane (`paneName`, `paneUri`) and whether the account menu
+ opened it (`viaAccountMenu`). Pane details are never copied into the entry of a different URL.
+
+### What loads
+- **Reload, Enter and back/forward all show what the URL is.** Whatever the entry last displayed
+ through the navbar or the account menu is discarded first.
+ - A WebID (ends in `#me`): the profile pane, with Profile selected in the navbar.
+ - Any other URL, including the document that holds a WebID such as `/timea2/profile/card`: the
+ resource or container as it is, with its file explorer header (unless it is a root resource) and
+ its content. Only `#me` URLs count as profile pages.
+- **Enter on the current URL** does this without reloading the page. It is only handled when the
+ browser has the Navigation API; otherwise Enter reloads the page, which is the same result for a
+ URL without a fragment.
+- **Layout or theme change** (`refreshUI`) re-renders the current entry in place, keeping the URL
+ and the account-menu marker.
+
+### Displayed subject
+- A pane with its own subject (profile, friends, or the storage of a person) is displayed for that
+ subject, not for the URL.
+- For a storage, the container or resource from the URL is opened inside it, level by level.
+
+### Navbar
+- Shown for any URL except the viewer's own profile (WebID or its document); hidden by the account
+ menu.
+- Built from the pod the page is in. Profile and Friends are only offered when the owner of the pod
+ is known. When a navigation lands in another pod, the items are built again.
+- On a container or resource URL nothing is selected; on a WebID, Profile is.
+- Choosing an item displays it for the URL the page has at that moment, not the one it had when the
+ items were built.
+
+### Profile heading
+- Full on the profile pane, compact on friends and on other panes.
+- On other panes it shows the pod owner, unless the owner is the viewer, the view came from the
+ account menu, or the owner is unknown.
+- It reads the owner from the pod context, so it does not depend on the navbar existing.
+
+### File explorer header
+- Never shown for the profile and friends panes.
+- Never shown for root resources or WebIDs.
+- For account-menu views and for panes with their own subject: shown only when the URL is inside the
+ viewer's own pod.
+
+## Modules
+
+### Navigation state (`navigation/state.ts`)
+The only place that knows how a history entry records what was displayed.
+
+```ts
+type ViewKind = 'default' | 'profile' | 'friends' | 'storage' | 'pane'
+
+interface NavigationView {
+ kind: ViewKind
+ pane?: string // registry name of the pane; set unless the kind is 'default'
+ subject?: string // what the pane displays when that is not the page URL
+}
+
+interface NavigationState { view: NavigationView, viaAccountMenu: boolean }
+```
+
+- `readNavigationState()` and `parseNavigationState(raw)` read an entry.
+- `nextHistoryState(current, change)` is the rule for what to store after displaying something: pane
+ details never leak into another URL's entry, a subject does not outlive its pane, and the
+ account-menu marker is set or cleared.
+- `clearPaneState()` and `withoutPaneState()` forget what an entry displayed.
+- The stored shape is `paneName`, `paneUri`, `viaAccountMenu`. The kind is derived from the pane
+ name. There is no stored record of whether the URL or the navbar opened a view; the account-menu
+ marker is the only source any rule uses.
+
+### Chrome rules (`navigation/chrome.ts`)
+Pure functions that read no history, DOM or session:
+
+| Function | Decides | Used by |
+|---|---|---|
+| `deriveHeading` | whether the profile heading shows, full or compact, and for which profile; takes the pod `owner` and the pod's `storageUris` | `profileHeading.ts` |
+| `deriveNavbarSelection`, `isNavbarItemSelected`, `pickNavbarItem` | which navbar item is selected | `navbar.ts` |
+| `isNavbarHidden` (with `setNavbarHidden` in `navbarVisibility.ts`) | whether the navbar is shown | `navbar.ts`, `controller.ts`, `outline/manager.js` |
+| `deriveExplorerHeader`, `explorerHeaderForPane`, `isPersonPane` | whether the file explorer header shows | `outline/manager.js`, `FileExplorerProvider` |
+
+There is no single "derive everything" function: each consumer needs different inputs (the explorer
+header is decided per provider, the heading needs the pod), so the controller composes the effects.
+
+### Pod context (`navigation/podContext.ts`)
+Whose pod the page is in, loaded once and cached.
+
+```ts
+interface PodContext { viewer?: string, owner?: string, storagePanes: PaneItem[], viewerStorages: string[] }
+```
+
+- `getPodContext(url)` assembles it from the parts below. `isInViewerPod`, `isSamePod` and
+ `isPageInViewerPod` answer questions about a context.
+- The parts are cached separately because they depend on different things:
+ - the **owner**, per document: the profile lookup can end with that document's own
+ `foaf:primaryTopic`, so two pages of one pod need not share an owner;
+ - the **storages**, per pod (origin and first path segment);
+ - the **viewer's storages**, per viewer.
+- A failed lookup is not kept, so the next request asks again. At most 500 documents are remembered.
+- The owner is unknown when the lookup falls back to the page URL itself, so an unrelated resource
+ does not get a "pod owner" heading. Tests call `resetPodContexts()` between cases.
+
+### Event (`navigation/events.ts`)
+When `solo`, `GotoSubject` dispatches `solid-panes:navigated` on the document after writing the
+history entry, with the URL and the stored state. This is how calls that bypass the controller
+(`solid-ui`, mashlib) still reach the chrome: the navbar listens, to rebuild itself when a
+navigation lands in another pod, and the heading listens, to refresh itself. The heading does not
+watch the DOM.
+
+### Options (`navigation/options.ts`)
+`GotoSubject`'s seventh argument takes `{ source: 'accountMenu' | 'default' }`. The older boolean form
+(`false` for the account menu) is still accepted with the same meaning.
+
+### Controller (`navigation/controller.ts`)
+`navigation.show(intent)` is the one way to display a view and bring the chrome in line with it:
+
+```ts
+type NavigationIntent =
+ | { kind: 'restore', subject?, forceHeading? } // what the URL (or current entry) displays
+ | { kind: 'profile' | 'friends', subject, source? } // a person, by WebID
+ | { kind: 'storage', pane, source? }
+ | { kind: 'app', pane, source? } // by registry name: home, basicPreferences
+```
+
+- It resolves the pane, renders through `GotoSubject`, then sets navbar visibility and selection and
+ refreshes the heading, in that order.
+- For an account-menu source it hides the navbar before resolving the pane.
+- A newer intent wins over one still resolving its pane. This latest-wins token covers the
+ controller's renders only; the heading's own request counter and the manager's open-inside-storage
+ guard protect different async work, which callers that bypass the controller also use.
+- If a pane cannot be built for anything but `restore`, it warns and displays nothing, rather than
+ falling back to the page URL's default view.
+- `restore` without a pane shows the profile on a WebID, and the default view elsewhere.
+- `mainPage/navigation.ts` wires the controller to the real navbar and heading, one per outliner.
+ The controller is internal; it is not exported from the package.
+
+`header.ts`, `navbar.ts`, `initMainPage` and `refreshUI` are thin callers that map a choice to an
+intent. `createHeader` and `createNavbar` take the controller. Header and navbar clicks pass no table
+to `GotoSubject`, so the outline's current container is used. The account menu does nothing at all
+when nobody is logged in.
+
+### Browser events (`navigation/browserEvents.ts`)
+`registerBrowserNavigation(controller)` handles two things; both clear the saved pane and call
+`show({ kind: 'restore', forceHeading: true })`.
+
+- **Enter on the current URL.** The Navigation API `navigate` event is intercepted when
+ `canIntercept`, `userInitiated`, `navigationType === 'replace'` and the destination is the
+ current URL.
+- **Back and forward.** `currententrychange` with `navigationType === 'traverse'`, or `popstate` in
+ browsers without the Navigation API.
+
+The listeners are added once; a later call only changes which controller they use.
+`isHandlingBrowserNavigation()` (exported from the package) is true once they are registered.
+mashlib's `window.onpopstate` returns early when it is true, so only the main page reacts to back
+and forward. mashlib keeps its handler for pages that load it without running the main page, and
+reads the flag when the event fires, because it registers the handler at load, before the main page
+starts. mashlib pins `solid-panes` to an exact version, so the two release together; a mashlib built
+against a `solid-panes` without this export must not be paired with this version.
+
+### Profile heading placement
+The navbar is inserted at the top of the page after the heading's mount point exists, so
+`initMainPage` calls `placeProfileHeading()` once the navbar is created, which keeps the heading
+above it.
+
+## Tests
+
+- `test/unit/navigation/`: `state`, `chrome`, `podContext`, `controller`, `events`, `options` and
+ `browserEvents` tests, one per module.
+- `test/unit/mainPage/`: `header`, `navbar`, `index` and `profileHeading` tests against the
+ controller and pod context. `reload.test.ts` wires the real navbar, heading and outline manager
+ together, including a direct `GotoSubject` call into another pod (as `solid-ui` makes) and a
+ back navigation to the first pod.
+- `test/unit/outline/manager.test.ts` covers the header rule; `test/unit/components/fileExplorerProvider.test.ts`
+ covers the provider.
+
+The tests run in jsdom with the network mocked, and the setup answers every fetch with an empty
+Turtle response, so tests that render real file explorer headers need a partial `podUtils` mock.
+
+## Browser support
+
+The controller relies on the Navigation API for two things: handling Enter on the current URL, and
+telling back/forward apart from in-page `#` links. Everything else works without it.
+
+### Navigation API support
+
+Source: MDN browser-compat-data (last updated 2026-01-14). Every member we use (`navigate`,
+`currententrychange`, `userInitiated`, `canIntercept`, `navigationType`, `intercept`) is supported
+from the same version.
+
+| Browser | Supported from |
+|---|---|
+| Chrome, Edge, Opera, Samsung Internet, Android WebView | Chrome 102 (`intercept()` and `canIntercept` from 105) |
+| Firefox | 147 |
+| Safari (macOS and iOS) | 26.2 |
+
+Every iPhone and iPad browser uses Safari's engine, so it follows the Safari row.
+
+### What our build targets
+
+| Layer | Setting | Effect |
+|---|---|---|
+| TypeScript (`tsconfig.json`, also in mashlib) | `target: ES2019`, `lib: ["DOM", "ES2019"]` | Type checking only; both packages run `tsc --noEmit`. The `lib` setting makes newer built-ins fail the type check. |
+| `solid-panes` build (`vite.config.mts`) | no explicit target, so Vite 8's default `baseline-widely-available` | Syntax lowered to Chrome and Edge 111, Firefox 114, Safari and iOS 16.4 |
+| solidos-toolkit's Babel step | `preset-env` only when `transpileTargets` is passed; we don't pass it | Only TypeScript, decorators and class fields are transformed |
+| mashlib (`babel.config.mjs`) | `> 1%, last 3 versions, not dead` | Syntax lowered for the final bundle |
+
+Nothing in this setup polyfills runtime APIs (no `core-js`, no `useBuiltIns`). The Navigation API
+is therefore always feature-detected, and TypeScript does not know `window.navigation`, so the code
+casts it.
+
+mashlib's webpack config skips Babel for `node_modules` except `solid-panes/src`. A published
+`solid-panes` package is not re-transpiled for mashlib's list and keeps Vite's baseline floor. In
+the workspace, the symlinked source most likely does go through Babel.
+
+### Gap between targets and the Navigation API
+
+Browsers inside our build targets that lack the Navigation API:
+
+- Safari and iOS 16.4 to 26.1, including iOS 18.5 to 18.7, which is in mashlib's resolved set.
+- Firefox 114 to 146, including the ESR line.
+- From mashlib's resolved set: Opera Mini, Opera Mobile 80 and KaiOS. UC and QQ are probably in the
+ same position, but their engine versions have not been confirmed.
+
+The gap includes browsers a few months old, so it is not an edge case. The `popstate` fallback has
+its own test.
+
+### Behaviour without the Navigation API
+
+| Behaviour | With | Without |
+|---|---|---|
+| Back and forward | `currententrychange` with `traverse`, not fired for `#` links | `popstate`, which may also fire for in-page `#` links and re-render the page |
+| Enter on the current URL | Handled: shows the URL's own view | Not handled: content opened through the account menu stays in place |
+| Reload | Handled on page load | Handled on page load |
+
+## Known limits
+
+- **Back and forward render in the event itself.** Nothing else is expected to re-render on
+ `popstate`. A package that does would race with the main page.
+- **Calls that bypass the controller** (`solid-ui` links and buttons, mashlib's own `GotoSubject`
+ calls) reach the navbar and heading through the `navigated` event. They are not recorded as an
+ intent. The event is only dispatched when `solo`, after the history entry is written, so a call
+ that fails before that point announces nothing.
+- **The heading does not watch the DOM.** A change to what it should show that happens without a
+ `GotoSubject` call, a login or logout, or a profile save is missed. Today the only such change is
+ the main page's own `clearPaneState()`, which the controller always follows.
+- **Pod discovery is heuristic.** The owner comes from `loadProfileFromURI`, which assumes
+ `profile/card#me` at the origin or under the first path segment, and falls back to the document's
+ `foaf:primaryTopic`. The pod for the storages is keyed on the origin and first path segment, so a
+ storage nested deeper, or two storages under one first segment, share one lookup.
+- **Pods without a `pim:Storage` triple in the profile** cannot be recognised as "the viewer's pod",
+ so the file explorer header stays hidden there. The pod context could fall back to a path derived
+ from the WebID.
+- **Not covered by automated tests:** a real browser, the Navigation API itself, and mashlib's own
+ test suite. Worth checking by hand: a refresh on a WebID and on a container, the account menu on
+ someone else's container, back and forward across entries opened through the navbar or the account
+ menu, and a `solid-ui` link into another pod (the navbar and heading should follow).
+- **A stale `dist/` looks like a bug.** mashlib consumes `solid-panes/dist` through a symlink; if
+ `vite build --watch` stalls, the browser runs old code. The watcher also skips type declarations,
+ so mashlib's `tsc` needs a full `npm run build` in `solid-panes` first.
diff --git a/package-lock.json b/package-lock.json
index 615f1c2c..84c2be0e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,10 +20,11 @@
"marked": "^18.0.3",
"meeting-pane": "4.0.0",
"mime-types": "^3.0.2",
- "pane-registry": "5.0.1",
- "profile-pane": "4.0.0",
+ "package-lock.json": "^1.0.0",
+ "pane-registry": "5.0.2-0",
+ "profile-pane": "4.1.0-0",
"rdflib": "2.4.1",
- "solid-logic": "6.0.0",
+ "solid-logic": "6.0.1-1",
"solid-namespace": "^0.5.4",
"solid-ui": "5.0.1",
"source-pane": "4.0.0"
@@ -47,9 +48,9 @@
}
},
"node_modules/@acemir/cssom": {
- "version": "0.9.31",
- "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
- "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
+ "version": "0.9.32",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",
+ "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",
"devOptional": true,
"license": "MIT"
},
@@ -2064,12 +2065,13 @@
}
},
"node_modules/@codemirror/language": {
- "version": "6.13.0",
- "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.0.tgz",
- "integrity": "sha512-NP+YbEGM5g07C/d+W58a881Msq9p/SR4QsWqapelhPeNMlkce1ZIZUzD0IpVYIjgQbeWfbBx/NFyOqiGYYTZEA==",
+ "version": "6.13.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz",
+ "integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
+ "@codemirror/streamparser": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
@@ -2343,6 +2345,17 @@
"node": ">=18.17"
}
},
+ "node_modules/@dokieli/web-access-control": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@dokieli/web-access-control/-/web-access-control-1.1.0.tgz",
+ "integrity": "sha512-LId2cNz0pKafc6dwohaX6j+mr886uEV2o8L0VrAWUDxU0LPN4+8Yc8Yx3D94zrsRSJuBWRtQzSjgtEqhYg5y/w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@rdfjs/data-model": "^2.1.1",
+ "@rdfjs/types": "^2.0.1",
+ "n3": "^2.2.0"
+ }
+ },
"node_modules/@emotion/is-prop-valid": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.7.3.tgz",
@@ -3030,9 +3043,9 @@
}
},
"node_modules/@oxc-project/types": {
- "version": "0.152.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz",
- "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==",
+ "version": "0.153.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz",
+ "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/oxc-project"
@@ -3071,6 +3084,15 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
+ "node_modules/@rdfjs/data-model": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/@rdfjs/data-model/-/data-model-2.1.2.tgz",
+ "integrity": "sha512-yoSVlyCltqbaCTse3uSdf5Vo3q4UskcTm+3klaUQTwOq7Z5aCZLtLPZatXTPzr+S0WEcjTZvD++A+r3QmgamsA==",
+ "license": "MIT",
+ "bin": {
+ "rdfjs-data-model-test": "bin/test.js"
+ }
+ },
"node_modules/@rdfjs/types": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-2.0.1.tgz",
@@ -3081,9 +3103,9 @@
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
- "version": "1.2.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz",
- "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==",
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz",
+ "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==",
"cpu": [
"arm"
],
@@ -3097,9 +3119,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
- "version": "1.2.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz",
- "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==",
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz",
+ "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==",
"cpu": [
"arm64"
],
@@ -3113,9 +3135,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.2.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz",
- "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==",
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz",
+ "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==",
"cpu": [
"arm64"
],
@@ -3129,9 +3151,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.2.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz",
- "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==",
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz",
+ "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==",
"cpu": [
"x64"
],
@@ -3145,9 +3167,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.2.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz",
- "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==",
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz",
+ "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==",
"cpu": [
"x64"
],
@@ -3161,9 +3183,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.2.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz",
- "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==",
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz",
+ "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==",
"cpu": [
"arm"
],
@@ -3177,9 +3199,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.2.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz",
- "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==",
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz",
+ "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==",
"cpu": [
"arm64"
],
@@ -3196,9 +3218,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.2.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz",
- "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==",
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz",
+ "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==",
"cpu": [
"arm64"
],
@@ -3215,9 +3237,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.2.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz",
- "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==",
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz",
+ "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==",
"cpu": [
"ppc64"
],
@@ -3234,9 +3256,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.2.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz",
- "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==",
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz",
+ "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==",
"cpu": [
"s390x"
],
@@ -3253,9 +3275,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.2.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz",
- "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==",
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz",
+ "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==",
"cpu": [
"x64"
],
@@ -3272,9 +3294,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.2.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz",
- "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==",
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz",
+ "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==",
"cpu": [
"x64"
],
@@ -3291,9 +3313,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.2.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz",
- "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==",
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz",
+ "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==",
"cpu": [
"arm64"
],
@@ -3307,9 +3329,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.2.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz",
- "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==",
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz",
+ "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==",
"cpu": [
"arm64"
],
@@ -3323,9 +3345,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.2.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz",
- "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==",
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz",
+ "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==",
"cpu": [
"x64"
],
@@ -3597,17 +3619,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.71.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz",
- "integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==",
+ "version": "8.71.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz",
+ "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
- "@typescript-eslint/scope-manager": "8.71.1",
- "@typescript-eslint/type-utils": "8.71.1",
- "@typescript-eslint/utils": "8.71.1",
- "@typescript-eslint/visitor-keys": "8.71.1",
+ "@typescript-eslint/scope-manager": "8.71.2",
+ "@typescript-eslint/type-utils": "8.71.2",
+ "@typescript-eslint/utils": "8.71.2",
+ "@typescript-eslint/visitor-keys": "8.71.2",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -3620,7 +3642,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.71.1",
+ "@typescript-eslint/parser": "^8.71.2",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@@ -3636,15 +3658,15 @@
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.71.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz",
- "integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==",
+ "version": "8.71.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz",
+ "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==",
"license": "MIT",
"dependencies": {
- "@typescript-eslint/scope-manager": "8.71.1",
- "@typescript-eslint/types": "8.71.1",
- "@typescript-eslint/typescript-estree": "8.71.1",
- "@typescript-eslint/visitor-keys": "8.71.1",
+ "@typescript-eslint/scope-manager": "8.71.2",
+ "@typescript-eslint/types": "8.71.2",
+ "@typescript-eslint/typescript-estree": "8.71.2",
+ "@typescript-eslint/visitor-keys": "8.71.2",
"debug": "^4.4.3"
},
"engines": {
@@ -3660,13 +3682,13 @@
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.71.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz",
- "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==",
+ "version": "8.71.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz",
+ "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==",
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.71.1",
- "@typescript-eslint/types": "^8.71.1",
+ "@typescript-eslint/tsconfig-utils": "^8.71.2",
+ "@typescript-eslint/types": "^8.71.2",
"debug": "^4.4.3"
},
"engines": {
@@ -3681,13 +3703,13 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.71.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz",
- "integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==",
+ "version": "8.71.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz",
+ "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==",
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.71.1",
- "@typescript-eslint/visitor-keys": "8.71.1"
+ "@typescript-eslint/types": "8.71.2",
+ "@typescript-eslint/visitor-keys": "8.71.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3698,9 +3720,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.71.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz",
- "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==",
+ "version": "8.71.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz",
+ "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==",
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3714,15 +3736,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.71.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz",
- "integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==",
+ "version": "8.71.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz",
+ "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.71.1",
- "@typescript-eslint/typescript-estree": "8.71.1",
- "@typescript-eslint/utils": "8.71.1",
+ "@typescript-eslint/types": "8.71.2",
+ "@typescript-eslint/typescript-estree": "8.71.2",
+ "@typescript-eslint/utils": "8.71.2",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -3739,9 +3761,9 @@
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.71.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz",
- "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==",
+ "version": "8.71.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz",
+ "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==",
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3752,15 +3774,15 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.71.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz",
- "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==",
+ "version": "8.71.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz",
+ "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==",
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.71.1",
- "@typescript-eslint/tsconfig-utils": "8.71.1",
- "@typescript-eslint/types": "8.71.1",
- "@typescript-eslint/visitor-keys": "8.71.1",
+ "@typescript-eslint/project-service": "8.71.2",
+ "@typescript-eslint/tsconfig-utils": "8.71.2",
+ "@typescript-eslint/types": "8.71.2",
+ "@typescript-eslint/visitor-keys": "8.71.2",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -3779,16 +3801,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.71.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz",
- "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==",
+ "version": "8.71.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz",
+ "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
- "@typescript-eslint/scope-manager": "8.71.1",
- "@typescript-eslint/types": "8.71.1",
- "@typescript-eslint/typescript-estree": "8.71.1"
+ "@typescript-eslint/scope-manager": "8.71.2",
+ "@typescript-eslint/types": "8.71.2",
+ "@typescript-eslint/typescript-estree": "8.71.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3803,12 +3825,12 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.71.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz",
- "integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==",
+ "version": "8.71.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz",
+ "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==",
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.71.1",
+ "@typescript-eslint/types": "8.71.2",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -4092,6 +4114,31 @@
"timeago.js": "^4.0.2"
}
},
+ "node_modules/activitystreams-pane/node_modules/pane-registry": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/pane-registry/-/pane-registry-5.0.1.tgz",
+ "integrity": "sha512-3aeLxm6TnRHfIDoEbpZpNe3Tlm7wWT+ipEpiV6h3cRgxyzmEtrUpS9WB8yJa/VJq0RHXEoaYlLu94fcbuOPnbA==",
+ "license": "MIT",
+ "dependencies": {
+ "rdflib": "2.4.1",
+ "solid-logic": "6.0.0"
+ }
+ },
+ "node_modules/activitystreams-pane/node_modules/solid-logic": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.0.tgz",
+ "integrity": "sha512-irRWnU28Simo2WBhbCgAz4d+QawGJ4zJuiwuBQyObNwuwr4KbDC9evf0CFdnr/PJD8EhLrYT0Fk3nh1eJmQAbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@uvdsl/solid-oidc-client-browser": "^0.2.3",
+ "solid-namespace": "^0.5.4",
+ "vite": "^8.0.16",
+ "vitest": "^4.1.9"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -4440,9 +4487,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.11.27",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz",
- "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==",
+ "version": "2.11.28",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz",
+ "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -4610,9 +4657,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001815",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz",
- "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==",
+ "version": "1.0.30001816",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz",
+ "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==",
"dev": true,
"funding": [
{
@@ -4680,6 +4727,31 @@
"vitest-axe": "^0.1.0"
}
},
+ "node_modules/chat-pane/node_modules/pane-registry": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/pane-registry/-/pane-registry-5.0.1.tgz",
+ "integrity": "sha512-3aeLxm6TnRHfIDoEbpZpNe3Tlm7wWT+ipEpiV6h3cRgxyzmEtrUpS9WB8yJa/VJq0RHXEoaYlLu94fcbuOPnbA==",
+ "license": "MIT",
+ "dependencies": {
+ "rdflib": "2.4.1",
+ "solid-logic": "6.0.0"
+ }
+ },
+ "node_modules/chat-pane/node_modules/solid-logic": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.0.tgz",
+ "integrity": "sha512-irRWnU28Simo2WBhbCgAz4d+QawGJ4zJuiwuBQyObNwuwr4KbDC9evf0CFdnr/PJD8EhLrYT0Fk3nh1eJmQAbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@uvdsl/solid-oidc-client-browser": "^0.2.3",
+ "solid-namespace": "^0.5.4",
+ "vite": "^8.0.16",
+ "vitest": "^4.1.9"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/ci-info": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
@@ -4767,6 +4839,31 @@
"solid-ui": "5.0.1"
}
},
+ "node_modules/contacts-pane/node_modules/pane-registry": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/pane-registry/-/pane-registry-5.0.1.tgz",
+ "integrity": "sha512-3aeLxm6TnRHfIDoEbpZpNe3Tlm7wWT+ipEpiV6h3cRgxyzmEtrUpS9WB8yJa/VJq0RHXEoaYlLu94fcbuOPnbA==",
+ "license": "MIT",
+ "dependencies": {
+ "rdflib": "2.4.1",
+ "solid-logic": "6.0.0"
+ }
+ },
+ "node_modules/contacts-pane/node_modules/solid-logic": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.0.tgz",
+ "integrity": "sha512-irRWnU28Simo2WBhbCgAz4d+QawGJ4zJuiwuBQyObNwuwr4KbDC9evf0CFdnr/PJD8EhLrYT0Fk3nh1eJmQAbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@uvdsl/solid-oidc-client-browser": "^0.2.3",
+ "solid-namespace": "^0.5.4",
+ "vite": "^8.0.16",
+ "vitest": "^4.1.9"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -5078,9 +5175,9 @@
"license": "MIT"
},
"node_modules/dompurify": {
- "version": "3.4.16",
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
- "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
+ "version": "3.4.17",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz",
+ "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -5101,9 +5198,9 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.449",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.449.tgz",
- "integrity": "sha512-dKeYk/3SFQz+nXi5whp3OuJzeWABEG4zCtkWJ9+Vby1E1iFM2y0gElRtQvNXRPJTsj8WAF4HMP7hvP790QtJ9g==",
+ "version": "1.5.454",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.454.tgz",
+ "integrity": "sha512-NqD5GhkXnxJpDQVCmmpc546qjH6kei2VI9m45CTF+sQU7bcS7YeVt7ba0cnjueMH1AXGRu/driZUP9iYVSv7vg==",
"dev": true,
"license": "ISC"
},
@@ -5922,6 +6019,21 @@
"vitest-axe": "^0.1.0"
}
},
+ "node_modules/folder-pane/node_modules/solid-logic": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.0.tgz",
+ "integrity": "sha512-irRWnU28Simo2WBhbCgAz4d+QawGJ4zJuiwuBQyObNwuwr4KbDC9evf0CFdnr/PJD8EhLrYT0Fk3nh1eJmQAbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@uvdsl/solid-oidc-client-browser": "^0.2.3",
+ "solid-namespace": "^0.5.4",
+ "vite": "^8.0.16",
+ "vitest": "^4.1.9"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -6922,6 +7034,21 @@
"node": ">=20.19.2"
}
},
+ "node_modules/issue-pane/node_modules/solid-logic": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.0.tgz",
+ "integrity": "sha512-irRWnU28Simo2WBhbCgAz4d+QawGJ4zJuiwuBQyObNwuwr4KbDC9evf0CFdnr/PJD8EhLrYT0Fk3nh1eJmQAbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@uvdsl/solid-oidc-client-browser": "^0.2.3",
+ "solid-namespace": "^0.5.4",
+ "vite": "^8.0.16",
+ "vitest": "^4.1.9"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@@ -7772,6 +7899,12 @@
"node": "20 || >=22"
}
},
+ "node_modules/lucide": {
+ "version": "1.56.0",
+ "resolved": "https://registry.npmjs.org/lucide/-/lucide-1.56.0.tgz",
+ "integrity": "sha512-BOrr2xku0dGUzWvdBZK3NlEdEzcEvXoLRyGvC27lyFB1tSL7Lc7vW61CKeVcw92K2L9y6aNKS7fJ8fVzw/MXIw==",
+ "license": "ISC"
+ },
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -7820,9 +7953,9 @@
}
},
"node_modules/marked": {
- "version": "18.1.0",
- "resolved": "https://registry.npmjs.org/marked/-/marked-18.1.0.tgz",
- "integrity": "sha512-PamYXWWWg2nboG3oX5Ffzpy3EFZROqZJK9hSlMmUnekp7TxPs5DcT95vwd46m73/exfKCRVMtgXoPMI4FEJL6w==",
+ "version": "18.1.1",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-18.1.1.tgz",
+ "integrity": "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
@@ -7859,6 +7992,21 @@
"solid-ui": "5.0.1"
}
},
+ "node_modules/meeting-pane/node_modules/solid-logic": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.0.tgz",
+ "integrity": "sha512-irRWnU28Simo2WBhbCgAz4d+QawGJ4zJuiwuBQyObNwuwr4KbDC9evf0CFdnr/PJD8EhLrYT0Fk3nh1eJmQAbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@uvdsl/solid-oidc-client-browser": "^0.2.3",
+ "solid-namespace": "^0.5.4",
+ "vite": "^8.0.16",
+ "vitest": "^4.1.9"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -7981,9 +8129,9 @@
"license": "MIT"
},
"node_modules/n3": {
- "version": "2.13.9",
- "resolved": "https://registry.npmjs.org/n3/-/n3-2.13.9.tgz",
- "integrity": "sha512-in/z8EVU9QY3Q0K8+59sfcb+V44aaki9hTkfDLcbtudKS9ut40w+WjLkxBJWj54sMg0qZ/uN4sfeAbp4MyPdIA==",
+ "version": "2.13.12",
+ "resolved": "https://registry.npmjs.org/n3/-/n3-2.13.12.tgz",
+ "integrity": "sha512-z944NsyJi58WXcYevtNkUL2JhVr0KP6nvED9AgymQq5V35TDaNn1N8kO+OWJk/kj4PR0L/ilWB3d7/bWceg1CQ==",
"license": "MIT",
"dependencies": {
"buffer": "^6.0.3",
@@ -8214,9 +8362,9 @@
}
},
"node_modules/node-releases": {
- "version": "2.0.57",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz",
- "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==",
+ "version": "2.0.58",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz",
+ "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8433,6 +8581,11 @@
"node": ">=6"
}
},
+ "node_modules/package-lock.json": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/package-lock.json/-/package-lock.json-1.0.0.tgz",
+ "integrity": "sha512-+yEXtNdlCs5N0Zy/9uvkifgf/RqnGu0WqP4j9Wu1Us4YReFe1YNBh2Krmf8B1xGxjpYnta63K55QP8bkafnOzA=="
+ },
"node_modules/package-manager-detector": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz",
@@ -8441,13 +8594,29 @@
"license": "MIT"
},
"node_modules/pane-registry": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/pane-registry/-/pane-registry-5.0.1.tgz",
- "integrity": "sha512-3aeLxm6TnRHfIDoEbpZpNe3Tlm7wWT+ipEpiV6h3cRgxyzmEtrUpS9WB8yJa/VJq0RHXEoaYlLu94fcbuOPnbA==",
+ "version": "5.0.2-0",
+ "resolved": "https://registry.npmjs.org/pane-registry/-/pane-registry-5.0.2-0.tgz",
+ "integrity": "sha512-6fU9IyEAttMJwFThbGjLvyrXXNYx0+BmNc4InKA7kFJdVq4ozyojMcrpq4lT2g7xzR1o5xt+Ah+urTxEMQdpcQ==",
"license": "MIT",
"dependencies": {
"rdflib": "2.4.1",
- "solid-logic": "6.0.0"
+ "solid-logic": "6.0.1-0"
+ }
+ },
+ "node_modules/pane-registry/node_modules/solid-logic": {
+ "version": "6.0.1-0",
+ "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.1-0.tgz",
+ "integrity": "sha512-ZEJ4aRK5Q16sYoNTiH8aJ2ctvpZPwvsPYKawL9pmPC7qvF6wVnOucuNLp1EHHYhdX1Wkp9a25mp/my1IsQgofA==",
+ "license": "MIT",
+ "dependencies": {
+ "@dokieli/web-access-control": "^1.0.0",
+ "@uvdsl/solid-oidc-client-browser": "^0.2.3",
+ "solid-namespace": "^0.5.4",
+ "vite": "^8.0.16",
+ "vitest": "^4.1.9"
+ },
+ "engines": {
+ "node": ">=18"
}
},
"node_modules/parent-module": {
@@ -8603,9 +8772,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.29",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
- "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
+ "version": "8.5.30",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz",
+ "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==",
"funding": [
{
"type": "opencollective",
@@ -8622,7 +8791,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.19",
+ "nanoid": "^3.3.20",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.2"
},
@@ -8724,19 +8893,20 @@
}
},
"node_modules/profile-pane": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/profile-pane/-/profile-pane-4.0.0.tgz",
- "integrity": "sha512-0pM+jz5kLj3duOsLeHc4xwomQJ92sgAx0QMmYZaC+meY+gkT09qWhTzWqRKur7/HbOBJH1bPH/cu7UI0TBAxHw==",
+ "version": "4.1.0-0",
+ "resolved": "https://registry.npmjs.org/profile-pane/-/profile-pane-4.1.0-0.tgz",
+ "integrity": "sha512-bepwaxX6TaP5jpzKBLL1JRUYDETwWxBVbVDCqHTMZQGxlIs7x9I20R0dS3Y4shHOIkJOcyiKpSZvlfRUbDtuWQ==",
"license": "MIT",
"dependencies": {
"@solid-data-modules/contacts-rdflib": "^0.7.1",
"chat-pane": "4.0.0",
"lit-html": "^3.3.3",
- "pane-registry": "5.0.1",
+ "lucide": "^1.52.0",
+ "pane-registry": "5.0.2-0",
"patch-package": "^8.0.1",
"qrcode": "^1.5.4",
"rdflib": "2.4.1",
- "solid-logic": "6.0.0",
+ "solid-logic": "6.0.1-1",
"solid-ui": "5.0.1",
"validate-color": "^2.2.4",
"vite": "^8.0.16",
@@ -8889,9 +9059,9 @@
}
},
"node_modules/readable-stream": {
- "version": "4.7.0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
- "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
+ "version": "4.8.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz",
+ "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
@@ -9088,12 +9258,12 @@
}
},
"node_modules/rolldown": {
- "version": "1.2.12",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz",
- "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==",
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz",
+ "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==",
"license": "MIT",
"dependencies": {
- "@oxc-project/types": "=0.152.0",
+ "@oxc-project/types": "=0.153.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -9103,21 +9273,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
- "@rolldown/binding-android-arm-eabi": "1.2.12",
- "@rolldown/binding-android-arm64": "1.2.12",
- "@rolldown/binding-darwin-arm64": "1.2.12",
- "@rolldown/binding-darwin-x64": "1.2.12",
- "@rolldown/binding-freebsd-x64": "1.2.12",
- "@rolldown/binding-linux-arm-gnueabihf": "1.2.12",
- "@rolldown/binding-linux-arm64-gnu": "1.2.12",
- "@rolldown/binding-linux-arm64-musl": "1.2.12",
- "@rolldown/binding-linux-ppc64-gnu": "1.2.12",
- "@rolldown/binding-linux-s390x-gnu": "1.2.12",
- "@rolldown/binding-linux-x64-gnu": "1.2.12",
- "@rolldown/binding-linux-x64-musl": "1.2.12",
- "@rolldown/binding-openharmony-arm64": "1.2.12",
- "@rolldown/binding-win32-arm64-msvc": "1.2.12",
- "@rolldown/binding-win32-x64-msvc": "1.2.12"
+ "@rolldown/binding-android-arm-eabi": "1.2.13",
+ "@rolldown/binding-android-arm64": "1.2.13",
+ "@rolldown/binding-darwin-arm64": "1.2.13",
+ "@rolldown/binding-darwin-x64": "1.2.13",
+ "@rolldown/binding-freebsd-x64": "1.2.13",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.13",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.13",
+ "@rolldown/binding-linux-arm64-musl": "1.2.13",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.13",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.13",
+ "@rolldown/binding-linux-x64-gnu": "1.2.13",
+ "@rolldown/binding-linux-x64-musl": "1.2.13",
+ "@rolldown/binding-openharmony-arm64": "1.2.13",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.13",
+ "@rolldown/binding-win32-x64-msvc": "1.2.13"
}
},
"node_modules/safe-array-concat": {
@@ -9415,11 +9585,12 @@
}
},
"node_modules/solid-logic": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.0.tgz",
- "integrity": "sha512-irRWnU28Simo2WBhbCgAz4d+QawGJ4zJuiwuBQyObNwuwr4KbDC9evf0CFdnr/PJD8EhLrYT0Fk3nh1eJmQAbA==",
+ "version": "6.0.1-1",
+ "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.1-1.tgz",
+ "integrity": "sha512-k0EB26f3whZWfvePl6qHcHhOa4TWfnjPLphAa++s2ZR7+FvruM9Cc9eyvzJqEF6b3HF72jH8V7ENiRseFI51Ow==",
"license": "MIT",
"dependencies": {
+ "@dokieli/web-access-control": "^1.1.0",
"@uvdsl/solid-oidc-client-browser": "^0.2.3",
"solid-namespace": "^0.5.4",
"vite": "^8.0.16",
@@ -9471,10 +9642,35 @@
"fsevents": "*"
}
},
+ "node_modules/solid-ui/node_modules/pane-registry": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/pane-registry/-/pane-registry-5.0.1.tgz",
+ "integrity": "sha512-3aeLxm6TnRHfIDoEbpZpNe3Tlm7wWT+ipEpiV6h3cRgxyzmEtrUpS9WB8yJa/VJq0RHXEoaYlLu94fcbuOPnbA==",
+ "license": "MIT",
+ "dependencies": {
+ "rdflib": "2.4.1",
+ "solid-logic": "6.0.0"
+ }
+ },
+ "node_modules/solid-ui/node_modules/solid-logic": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.0.tgz",
+ "integrity": "sha512-irRWnU28Simo2WBhbCgAz4d+QawGJ4zJuiwuBQyObNwuwr4KbDC9evf0CFdnr/PJD8EhLrYT0Fk3nh1eJmQAbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@uvdsl/solid-oidc-client-browser": "^0.2.3",
+ "solid-namespace": "^0.5.4",
+ "vite": "^8.0.16",
+ "vitest": "^4.1.9"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/solidos-toolkit": {
- "version": "0.0.0-dev.998a984ddf8fda587eedb0c8125cb952ac28d191",
- "resolved": "https://registry.npmjs.org/solidos-toolkit/-/solidos-toolkit-0.0.0-dev.998a984ddf8fda587eedb0c8125cb952ac28d191.tgz",
- "integrity": "sha512-m2b3sZkKbjV5X/J8N25h3UIbsiQARdnXCGs1AeGqFiueoq8cWm+pdOyPpqiVuqRnnA5osmKG2wovUcBBYumL0Q==",
+ "version": "1.0.0-dev.c11da40967c5d278451118142650415d688d8d4c",
+ "resolved": "https://registry.npmjs.org/solidos-toolkit/-/solidos-toolkit-1.0.0-dev.c11da40967c5d278451118142650415d688d8d4c.tgz",
+ "integrity": "sha512-i5FOuvpBBbXfYpRKBv4ygdccO5EUUpfo2rp1HoOspG+AOKMVeFqizuPokl+RZX6AVn3AulqRfnlo+NUEWkidEA==",
"dev": true,
"dependencies": {
"@babel/core": "^7.29.0",
@@ -9493,10 +9689,10 @@
"vite-plugin-lit-css": "^3.0.0"
},
"peerDependencies": {
- "pane-registry": "^3.1.1 || ^3.1.2-0 || ^5.0.0 || ^5.0.0-0",
+ "pane-registry": "^3.1.1 || ^3.1.2-0 || >=5.0.2-0 <6.0.0",
"rdflib": "^2.3.6",
- "solid-logic": "^4.0.7 || ^4.0.8-0 || ^6.0.0 || ^6.0.0-0",
- "solid-ui": "^3.1.2 || ^3.1.3-0 || ^5.0.0 || ^5.0.0-0",
+ "solid-logic": "^4.0.7 || ^4.0.8-0 || >=6.0.1-0 <7.0.0",
+ "solid-ui": "^3.1.2 || ^3.1.3-0 || >=5.0.1-0 <6.0.0",
"vite": "^8.0.0",
"vitest": "^4.0.0"
},
@@ -9548,6 +9744,31 @@
"vitest-axe": "^0.1.0"
}
},
+ "node_modules/source-pane/node_modules/pane-registry": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/pane-registry/-/pane-registry-5.0.1.tgz",
+ "integrity": "sha512-3aeLxm6TnRHfIDoEbpZpNe3Tlm7wWT+ipEpiV6h3cRgxyzmEtrUpS9WB8yJa/VJq0RHXEoaYlLu94fcbuOPnbA==",
+ "license": "MIT",
+ "dependencies": {
+ "rdflib": "2.4.1",
+ "solid-logic": "6.0.0"
+ }
+ },
+ "node_modules/source-pane/node_modules/solid-logic": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.0.tgz",
+ "integrity": "sha512-irRWnU28Simo2WBhbCgAz4d+QawGJ4zJuiwuBQyObNwuwr4KbDC9evf0CFdnr/PJD8EhLrYT0Fk3nh1eJmQAbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@uvdsl/solid-oidc-client-browser": "^0.2.3",
+ "solid-namespace": "^0.5.4",
+ "vite": "^8.0.16",
+ "vitest": "^4.1.9"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -9863,22 +10084,22 @@
}
},
"node_modules/tldts": {
- "version": "7.4.16",
- "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz",
- "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==",
+ "version": "7.4.18",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz",
+ "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
- "tldts-core": "^7.4.16"
+ "tldts-core": "^7.4.18"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
- "version": "7.4.16",
- "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz",
- "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==",
+ "version": "7.4.18",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz",
+ "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==",
"devOptional": true,
"license": "MIT"
},
@@ -10067,16 +10288,16 @@
}
},
"node_modules/typescript-eslint": {
- "version": "8.71.1",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.1.tgz",
- "integrity": "sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==",
+ "version": "8.71.2",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz",
+ "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.71.1",
- "@typescript-eslint/parser": "8.71.1",
- "@typescript-eslint/typescript-estree": "8.71.1",
- "@typescript-eslint/utils": "8.71.1"
+ "@typescript-eslint/eslint-plugin": "8.71.2",
+ "@typescript-eslint/parser": "8.71.2",
+ "@typescript-eslint/typescript-estree": "8.71.2",
+ "@typescript-eslint/utils": "8.71.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -10307,9 +10528,9 @@
}
},
"node_modules/update-browserslist-db": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
- "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz",
+ "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==",
"dev": true,
"funding": [
{
@@ -10347,9 +10568,9 @@
}
},
"node_modules/uuid": {
- "version": "14.0.2",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz",
- "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==",
+ "version": "14.0.3",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.3.tgz",
+ "integrity": "sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
@@ -10366,15 +10587,15 @@
"license": "MIT"
},
"node_modules/vite": {
- "version": "8.3.3",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz",
- "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==",
+ "version": "8.3.4",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
+ "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",
- "postcss": "^8.5.28",
- "rolldown": "~1.2.11",
+ "postcss": "^8.5.29",
+ "rolldown": "~1.2.12",
"tinyglobby": "^0.2.17"
},
"bin": {
diff --git a/package.json b/package.json
index 92d69c55..34515d6b 100644
--- a/package.json
+++ b/package.json
@@ -67,10 +67,11 @@
"marked": "^18.0.3",
"meeting-pane": "4.0.0",
"mime-types": "^3.0.2",
- "pane-registry": "5.0.1",
- "profile-pane": "4.0.0",
+ "package-lock.json": "^1.0.0",
+ "pane-registry": "5.0.2-0",
+ "profile-pane": "4.1.0-0",
"rdflib": "2.4.1",
- "solid-logic": "6.0.0",
+ "solid-logic": "6.0.1-1",
"solid-namespace": "^0.5.4",
"solid-ui": "5.0.1",
"source-pane": "4.0.0"
diff --git a/src/components/file-explorer-header/FileExplorerProvider.ts b/src/components/file-explorer-header/FileExplorerProvider.ts
index 3696b3e5..2b46dc12 100644
--- a/src/components/file-explorer-header/FileExplorerProvider.ts
+++ b/src/components/file-explorer-header/FileExplorerProvider.ts
@@ -10,6 +10,7 @@ import styles from './FileExplorerProvider.styles.css'
import personIcon from '../../icons/person.svg'
import friendsIcon from '../../icons/friends.svg'
import '~icons/lucide/share-2'
+import { explorerHeaderForPane } from '../../navigation/chrome'
const PERSON_ICON = personIcon
const FRIENDS_ICON = friendsIcon
@@ -240,9 +241,11 @@ export default class FileExplorerProvider extends WebComponent {
render () {
const store = this.context?.session.store as LiveStore
const subject = store.sym(this.subjectUri as string)
+ // Profile and friends panes present a person, not the resource they are opened on.
+ const showHeader = explorerHeaderForPane(this.showHeader, this.pane?.name)
return html`
- ${this.showHeader
+ ${showHeader
? html`
}
diff --git a/src/components/profile-heading/ProfileHeading.styles.css b/src/components/profile-heading/ProfileHeading.styles.css
new file mode 100644
index 00000000..b1c04103
--- /dev/null
+++ b/src/components/profile-heading/ProfileHeading.styles.css
@@ -0,0 +1,417 @@
+:host {
+ --profile-avatar-size: 8.75rem;
+ --profile-fallback-padding: 36.4px;
+ display: block;
+ width: 100%;
+ color: var(--color-text-secondary, #4a5565);
+ font-family: inherit;
+}
+
+.profile-heading {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ grid-template-areas:
+ "avatar info edit"
+ "avatar details details";
+ align-items: start;
+ column-gap: 20px;
+ row-gap: 20px;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 25px;
+ overflow-x: clip;
+ background: var(--solid-ui-color-white, #fff);
+ border: 1px solid var(--solid-ui-color-slate-200, #e2e8f0);
+ border-radius: 5px;
+ transition: box-shadow 0.2s ease, transform 0.2s ease, padding 0.3s ease, row-gap 0.3s ease;
+}
+
+.profile-heading:hover {
+ box-shadow: 0 8px 10px rgba(124, 77, 255, 0.1);
+}
+
+.avatar {
+ grid-area: avatar;
+}
+
+.image-frame {
+ position: relative;
+ display: grid;
+ place-items: center;
+ width: var(--profile-avatar-size);
+ aspect-ratio: 1;
+ overflow: hidden;
+ transition: width 0.3s ease;
+}
+
+.image-frame--fallback {
+ border: 1px dashed var(--solid-ui-color-slate-200, #e2e8f0);
+ background: var(--solid-ui-color-slate-50, #f8fafc);
+ box-sizing: border-box;
+}
+
+.hero {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ border: 1px solid var(--solid-ui-color-primary, #7c4dff);
+ background: var(--color-surface-placeholder, var(--gray-300, #d1d5dc)) 50% / cover no-repeat;
+ object-fit: cover;
+ object-position: center;
+ box-sizing: border-box;
+ border-radius: 10px;
+}
+
+.hero-fallback {
+ display: none;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ padding: 0 var(--profile-fallback-padding);
+ box-sizing: border-box;
+ transition: padding 0.3s ease;
+}
+
+.hero-fallback icon-lucide-circle-user-round {
+ display: block;
+ width: 100%;
+ height: 100%;
+ color: #cbd5e1;
+}
+
+.image-frame--fallback .hero-fallback {
+ display: flex;
+}
+
+.info {
+ grid-area: info;
+ min-width: 0;
+}
+
+.profile__heading-edit-action {
+ grid-area: edit;
+ margin-left: auto;
+ margin-right: 0;
+ margin-bottom: 0;
+ align-self: flex-start;
+ justify-self: end;
+ display: flex;
+ justify-content: flex-end;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 0;
+}
+
+.profile-friends-button__section {
+ display: grid;
+ gap: 8px;
+ max-width: 16rem;
+}
+
+.profile__btn-friends-icon {
+ display: block;
+ width: 14px;
+ height: 14px;
+ align-self: center;
+ transform: translateY(-3px);
+}
+
+.friend-message {
+ text-align: center;
+ font-size: var(--solid-ui-font-size-sm, 0.875rem);
+ overflow-wrap: anywhere;
+}
+
+.friend-message[role="alert"] {
+ color: var(--solid-ui-color-red-700, #b91c1c);
+}
+
+.friend-message[role="status"] {
+ color: var(--solid-ui-color-green-700, #15803d);
+}
+
+.profile-section-collapsible__edit-button {
+ min-width: 0;
+}
+
+.mobile-edit-button {
+ display: none;
+ color: var(--solid-ui-color-primary, #7c4dff);
+}
+
+.profile-section-collapsible__action-label {
+ font-size: var(--solid-ui-font-size-sm, 0.875rem);
+}
+
+.profile__add-more-icon {
+ margin-right: 0;
+ line-height: 0;
+ position: relative;
+ top: 0;
+ transform: translateY(0);
+}
+
+.profile__add-more-icon icon-lucide-pencil,
+.profile-section-collapsible__edit-icon icon-lucide-pencil {
+ display: block;
+ width: 14px;
+ height: 14px;
+}
+
+.header-bar {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+ width: 100%;
+ margin: 0;
+ padding: 0;
+}
+
+.identity {
+ display: flex;
+ align-items: baseline;
+ gap: 7px;
+ width: 100%;
+ flex-wrap: wrap;
+}
+
+.name {
+ display: block;
+ margin: 0;
+ color: var(--color-text-heading, #000);
+ font-size: 1.75rem;
+ font-weight: 600;
+ line-height: 1.2;
+ overflow-wrap: anywhere;
+}
+
+.pronouns {
+ color: var(--color-text-secondary, #4a5565);
+ font-size: var(--solid-ui-font-size-sm, 0.875rem);
+ font-weight: 400;
+ line-height: 1.2;
+}
+
+.role {
+ width: 100%;
+ color: var(--solid-ui-color-gray-800, #101828);
+ font-size: var(--solid-ui-font-size-lg, 1.125rem);
+ font-weight: 500;
+ line-height: 1.25;
+ overflow-wrap: anywhere;
+}
+
+.details-collapse {
+ grid-area: details;
+ display: grid;
+ grid-template-rows: 1fr;
+ transition: grid-template-rows 0.3s ease, opacity 0.3s ease;
+}
+
+.details {
+ display: flex;
+ flex-wrap: wrap;
+ align-content: flex-start;
+ align-items: flex-start;
+ gap: 12px 32px;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+ width: 100%;
+ font-size: var(--font-size-sm, 0.875rem);
+}
+
+:host([compact]) .profile-heading {
+ padding: 12.5px;
+ row-gap: 0;
+}
+
+:host([compact]) .image-frame {
+ width: calc(var(--profile-avatar-size) / 2);
+}
+
+:host([compact]) .hero-fallback {
+ padding-inline: calc(var(--profile-fallback-padding) / 2);
+}
+
+:host([compact]) .info {
+ align-self: center;
+}
+
+:host([compact]) .name {
+ white-space: normal;
+ overflow-wrap: anywhere;
+}
+
+:host([compact]) .details-collapse {
+ grid-template-rows: 0fr;
+ opacity: 0;
+ visibility: hidden;
+}
+
+.pronouns[hidden] {
+ display: none;
+}
+
+.detail-row {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ align-items: start;
+ column-gap: clamp(12px, 2vw, 24px);
+ row-gap: 12px;
+ width: min(100%, 544px);
+}
+
+.detail-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 7px;
+ min-width: 0;
+}
+
+.detail-icon {
+ display: inline-flex;
+ flex: 0 0 16px;
+ width: 16px;
+ height: 16px;
+ margin-top: 1px;
+}
+
+.detail-icon > icon-lucide-cake,
+.detail-icon > icon-lucide-map-pin,
+.detail-icon > icon-lucide-phone,
+.detail-icon > icon-lucide-mail {
+ display: block;
+ width: 16px;
+ height: 16px;
+ color: currentColor;
+}
+
+.detail-value {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+@media (max-width: 1200px) {
+ :host {
+ --profile-avatar-size: 6.5rem;
+ --profile-fallback-padding: 27px;
+ }
+
+ .profile-heading {
+ column-gap: 15px;
+ }
+
+}
+
+@media (max-width: 768px) {
+ .profile-heading:has(.profile-friends-button__section) .name {
+ white-space: normal;
+ overflow-wrap: anywhere;
+ }
+
+ .profile__btn-friends-icon {
+ display: none;
+ }
+
+ .profile__btn-friends[disabled]:not([aria-busy="true"]) {
+ display: none;
+ }
+
+ :host {
+ --profile-avatar-size: 4.375rem;
+ --profile-fallback-padding: 0px;
+ }
+
+ .profile-heading {
+ grid-template-areas:
+ "avatar info edit"
+ "details details details";
+ column-gap: 8px;
+ row-gap: 12px;
+ }
+
+ .profile-section-collapsible__edit-button {
+ width: var(--solid-ui-clickable-area, 42px);
+ height: var(--solid-ui-clickable-area, 42px);
+ }
+
+ .desktop-edit-button {
+ display: none;
+ }
+
+ .mobile-edit-button {
+ display: inline-flex;
+ position: relative;
+ top: -12px;
+ right: -12px;
+ }
+
+ .profile-section-collapsible__edit-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ line-height: 0;
+ }
+
+ .profile-heading {
+ column-gap: 10px;
+ }
+
+ .hero-fallback icon-lucide-circle-user-round {
+ width: 3rem;
+ height: 3rem;
+ transition: width 0.3s ease, height 0.3s ease;
+ }
+
+ :host([compact]) .hero-fallback icon-lucide-circle-user-round {
+ width: 1.5rem;
+ height: 1.5rem;
+ }
+
+ .name {
+ display: inline;
+ font-size: 1.375rem;
+ line-height: 1.15;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ }
+
+ .identity {
+ gap: 0;
+ }
+
+ .pronouns {
+ margin-left: 2px;
+ font-size: 0.6875rem;
+ line-height: 1.1;
+ white-space: nowrap;
+ }
+
+ .role {
+ font-size: var(--solid-ui-font-size-sm, 0.875rem);
+ }
+
+ .details {
+ gap: 12px;
+ font-size: 0.75rem;
+ }
+
+ .detail-row {
+ column-gap: 2px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .profile-heading,
+ .image-frame,
+ .hero-fallback,
+ .hero-fallback icon-lucide-circle-user-round,
+ .details-collapse {
+ transition: none;
+ }
+}
diff --git a/src/components/profile-heading/ProfileHeading.ts b/src/components/profile-heading/ProfileHeading.ts
new file mode 100644
index 00000000..1198fc5c
--- /dev/null
+++ b/src/components/profile-heading/ProfileHeading.ts
@@ -0,0 +1,344 @@
+import { authn, store } from 'solid-logic'
+import type { NamedNode, Node } from 'rdflib'
+import { customElement, WebComponent } from 'solid-ui'
+import { html, nothing, type PropertyValues, type TemplateResult } from 'lit'
+import { property, state } from 'lit/decorators.js'
+import { createHeadingEditDialog } from './editor/sections/heading/HeadingEditDialog'
+import type { ProfileDetails, ProfilePresentation } from './editor/sections/heading/types'
+import type { PointDetails } from './editor/sections/contactInfo/types'
+import type { ViewerMode } from '../../types'
+import { addFriend, checkFriend, friendTexts } from './friendActions'
+import { formatDisplayError } from './editor/utils/errorDisplay'
+import 'solid-ui/components/button'
+import '~icons/lucide/cake'
+import '~icons/lucide/circle-user-round'
+import '~icons/lucide/mail'
+import '~icons/lucide/map-pin'
+import '~icons/lucide/pencil'
+import '~icons/lucide/phone'
+import '~icons/lucide/user-round-plus'
+import styles from './ProfileHeading.styles.css'
+
+export type ProfileHeadingPoint = Pick
+
+// The heading renders a subset of the shared presentation model used by the editor.
+export type ProfileHeadingData =
+ Pick & {
+ primaryPhone?: ProfileHeadingPoint
+ primaryEmail?: ProfileHeadingPoint
+ }
+
+export interface ProfileHeadingEditContext {
+ subject: NamedNode
+ profileData: ProfileDetails
+ viewerMode: ViewerMode
+ onSaved: () => Promise | void
+}
+
+@customElement('solid-panes-profile-heading')
+export default class ProfileHeading extends WebComponent {
+ static styles = styles
+
+ @property({ attribute: false })
+ accessor profileData: ProfileHeadingData | undefined = undefined
+
+ @property({ attribute: false })
+ accessor editContext: ProfileHeadingEditContext | undefined = undefined
+
+ @property({ attribute: false })
+ accessor friendSubject: NamedNode | undefined = undefined
+
+ @property({ type: Boolean, reflect: true })
+ accessor compact = false
+
+ @state()
+ accessor failedImageSrc: string | undefined = undefined
+
+ @state()
+ accessor friendState: 'loading' | 'available' | 'exists' | 'saving' | 'error' = 'loading'
+
+ @state()
+ accessor friendMessage = ''
+
+ @state()
+ accessor friendError = false
+
+ private friendRequest = 0
+ private friendRequestInterrupted = false
+ private friendMessageTimer: ReturnType | undefined
+
+ connectedCallback (): void {
+ super.connectedCallback()
+ if (this.friendRequestInterrupted) {
+ this.friendRequestInterrupted = false
+ this.startFriendCheck()
+ }
+ }
+
+ disconnectedCallback (): void {
+ super.disconnectedCallback()
+ this.friendRequest++
+ if (this.friendState === 'loading' || this.friendState === 'saving') {
+ this.friendRequestInterrupted = true
+ }
+ this.clearFriendMessage()
+ }
+
+ private clearFriendMessage (): void {
+ clearTimeout(this.friendMessageTimer)
+ this.friendMessageTimer = undefined
+ this.friendMessage = ''
+ }
+
+ protected willUpdate (changed: PropertyValues): void {
+ super.willUpdate(changed)
+ if (changed.has('profileData') && changed.get('profileData')?.imageSrc !== this.profileData?.imageSrc) {
+ this.failedImageSrc = undefined
+ }
+ if (!changed.has('friendSubject')) return
+ this.friendRequestInterrupted = false
+ this.startFriendCheck()
+ }
+
+ private startFriendCheck (): void {
+ const request = ++this.friendRequest
+ this.friendState = 'loading'
+ this.clearFriendMessage()
+ this.friendError = false
+ const subject = this.friendSubject
+ const viewer = authn.currentUser()
+ if (!subject || !viewer || viewer.sameTerm(subject)) return
+
+ checkFriend(viewer, subject).then((exists) => {
+ if (request !== this.friendRequest || this.friendSubject !== subject) return
+ this.friendState = exists ? 'exists' : 'available'
+ }).catch((error: unknown) => {
+ if (request !== this.friendRequest || this.friendSubject !== subject) return
+ this.friendState = 'error'
+ this.showFriendMessage(error, true)
+ })
+ }
+
+ private async showFriendMessage (message: unknown, error: boolean): Promise {
+ this.clearFriendMessage()
+ this.friendMessage = formatDisplayError(message)
+ this.friendError = error
+ this.friendMessageTimer = setTimeout(() => {
+ this.clearFriendMessage()
+ }, 10000)
+ await this.updateComplete
+ this.shadowRoot?.querySelector('.friend-message')?.focus()
+ }
+
+ private handleAddFriend = async (event: Event): Promise => {
+ event.preventDefault()
+ if (this.friendState !== 'available' && this.friendState !== 'error') return
+ const subject = this.friendSubject
+ if (!subject) return
+ const request = this.friendRequest
+ this.friendState = 'saving'
+ this.clearFriendMessage()
+ try {
+ const viewer = authn.currentUser()
+ if (!viewer) throw new Error(friendTexts.loginRequired)
+ await addFriend(viewer, subject)
+ if (request !== this.friendRequest || this.friendSubject !== subject) return
+ this.friendState = 'exists'
+ await this.showFriendMessage(friendTexts.success, false)
+ } catch (error: unknown) {
+ if (request !== this.friendRequest || this.friendSubject !== subject) return
+ this.friendState = 'error'
+ await this.showFriendMessage(error, true)
+ }
+ }
+
+ private handleEdit = (event: Event) => {
+ const context = this.editContext
+ if (!context) return
+
+ createHeadingEditDialog(
+ event,
+ store,
+ context.subject,
+ context.profileData,
+ context.viewerMode,
+ async () => {
+ this.dispatchEvent(new CustomEvent('profile-heading-saved', {
+ bubbles: true,
+ composed: true,
+ detail: { subjectUri: context.subject.value }
+ }))
+ await context.onSaved()
+ }
+ ).catch((error: unknown) => {
+ console.error('Failed to edit profile heading.', error)
+ })
+ }
+
+ private handleImageError = (event: Event) => {
+ this.failedImageSrc = (event.currentTarget as HTMLImageElement).getAttribute('src') ?? undefined
+ }
+
+ render (): TemplateResult<1> {
+ const profile = this.profileData
+ if (!profile) return html``
+
+ const phone = textValue(profile.primaryPhone?.valueNode).replace(/^tel:/i, '')
+ const email = textValue(profile.primaryEmail?.valueNode).replace(/^mailto:/i, '')
+ const dateOfBirth = displayDate(profile.dateOfBirth)
+ const showImage = Boolean(profile.imageSrc && this.failedImageSrc !== profile.imageSrc)
+ const viewer = authn.currentUser()
+ const showFriend = Boolean(this.friendSubject && viewer && !viewer.sameTerm(this.friendSubject))
+
+ return html`
+
+
+
+ ${showImage
+ ? html`
`
+ : nothing}
+
+
+
+
+
+
+
+
+
+
+ ${(this.editContext || showFriend) && !this.compact
+ ? html`
+
+ ${showFriend
+ ? html`
+
+ `
+ : nothing}
+ ${this.editContext
+ ? html`
+
+
+ ${this.renderEditIcon()}
+
+ Edit
+
+
+
+ ${this.renderEditIcon()}
+
+
+ `
+ : nothing}
+
+ `
+ : nothing}
+
+
+
+
+ ${this.renderLine(dateOfBirth, 'birthday')}
+ ${this.renderLine(profile.location, 'location')}
+
+
+ ${this.renderLine(phone, 'phone')}
+ ${this.renderLine(email, 'email')}
+
+
+
+
+ `
+ }
+
+ private renderLine (value: string | undefined, icon: 'birthday' | 'location' | 'phone' | 'email') {
+ if (!value) return nothing
+
+ return html`
+
+
+ ${this.renderIcon(icon)}
+
+ ${value}
+
+ `
+ }
+
+ private renderIcon (icon: 'birthday' | 'location' | 'phone' | 'email'): TemplateResult {
+ switch (icon) {
+ case 'birthday':
+ return html` `
+ case 'location':
+ return html` `
+ case 'phone':
+ return html` `
+ case 'email':
+ return html` `
+ }
+ }
+
+ private renderEditIcon (): TemplateResult {
+ return html` `
+ }
+}
+
+function textValue (value: Node | undefined): string {
+ return value?.value ?? ''
+}
+
+function displayDate (value?: string): string {
+ const raw = (value || '').trim()
+ const isoDate = raw.match(/^(\d{4})-(\d{2})-(\d{2})/)
+ if (isoDate) {
+ const [, year, month, day] = isoDate
+ return `${day}-${month}-${year}`
+ }
+
+ const dmyDate = raw.match(/^(\d{2})[/-](\d{2})[/-](\d{4})$/)
+ if (dmyDate) {
+ const [, day, month, year] = dmyDate
+ return `${day}-${month}-${year}`
+ }
+
+ return raw
+}
diff --git a/src/components/profile-heading/editor/index.ts b/src/components/profile-heading/editor/index.ts
new file mode 100644
index 00000000..a90608ef
--- /dev/null
+++ b/src/components/profile-heading/editor/index.ts
@@ -0,0 +1,2 @@
+export { createHeadingEditDialog } from './sections/heading/HeadingEditDialog'
+export type { ProfileDetails } from './sections/heading/types'
diff --git a/src/components/profile-heading/editor/sections/contactInfo/types.ts b/src/components/profile-heading/editor/sections/contactInfo/types.ts
new file mode 100644
index 00000000..3c4dd374
--- /dev/null
+++ b/src/components/profile-heading/editor/sections/contactInfo/types.ts
@@ -0,0 +1,50 @@
+import { Node } from 'rdflib'
+import { MutationOps } from '../shared/types'
+/* using Node instead of NamedNode because the data could
+also be a blank node because it's coming from user-edited profile data */
+export interface PointDetails {
+ entryNode: Node,
+ type: Node,
+ valueNode: Node
+}
+export interface AddressDetails {
+ entryNode: Node,
+ type?: Node,
+ streetAddress?: string,
+ locality?: string,
+ region?: string,
+ postalCode?: string,
+ countryName?: string
+}
+
+export interface ContactInfo {
+ emails: PointDetails[],
+ phones: PointDetails[],
+ addresses: AddressDetails[]
+}
+
+export type ContactRowStatus = 'existing' | 'new' | 'modified' | 'deleted'
+
+export type ContactPointRow = {
+ value: string
+ type: string
+ entryNode: string
+ status: ContactRowStatus
+}
+
+export type ContactAddressRow = {
+ streetAddress?: string,
+ locality?: string,
+ region?: string,
+ postalCode?: string,
+ countryName?: string
+ type: string
+ entryNode: string
+ status: ContactRowStatus
+}
+
+export type ContactMutationPlan = {
+ phoneOps: MutationOps
+ emailOps: MutationOps
+ addressOps: MutationOps
+}
diff --git a/src/components/profile-heading/editor/sections/heading/HeadingEditDialog.ts b/src/components/profile-heading/editor/sections/heading/HeadingEditDialog.ts
new file mode 100644
index 00000000..7d42fac7
--- /dev/null
+++ b/src/components/profile-heading/editor/sections/heading/HeadingEditDialog.ts
@@ -0,0 +1,933 @@
+import { openInputDialog } from '../../ui/dialog'
+import { html, render, TemplateResult } from 'lit-html'
+import 'solid-ui/components/button'
+import 'solid-ui/components/combobox'
+import 'solid-ui/components/combobox-option'
+import { type ComboboxChangeEvent } from 'solid-ui/components/combobox'
+import 'solid-ui/components/photo-capture'
+import type { PhotoCapture } from 'solid-ui/components/photo-capture'
+import { ProfileDetails, HeadingMutationPlan, ProfileBasicRow } from './types'
+import { Image } from './imagePreview'
+import '../../styles/EditDialogs.css'
+import { LiveStore, NamedNode } from 'rdflib'
+import { processHeadingMutations } from './mutations'
+import { ViewerMode } from '../../../../../types'
+import {
+ combinePhoneValue,
+ splitPhoneValue
+} from '../shared/phoneCountries'
+import {
+ normalizeEmailTypeForEdit,
+ normalizePhoneTypeForEdit,
+ toSavedHeadingEmailType,
+ toSavedHeadingPhoneType
+} from '../shared/contactTypeUtils'
+import { applyRowFieldChange, applyRowSelectChange, summarizeRowOps } from '../shared/rowState'
+import { hasNonEmptyText, sanitizeTextValue, toText, toTypeLabel } from '../../textUtils'
+import {
+ dialogCancelLabelText,
+ dialogSubmitLabelText,
+ editHeadingDialogTitleText,
+ ownerLoginRequiredDialogMessageText,
+ saveHeadingUpdatesFailedMessageText
+} from '../../texts'
+import '~icons/lucide/camera'
+import { ContactAddressRow, ContactPointRow } from '../contactInfo/types'
+import { sanitizeAddressFieldValue, sanitizeBasicInputFieldValue, sanitizeEmailValue, sanitizePhoneLocalValue } from '../shared/sanitizeUtils'
+import { toStorageDateISO } from './dateHelpers'
+import { invalidateResolvedPhotoDisplaySrc, resolvePhotoDisplaySrc, uploadPhotoFile } from './imageHelpers'
+/* Note: new design - has address type in More Edit Contacts for now we will leave
+ out Address Type, but a ticket will be created to add type later
+ so I will keep the code and just comment it out for now.
+ new design - has country code, will comment out code for now and create a ticket to add later. */
+
+type HeadingFormState = {
+ basicInfo: ProfileBasicRow
+ email: ContactPointRow
+ phone: ContactPointRow
+ address: ContactAddressRow
+ emailTypeWasMissing: boolean
+ phoneTypeWasMissing: boolean
+ pendingImageFile?: File | null
+ imagePreviewSrc: string
+ clearImagePreview: () => void
+}
+
+type HeadingContactTypeOption = {
+ label: string
+ value: string
+}
+
+type HeadingPronounsOption = {
+ label: string
+ value: string
+}
+
+type HeadingContactTypeKind = 'phone' | 'email'
+
+const HEADING_PHONE_TYPE_OPTIONS: HeadingContactTypeOption[] = [
+ { label: 'Mobile', value: 'Mobile' },
+ { label: 'Home', value: 'Home' },
+ { label: 'Work', value: 'Work' }
+]
+
+const HEADING_EMAIL_TYPE_OPTIONS: HeadingContactTypeOption[] = [
+ { label: 'Personal', value: 'Personal' },
+ { label: 'Office', value: 'Office' }
+]
+
+const HEADING_PRONOUN_OPTIONS: HeadingPronounsOption[] = [
+ { label: 'He/Him', value: 'He/Him' },
+ { label: 'She/Her', value: 'She/Her' },
+ { label: 'They/Them', value: 'They/Them' }
+]
+
+type Row = ProfileBasicRow | ContactPointRow | ContactAddressRow
+
+function isContactPointRow (row: Row): row is ContactPointRow {
+ return 'value' in row
+}
+
+function isAddressRow (row: Row): row is ContactAddressRow {
+ return 'streetAddress' in row
+}
+
+function isProfileBasicRow (row: Row): row is ProfileBasicRow {
+ return 'name' in row
+}
+
+function normalizeHeadingContactTypeValue (value: string, options: HeadingContactTypeOption[]): string {
+ return options.some((option) => option.value === value) ? value : options[0]?.value || ''
+}
+
+function getHeadingContactTypeOptions (kind: HeadingContactTypeKind): HeadingContactTypeOption[] {
+ return kind === 'phone' ? HEADING_PHONE_TYPE_OPTIONS : HEADING_EMAIL_TYPE_OPTIONS
+}
+
+function withDefaultHeadingContactType (
+ row: ContactPointRow,
+ kind: HeadingContactTypeKind
+): ContactPointRow {
+ return {
+ ...row,
+ type: normalizeHeadingContactTypeValue(row.type || '', getHeadingContactTypeOptions(kind))
+ }
+}
+
+function rowHasContent (row: Row): boolean {
+ if (isContactPointRow(row)) {
+ return hasNonEmptyText(row.value)
+ }
+ if (isAddressRow(row)) {
+ return [
+ row.streetAddress,
+ row.locality,
+ row.region,
+ row.postalCode,
+ row.countryName
+ ].some(hasNonEmptyText)
+ }
+ if (isProfileBasicRow(row)) {
+ return [
+ row.name,
+ row.nickname,
+ row.imageSrc,
+ row.location,
+ row.pronouns,
+ row.dateOfBirth
+ ].some(hasNonEmptyText)
+ }
+ return false
+}
+
+function normalizePronounsValue (value: string | undefined): string {
+ const normalized = sanitizeTextValue(value || '').toLowerCase().replace(/\s+/g, '')
+ if (!normalized) return ''
+ if (normalized === 'he' || normalized === 'he/him') return 'He/Him'
+ if (normalized === 'she' || normalized === 'she/her') return 'She/Her'
+ if (normalized === 'they' || normalized === 'they/them') return 'They/Them'
+ return value || ''
+}
+
+function toFormState (profileData: ProfileDetails): HeadingFormState {
+ const basicInfo: ProfileBasicRow = {
+ name: sanitizeTextValue(toText(profileData.name)),
+ nickname: sanitizeTextValue(toText(profileData.nickname || '')),
+ imageSrc: sanitizeTextValue(toText(profileData.imageSrc || '')),
+ location: sanitizeTextValue(toText(profileData.location || '')),
+ pronouns: normalizePronounsValue(toText(profileData.pronouns || '')),
+ dateOfBirth: sanitizeTextValue(toText(profileData.dateOfBirth || '')),
+ entryNode: toText(profileData.entryNode),
+ status: toText(profileData.entryNode) ? 'existing' as const : 'new' as const
+ }
+ const primaryEmail = profileData.primaryEmail
+ const primaryPhone = profileData.primaryPhone
+ const primaryAddress = profileData.primaryAddress
+ const emailTypeWasMissing = !normalizeEmailTypeForEdit(primaryEmail?.type)
+ const phoneTypeWasMissing = !normalizePhoneTypeForEdit(primaryPhone?.type)
+
+ const normalizedEmailType = normalizeEmailTypeForEdit(primaryEmail?.type)
+ const normalizedPhoneType = normalizePhoneTypeForEdit(primaryPhone?.type)
+
+ const email: ContactPointRow = {
+ value: sanitizeEmailValue(toText(primaryEmail?.valueNode).replace(/^mailto:/i, '')),
+ type: normalizedEmailType,
+ entryNode: toText(primaryEmail?.entryNode),
+ status: toText(primaryEmail?.entryNode) ? 'existing' as const : 'new' as const
+ }
+ const phone: ContactPointRow = {
+ value: sanitizeTextValue(
+ toText(primaryPhone?.valueNode || primaryPhone?.entryNode || '').replace(/^tel:/i, '')
+ ),
+ type: normalizedPhoneType,
+ entryNode: toText(primaryPhone?.entryNode || ''),
+ status: toText(primaryPhone?.entryNode || '') ? 'existing' as const : 'new' as const
+ }
+ const address: ContactAddressRow = {
+ streetAddress: sanitizeAddressFieldValue(toText(primaryAddress?.streetAddress)),
+ locality: sanitizeAddressFieldValue(toText(primaryAddress?.locality)),
+ region: sanitizeAddressFieldValue(toText(primaryAddress?.region)),
+ postalCode: sanitizeAddressFieldValue(toText(primaryAddress?.postalCode)),
+ countryName: sanitizeAddressFieldValue(toText(primaryAddress?.countryName)),
+ type: toTypeLabel(primaryAddress?.type),
+ entryNode: toText(primaryAddress?.entryNode),
+ status: toText(primaryAddress?.entryNode) ? 'existing' as const : 'new' as const
+ }
+
+ return {
+ basicInfo: (basicInfo) || { name: '', nickname: '', imageSrc: '', location: '', pronouns: '', dateOfBirth: '', entryNode: '', status: 'new' },
+ email: (email) || { value: '', type: '', entryNode: '', status: 'new' },
+ phone: (phone) || { value: '', type: '', entryNode: '', status: 'new' },
+ address: (address) || { streetAddress: '', locality: '', region: '', postalCode: '', countryName: '', type: '', entryNode: '', status: 'new' },
+ emailTypeWasMissing,
+ phoneTypeWasMissing,
+ imagePreviewSrc: '',
+ clearImagePreview: () => undefined
+ }
+}
+
+function setHeadingImagePreview (formState: HeadingFormState, file: File) {
+ formState.clearImagePreview()
+
+ if (typeof URL.createObjectURL !== 'function') {
+ formState.imagePreviewSrc = ''
+ formState.clearImagePreview = () => undefined
+ return
+ }
+
+ const previewUrl = URL.createObjectURL(file)
+ formState.imagePreviewSrc = previewUrl
+ formState.clearImagePreview = () => {
+ URL.revokeObjectURL(previewUrl)
+ formState.imagePreviewSrc = ''
+ formState.clearImagePreview = () => undefined
+ }
+}
+
+function setResolvedHeadingPreview (formState: HeadingFormState, resolvedImageSrc?: string) {
+ formState.clearImagePreview()
+
+ if (!resolvedImageSrc) {
+ return
+ }
+
+ formState.imagePreviewSrc = resolvedImageSrc
+
+ formState.clearImagePreview = () => {
+ formState.imagePreviewSrc = ''
+ formState.clearImagePreview = () => undefined
+ }
+}
+
+type ProfileBasicEditableField =
+ | 'name'
+ | 'nickname'
+ | 'imageSrc'
+ | 'pronouns'
+ | 'dateOfBirth'
+
+type ContactAddressEditableField =
+ | 'streetAddress'
+ | 'locality'
+ | 'region'
+ | 'postalCode'
+ | 'countryName'
+
+type ContactPhoneInputRowProps = {
+ phone: ContactPointRow
+}
+
+type ContactEmailInputRowProps = {
+ email: ContactPointRow
+}
+
+type ContactAddressInputRowProps = {
+ address: ContactAddressRow
+}
+
+function mapEmailOpsForSave (ops: { create: ContactPointRow[], update: ContactPointRow[], remove: ContactPointRow[] }) {
+ const mapRow = (row: ContactPointRow): ContactPointRow => ({
+ ...withDefaultHeadingContactType(row, 'email'),
+ type: toSavedHeadingEmailType(withDefaultHeadingContactType(row, 'email').type)
+ })
+
+ return {
+ create: ops.create.map(mapRow),
+ update: ops.update.map(mapRow),
+ remove: ops.remove.map(mapRow)
+ }
+}
+
+function mapPhoneOpsForSave (ops: { create: ContactPointRow[], update: ContactPointRow[], remove: ContactPointRow[] }) {
+ const mapRow = (row: ContactPointRow): ContactPointRow => ({
+ ...withDefaultHeadingContactType(row, 'phone'),
+ type: toSavedHeadingPhoneType(withDefaultHeadingContactType(row, 'phone').type)
+ })
+
+ return {
+ create: ops.create.map(mapRow),
+ update: ops.update.map(mapRow),
+ remove: ops.remove.map(mapRow)
+ }
+}
+
+function summarizeHeadingContactOps (
+ row: ContactPointRow,
+ kind: HeadingContactTypeKind,
+ typeWasMissing: boolean
+) {
+ const ops = summarizeRowOps([row], rowHasContent)
+
+ if (
+ typeWasMissing &&
+ row.entryNode &&
+ row.status === 'existing' &&
+ rowHasContent(row) &&
+ ops.create.length === 0 &&
+ ops.update.length === 0 &&
+ ops.remove.length === 0
+ ) {
+ return {
+ create: ops.create,
+ update: [withDefaultHeadingContactType({ ...row, status: 'modified' }, kind)],
+ remove: ops.remove
+ }
+ }
+
+ return ops
+}
+
+function renderContactPhoneInput ({
+ phone
+}: ContactPhoneInputRowProps) {
+ const label = 'Mobile Number'
+ const typeLabel = 'Phone Type 1'
+ const inputName = 'phone-value'
+ const splitValue = splitPhoneValue(phone?.value || '')
+ const selectedDialCode = splitValue.dialCode
+
+ const handleValueInput = (e: Event) => {
+ const target = e.target as HTMLInputElement
+ const nextValue = sanitizePhoneLocalValue(target.value)
+ if (phone) {
+ applyRowFieldChange(phone, 'value', combinePhoneValue(selectedDialCode, nextValue), rowHasContent)
+ }
+ }
+
+ const handleTypeInput = (e: Event) => {
+ const event = e as ComboboxChangeEvent
+ if (!event.detail.option) return
+ const nextType = String(event.detail.option.value)
+ if (phone) {
+ applyRowSelectChange(phone, 'type', nextType)
+ }
+ }
+
+ return html`
+
+ `
+}
+
+function renderContactEmailInputRow ({
+ email
+}: ContactEmailInputRowProps) {
+ const label = 'Email'
+ const typeLabel = 'Email Type'
+ const inputName = 'email-value'
+
+ const handleValueInput = (e: Event) => {
+ const target = e.target as HTMLInputElement
+ const nextValue = sanitizeEmailValue(target.value)
+ if (email) {
+ applyRowFieldChange(email, 'value', nextValue, rowHasContent)
+ }
+ }
+
+ const handleTypeInput = (e: Event) => {
+ const event = e as ComboboxChangeEvent
+ if (!event.detail.option) return
+ const nextType = String(event.detail.option.value)
+ if (email) {
+ applyRowSelectChange(email, 'type', nextType)
+ }
+ }
+
+ return html`
+
+
+ ${label}
+
+
+
+
+ ${HEADING_EMAIL_TYPE_OPTIONS.map((option) => html`${option.label} `)}
+
+
+
+ `
+}
+
+function renderContactAddressInput ({
+ address
+}: ContactAddressInputRowProps) {
+ const label = 'Address'
+ /* const typeLabel = 'Address Type' */
+ /* const typeInputName = 'address-type' */
+ /* const addressTypeSelectId = 'address-type-select' */
+ const streetAddressName = 'address-street'
+ const localityName = 'address-locality'
+ const regionName = 'address-region'
+ const postalCodeName = 'address-postal'
+ const countryName = 'address-country'
+
+ const handleAddressInput = (field: ContactAddressEditableField) => (e: Event) => {
+ const target = e.target as HTMLInputElement
+ const nextValue = sanitizeAddressFieldValue(target.value)
+ if (address) {
+ applyRowFieldChange(address, field, nextValue, rowHasContent)
+ }
+ }
+
+ /* const handleTypeInput = (e: Event) => {
+ const target = e.target as HTMLInputElement
+ const nextType = target.value
+ if (address) {
+ applyRowSelectChange(address, 'type', nextType)
+ }
+ } */
+
+ return html`
+
+ Street Address
+
+
+
+
+
+ Locality
+
+
+
+ Postal Code
+
+
+
+
+
+
+ Region
+
+
+
+ Country
+
+
+
+ `
+}
+
+function renderHeadingInfoInput (
+ formState: HeadingFormState,
+ rerender: () => void
+): TemplateResult {
+ const { basicInfo, phone, email, imagePreviewSrc } = formState
+ const imageSrcLabel = 'Profile Photo'
+ const recommendedImageToLoad = 'Recommended: Square JPG, PNG. Max 2MB.'
+ const nameLabel = 'Full Name'
+ const nicknameLabel = 'Nickname'
+ const pronounsLabel = 'Pronouns'
+ const dateOfBirthLabel = 'DOB'
+
+ const handleBasicInfoInput = (field: ProfileBasicEditableField) => (e: Event) => {
+ const target = e.target as HTMLInputElement
+ const nextValue = sanitizeBasicInputFieldValue(target.value)
+ if (basicInfo) {
+ applyRowFieldChange(basicInfo, field, nextValue, rowHasContent)
+ }
+ }
+
+ const handleDateOfBirthInput = (e: Event) => {
+ const target = e.target as HTMLInputElement
+ const nextValue = sanitizeBasicInputFieldValue(target.value)
+ if (basicInfo) {
+ applyRowFieldChange(basicInfo, 'dateOfBirth', toStorageDateISO(nextValue), rowHasContent)
+ }
+ }
+ const handlePronounsInput = (e: Event) => {
+ const event = e as ComboboxChangeEvent
+ if (!event.detail.option) return
+ const nextValue = normalizePronounsValue(String(event.detail.option.value))
+ if (basicInfo) {
+ applyRowFieldChange(basicInfo, 'pronouns', nextValue, rowHasContent)
+ }
+ }
+
+ const handleUpload = async (e: Event) => {
+ const button = e.currentTarget as HTMLButtonElement | null
+ const dom = button?.ownerDocument || document
+ const fileInput = dom.createElement('input')
+ fileInput.type = 'file'
+ fileInput.accept = 'image/*'
+ fileInput.hidden = true
+
+ const cleanupFileInput = () => {
+ fileInput.remove()
+ }
+
+ fileInput.addEventListener('change', async () => {
+ const file = fileInput.files?.[0]
+ cleanupFileInput()
+ if (!file || !basicInfo) return
+
+ try {
+ formState.pendingImageFile = file
+ setHeadingImagePreview(formState, file)
+ basicInfo.status = basicInfo.entryNode ? 'modified' : 'new'
+ rerender()
+ } catch (error) {
+ console.error('Profile image upload failed', error)
+ }
+ }, { once: true })
+
+ dom.body.appendChild(fileInput)
+ fileInput.click()
+ }
+
+ const handleCameraInput = async (event: InputEvent) => {
+ const file = (event.target as PhotoCapture).value
+
+ if (!file || !basicInfo) return
+
+ try {
+ formState.pendingImageFile = file
+ setHeadingImagePreview(formState, file)
+ basicInfo.status = basicInfo.entryNode ? 'modified' : 'new'
+ rerender()
+ } catch (error) {
+ console.error('Profile camera upload failed', error)
+ }
+ }
+
+ const handleDelete = async () => {
+ if (!basicInfo) return
+ formState.pendingImageFile = null
+ formState.clearImagePreview()
+ applyRowFieldChange(basicInfo, 'imageSrc', '', rowHasContent)
+ rerender()
+ }
+
+ return html`
+
+
+
+
+
${imageSrcLabel}
+
${recommendedImageToLoad}
+
+
+
+ Upload
+
+
+ Remove
+
+
+
+
+
+
+
+
+ ${nameLabel}
+
+
+
+ ${nicknameLabel}
+
+
+
+
+
+
+ ${pronounsLabel}
+
+
+ ${HEADING_PRONOUN_OPTIONS.map((option) => html`${option.label} `)}
+
+
+
+
+ ${dateOfBirthLabel}
+
+
+
+
+
+
+ ${renderContactPhoneInput({ phone })}
+
+
+ ${renderContactEmailInputRow({ email })}
+
+
+
+ `
+}
+
+function renderHeadingEditTemplate (
+ form: HTMLFormElement,
+ formState: HeadingFormState,
+ viewerMode: ViewerMode
+) {
+ const rerender = () => renderHeadingEditTemplate(form, formState, viewerMode)
+
+ render(html`
+ ${renderHeadingInfoInput(formState, rerender)}
+ ${renderContactAddressInput({ address: formState.address })}
+ ${viewerMode !== 'owner'
+ ? html`${ownerLoginRequiredDialogMessageText}
`
+ : null}
+ `, form)
+}
+
+function createHeadingEditForm (
+ profileData: ProfileDetails,
+ viewerMode: ViewerMode
+) {
+ const form = document.createElement('form')
+ form.classList.add('profile__edit-form', 'profile__edit-form--heading', 'profile-edit-dialog--heading')
+ form.autocomplete = 'off'
+ form.setAttribute('data-lpignore', 'true')
+ form.setAttribute('data-1p-ignore', 'true')
+ form.setAttribute('data-bwignore', 'true')
+
+ const formState = toFormState(profileData)
+ renderHeadingEditTemplate(form, formState, viewerMode)
+
+ return { form, formState }
+}
+
+function isValidHeadingEmailAddress (value: string): boolean {
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
+}
+
+function isValidHeadingPhoneNumber (value: string): boolean {
+ return /^\d+$/.test(value)
+}
+
+function validateHeadingDataBeforeSave (formState: HeadingFormState): string | null {
+ if (!hasNonEmptyText(formState.basicInfo.name)) {
+ return 'Full Name is required.'
+ }
+
+ const { localNumber } = splitPhoneValue(formState.phone?.value || '')
+ if (rowHasContent(formState.phone) && !isValidHeadingPhoneNumber(localNumber)) {
+ return 'Phone Number 1 should contain only numbers.'
+ }
+
+ if (rowHasContent(formState.email) && !isValidHeadingEmailAddress(formState.email?.value || '')) {
+ return 'Email address must be a valid email address.'
+ }
+
+ return null
+}
+
+export async function createHeadingEditDialog (
+ _event: Event,
+ store: LiveStore,
+ subject: NamedNode,
+ profileData: ProfileDetails,
+ viewerMode: ViewerMode,
+ onSaved?: () => Promise | void
+) {
+ const dom = document
+ const originalPhotoUri = sanitizeTextValue(toText(profileData.imageSrc || ''))
+ const { form, formState } = createHeadingEditForm(profileData, viewerMode)
+
+ if (formState.basicInfo.imageSrc) {
+ const resolvedImageSrc = await resolvePhotoDisplaySrc(store, formState.basicInfo.imageSrc)
+ if (resolvedImageSrc && resolvedImageSrc !== formState.basicInfo.imageSrc) {
+ setResolvedHeadingPreview(formState, resolvedImageSrc)
+ renderHeadingEditTemplate(form, formState, viewerMode)
+ }
+ }
+
+ const result = await openInputDialog({
+ title: editHeadingDialogTitleText,
+ dom,
+ form,
+ headerAction: { type: 'none' },
+ submitLabel: dialogSubmitLabelText,
+ cancelLabel: dialogCancelLabelText,
+ shouldCloseWithoutSave: () => {
+ const basicInfoOps = summarizeRowOps([formState.basicInfo], rowHasContent)
+ const phoneOps = summarizeHeadingContactOps(formState.phone, 'phone', formState.phoneTypeWasMissing)
+ const emailOps = summarizeHeadingContactOps(formState.email, 'email', formState.emailTypeWasMissing)
+ const addressOps = summarizeRowOps([formState.address], rowHasContent)
+
+ return (
+ basicInfoOps.create.length === 0 && basicInfoOps.update.length === 0 && basicInfoOps.remove.length === 0 &&
+ phoneOps.create.length === 0 && phoneOps.update.length === 0 && phoneOps.remove.length === 0 &&
+ emailOps.create.length === 0 && emailOps.update.length === 0 && emailOps.remove.length === 0 &&
+ addressOps.create.length === 0 && addressOps.update.length === 0 && addressOps.remove.length === 0
+ )
+ },
+ validate: () => {
+ if (viewerMode !== 'owner') {
+ return ownerLoginRequiredDialogMessageText
+ }
+ return validateHeadingDataBeforeSave(formState)
+ },
+ onSave: async () => {
+ if (formState.pendingImageFile) {
+ const uploadedUri = await uploadPhotoFile(store, subject, formState.pendingImageFile)
+ applyRowFieldChange(formState.basicInfo, 'imageSrc', uploadedUri, rowHasContent)
+ }
+
+ const phoneOps = summarizeHeadingContactOps(formState.phone, 'phone', formState.phoneTypeWasMissing)
+ const emailOps = summarizeHeadingContactOps(formState.email, 'email', formState.emailTypeWasMissing)
+ const plan: HeadingMutationPlan = {
+ basicOps: summarizeRowOps([formState.basicInfo], rowHasContent),
+ phoneOps: mapPhoneOpsForSave(phoneOps),
+ emailOps: mapEmailOpsForSave(emailOps),
+ addressOps: summarizeRowOps([formState.address], rowHasContent)
+ }
+ await processHeadingMutations(store, subject, plan)
+
+ const nextPhotoUri = sanitizeTextValue(formState.basicInfo.imageSrc || '')
+ if (originalPhotoUri && originalPhotoUri !== nextPhotoUri) {
+ try {
+ invalidateResolvedPhotoDisplaySrc(originalPhotoUri)
+ } catch (error) {
+ console.warn('Failed to invalidate resolved photo cache', error)
+ }
+ }
+ },
+ formatSaveError: (error: unknown) => {
+ return error instanceof Error ? error.message : saveHeadingUpdatesFailedMessageText
+ }
+ })
+
+ if (!result) {
+ return
+ }
+
+ if (onSaved) {
+ await onSaved()
+ }
+}
diff --git a/src/components/profile-heading/editor/sections/heading/dateHelpers.ts b/src/components/profile-heading/editor/sections/heading/dateHelpers.ts
new file mode 100644
index 00000000..186c0ed6
--- /dev/null
+++ b/src/components/profile-heading/editor/sections/heading/dateHelpers.ts
@@ -0,0 +1,52 @@
+function normalizeDmy (value: string): string {
+ const dmyMatch = value.match(/^(\d{2})[/-](\d{2})[/-](\d{4})$/)
+ if (!dmyMatch) return ''
+
+ const [, day, month, year] = dmyMatch
+ return `${day}-${month}-${year}`
+}
+
+export function toDisplayDateDMY (value: string | undefined, emptyFallback = ''): string {
+ const raw = (value || '').trim()
+ if (!raw) return emptyFallback
+
+ const isoMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})/)
+ if (isoMatch) {
+ const [, year, month, day] = isoMatch
+ return `${day}-${month}-${year}`
+ }
+
+ const normalizedDmy = normalizeDmy(raw)
+ if (normalizedDmy) return normalizedDmy
+
+ return raw
+}
+
+export function toEditableDateDMY (value: string | undefined): string {
+ const raw = (value || '').trim()
+ if (!raw) return ''
+
+ const isoMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/)
+ if (isoMatch) {
+ const [, year, month, day] = isoMatch
+ return `${day}-${month}-${year}`
+ }
+
+ return normalizeDmy(raw)
+}
+
+export function toStorageDateISO (value: string | undefined): string {
+ const raw = (value || '').trim()
+ if (!raw) return ''
+
+ const isoMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/)
+ if (isoMatch) return raw
+
+ const dmyMatch = raw.match(/^(\d{2})[/-](\d{2})[/-](\d{4})$/)
+ if (dmyMatch) {
+ const [, day, month, year] = dmyMatch
+ return `${year}-${month}-${day}`
+ }
+
+ return ''
+}
diff --git a/src/components/profile-heading/editor/sections/heading/imageHelpers.ts b/src/components/profile-heading/editor/sections/heading/imageHelpers.ts
new file mode 100644
index 00000000..dd3dcb9b
--- /dev/null
+++ b/src/components/profile-heading/editor/sections/heading/imageHelpers.ts
@@ -0,0 +1,134 @@
+import { LiveStore, NamedNode, sym } from 'rdflib'
+import { ns } from 'solid-ui'
+import { authn, solidLogicSingleton } from 'solid-logic'
+
+const resolvedHeadingImageCache = new Map()
+
+export function invalidateResolvedPhotoDisplaySrc (imageSrc?: string): void {
+ if (!imageSrc) return
+
+ const cachedImageSrc = resolvedHeadingImageCache.get(imageSrc)
+ if (!cachedImageSrc) return
+
+ resolvedHeadingImageCache.delete(imageSrc)
+ if (cachedImageSrc.startsWith('blob:') && typeof URL.revokeObjectURL === 'function') {
+ URL.revokeObjectURL(cachedImageSrc)
+ }
+}
+/* Code copied from contact-pane/src/mugshotGallery and modified to fit the needs of the new design */
+const mimeMap: Record = {
+ 'image/png': 'png',
+ 'image/jpeg': 'jpg',
+ 'image/gif': 'gif',
+ 'image/svg+xml': 'svg',
+ 'image/webp': 'webp',
+ 'image/bmp': 'bmp',
+ 'image/tiff': 'tiff',
+ 'application/pdf': 'pdf',
+ 'text/plain': 'txt',
+ 'text/html': 'html',
+ 'application/json': 'json',
+ 'application/octet-stream': 'bin'
+}
+
+const extMap = Object.fromEntries(
+ Object.entries(mimeMap).map(([contentType, extension]) => [extension, contentType])
+) as Record
+
+const mime = {
+ extension: (contentType: string): string | false => mimeMap[contentType] || false,
+ lookup: (filename: string): string | false => {
+ const ext = filename.split('.').pop()?.toLowerCase() || ''
+ return extMap[ext] || false
+ }
+}
+
+function subjectDirectoryUri (subject: NamedNode): string {
+ const docUri = subject.doc().uri
+ const lastSlash = docUri.lastIndexOf('/')
+ return lastSlash >= 0 ? docUri.slice(0, lastSlash + 1) : docUri
+}
+
+export async function resolvePhotoDisplaySrc (store: LiveStore, imageSrc?: string): Promise {
+ if (!imageSrc || imageSrc.startsWith('blob:') || imageSrc.startsWith('data:')) {
+ return imageSrc
+ }
+
+ const cachedImageSrc = resolvedHeadingImageCache.get(imageSrc)
+ if (cachedImageSrc) {
+ return cachedImageSrc
+ }
+
+ const fetcher = store.fetcher as { _fetch?: (url: string) => Promise } | undefined
+ if (!fetcher?._fetch || typeof URL.createObjectURL !== 'function') {
+ return imageSrc
+ }
+
+ try {
+ const response = await fetcher._fetch(imageSrc)
+ if (!response.ok) {
+ return imageSrc
+ }
+
+ const imageBlob = await response.blob()
+ const resolvedImageSrc = URL.createObjectURL(imageBlob)
+ resolvedHeadingImageCache.set(imageSrc, resolvedImageSrc)
+ return resolvedImageSrc
+ } catch {
+ return imageSrc
+ }
+}
+
+export async function uploadPhotoFile (store: LiveStore, subject: NamedNode, file: File): Promise {
+ if (!store.fetcher) {
+ throw new Error('Store has no fetcher.')
+ }
+
+ const detectedContentType = file.type || mime.lookup(file.name) || 'application/octet-stream'
+ if (!detectedContentType.startsWith('image/')) {
+ throw new Error('Selected file is not an image.')
+ }
+ if (file.size > 2 * 1024 * 1024) {
+ throw new Error('Selected image must be 2 MB or smaller.')
+ }
+
+ const extension = mime.extension(detectedContentType) || 'bin'
+ let filename = encodeURIComponent(file.name || `image.${extension}`)
+
+ if (detectedContentType !== mime.lookup(file.name || '')) {
+ filename += `_.${extension}`
+ }
+
+ const directoryUri = subjectDirectoryUri(subject)
+ let candidateUri = `${directoryUri}${filename}`
+
+ for (let index = 0; store.holds(subject, ns.vcard('hasPhoto'), sym(candidateUri)); index++) {
+ const fallbackName = `image_${index}.${extension}`
+ candidateUri = `${directoryUri}${fallbackName}`
+ }
+ try {
+ const data = await file.arrayBuffer()
+ const response = await store.fetcher.webOperation('PUT', candidateUri, {
+ data: data as unknown as string,
+ contentType: detectedContentType
+ } as any)
+ if (!response.ok) {
+ throw new Error(`Error uploading picture: ${response.status} ${response.statusText}`)
+ }
+ } catch (error) {
+ throw new Error(`Error uploading picture: ${error}`)
+ }
+
+ const currentUser = authn.currentUser()
+ if (currentUser) {
+ try {
+ await solidLogicSingleton.acl.setACLUserPublic(candidateUri, currentUser, {
+ public: ['Read'] as unknown as []
+ })
+ } catch (error) {
+ console.error(`Error setting uploaded picture permissions: ${error}`)
+ }
+ }
+
+ return candidateUri
+}
diff --git a/src/components/profile-heading/editor/sections/heading/imagePreview.ts b/src/components/profile-heading/editor/sections/heading/imagePreview.ts
new file mode 100644
index 00000000..6effcdb6
--- /dev/null
+++ b/src/components/profile-heading/editor/sections/heading/imagePreview.ts
@@ -0,0 +1,34 @@
+import { html, nothing } from 'lit-html'
+import '~icons/lucide/circle-user-round'
+
+const personInCircleIcon = html` `
+
+const showHeadingImageFallback = (event: Event) => {
+ const image = event.currentTarget as HTMLImageElement | null
+ const frame = image?.parentElement
+ if (!image || !frame) return
+
+ image.hidden = true
+ frame.classList.add('profile__image-frame--fallback')
+}
+
+export const Image = (src?: string, alt?: string) => html`
+
+ ${src
+ ? html`
+
+ `
+ : nothing}
+
+ ${personInCircleIcon}
+
+
+`
diff --git a/src/components/profile-heading/editor/sections/heading/mutations.ts b/src/components/profile-heading/editor/sections/heading/mutations.ts
new file mode 100644
index 00000000..7e74b5e9
--- /dev/null
+++ b/src/components/profile-heading/editor/sections/heading/mutations.ts
@@ -0,0 +1,318 @@
+import { LiveStore, NamedNode, Node, st, literal, sym } from 'rdflib'
+import { ns } from 'solid-ui'
+import { ProfileBasicRow, HeadingMutationPlan } from './types'
+import { MutationOps } from '../shared/types'
+import { MutationDocumentTextCache, collectLinkedNodeStatements, collectNodeStatements, findExistingNode, replacePredicateStatements, runUpdateTransport, shouldForceDocumentPutForStatements } from '../shared/rdfMutationHelpers'
+import { createIdNode } from '../shared/idNodeFactory'
+import { ContactAddressRow, ContactPointRow } from '../contactInfo/types'
+import { headingMutationSaveFailedDebugText, saveHeadingUpdatesFailedMessageText, updaterUnsupportedStoreErrorMessageText } from '../../texts'
+
+function splitPronouns (value?: string): { subjectPronoun?: string, objectPronoun?: string } {
+ const normalized = (value || '').trim()
+ if (!normalized) return {}
+
+ const [subjectPronounRaw, objectPronounRaw] = normalized.split('/').map((part) => part.trim())
+ const subjectPronoun = subjectPronounRaw || undefined
+ const objectPronoun = objectPronounRaw || undefined
+
+ return { subjectPronoun, objectPronoun }
+}
+
+function buildPhoneStatements (subject: NamedNode, doc: NamedNode, node: Node, phone: ContactPointRow) {
+ const normalizedValue = phone.value.startsWith('tel:') ? phone.value : `tel:${phone.value}`
+ const valueNode = sym(normalizedValue)
+ const inserts = [
+ st(subject, ns.vcard('hasTelephone'), node as any, doc),
+ st(node as any, ns.vcard('value'), valueNode as any, doc)
+ ]
+
+ if (phone.type) {
+ inserts.push(st(node as any, ns.rdf('type'), ns.vcard(phone.type), doc))
+ }
+
+ return inserts
+}
+
+function buildEmailStatements (subject: NamedNode, doc: NamedNode, node: Node, email: ContactPointRow) {
+ const normalizedValue = email.value.startsWith('mailto:') ? email.value : `mailto:${email.value}`
+ const valueNode = sym(normalizedValue)
+ const inserts = [
+ st(subject, ns.vcard('hasEmail'), node as any, doc),
+ st(node as any, ns.vcard('value'), valueNode as any, doc)
+ ]
+
+ if (email.type) {
+ inserts.push(st(node as any, ns.rdf('type'), ns.vcard(email.type), doc))
+ }
+
+ return inserts
+}
+
+function buildAddressStatements (subject: NamedNode, doc: NamedNode, node: Node, address: ContactAddressRow) {
+ const inserts = [st(subject, ns.vcard('hasAddress'), node as any, doc)]
+
+ if (address.type) inserts.push(st(node as any, ns.rdf('type'), ns.vcard(address.type), doc))
+ if (address.streetAddress) inserts.push(st(node as any, ns.vcard('street-address'), literal(address.streetAddress), doc))
+ if (address.locality) inserts.push(st(node as any, ns.vcard('locality'), literal(address.locality), doc))
+ if (address.region) inserts.push(st(node as any, ns.vcard('region'), literal(address.region), doc))
+ if (address.postalCode) inserts.push(st(node as any, ns.vcard('postal-code'), literal(address.postalCode), doc))
+ if (address.countryName) inserts.push(st(node as any, ns.vcard('country-name'), literal(address.countryName), doc))
+
+ return inserts
+}
+
+function findOpWithExistingEntry (ops: T[], existingNodes: Node[]): T | undefined {
+ return ops.find((op) => Boolean(op.entryNode) && Boolean(findExistingNode(existingNodes, op.entryNode)))
+}
+
+function findCreateOp (ops: T[]): T | undefined {
+ return ops.find((op) => !op.entryNode)
+}
+
+async function mutatePhoneEntry (store: LiveStore, subject: NamedNode, phoneOps: MutationOps, documentTextCache?: MutationDocumentTextCache) {
+ const doc = subject.doc()
+ const existingPhoneNodes = store.each(subject, ns.vcard('hasTelephone'), null, doc) as Node[]
+ const deletions: any[] = []
+ const insertions: any[] = []
+
+ const removePhone = findOpWithExistingEntry(phoneOps.remove, existingPhoneNodes)
+ const updatePhone = findOpWithExistingEntry(phoneOps.update, existingPhoneNodes)
+ const createPhone = findCreateOp(phoneOps.create) || phoneOps.create[0]
+
+ if (removePhone?.entryNode) {
+ const existingNode = findExistingNode(existingPhoneNodes, removePhone.entryNode)
+ if (existingNode) {
+ const linkedPhoneStatements = collectLinkedNodeStatements(store, subject, ns.vcard('hasTelephone'), doc)
+ const matchingLinkStatement = linkedPhoneStatements.linkStatements.find((statement) => statement.object?.value === existingNode.value)
+ if (matchingLinkStatement) {
+ deletions.push(matchingLinkStatement)
+ }
+ deletions.push(...collectNodeStatements(store, existingNode, doc))
+ }
+ }
+
+ if (updatePhone) {
+ const existingNode = findExistingNode(existingPhoneNodes, updatePhone.entryNode)
+ if (existingNode) {
+ deletions.push(...collectNodeStatements(store, existingNode, doc))
+ insertions.push(...buildPhoneStatements(subject, doc, existingNode, updatePhone))
+ }
+ }
+
+ if (createPhone) {
+ insertions.push(...buildPhoneStatements(subject, doc, createIdNode(doc), createPhone))
+ }
+
+ const shouldSerializeDocument = await shouldForceDocumentPutForStatements(store, doc, insertions, undefined, { documentTextCache })
+
+ await runUpdateTransport(store, doc, deletions, insertions, {
+ unsupportedMessage: updaterUnsupportedStoreErrorMessageText,
+ failureMessage: 'Failed to save heading updates',
+ documentTextCache,
+ useDavFallback: false,
+ usePutFallback: shouldSerializeDocument,
+ forcePut: shouldSerializeDocument
+ })
+}
+
+async function mutateEmailEntry (store: LiveStore, subject: NamedNode, emailOps: MutationOps, documentTextCache?: MutationDocumentTextCache) {
+ const doc = subject.doc()
+ const existingEmailNodes = store.each(subject, ns.vcard('hasEmail'), null, doc) as Node[]
+ const deletions: any[] = []
+ const insertions: any[] = []
+
+ const removeEmail = findOpWithExistingEntry(emailOps.remove, existingEmailNodes)
+ const updateEmail = findOpWithExistingEntry(emailOps.update, existingEmailNodes)
+ const createEmail = findCreateOp(emailOps.create) || emailOps.create[0]
+
+ if (removeEmail?.entryNode) {
+ const existingNode = findExistingNode(existingEmailNodes, removeEmail.entryNode)
+ if (existingNode) {
+ const linkedEmailStatements = collectLinkedNodeStatements(store, subject, ns.vcard('hasEmail'), doc)
+ const matchingLinkStatement = linkedEmailStatements.linkStatements.find((statement) => statement.object?.value === existingNode.value)
+ if (matchingLinkStatement) {
+ deletions.push(matchingLinkStatement)
+ }
+ deletions.push(...collectNodeStatements(store, existingNode, doc))
+ }
+ }
+
+ if (updateEmail) {
+ const existingNode = findExistingNode(existingEmailNodes, updateEmail.entryNode)
+ if (existingNode) {
+ deletions.push(...collectNodeStatements(store, existingNode, doc))
+ insertions.push(...buildEmailStatements(subject, doc, existingNode, updateEmail))
+ }
+ }
+
+ if (createEmail) {
+ insertions.push(...buildEmailStatements(subject, doc, createIdNode(doc), createEmail))
+ }
+
+ const shouldSerializeDocument = await shouldForceDocumentPutForStatements(store, doc, insertions, undefined, { documentTextCache })
+
+ await runUpdateTransport(store, doc, deletions, insertions, {
+ unsupportedMessage: updaterUnsupportedStoreErrorMessageText,
+ failureMessage: 'Failed to save heading updates',
+ documentTextCache,
+ useDavFallback: false,
+ usePutFallback: shouldSerializeDocument,
+ forcePut: shouldSerializeDocument
+ })
+}
+
+async function mutateAddressEntry (store: LiveStore, subject: NamedNode, addressOps: MutationOps, documentTextCache?: MutationDocumentTextCache) {
+ const doc = subject.doc()
+ const existingAddressNodes = store.each(subject, ns.vcard('hasAddress'), null, doc) as Node[]
+ const deletions: any[] = []
+ const insertions: any[] = []
+
+ const removeAddress = findOpWithExistingEntry(addressOps.remove, existingAddressNodes)
+ const updateAddress = findOpWithExistingEntry(addressOps.update, existingAddressNodes)
+ const createAddress = findCreateOp(addressOps.create) || addressOps.create[0]
+
+ if (removeAddress?.entryNode) {
+ const existingNode = findExistingNode(existingAddressNodes, removeAddress.entryNode)
+ if (existingNode) {
+ const linkedAddressStatements = collectLinkedNodeStatements(store, subject, ns.vcard('hasAddress'), doc)
+ const matchingLinkStatement = linkedAddressStatements.linkStatements.find((statement) => statement.object?.value === existingNode.value)
+ if (matchingLinkStatement) {
+ deletions.push(matchingLinkStatement)
+ }
+ deletions.push(...collectNodeStatements(store, existingNode, doc))
+ }
+ }
+
+ if (updateAddress) {
+ const existingNode = findExistingNode(existingAddressNodes, updateAddress.entryNode)
+ if (existingNode) {
+ deletions.push(...collectNodeStatements(store, existingNode, doc))
+ insertions.push(...buildAddressStatements(subject, doc, existingNode, updateAddress))
+ }
+ }
+
+ if (createAddress) {
+ insertions.push(...buildAddressStatements(subject, doc, createIdNode(doc), createAddress))
+ }
+
+ const shouldSerializeDocument = await shouldForceDocumentPutForStatements(store, doc, insertions, undefined, { documentTextCache })
+
+ await runUpdateTransport(store, doc, deletions, insertions, {
+ unsupportedMessage: updaterUnsupportedStoreErrorMessageText,
+ failureMessage: 'Failed to save heading updates',
+ documentTextCache,
+ useDavFallback: false,
+ usePutFallback: shouldSerializeDocument,
+ forcePut: shouldSerializeDocument
+ })
+}
+
+async function mutateBasicProfileEntry (store: LiveStore, subject: NamedNode, basicOps: MutationOps, documentTextCache?: MutationDocumentTextCache) {
+ const doc = subject.doc()
+ const deletions: any[] = []
+ const insertions: any[] = []
+
+ const replaceLiteralField = (predicate: NamedNode, value?: string) => {
+ const normalized = (value || '').trim()
+ const nextObject = normalized ? literal(normalized) : null
+ replacePredicateStatements(store, subject, predicate, doc, deletions, insertions, nextObject)
+ }
+
+ const replacePhotoField = (value?: string) => {
+ const normalized = (value || '').trim()
+ const nextObject = !normalized
+ ? null
+ : (normalized.startsWith('http://') || normalized.startsWith('https://'))
+ ? sym(normalized)
+ : literal(normalized)
+ replacePredicateStatements(store, subject, ns.vcard('hasPhoto'), doc, deletions, insertions, nextObject)
+ }
+
+ const replacePronounFields = (value?: string) => {
+ const { subjectPronoun, objectPronoun } = splitPronouns(value)
+
+ replacePredicateStatements(
+ store,
+ subject,
+ ns.solid('preferredSubjectPronoun'),
+ doc,
+ deletions,
+ insertions,
+ subjectPronoun ? literal(subjectPronoun) : null
+ )
+ replacePredicateStatements(
+ store,
+ subject,
+ ns.solid('preferredObjectPronoun'),
+ doc,
+ deletions,
+ insertions,
+ objectPronoun ? literal(objectPronoun) : null
+ )
+ replacePredicateStatements(
+ store,
+ subject,
+ ns.solid('preferredRelativePronoun'),
+ doc,
+ deletions,
+ insertions,
+ null
+ )
+ }
+
+ const applyBasics = (basic: ProfileBasicRow, clearAll = false) => {
+ const data = clearAll
+ ? {
+ name: '',
+ nickname: '',
+ pronouns: '',
+ dateOfBirth: '',
+ imageSrc: ''
+ }
+ : basic
+
+ replaceLiteralField(ns.vcard('fn'), data.name)
+ // Keep foaf:nick in sync where available in existing data.
+ replaceLiteralField(ns.foaf('nick'), data.nickname)
+ replaceLiteralField(ns.vcard('nickname'), data.nickname)
+ replaceLiteralField(ns.vcard('bday'), data.dateOfBirth)
+ replacePronounFields(data.pronouns)
+ replacePhotoField(data.imageSrc)
+ }
+
+ const removeBasic = basicOps.remove[0]
+ const updateBasic = basicOps.update[0]
+ const createBasic = basicOps.create[0]
+
+ const selectedBasic = updateBasic || createBasic || removeBasic
+ if (selectedBasic) {
+ applyBasics(selectedBasic, Boolean(removeBasic && !updateBasic && !createBasic))
+ }
+
+ const shouldSerializeDocument = await shouldForceDocumentPutForStatements(store, doc, insertions, undefined, { documentTextCache })
+
+ await runUpdateTransport(store, doc, deletions, insertions, {
+ unsupportedMessage: updaterUnsupportedStoreErrorMessageText,
+ failureMessage: 'Failed to save heading updates',
+ documentTextCache,
+ useDavFallback: false,
+ usePutFallback: shouldSerializeDocument,
+ forcePut: shouldSerializeDocument
+ })
+}
+
+export async function processHeadingMutations (store: LiveStore, subject: NamedNode, mutationPlan: HeadingMutationPlan) {
+ try {
+ const documentTextCache: MutationDocumentTextCache = new Map()
+ await mutateBasicProfileEntry(store, subject, mutationPlan.basicOps, documentTextCache)
+ await mutatePhoneEntry(store, subject, mutationPlan.phoneOps, documentTextCache)
+ await mutateEmailEntry(store, subject, mutationPlan.emailOps, documentTextCache)
+ await mutateAddressEntry(store, subject, mutationPlan.addressOps, documentTextCache)
+ } catch (error) {
+ const rootError = error instanceof Error ? error : new Error(String(error))
+ console.error(headingMutationSaveFailedDebugText, rootError)
+ const saveError = new Error(saveHeadingUpdatesFailedMessageText)
+ Object.defineProperty(saveError, 'cause', { value: rootError })
+ throw saveError
+ }
+}
diff --git a/src/components/profile-heading/editor/sections/heading/types.ts b/src/components/profile-heading/editor/sections/heading/types.ts
new file mode 100644
index 00000000..5f9bd6b7
--- /dev/null
+++ b/src/components/profile-heading/editor/sections/heading/types.ts
@@ -0,0 +1,43 @@
+import { Node } from 'rdflib'
+import { RowStatus } from '../shared/types'
+import { MutationOps } from '../shared/types'
+import type { AddressDetails, ContactAddressRow, ContactPointRow, PointDetails } from 'profile-pane'
+
+export interface ProfilePresentation {
+ name: string;
+ nickname?: string;
+ imageSrc?: string;
+ location?: string;
+ pronouns?: string;
+ dateOfBirth?: string;
+ jobTitle?: string;
+ primaryPhone?: PointDetails;
+ primaryEmail?: PointDetails;
+ primaryAddress?: AddressDetails;
+}
+
+export interface ProfileDetails extends ProfilePresentation {
+ entryNode: Node
+}
+
+export interface ProfileRow extends ProfilePresentation {
+ entryNode: string
+ status: RowStatus
+}
+
+export type ProfileBasicFields = Omit<
+ ProfilePresentation,
+ 'jobTitle' | 'primaryPhone' | 'primaryEmail' | 'primaryAddress'
+>
+
+export type ProfileBasicRow = ProfileBasicFields & {
+ entryNode: string
+ status: RowStatus
+}
+
+export type HeadingMutationPlan = {
+ basicOps: MutationOps
+ phoneOps: MutationOps
+ emailOps: MutationOps
+ addressOps: MutationOps
+}
diff --git a/src/components/profile-heading/editor/sections/shared/contactTypeUtils.ts b/src/components/profile-heading/editor/sections/shared/contactTypeUtils.ts
new file mode 100644
index 00000000..73270400
--- /dev/null
+++ b/src/components/profile-heading/editor/sections/shared/contactTypeUtils.ts
@@ -0,0 +1,47 @@
+import { toTypeLabel } from '../../textUtils'
+
+export function normalizePhoneTypeForEdit (value: unknown): string {
+ const label = toTypeLabel(value).trim().toLowerCase()
+ if (!label) return ''
+
+ if (label === 'cell' || label === 'mobile') return 'Mobile'
+ if (label === 'home') return 'Home'
+ if (label === 'work' || label === 'office') return 'Work'
+ return ''
+}
+
+export function normalizePhoneTypeForContactInfoEdit (value: unknown): string {
+ const label = toTypeLabel(value).trim().toLowerCase()
+ if (!label) return ''
+
+ if (label === 'cell' || label === 'mobile') return 'Cell'
+ if (label === 'home') return 'Home'
+ if (label === 'work' || label === 'office') return 'Work'
+ return ''
+}
+
+export function normalizeEmailTypeForEdit (value: unknown): string {
+ const label = toTypeLabel(value).trim().toLowerCase()
+ if (!label) return ''
+
+ if (label === 'personal' || label === 'home') return 'Personal'
+ if (label === 'office' || label === 'work') return 'Office'
+ return ''
+}
+
+export function normalizeEmailTypeForContactInfoEdit (value: unknown): string {
+ const label = toTypeLabel(value).trim().toLowerCase()
+ if (!label) return ''
+
+ if (label === 'personal' || label === 'home') return 'Home'
+ if (label === 'office' || label === 'work') return 'Office'
+ return ''
+}
+
+export function toSavedHeadingEmailType (type: string | undefined): string {
+ return type === 'Personal' ? 'Home' : (type || '')
+}
+
+export function toSavedHeadingPhoneType (type: string | undefined): string {
+ return type === 'Mobile' ? 'Cell' : (type || '')
+}
diff --git a/src/components/profile-heading/editor/sections/shared/idNodeFactory.ts b/src/components/profile-heading/editor/sections/shared/idNodeFactory.ts
new file mode 100644
index 00000000..834d58e0
--- /dev/null
+++ b/src/components/profile-heading/editor/sections/shared/idNodeFactory.ts
@@ -0,0 +1,10 @@
+import { NamedNode, sym } from 'rdflib'
+
+let lastIssuedTimestamp = 0
+
+export function createIdNode (doc: NamedNode): NamedNode {
+ const now = Date.now()
+ const timestampId = now > lastIssuedTimestamp ? now : lastIssuedTimestamp + 1
+ lastIssuedTimestamp = timestampId
+ return sym(`${doc.uri}#id${timestampId}`) as NamedNode
+}
diff --git a/src/components/profile-heading/editor/sections/shared/phoneCountries.ts b/src/components/profile-heading/editor/sections/shared/phoneCountries.ts
new file mode 100644
index 00000000..58bb6073
--- /dev/null
+++ b/src/components/profile-heading/editor/sections/shared/phoneCountries.ts
@@ -0,0 +1,354 @@
+// Not in use now.
+
+export type CountryDialOption = {
+ iso2: string
+ dialCode: string
+ name: string
+}
+
+export const DEFAULT_DIAL_CODE = ''
+
+export const COUNTRY_DIAL_OPTIONS: CountryDialOption[] = [
+ { iso2: 'EG', dialCode: '+20', name: 'Egypt' },
+ { iso2: 'ZA', dialCode: '+27', name: 'South Africa' },
+ { iso2: 'GR', dialCode: '+30', name: 'Greece' },
+ { iso2: 'NL', dialCode: '+31', name: 'Netherlands' },
+ { iso2: 'BE', dialCode: '+32', name: 'Belgium' },
+ { iso2: 'FR', dialCode: '+33', name: 'France' },
+ { iso2: 'ES', dialCode: '+34', name: 'Spain' },
+ { iso2: 'HU', dialCode: '+36', name: 'Hungary' },
+ { iso2: 'IT', dialCode: '+39', name: 'Italy' },
+ { iso2: 'RO', dialCode: '+40', name: 'Romania' },
+ { iso2: 'CH', dialCode: '+41', name: 'Switzerland' },
+ { iso2: 'AT', dialCode: '+43', name: 'Austria' },
+ { iso2: 'GG', dialCode: '+44', name: 'Guernsey' },
+ { iso2: 'IM', dialCode: '+44', name: 'Isle of Man' },
+ { iso2: 'JE', dialCode: '+44', name: 'Jersey' },
+ { iso2: 'GB', dialCode: '+44', name: 'United Kingdom' },
+ { iso2: 'DK', dialCode: '+45', name: 'Denmark' },
+ { iso2: 'SE', dialCode: '+46', name: 'Sweden' },
+ { iso2: 'BV', dialCode: '+47', name: 'Bouvet Island' },
+ { iso2: 'NO', dialCode: '+47', name: 'Norway' },
+ { iso2: 'PL', dialCode: '+48', name: 'Poland' },
+ { iso2: 'DE', dialCode: '+49', name: 'Germany' },
+ { iso2: 'PE', dialCode: '+51', name: 'Peru' },
+ { iso2: 'MX', dialCode: '+52', name: 'Mexico' },
+ { iso2: 'CU', dialCode: '+53', name: 'Cuba' },
+ { iso2: 'AR', dialCode: '+54', name: 'Argentina' },
+ { iso2: 'BR', dialCode: '+55', name: 'Brazil' },
+ { iso2: 'CL', dialCode: '+56', name: 'Chile' },
+ { iso2: 'CO', dialCode: '+57', name: 'Colombia' },
+ { iso2: 'VE', dialCode: '+58', name: 'Venezuela' },
+ { iso2: 'MY', dialCode: '+60', name: 'Malaysia' },
+ { iso2: 'AU', dialCode: '+61', name: 'Australia' },
+ { iso2: 'CX', dialCode: '+61', name: 'Christmas Island' },
+ { iso2: 'CC', dialCode: '+61', name: 'Cocos (Keeling) Islands' },
+ { iso2: 'ID', dialCode: '+62', name: 'Indonesia' },
+ { iso2: 'PH', dialCode: '+63', name: 'Philippines' },
+ { iso2: 'NZ', dialCode: '+64', name: 'New Zealand' },
+ { iso2: 'PN', dialCode: '+64', name: 'Pitcairn Islands' },
+ { iso2: 'SG', dialCode: '+65', name: 'Singapore' },
+ { iso2: 'TH', dialCode: '+66', name: 'Thailand' },
+ { iso2: 'RU', dialCode: '+73', name: 'Russia' },
+ { iso2: 'KZ', dialCode: '+76', name: 'Kazakhstan' },
+ { iso2: 'JP', dialCode: '+81', name: 'Japan' },
+ { iso2: 'KR', dialCode: '+82', name: 'South Korea' },
+ { iso2: 'VN', dialCode: '+84', name: 'Vietnam' },
+ { iso2: 'CN', dialCode: '+86', name: 'China' },
+ { iso2: 'TR', dialCode: '+90', name: 'Turkiye' },
+ { iso2: 'IN', dialCode: '+91', name: 'India' },
+ { iso2: 'PK', dialCode: '+92', name: 'Pakistan' },
+ { iso2: 'AF', dialCode: '+93', name: 'Afghanistan' },
+ { iso2: 'LK', dialCode: '+94', name: 'Sri Lanka' },
+ { iso2: 'MM', dialCode: '+95', name: 'Myanmar' },
+ { iso2: 'IR', dialCode: '+98', name: 'Iran' },
+ { iso2: 'SS', dialCode: '+211', name: 'South Sudan' },
+ { iso2: 'MA', dialCode: '+212', name: 'Morocco' },
+ { iso2: 'DZ', dialCode: '+213', name: 'Algeria' },
+ { iso2: 'TN', dialCode: '+216', name: 'Tunisia' },
+ { iso2: 'LY', dialCode: '+218', name: 'Libya' },
+ { iso2: 'GM', dialCode: '+220', name: 'Gambia' },
+ { iso2: 'SN', dialCode: '+221', name: 'Senegal' },
+ { iso2: 'MR', dialCode: '+222', name: 'Mauritania' },
+ { iso2: 'ML', dialCode: '+223', name: 'Mali' },
+ { iso2: 'GN', dialCode: '+224', name: 'Guinea' },
+ { iso2: 'CI', dialCode: '+225', name: 'Ivory Coast' },
+ { iso2: 'BF', dialCode: '+226', name: 'Burkina Faso' },
+ { iso2: 'NE', dialCode: '+227', name: 'Niger' },
+ { iso2: 'TG', dialCode: '+228', name: 'Togo' },
+ { iso2: 'BJ', dialCode: '+229', name: 'Benin' },
+ { iso2: 'MU', dialCode: '+230', name: 'Mauritius' },
+ { iso2: 'LR', dialCode: '+231', name: 'Liberia' },
+ { iso2: 'SL', dialCode: '+232', name: 'Sierra Leone' },
+ { iso2: 'GH', dialCode: '+233', name: 'Ghana' },
+ { iso2: 'NG', dialCode: '+234', name: 'Nigeria' },
+ { iso2: 'TD', dialCode: '+235', name: 'Chad' },
+ { iso2: 'CF', dialCode: '+236', name: 'Central African Republic' },
+ { iso2: 'CM', dialCode: '+237', name: 'Cameroon' },
+ { iso2: 'CV', dialCode: '+238', name: 'Cape Verde' },
+ { iso2: 'ST', dialCode: '+239', name: 'Sao Tome and Principe' },
+ { iso2: 'GQ', dialCode: '+240', name: 'Equatorial Guinea' },
+ { iso2: 'GA', dialCode: '+241', name: 'Gabon' },
+ { iso2: 'CG', dialCode: '+242', name: 'Congo' },
+ { iso2: 'CD', dialCode: '+243', name: 'DR Congo' },
+ { iso2: 'AO', dialCode: '+244', name: 'Angola' },
+ { iso2: 'GW', dialCode: '+245', name: 'Guinea-Bissau' },
+ { iso2: 'IO', dialCode: '+246', name: 'British Indian Ocean Territory' },
+ { iso2: 'SC', dialCode: '+248', name: 'Seychelles' },
+ { iso2: 'SD', dialCode: '+249', name: 'Sudan' },
+ { iso2: 'RW', dialCode: '+250', name: 'Rwanda' },
+ { iso2: 'ET', dialCode: '+251', name: 'Ethiopia' },
+ { iso2: 'SO', dialCode: '+252', name: 'Somalia' },
+ { iso2: 'DJ', dialCode: '+253', name: 'Djibouti' },
+ { iso2: 'KE', dialCode: '+254', name: 'Kenya' },
+ { iso2: 'TZ', dialCode: '+255', name: 'Tanzania' },
+ { iso2: 'UG', dialCode: '+256', name: 'Uganda' },
+ { iso2: 'BI', dialCode: '+257', name: 'Burundi' },
+ { iso2: 'MZ', dialCode: '+258', name: 'Mozambique' },
+ { iso2: 'ZM', dialCode: '+260', name: 'Zambia' },
+ { iso2: 'MG', dialCode: '+261', name: 'Madagascar' },
+ { iso2: 'TF', dialCode: '+262', name: 'French Southern and Antarctic Lands' },
+ { iso2: 'YT', dialCode: '+262', name: 'Mayotte' },
+ { iso2: 'RE', dialCode: '+262', name: 'Reunion' },
+ { iso2: 'ZW', dialCode: '+263', name: 'Zimbabwe' },
+ { iso2: 'NA', dialCode: '+264', name: 'Namibia' },
+ { iso2: 'MW', dialCode: '+265', name: 'Malawi' },
+ { iso2: 'LS', dialCode: '+266', name: 'Lesotho' },
+ { iso2: 'BW', dialCode: '+267', name: 'Botswana' },
+ { iso2: 'SZ', dialCode: '+268', name: 'Eswatini' },
+ { iso2: 'UM', dialCode: '+268', name: 'United States Minor Outlying Islands' },
+ { iso2: 'KM', dialCode: '+269', name: 'Comoros' },
+ { iso2: 'SH', dialCode: '+290', name: 'Saint Helena, Ascension and Tristan da Cunha' },
+ { iso2: 'ER', dialCode: '+291', name: 'Eritrea' },
+ { iso2: 'AW', dialCode: '+297', name: 'Aruba' },
+ { iso2: 'FO', dialCode: '+298', name: 'Faroe Islands' },
+ { iso2: 'GL', dialCode: '+299', name: 'Greenland' },
+ { iso2: 'GI', dialCode: '+350', name: 'Gibraltar' },
+ { iso2: 'PT', dialCode: '+351', name: 'Portugal' },
+ { iso2: 'LU', dialCode: '+352', name: 'Luxembourg' },
+ { iso2: 'IE', dialCode: '+353', name: 'Ireland' },
+ { iso2: 'IS', dialCode: '+354', name: 'Iceland' },
+ { iso2: 'AL', dialCode: '+355', name: 'Albania' },
+ { iso2: 'MT', dialCode: '+356', name: 'Malta' },
+ { iso2: 'CY', dialCode: '+357', name: 'Cyprus' },
+ { iso2: 'FI', dialCode: '+358', name: 'Finland' },
+ { iso2: 'BG', dialCode: '+359', name: 'Bulgaria' },
+ { iso2: 'LT', dialCode: '+370', name: 'Lithuania' },
+ { iso2: 'LV', dialCode: '+371', name: 'Latvia' },
+ { iso2: 'EE', dialCode: '+372', name: 'Estonia' },
+ { iso2: 'MD', dialCode: '+373', name: 'Moldova' },
+ { iso2: 'AM', dialCode: '+374', name: 'Armenia' },
+ { iso2: 'BY', dialCode: '+375', name: 'Belarus' },
+ { iso2: 'AD', dialCode: '+376', name: 'Andorra' },
+ { iso2: 'MC', dialCode: '+377', name: 'Monaco' },
+ { iso2: 'SM', dialCode: '+378', name: 'San Marino' },
+ { iso2: 'UA', dialCode: '+380', name: 'Ukraine' },
+ { iso2: 'RS', dialCode: '+381', name: 'Serbia' },
+ { iso2: 'ME', dialCode: '+382', name: 'Montenegro' },
+ { iso2: 'XK', dialCode: '+383', name: 'Kosovo' },
+ { iso2: 'HR', dialCode: '+385', name: 'Croatia' },
+ { iso2: 'SI', dialCode: '+386', name: 'Slovenia' },
+ { iso2: 'BA', dialCode: '+387', name: 'Bosnia and Herzegovina' },
+ { iso2: 'MK', dialCode: '+389', name: 'North Macedonia' },
+ { iso2: 'CZ', dialCode: '+420', name: 'Czechia' },
+ { iso2: 'SK', dialCode: '+421', name: 'Slovakia' },
+ { iso2: 'LI', dialCode: '+423', name: 'Liechtenstein' },
+ { iso2: 'FK', dialCode: '+500', name: 'Falkland Islands' },
+ { iso2: 'GS', dialCode: '+500', name: 'South Georgia' },
+ { iso2: 'BZ', dialCode: '+501', name: 'Belize' },
+ { iso2: 'GT', dialCode: '+502', name: 'Guatemala' },
+ { iso2: 'SV', dialCode: '+503', name: 'El Salvador' },
+ { iso2: 'HN', dialCode: '+504', name: 'Honduras' },
+ { iso2: 'NI', dialCode: '+505', name: 'Nicaragua' },
+ { iso2: 'CR', dialCode: '+506', name: 'Costa Rica' },
+ { iso2: 'PA', dialCode: '+507', name: 'Panama' },
+ { iso2: 'PM', dialCode: '+508', name: 'Saint Pierre and Miquelon' },
+ { iso2: 'HT', dialCode: '+509', name: 'Haiti' },
+ { iso2: 'GP', dialCode: '+590', name: 'Guadeloupe' },
+ { iso2: 'BL', dialCode: '+590', name: 'Saint Barthelemy' },
+ { iso2: 'MF', dialCode: '+590', name: 'Saint Martin' },
+ { iso2: 'BO', dialCode: '+591', name: 'Bolivia' },
+ { iso2: 'GY', dialCode: '+592', name: 'Guyana' },
+ { iso2: 'EC', dialCode: '+593', name: 'Ecuador' },
+ { iso2: 'GF', dialCode: '+594', name: 'French Guiana' },
+ { iso2: 'PY', dialCode: '+595', name: 'Paraguay' },
+ { iso2: 'MQ', dialCode: '+596', name: 'Martinique' },
+ { iso2: 'SR', dialCode: '+597', name: 'Suriname' },
+ { iso2: 'UY', dialCode: '+598', name: 'Uruguay' },
+ { iso2: 'BQ', dialCode: '+599', name: 'Caribbean Netherlands' },
+ { iso2: 'CW', dialCode: '+599', name: 'Curacao' },
+ { iso2: 'TL', dialCode: '+670', name: 'Timor-Leste' },
+ { iso2: 'NF', dialCode: '+672', name: 'Norfolk Island' },
+ { iso2: 'BN', dialCode: '+673', name: 'Brunei' },
+ { iso2: 'NR', dialCode: '+674', name: 'Nauru' },
+ { iso2: 'PG', dialCode: '+675', name: 'Papua New Guinea' },
+ { iso2: 'TO', dialCode: '+676', name: 'Tonga' },
+ { iso2: 'SB', dialCode: '+677', name: 'Solomon Islands' },
+ { iso2: 'VU', dialCode: '+678', name: 'Vanuatu' },
+ { iso2: 'FJ', dialCode: '+679', name: 'Fiji' },
+ { iso2: 'PW', dialCode: '+680', name: 'Palau' },
+ { iso2: 'WF', dialCode: '+681', name: 'Wallis and Futuna' },
+ { iso2: 'CK', dialCode: '+682', name: 'Cook Islands' },
+ { iso2: 'NU', dialCode: '+683', name: 'Niue' },
+ { iso2: 'WS', dialCode: '+685', name: 'Samoa' },
+ { iso2: 'KI', dialCode: '+686', name: 'Kiribati' },
+ { iso2: 'NC', dialCode: '+687', name: 'New Caledonia' },
+ { iso2: 'TV', dialCode: '+688', name: 'Tuvalu' },
+ { iso2: 'PF', dialCode: '+689', name: 'French Polynesia' },
+ { iso2: 'TK', dialCode: '+690', name: 'Tokelau' },
+ { iso2: 'FM', dialCode: '+691', name: 'Micronesia' },
+ { iso2: 'MH', dialCode: '+692', name: 'Marshall Islands' },
+ { iso2: 'KP', dialCode: '+850', name: 'North Korea' },
+ { iso2: 'HK', dialCode: '+852', name: 'Hong Kong' },
+ { iso2: 'MO', dialCode: '+853', name: 'Macau' },
+ { iso2: 'KH', dialCode: '+855', name: 'Cambodia' },
+ { iso2: 'LA', dialCode: '+856', name: 'Laos' },
+ { iso2: 'BD', dialCode: '+880', name: 'Bangladesh' },
+ { iso2: 'TW', dialCode: '+886', name: 'Taiwan' },
+ { iso2: 'MV', dialCode: '+960', name: 'Maldives' },
+ { iso2: 'LB', dialCode: '+961', name: 'Lebanon' },
+ { iso2: 'JO', dialCode: '+962', name: 'Jordan' },
+ { iso2: 'SY', dialCode: '+963', name: 'Syria' },
+ { iso2: 'IQ', dialCode: '+964', name: 'Iraq' },
+ { iso2: 'KW', dialCode: '+965', name: 'Kuwait' },
+ { iso2: 'SA', dialCode: '+966', name: 'Saudi Arabia' },
+ { iso2: 'YE', dialCode: '+967', name: 'Yemen' },
+ { iso2: 'OM', dialCode: '+968', name: 'Oman' },
+ { iso2: 'PS', dialCode: '+970', name: 'Palestine' },
+ { iso2: 'AE', dialCode: '+971', name: 'United Arab Emirates' },
+ { iso2: 'IL', dialCode: '+972', name: 'Israel' },
+ { iso2: 'BH', dialCode: '+973', name: 'Bahrain' },
+ { iso2: 'QA', dialCode: '+974', name: 'Qatar' },
+ { iso2: 'BT', dialCode: '+975', name: 'Bhutan' },
+ { iso2: 'MN', dialCode: '+976', name: 'Mongolia' },
+ { iso2: 'NP', dialCode: '+977', name: 'Nepal' },
+ { iso2: 'TJ', dialCode: '+992', name: 'Tajikistan' },
+ { iso2: 'TM', dialCode: '+993', name: 'Turkmenistan' },
+ { iso2: 'AZ', dialCode: '+994', name: 'Azerbaijan' },
+ { iso2: 'GE', dialCode: '+995', name: 'Georgia' },
+ { iso2: 'KG', dialCode: '+996', name: 'Kyrgyzstan' },
+ { iso2: 'UZ', dialCode: '+998', name: 'Uzbekistan' },
+ { iso2: 'US', dialCode: '+1201', name: 'United States' },
+ { iso2: 'CA', dialCode: '+1204', name: 'Canada' },
+ { iso2: 'BS', dialCode: '+1242', name: 'Bahamas' },
+ { iso2: 'BB', dialCode: '+1246', name: 'Barbados' },
+ { iso2: 'AI', dialCode: '+1264', name: 'Anguilla' },
+ { iso2: 'AG', dialCode: '+1268', name: 'Antigua and Barbuda' },
+ { iso2: 'VG', dialCode: '+1284', name: 'British Virgin Islands' },
+ { iso2: 'VI', dialCode: '+1340', name: 'United States Virgin Islands' },
+ { iso2: 'KY', dialCode: '+1345', name: 'Cayman Islands' },
+ { iso2: 'BM', dialCode: '+1441', name: 'Bermuda' },
+ { iso2: 'GD', dialCode: '+1473', name: 'Grenada' },
+ { iso2: 'TC', dialCode: '+1649', name: 'Turks and Caicos Islands' },
+ { iso2: 'MS', dialCode: '+1664', name: 'Montserrat' },
+ { iso2: 'MP', dialCode: '+1670', name: 'Northern Mariana Islands' },
+ { iso2: 'GU', dialCode: '+1671', name: 'Guam' },
+ { iso2: 'AS', dialCode: '+1684', name: 'American Samoa' },
+ { iso2: 'SX', dialCode: '+1721', name: 'Sint Maarten' },
+ { iso2: 'LC', dialCode: '+1758', name: 'Saint Lucia' },
+ { iso2: 'DM', dialCode: '+1767', name: 'Dominica' },
+ { iso2: 'VC', dialCode: '+1784', name: 'Saint Vincent and the Grenadines' },
+ { iso2: 'PR', dialCode: '+1787', name: 'Puerto Rico' },
+ { iso2: 'DO', dialCode: '+1809', name: 'Dominican Republic' },
+ { iso2: 'TT', dialCode: '+1868', name: 'Trinidad and Tobago' },
+ { iso2: 'KN', dialCode: '+1869', name: 'Saint Kitts and Nevis' },
+ { iso2: 'JM', dialCode: '+1876', name: 'Jamaica' },
+ { iso2: 'SJ', dialCode: '+4779', name: 'Svalbard and Jan Mayen' },
+ { iso2: 'AX', dialCode: '+35818', name: 'Aland Islands' },
+ { iso2: 'EH', dialCode: '+2125288', name: 'Western Sahara' },
+ { iso2: 'VA', dialCode: '+3906698', name: 'Vatican City' },
+]
+
+const NON_USER_FACING_ISO2 = new Set([
+ 'AQ', 'AX', 'BL', 'BM', 'BQ', 'BV', 'CC', 'CK', 'CP', 'CW', 'CX', 'EH', 'FK', 'FO',
+ 'GF', 'GG', 'GI', 'GL', 'GP', 'GS', 'GU', 'HK', 'HM', 'IM', 'IO', 'JE', 'KY', 'MF',
+ 'MO', 'MP', 'MQ', 'MS', 'NC', 'NF', 'NU', 'PF', 'PM', 'PN', 'PR', 'RE', 'SH', 'SJ',
+ 'TC', 'TF', 'TK', 'UM', 'VG', 'VI', 'WF', 'YT'
+])
+
+function normalizeDialCode (rawDialCode: string): string {
+ if (/^\+1\d{3,}$/.test(rawDialCode)) return '+1'
+ if (/^\+7\d+$/.test(rawDialCode)) return '+7'
+ if (/^\+39\d{3,}$/.test(rawDialCode)) return '+39'
+ if (/^\+358\d+$/.test(rawDialCode)) return '+358'
+ if (/^\+47\d+$/.test(rawDialCode)) return '+47'
+ if (/^\+212\d+$/.test(rawDialCode)) return '+212'
+ return rawDialCode
+}
+
+const COUNTRY_DIAL_OPTIONS_USER = COUNTRY_DIAL_OPTIONS
+ .filter((option) => !NON_USER_FACING_ISO2.has(option.iso2))
+ .map((option) => ({ ...option, dialCode: normalizeDialCode(option.dialCode) }))
+ .filter((option, index, source) => {
+ const key = `${option.iso2}|${option.dialCode}`
+ return source.findIndex((candidate) => `${candidate.iso2}|${candidate.dialCode}` === key) === index
+ })
+
+export const COUNTRY_DIAL_OPTIONS_ASC = [...COUNTRY_DIAL_OPTIONS_USER].sort((a, b) => {
+ const codeA = Number.parseInt(a.dialCode.replace('+', ''), 10)
+ const codeB = Number.parseInt(b.dialCode.replace('+', ''), 10)
+ if (codeA !== codeB) return codeA - codeB
+ return a.name.localeCompare(b.name)
+})
+
+const PREFERRED_ISO_BY_DIAL_CODE: Record = {
+ '+1': 'US',
+ '+7': 'RU'
+}
+
+export const COUNTRY_PREFIX_OPTIONS = Array.from(
+ COUNTRY_DIAL_OPTIONS_ASC.reduce((map, option) => {
+ const current = map.get(option.dialCode)
+ if (!current) {
+ map.set(option.dialCode, option)
+ return map
+ }
+
+ const preferredIso = PREFERRED_ISO_BY_DIAL_CODE[option.dialCode]
+ if (preferredIso && option.iso2 === preferredIso) {
+ map.set(option.dialCode, option)
+ }
+
+ return map
+ }, new Map()).values()
+)
+
+const DIAL_CODES_DESC = Array.from(new Set(COUNTRY_PREFIX_OPTIONS.map((option) => option.dialCode))).sort(
+ (a, b) => b.length - a.length
+)
+
+export function countryCodeToFlag (iso2: string): string {
+ const normalized = iso2.toUpperCase()
+ if (!/^[A-Z]{2}$/.test(normalized)) return ''
+ const codePoints = [...normalized].map((char) => 127397 + char.charCodeAt(0))
+ return String.fromCodePoint(...codePoints)
+}
+
+export function splitPhoneValue (raw: string) {
+ const value = raw.trim()
+ if (!value) return { dialCode: DEFAULT_DIAL_CODE, localNumber: '' }
+ if (!value.startsWith('+')) return { dialCode: DEFAULT_DIAL_CODE, localNumber: value }
+
+ const matchedDialCode = DIAL_CODES_DESC.find((dialCode) => value.startsWith(dialCode))
+ if (!matchedDialCode) {
+ return { dialCode: DEFAULT_DIAL_CODE, localNumber: value }
+ }
+
+ const normalizedDialCode = normalizeDialCode(matchedDialCode)
+ const localNumber = value.slice(matchedDialCode.length)
+ const fallbackLocalNumber = value.slice(normalizedDialCode.length)
+
+ return {
+ dialCode: COUNTRY_PREFIX_OPTIONS.some((option) => option.dialCode === normalizedDialCode)
+ ? normalizedDialCode
+ : DEFAULT_DIAL_CODE,
+ localNumber: matchedDialCode.length > normalizedDialCode.length ? fallbackLocalNumber : localNumber
+ }
+}
+
+export function combinePhoneValue (dialCode: string, localNumber: string): string {
+ const trimmed = localNumber.trim()
+ if (!trimmed) return ''
+ if (trimmed.startsWith('+')) return trimmed
+ return dialCode ? `${dialCode}${trimmed}` : trimmed
+}
diff --git a/src/components/profile-heading/editor/sections/shared/rdfMutationHelpers.ts b/src/components/profile-heading/editor/sections/shared/rdfMutationHelpers.ts
new file mode 100644
index 00000000..ffdf5ca9
--- /dev/null
+++ b/src/components/profile-heading/editor/sections/shared/rdfMutationHelpers.ts
@@ -0,0 +1,503 @@
+import { graph, LiveStore, NamedNode, Node, st } from 'rdflib'
+import { fallbackSaveUpdatesErrorMessageText, updaterUnsupportedStoreErrorMessageText } from '../../texts'
+import { PrefixCapable, RdfFetcher, RdfStatement, RdfUpdater } from './types'
+/* Some of the code below was generated by AI Model: GPT-5.4.
+ Prompt: can you add types to rdfMutationHelpers.ts and consolidate mutation logic
+ where appropriate. */
+
+const STANDARD_MUTATION_PREFIXES: Record = {
+ rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
+ vcard: 'http://www.w3.org/2006/vcard/ns#',
+ foaf: 'http://xmlns.com/foaf/0.1/',
+ solid: 'http://www.w3.org/ns/solid/terms#',
+ schema: 'http://schema.org/',
+ org: 'http://www.w3.org/ns/org#',
+ owl: 'http://www.w3.org/2002/07/owl#',
+ dc: 'http://purl.org/dc/elements/1.1/'
+}
+
+const STANDARD_MUTATION_NAMESPACE_URIS = Object.values(STANDARD_MUTATION_PREFIXES)
+
+export type MutationDocumentTextCache = Map
+
+type ForceDocumentPutCheckOptions = {
+ documentText?: string
+ documentTextCache?: MutationDocumentTextCache
+}
+
+function normalizeNodeId (value: string): string {
+ return value.startsWith('_:') ? value.slice(2) : value
+}
+
+function isCompleteStatement (statement: RdfStatement | null | undefined): statement is RdfStatement {
+ return Boolean(statement?.subject && statement?.predicate && statement?.object)
+}
+
+function statementTermKey (term: { toNT?: () => string; value?: string } | null | undefined): string {
+ if (!term) return ''
+ if (typeof term.toNT === 'function') return term.toNT()
+ return term.value || ''
+}
+
+export function registerStorePrefix (target: PrefixCapable | undefined, prefix: string, uri: string): void {
+ if (!target) return
+ if (typeof target.setPrefixForURI === 'function') {
+ target.setPrefixForURI(prefix, uri)
+ return
+ }
+ if (!target.namespaces) {
+ target.namespaces = {}
+ }
+ target.namespaces[prefix] = uri
+}
+
+export function ensureStandardMutationPrefixes (store: LiveStore | undefined): void {
+ if (!store) return
+
+ const updater = getStoreUpdater(store)
+ const targets: Array = [
+ store as PrefixCapable,
+ updater,
+ updater?.store as PrefixCapable | undefined
+ ]
+
+ Object.entries(STANDARD_MUTATION_PREFIXES).forEach(([prefix, uri]) => {
+ targets.forEach((target) => {
+ registerStorePrefix(target, prefix, uri)
+ })
+ })
+}
+
+export function statementKey (statement: RdfStatement): string {
+ return `${statementTermKey(statement.subject)} ${statementTermKey(statement.predicate)} ${statementTermKey(statement.object)} ${statementTermKey(statement.why)}`
+}
+
+export function uniqueStatements (statements: RdfStatement[]): RdfStatement[] {
+ return Array.from(new Map((statements || []).map((statement) => [statementKey(statement), statement])).values())
+}
+
+export function applyStatementsToStore (store: LiveStore, deletions: RdfStatement[], insertions: RdfStatement[]): void {
+ store.remove(deletions)
+ insertions.forEach((statement) => {
+ store.add(statement.subject, statement.predicate, statement.object, statement.why)
+ })
+}
+
+export function sanitizePatchStatements (
+ store: LiveStore,
+ deletions: RdfStatement[],
+ insertions: RdfStatement[],
+ requireStoredDelete = false
+): {
+ safeDeletions: RdfStatement[]
+ safeInsertions: RdfStatement[]
+ } {
+ const safeDeletions = uniqueStatements(
+ (deletions || []).filter((statement) => {
+ if (!isCompleteStatement(statement)) return false
+ if (store.holds(statement.subject, statement.predicate, statement.object, statement.why)) return true
+ return requireStoredDelete
+ ? false
+ : store.statementsMatching(statement.subject, statement.predicate, statement.object, statement.why).length > 0
+ })
+ )
+
+ const safeInsertions = uniqueStatements((insertions || []).filter(isCompleteStatement))
+
+ return { safeDeletions, safeInsertions }
+}
+
+function getStoreUpdater (store: LiveStore): RdfUpdater | undefined {
+ return store.updater as RdfUpdater | undefined
+}
+
+function getStoreFetcher (store: LiveStore): RdfFetcher | undefined {
+ return store.fetcher as RdfFetcher | undefined
+}
+
+async function bestEffortLoadDocIfStoreEmpty (store: LiveStore, doc: NamedNode | null): Promise {
+ if (!doc) return
+
+ const fetcher = getStoreFetcher(store)
+ if (typeof fetcher?.load !== 'function') return
+ if (store.statementsMatching(undefined, undefined, undefined, doc).length > 0) return
+
+ try {
+ await fetcher.load(doc)
+ } catch {
+ // Continue with current in-memory statements if the pre-load fails.
+ }
+}
+
+function escapeRegex (text: string): string {
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
+function statementNamespaceUris (statement: RdfStatement, candidateNamespaceUris: string[]): string[] {
+ const uris = new Set()
+ const terms = [statement.subject, statement.predicate, statement.object]
+
+ terms.forEach((term: any) => {
+ if (!term) return
+
+ if (typeof term.value === 'string') {
+ candidateNamespaceUris.forEach((namespaceUri) => {
+ if (term.value.startsWith(namespaceUri)) {
+ uris.add(namespaceUri)
+ }
+ })
+ }
+
+ if (term.termType === 'Literal' && typeof term.datatype?.value === 'string') {
+ candidateNamespaceUris.forEach((namespaceUri) => {
+ if (term.datatype.value.startsWith(namespaceUri)) {
+ uris.add(namespaceUri)
+ }
+ })
+ }
+ })
+
+ return [...uris]
+}
+
+function documentDeclaresNamespacePrefix (documentText: string, namespaceUri: string): boolean {
+ const escapedNamespaceUri = escapeRegex(namespaceUri)
+ const prefixPattern = new RegExp(`(^|\\n)\\s*(?:@prefix|PREFIX)\\s+[A-Za-z][\\w-]*:\\s*<${escapedNamespaceUri}>`, 'i')
+ return prefixPattern.test(documentText)
+}
+
+export async function shouldForceDocumentPutForStatements (
+ store: LiveStore,
+ doc: NamedNode,
+ statements: RdfStatement[],
+ candidateNamespaceUris = STANDARD_MUTATION_NAMESPACE_URIS,
+ options: ForceDocumentPutCheckOptions = {}
+): Promise {
+ const updater = getStoreUpdater(store)
+ const fetcher = getStoreFetcher(store)
+
+ if (typeof updater?.serialize !== 'function' || typeof fetcher?.webOperation !== 'function') {
+ return false
+ }
+
+ const neededNamespaceUris = Array.from(new Set(
+ (statements || []).flatMap((statement) => statementNamespaceUris(statement, candidateNamespaceUris))
+ ))
+
+ if (neededNamespaceUris.length === 0) {
+ return false
+ }
+
+ const cachedDocumentText = options.documentTextCache?.get(doc.value)
+ const existingDocumentText = typeof options.documentText === 'string'
+ ? options.documentText
+ : cachedDocumentText
+
+ if (typeof existingDocumentText === 'string') {
+ return neededNamespaceUris.some((namespaceUri) => {
+ return !documentDeclaresNamespacePrefix(existingDocumentText, namespaceUri)
+ })
+ }
+
+ try {
+ const response = await fetcher.webOperation('GET', doc.value, {
+ noMeta: true,
+ headers: { accept: 'text/turtle' }
+ })
+
+ if (!response?.ok || typeof response.responseText !== 'string') {
+ return false
+ }
+
+ options.documentTextCache?.set(doc.value, response.responseText)
+
+ return neededNamespaceUris.some((namespaceUri) => {
+ return !documentDeclaresNamespacePrefix(response.responseText || '', namespaceUri)
+ })
+ } catch {
+ return false
+ }
+}
+
+export function isPatchFailureMessage (message: string): boolean {
+ const text = (message || '').toLowerCase()
+ return (
+ text.includes('fetch error for patch') ||
+ text.includes('failed to fetch') ||
+ text.includes(' on patch ') ||
+ text.includes('web error: 400') ||
+ text.includes('web error: 405') ||
+ text.includes('web error: 500') ||
+ text.includes('web error: 501')
+ )
+}
+
+export function isMissingGetRecordErrorMessage (message: string): boolean {
+ return (message || '').toLowerCase().includes('no record of our http get request for document')
+}
+
+function runUpdaterUpdate (
+ updater: RdfUpdater,
+ deletions: RdfStatement[],
+ insertions: RdfStatement[],
+ failureMessage: string
+) {
+ if (typeof updater.update !== 'function') {
+ throw new Error(updaterUnsupportedStoreErrorMessageText)
+ }
+ const update = updater.update.bind(updater)
+
+ return new Promise((resolve, reject) => {
+ try {
+ update(deletions, insertions, (_uri: string, ok: boolean, message?: string) => {
+ if (ok === true) {
+ resolve()
+ return
+ }
+ reject(new Error(message || failureMessage))
+ })
+ } catch (error) {
+ reject(error instanceof Error ? error : new Error(String(error)))
+ }
+ })
+}
+
+export async function runDirectUpdaterUpdate (
+ store: LiveStore,
+ deletions: RdfStatement[],
+ insertions: RdfStatement[],
+ failureMessage = fallbackSaveUpdatesErrorMessageText
+) {
+ ensureStandardMutationPrefixes(store)
+
+ const updater = getStoreUpdater(store)
+ if (!updater) {
+ throw new Error(updaterUnsupportedStoreErrorMessageText)
+ }
+
+ await runUpdaterUpdate(updater, deletions, insertions, failureMessage)
+}
+
+export async function putResourceWithStatements (
+ store: LiveStore,
+ doc: NamedNode,
+ deletions: RdfStatement[],
+ insertions: RdfStatement[],
+ unsupportedMessage: string,
+ documentTextCache?: MutationDocumentTextCache
+) {
+ ensureStandardMutationPrefixes(store)
+
+ const updater = getStoreUpdater(store)
+ const fetcher = getStoreFetcher(store)
+
+ if (!updater || typeof updater.serialize !== 'function' || !fetcher || typeof fetcher.webOperation !== 'function') {
+ throw new Error(unsupportedMessage)
+ }
+
+ await bestEffortLoadDocIfStoreEmpty(store, doc)
+
+ const currentStatements = store.statementsMatching(undefined, undefined, undefined, doc).slice()
+
+ const deletionKeys = new Set((deletions || []).map((statement) => statementKey(statement)))
+ const nextStatements = currentStatements.filter((statement) => !deletionKeys.has(statementKey(statement))).concat(insertions || [])
+ const serializationStore = graph() as LiveStore
+ nextStatements.forEach((statement) => {
+ serializationStore.add(statement.subject, statement.predicate, statement.object, statement.why)
+ })
+
+ const contentType = 'text/turtle'
+ const body = updater.serialize(doc.value, serializationStore.statementsMatching(undefined, undefined, undefined, doc), contentType)
+ const response = await fetcher.webOperation('PUT', doc.value, {
+ noMeta: true,
+ contentType,
+ body
+ })
+
+ if (!response || response.ok !== true) {
+ const status = response?.status || 'unknown'
+ throw new Error(`Web error: ${status} on PUT of <${doc.value}>`)
+ }
+
+ documentTextCache?.set(doc.value, body)
+
+ applyStatementsToStore(store, deletions, insertions)
+}
+
+type UpdateTransportOptions = {
+ unsupportedMessage: string
+ failureMessage?: string
+ requireStoredDelete?: boolean
+ forcePut?: boolean
+ documentTextCache?: MutationDocumentTextCache
+ useDavFallback?: boolean
+ usePutFallback?: boolean
+ patchFailureMatcher?: (message: string) => boolean
+ missingGetRecordMatcher?: (message: string) => boolean
+}
+
+export async function runUpdateTransport (
+ store: LiveStore,
+ doc: NamedNode | null,
+ deletions: RdfStatement[],
+ insertions: RdfStatement[],
+ options: UpdateTransportOptions
+) {
+ ensureStandardMutationPrefixes(store)
+
+ const updater = getStoreUpdater(store)
+ if (!updater) {
+ throw new Error(options.unsupportedMessage)
+ }
+
+ if (options.forcePut) {
+ await bestEffortLoadDocIfStoreEmpty(store, doc)
+ }
+
+ const {
+ safeDeletions,
+ safeInsertions
+ } = sanitizePatchStatements(store, deletions, insertions, options.requireStoredDelete === true)
+
+ if (safeDeletions.length === 0 && safeInsertions.length === 0) {
+ return
+ }
+
+ if (options.forcePut) {
+ if (!doc) {
+ throw new Error(options.unsupportedMessage)
+ }
+ await putResourceWithStatements(store, doc, safeDeletions, safeInsertions, options.unsupportedMessage, options.documentTextCache)
+ return
+ }
+
+ const failureMessage = options.failureMessage || fallbackSaveUpdatesErrorMessageText
+
+ const isPatchFailure = options.patchFailureMatcher || isPatchFailureMessage
+ const isMissingGetRecord = options.missingGetRecordMatcher || isMissingGetRecordErrorMessage
+
+ try {
+ await runUpdaterUpdate(updater, safeDeletions, safeInsertions, failureMessage)
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ if (!isPatchFailure(message)) {
+ throw error
+ }
+
+ const davDoc = doc
+ if (options.useDavFallback && typeof updater.updateDav === 'function' && davDoc) {
+ if (getStoreFetcher(store)?.load) {
+ try {
+ await getStoreFetcher(store)?.load?.(davDoc)
+ } catch {
+ // Continue to DAV fallback and then PUT fallback if needed.
+ }
+ }
+
+ try {
+ await new Promise((resolve, reject) => {
+ updater.updateDav?.(davDoc, safeDeletions, safeInsertions, (_uri: string, ok: boolean, body?: string) => {
+ if (ok === true) {
+ resolve()
+ return
+ }
+ reject(new Error(body || message || failureMessage))
+ })
+ })
+ return
+ } catch (davError) {
+ const davMessage = davError instanceof Error ? davError.message : String(davError)
+ if (!options.usePutFallback || !isMissingGetRecord(davMessage)) {
+ throw davError
+ }
+ }
+ }
+
+ if (options.usePutFallback) {
+ if (!doc) {
+ throw new Error(options.unsupportedMessage)
+ }
+ await putResourceWithStatements(store, doc, safeDeletions, safeInsertions, options.unsupportedMessage, options.documentTextCache)
+ return
+ }
+
+ throw error
+ }
+}
+
+export function applyUpdaterPatch (store: LiveStore, deletions: RdfStatement[], insertions: RdfStatement[]) {
+ if (!store.updater) {
+ throw new Error(updaterUnsupportedStoreErrorMessageText)
+ }
+
+ return runUpdateTransport(store, null, deletions, insertions, {
+ unsupportedMessage: updaterUnsupportedStoreErrorMessageText,
+ failureMessage: fallbackSaveUpdatesErrorMessageText,
+ requireStoredDelete: true,
+ useDavFallback: false,
+ usePutFallback: false
+ })
+}
+
+export function collectNodeStatements (store: LiveStore, node: Node, doc: NamedNode): RdfStatement[] {
+ return store.statementsMatching(node as any, null, null, doc)
+}
+
+export function collectLinkStatements (
+ store: LiveStore,
+ subject: NamedNode,
+ predicate: NamedNode,
+ node: Node,
+ doc: NamedNode
+): RdfStatement[] {
+ return store.statementsMatching(subject, predicate, node, doc)
+}
+
+/* The following helper was generated by AI Model: GPT-5.3-Codex.
+ Prompt: 'do you see anything I can do differently in the mutation code so that it's not so verbose and possible reusable accross sections' */
+export function replacePredicateStatements (
+ store: LiveStore,
+ subject: NamedNode,
+ predicate: NamedNode,
+ doc: NamedNode,
+ deletions: RdfStatement[],
+ insertions: RdfStatement[],
+ nextObject?: Node | null
+) {
+ deletions.push(...store.statementsMatching(subject, predicate, null, doc))
+ if (nextObject) {
+ insertions.push(st(subject, predicate, nextObject, doc))
+ }
+}
+
+/* The following helper was generated by AI Model: GPT-5.3-Codex.
+ Prompt: 'do you see anything I can do differently in the mutation code so that it's not so verbose and possible reusable accross sections' */
+export function collectLinkedNodeStatements (
+ store: LiveStore,
+ sourceNode: Node,
+ predicate: NamedNode,
+ doc: NamedNode
+): {
+ linkedNodes: Node[]
+ linkedStatements: RdfStatement[]
+ linkStatements: RdfStatement[]
+ } {
+ const linkedNodes = store.each(sourceNode as any, predicate, null, doc) as Node[]
+ const linkedStatements = linkedNodes.flatMap((linkedNode) => collectNodeStatements(store, linkedNode, doc))
+ const linkStatements = store.statementsMatching(sourceNode as any, predicate, null, doc)
+
+ return {
+ linkedNodes,
+ linkedStatements,
+ linkStatements
+ }
+}
+
+export function findExistingNode (nodes: Node[], entryNode: string) {
+ const normalizedEntryNode = normalizeNodeId(entryNode)
+ return nodes.find((node) => {
+ const nodeValue = normalizeNodeId(node.value)
+ if (nodeValue === normalizedEntryNode) return true
+ return node.value === entryNode
+ })
+}
diff --git a/src/components/profile-heading/editor/sections/shared/rowState.ts b/src/components/profile-heading/editor/sections/shared/rowState.ts
new file mode 100644
index 00000000..003c7b38
--- /dev/null
+++ b/src/components/profile-heading/editor/sections/shared/rowState.ts
@@ -0,0 +1,72 @@
+import { RowStatus } from './types'
+
+export type EditableRow = {
+ entryNode: string
+ status: RowStatus
+}
+
+export type TypedEditableRow = EditableRow & {
+ type: string
+}
+
+type StringKeyOf = {
+ [K in keyof T]-?: T[K] extends string ? K : never
+}[keyof T]
+
+export function deleteRow (rows: T[], index: number) {
+ const row = rows[index]
+ if (!row) return
+
+ if (row.entryNode) {
+ row.status = 'deleted'
+ return
+ }
+
+ rows.splice(index, 1)
+}
+
+export function applyRowSelectChange> (
+ row: T,
+ field: K,
+ nextValue: Extract
+) {
+ row[field] = nextValue.trim() as T[K]
+
+ if (row.entryNode) {
+ row.status = row.status === 'existing' || row.status === 'deleted' ? 'modified' : row.status
+ return
+ }
+
+ row.status = 'new'
+}
+
+export function summarizeRowOps (rows: T[], rowHasContent: (row: T) => boolean) {
+ const create = rows.filter((row) => row.status === 'new' && rowHasContent(row))
+ const update = rows.filter((row) => row.status === 'modified' && rowHasContent(row))
+ const remove = rows.filter((row) => row.status === 'deleted')
+ return { create, update, remove }
+}
+
+/* The following function was generated by AI Model: GPT-5.3-Codex */
+/* it was originally only for addresses but I made it more generic so it can be used for any type of rows */
+/* Prompt: can you have applyAddressFieldChange update the row status based on whether any fields have content */
+export function applyRowFieldChange (
+ row: T,
+ field: K,
+ nextValue: T[K],
+ hasContent: (row: T) => boolean
+) {
+ row[field] = nextValue
+
+ if (!hasContent(row)) {
+ row.status = row.entryNode ? 'deleted' : 'new'
+ return
+ }
+
+ if (row.entryNode) {
+ row.status = row.status === 'existing' || row.status === 'deleted' ? 'modified' : row.status
+ return
+ }
+
+ row.status = 'new'
+}
diff --git a/src/components/profile-heading/editor/sections/shared/sanitizeUtils.ts b/src/components/profile-heading/editor/sections/shared/sanitizeUtils.ts
new file mode 100644
index 00000000..d7b2930d
--- /dev/null
+++ b/src/components/profile-heading/editor/sections/shared/sanitizeUtils.ts
@@ -0,0 +1,17 @@
+import { sanitizeTextValue } from '../../textUtils'
+
+export function sanitizeEmailValue (value: string): string {
+ return sanitizeTextValue(value).replace(/\s+/g, '')
+}
+
+export function sanitizePhoneLocalValue (value: string): string {
+ return sanitizeTextValue(value).replace(/[^0-9()\-\s.]/g, '')
+}
+
+export function sanitizeAddressFieldValue (value: string): string {
+ return sanitizeTextValue(value)
+}
+
+export function sanitizeBasicInputFieldValue (value: string): string {
+ return sanitizeTextValue(value)
+}
diff --git a/src/components/profile-heading/editor/sections/shared/types.ts b/src/components/profile-heading/editor/sections/shared/types.ts
new file mode 100644
index 00000000..b7310e4b
--- /dev/null
+++ b/src/components/profile-heading/editor/sections/shared/types.ts
@@ -0,0 +1,40 @@
+import { LiveStore, NamedNode, st } from 'rdflib'
+
+export type RowStatus = 'existing' | 'new' | 'modified' | 'deleted'
+
+export type MutationOps = {
+ create: T[]
+ update: T[]
+ remove: T[]
+}
+
+export type RdfStatement = ReturnType
+
+export type PrefixCapable = {
+ namespaces?: Record
+ setPrefixForURI?: (prefix: string, uri: string) => void
+}
+
+export type UpdateCallback = (_uri: string, ok: boolean, message?: string) => void
+
+export type DavUpdateCallback = (_uri: string, ok: boolean, body?: string) => void
+
+export type RdfUpdater = PrefixCapable & {
+ update?: (deletions: RdfStatement[], insertions: RdfStatement[], callback: UpdateCallback) => void
+ updateDav?: (doc: NamedNode, deletions: RdfStatement[], insertions: RdfStatement[], callback: DavUpdateCallback) => void
+ serialize?: (docUri: string, statements: RdfStatement[], contentType: string) => string
+ store?: PrefixCapable
+}
+
+export type WebOperationResponse = {
+ ok?: boolean
+ status?: number
+ responseText?: string
+}
+
+export type RdfFetcher = {
+ load?: (doc: NamedNode) => Promise
+ webOperation?: (...args: unknown[]) => Promise
+}
+
+export type MutationCapableStore = LiveStore
diff --git a/src/components/profile-heading/editor/styles/EditDialogs.css b/src/components/profile-heading/editor/styles/EditDialogs.css
new file mode 100644
index 00000000..a1ea0ad2
--- /dev/null
+++ b/src/components/profile-heading/editor/styles/EditDialogs.css
@@ -0,0 +1,743 @@
+@import './EditDialogs.responsive.css';
+
+.profile__edit-form {
+ --profile-edit-dialog-row-max-width: 544px;
+ --profile-edit-dialog-muted-text: var(--solid-ui-color-gray-600, #4A5565);
+ --profile-edit-dialog-quiet-text: var(--solid-ui-color-gray-700, #364153);
+ --profile-edit-dialog-mobile-image-button-font-size: 0.75rem;
+ --profile-edit-dialog-mobile-image-label-font-size: var(--solid-ui-font-size-sm, 0.875rem);
+ --profile-edit-dialog-mobile-image-description-font-size: 0.625rem;
+ --profile-edit-dialog-mobile-image-button-padding: 6.6px 12px 7px 14px;
+ width: 100%;
+}
+
+.profile-edit-dialog__section,
+.profile-edit-dialog__section--education {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.profile-edit {
+ display: flex;
+ flex-direction: column;
+ gap: 25px;
+}
+
+.profile-edit-dialog__section h4 {
+ margin: 0;
+}
+
+.profile-edit-dialog__section fieldset {
+ width: 100%;
+ margin: 0;
+ display: grid;
+ gap: 15px;
+ border: 0;
+ padding: 0;
+ min-inline-size: 0;
+}
+
+.profile-edit-dialog__field-description {
+ color: var(--profile-edit-dialog-muted-text);
+ font-size: var(--solid-ui-font-size-sm, 0.875rem);
+ font-weight: 400;
+ line-height: 1.5;
+}
+
+.profile-edit-dialog__row {
+ display: flex;
+ align-items: flex-start;
+ gap: 15px;
+ max-width: var(--profile-edit-dialog-row-max-width);
+}
+
+.profile-edit-dialog__row--heading-photo {
+ gap: 10px;
+}
+
+.profile-edit-dialog__row--full {
+ max-width: 100%;
+ width: 100%;
+}
+
+.profile-edit-dialog__field,
+.profile-edit-dialog__field-type {
+ display: block;
+ margin: 0;
+}
+
+.profile-edit-dialog__field-type {
+ width: 100%;
+}
+
+.profile-edit-dialog__field {
+ min-width: 0;
+}
+
+.profile-edit-dialog__field--stack {
+ display: grid;
+}
+
+.profile-edit-dialog__field--stack > small {
+ display: block;
+ width: 100%;
+ text-align: right;
+ justify-self: end;
+}
+
+.profile-edit-dialog__field--full {
+ max-width: 100% !important;
+ width: 100%;
+}
+
+.profile-edit-dialog__field--row-width {
+ max-width: 544px;
+ width: 100%;
+}
+
+.profile-edit-dialog__login-message {
+ margin: 0;
+ font-size: var(--solid-ui-font-size-xs, 0.75rem);
+ color: var(--solid-ui-color-error, #b00020);
+}
+
+.profile-edit-dialog__section + .profile-edit-dialog__login-message {
+ margin-top: 15px;
+}
+
+.profile__edit-form--projects .profile-edit-dialog__login-message {
+ margin-top: 15px;
+}
+
+.profile-edit-dialog__actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ flex: 0 0 auto;
+}
+
+.profile-edit-dialog__actions--edge {
+ margin-left: auto;
+}
+
+.profile-edit-dialog__input-wrap {
+ position: relative;
+ width: 100%;
+}
+
+.profile-edit-dialog__input--with-action {
+ width: 100%;
+ box-sizing: border-box;
+ padding-right: 120px;
+}
+
+.profile-edit-dialog__image-preview {
+ display: grid;
+}
+
+.profile-edit-dialog__image-preview-header {
+ margin-bottom: 0;
+}
+
+.profile-edit-dialog__image-preview-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.profile-edit-dialog__image-preview-label {
+ color: var(--gray-900, #101828);
+ font-size: var(--solid-ui-font-size-sm, .875rem);
+ font-weight: 600;
+}
+
+.profile-edit-dialog__image-preview-description {
+ color: var(--solid-ui-color-gray-600, #4a5565);
+ font-size: var(--solid-ui-font-size-sm, .875rem);
+ font-weight: 400;
+}
+
+.profile-edit-dialog__image-preview-label,
+.profile-edit-dialog__image-preview-description {
+ margin: 0;
+}
+
+.profile-edit-dialog--heading {
+ --profile-edit-dialog-avatar-size: 5.625rem;
+}
+
+.profile-edit-dialog__image-frame {
+ position: relative;
+ display: inline-block;
+ width: var(--profile-edit-dialog-avatar-size, 5.625rem);
+ height: var(--profile-edit-dialog-avatar-size, 5.625rem);
+ line-height: 0;
+ font-size: 0;
+ vertical-align: top;
+ overflow: visible;
+}
+
+.profile-edit-dialog__image-frame > solid-ui-photo-capture {
+ position: absolute;
+ inset: 0;
+ display: block;
+ pointer-events: none;
+}
+
+.profile-edit-dialog__image-frame > solid-ui-photo-capture > * {
+ pointer-events: auto;
+}
+
+.profile__image-frame {
+ position: relative;
+ display: block;
+ width: var(--profile-edit-dialog-avatar-size, 5.625rem);
+ height: var(--profile-edit-dialog-avatar-size, 5.625rem);
+ overflow: hidden;
+ border-radius: 10px;
+ border: 1px solid var(--solid-ui-color-primary, #7c4dff);
+ background: var(--solid-ui-color-slate-50, #F8FAFC);
+ box-sizing: border-box;
+ padding: 0;
+ margin: 0;
+}
+
+.profile__image-frame--fallback {
+ border: 1px dashed var(--solid-ui-color-slate-200, #E2E8F0);
+ background: var(--solid-ui-color-slate-50, #F8FAFC);
+}
+
+.profile__hero,
+.profile__hero-alt {
+ display: block;
+ width: 100%;
+ height: 100%;
+ box-sizing: border-box;
+ border-radius: 10px;
+}
+
+.profile__hero {
+ object-fit: cover;
+ object-position: center;
+ background: var(--color-surface-placeholder, var(--gray-300, #d1d5dc)) 50% / cover no-repeat;
+}
+
+.profile__hero-alt {
+ display: none;
+ align-items: center;
+ justify-content: center;
+ background: var(--solid-ui-color-slate-50, #F8FAFC);
+}
+
+.profile__image-frame--fallback .profile__hero-alt {
+ display: flex;
+}
+
+.profile__hero-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ padding: 1.125rem;
+ box-sizing: border-box;
+}
+
+.profile__hero-icon icon-lucide-circle-user-round,
+.profile__hero-alt icon-lucide-circle-user-round {
+ display: block;
+ width: 1.75rem;
+ height: 1.75rem;
+ color: #D9E1F2;
+}
+
+.profile-edit-dialog__image-camera-button {
+ position: absolute;
+ right: 5px;
+ bottom: 5px;
+ z-index: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.75rem;
+ height: 1.75rem;
+ min-width: 1.75rem;
+ min-height: 1.75rem;
+ padding: 0;
+ border: 1px solid var(--solid-ui-color-slate-200, #E2E8F0);
+ border-radius: 7px;
+ background: var(--solid-ui-color-white, #FFFFFF);
+ box-shadow: 0 2px 6px rgba(15, 23, 42, 0.12);
+ color: var(--solid-ui-color-gray-700, #364153);
+ cursor: pointer;
+ box-sizing: border-box;
+ transition: none;
+ line-height: 0;
+}
+
+.profile-edit-dialog__image-camera-button:hover,
+.profile-edit-dialog__image-camera-button:focus-visible,
+.profile-edit-dialog__image-camera-button:active {
+ width: 1.75rem;
+ height: 1.75rem;
+ min-width: 1.75rem;
+ min-height: 1.75rem;
+ padding: 0;
+ border-color: var(--solid-ui-color-slate-200, #E2E8F0);
+ background: color-mix(in srgb, var(--solid-ui-color-primary, #7c4dff) 12%, #FFFFFF);
+ box-shadow: 0 2px 6px rgba(15, 23, 42, 0.12);
+ transform: none;
+ color: var(--solid-ui-color-primary, #7c4dff);
+}
+
+.profile-edit-dialog__image-camera-button icon-lucide-camera {
+ display: block;
+ width: 0.875rem;
+ height: 0.875rem;
+ flex-shrink: 0;
+ vertical-align: middle;
+ margin: 0.05rem;
+}
+
+.profile-edit-dialog__image-camera-capture-row {
+ display: flex;
+ justify-content: center;
+ width: 100%;
+}
+
+.profile-edit-dialog__image-camera-capture-frame {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: fit-content;
+ max-width: 100%;
+ margin-left: auto;
+ margin-right: auto;
+ margin-top: calc(12px / 2);
+ margin-bottom: 12px;
+ padding: 10px;
+ box-sizing: border-box;
+ border: 1px solid var(--solid-color-gray-200, #E5E7EB);
+ border-radius: 8px;
+}
+
+.profile-edit-dialog__image-camera-capture-frame[hidden] {
+ display: none;
+}
+
+.profile-edit-dialog__paste-button {
+ position: absolute;
+ white-space: nowrap;
+ right: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ z-index: 1;
+}
+
+.profile-edit-dialog__paste-icon {
+ width: 0.875rem;
+ height: 0.875rem;
+ stroke: var(--solid-ui-color-primary, #7c4dff);
+ fill: var(--solid-ui-color-primary, #7c4dff);
+}
+
+.profile-edit-dialog__paste-icon svg {
+ display: block;
+ transform: translate(1px, 1px);
+}
+
+solid-ui-button.profile-edit-dialog__drag-handle {
+ cursor: grab;
+ touch-action: none;
+ -webkit-user-select: none;
+ user-select: none;
+}
+
+solid-ui-button.profile-edit-dialog__drag-handle:active {
+ cursor: grabbing;
+}
+
+.profile-edit-dialog__row--drop-target {
+ outline: 1px dashed var(--solid-ui-color-gray-400, #99A1AF);
+ outline-offset: 2px;
+}
+
+.profile-edit-dialog__field input,
+.profile-edit-dialog__field select,
+.profile-edit-dialog__field-type input,
+.profile-edit-dialog__field-type select {
+ margin-block-start: 0;
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+ min-height: var(--solid-ui-clickable-area, 42px);
+ height: var(--solid-ui-clickable-area, 42px);
+ border-radius: 5px;
+ border: 1px solid var(--solid-ui-color-gray-400, #99A1AF);
+ background: var(--solid-ui-color-white, #FFF);
+ color: var(--color-text-subheading, var(--gray-900, #101828));
+ font-size: var(--solid-ui-font-size-sm, 0.875rem);
+ font-weight: 500;
+ padding: 10px;
+}
+
+.profile-edit-dialog__field input[type='date'],
+.profile-edit-dialog__field-type input[type='date'] {
+ min-width: 0;
+}
+
+.profile-edit-dialog__field textarea,
+.profile-edit-dialog__field-type textarea {
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+ min-height: var(--solid-ui-clickable-area, 42px);
+}
+
+.profile-edit-dialog__textarea {
+ color: var(--color-text-subheading, var(--gray-900, #101828));
+ font-size: var(--solid-ui-font-size-sm, 0.875rem);
+ font-weight: 500;
+ line-height: 1.6;
+ border-radius: 5px;
+ border: 1px solid var(--solid-ui-color-gray-400, #99A1AF);
+ background: var(--solid-ui-color-white, #FFFFFF);
+ min-height: 160px !important;
+ height: auto;
+ resize: vertical;
+}
+
+.profile__edit-form--heading {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+/* Section-specific edit dialog overrides */
+
+.profile-edit-dialog__bio-field-container {
+ display: flex;
+ width: 100%;
+ max-width: var(--profile-edit-dialog-row-max-width);
+ padding: 0;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 12px;
+ box-sizing: border-box;
+ margin: 0;
+}
+
+.profile-edit-dialog__row--language {
+ margin-left: 0;
+}
+
+.profile-edit-dialog__row--language > .profile-edit-dialog__field--language-name {
+ flex: 1 1 0;
+ min-width: 0;
+}
+
+.profile-edit-dialog__field-type--language-proficiency,
+.profile-edit-dialog__field-type--language-proficiency[hidden] {
+ display: none !important;
+}
+
+.profile-edit-dialog__actions--language {
+ margin-left: 0;
+}
+
+.profile-edit-dialog__row--social {
+ margin-left: calc(-1 * 10px);
+ max-width: 100%;
+ width: 100%;
+}
+
+.profile-edit-dialog__field--social-type {
+ flex: 0 0 96px;
+ width: 96px;
+ max-width: 96px;
+}
+
+.profile-edit-dialog__field--social-url {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.profile-edit-dialog__row--project {
+ max-width: 100%;
+ width: 100%;
+}
+
+.profile-edit-dialog__row--resume-entry-header {
+ align-items: center;
+ border-bottom: 1px solid var(--solid-ui-color-gray-400, #99A1AF);
+}
+
+.profile-edit-dialog__row--resume-entry-header > .profile-edit-dialog__entry-heading {
+ margin: 0;
+}
+
+.profile-edit-dialog__entry-heading {
+ margin: 0;
+ color: var(--solid-ui-color-gray-700, #364153);
+ font-size: var(--solid-ui-font-size-sm, 0.875rem);
+ font-weight: 500;
+}
+
+.profile-edit-dialog__section--education .profile-edit-dialog__section-heading {
+ margin: 0;
+ color: var(--solid-ui-color-gray-700, #364153);
+ font-size: var(--solid-ui-font-size-md, 1rem);
+ font-weight: 500;
+}
+
+/* Force two direct children to split the row into equal columns. */
+.profile-edit-dialog__row--equal > .profile-edit-dialog__field,
+.profile-edit-dialog__row--equal > .profile-edit-dialog__field-type {
+ flex: 1 1 0;
+ min-width: 0;
+}
+
+.profile-edit-dialog--heading .profile-edit-dialog__image-camera-capture-row {
+ margin-bottom: calc(25px / -2);
+}
+
+/* An empty capture row would still add a flex gap below the divider. */
+.profile-edit-dialog--heading .profile-edit-dialog__image-camera-capture-row:has(> [hidden]) {
+ display: none;
+}
+
+.profile-edit-dialog--heading .profile-edit-dialog__image-camera-capture-frame {
+ margin-top: calc(12px / 4);
+ margin-bottom: calc(12px / 2);
+}
+
+.profile__edit-form--resume .profile-edit-dialog__field,
+.profile__edit-form--resume .profile-edit-dialog__field-type:not(.hidden),
+.profile__edit-form--contact-info .profile-edit-dialog__field,
+.profile__edit-form--contact-info .profile-edit-dialog__field-type:not(.hidden) {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 5px;
+ flex: 1 0 0;
+}
+
+.profile__edit-form--resume .profile-edit-dialog__row {
+ max-width: 100%;
+ width: 100%;
+}
+
+.profile__edit-form--resume .profile-edit-dialog__section fieldset > .profile-edit-dialog__field {
+ max-width: 100%;
+}
+
+.profile__edit-form--resume .profile-edit-dialog__field > input,
+.profile__edit-form--resume .profile-edit-dialog__field > solid-ui-select,
+.profile__edit-form--resume .profile-edit-dialog__field > solid-ui-combobox,
+.profile__edit-form--resume .profile-edit-dialog__field > textarea,
+.profile__edit-form--resume .profile-edit-dialog__field-type > input,
+.profile__edit-form--resume .profile-edit-dialog__field-type > solid-ui-select,
+.profile__edit-form--resume .profile-edit-dialog__field-type > textarea,
+.profile__edit-form--contact-info .profile-edit-dialog__field > input,
+.profile__edit-form--contact-info .profile-edit-dialog__field > solid-ui-select,
+.profile__edit-form--contact-info .profile-edit-dialog__field > textarea,
+.profile__edit-form--contact-info .profile-edit-dialog__field-type > input,
+.profile__edit-form--contact-info .profile-edit-dialog__field-type > solid-ui-select,
+.profile__edit-form--contact-info .profile-edit-dialog__field-type > textarea {
+ align-self: stretch;
+}
+
+.profile-edit-dialog__field > solid-ui-combobox,
+.profile-edit-dialog__field-type > solid-ui-combobox {
+ display: block;
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+ box-sizing: border-box;
+ align-self: stretch;
+}
+
+/* The combobox input is transparent and inherits its font from the host, so the host provides the field background and matches the 42px plain input height. */
+.profile__edit-form solid-ui-combobox {
+ background: var(--solid-ui-color-white, #FFFFFF);
+ border-radius: 5px;
+ color: var(--color-text-subheading, var(--gray-900, #101828));
+ font-size: var(--solid-ui-font-size-sm, 0.875rem);
+ font-weight: 500;
+ line-height: calc(var(--solid-ui-clickable-area, 42px) - 22px);
+}
+
+.profile-edit-dialog__field--resume-location {
+ flex: 1 0 0;
+ width: auto;
+ max-width: none;
+}
+
+.profile-edit-dialog__row--resume-dates {
+ max-width: 100%;
+ width: 100%;
+}
+
+.profile-edit-dialog__field--date-group {
+ flex: 1 1 0;
+ min-width: 0;
+}
+
+.profile-edit-dialog__date-pair {
+ display: flex;
+ align-items: center;
+ gap: 15px;
+ width: 100%;
+}
+
+.profile-edit-dialog__date-pair > solid-ui-select {
+ flex: 1 1 0;
+ min-width: 0;
+}
+
+.profile-edit-dialog__resume-date-select {
+ display: block;
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+ box-sizing: border-box;
+}
+
+.profile-edit-dialog__resume-date-select--disabled {
+ pointer-events: none;
+ opacity: var(--opacity-input-disabled, 0.75);
+}
+
+.profile-edit-dialog__bio-field-container > small {
+ align-self: flex-end;
+ text-align: right;
+}
+
+.profile-edit-dialog--heading .profile-edit-dialog__row,
+.profile-edit-dialog--heading .profile-edit-dialog__field--row-width {
+ max-width: 100%;
+ width: 100%;
+}
+
+.profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo {
+ padding-bottom: 20px;
+ border-bottom: 1px solid var(--solid-ui-color-slate-200, #E2E8F0);
+}
+
+.profile-edit-dialog--heading .profile-edit {
+ gap: calc(25px / 2);
+}
+
+.profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile__hero-alt {
+ padding: 0;
+}
+
+.profile-edit-dialog--heading .profile-edit-dialog__row--contact-point {
+ max-width: 100%;
+ width: 100%;
+ gap: 10px;
+}
+
+.profile-edit-dialog--heading .profile-edit-dialog__row--contact-point > .profile-edit-dialog__field {
+ flex: 1 1 0;
+ min-width: 0;
+}
+
+.profile-edit-dialog--heading .profile-edit-dialog__field-type--contact-point {
+ padding-top: calc(var(--solid-ui-font-size-sm, 0.875rem) * 1.5);
+}
+
+.profile-edit-dialog--heading .profile-edit-dialog__row--contact-point > .profile-edit-dialog__field > .label {
+ display: block;
+ width: 100%;
+}
+
+.profile-edit-dialog--heading .profile-edit-dialog__row--contact-point.profile-edit-dialog__row--equal > .profile-edit-dialog__field-type.profile-edit-dialog__field-type--contact-point {
+ flex: 0 0 103px;
+ width: 103px;
+ min-width: 103px;
+ max-width: 103px;
+}
+
+.profile-edit-dialog__row--address-header {
+ max-width: var(--profile-edit-dialog-row-max-width);
+ align-items: flex-end;
+}
+
+.profile-edit-dialog__row--inline-end {
+ justify-content: flex-end;
+ max-width: 100%;
+}
+
+.profile-edit-dialog__checkbox-label {
+ margin-left: auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 12px;
+ cursor: var(--profile-interactive-cursor, pointer);
+}
+
+.profile-edit-dialog__checkbox-input {
+ width: var(--icon-xxs, 1rem);
+ height: var(--icon-xxs, 1rem);
+ margin: 0;
+ flex: 0 0 auto;
+ accent-color: var(--solid-ui-color-primary, #7C4DFF);
+ cursor: var(--profile-interactive-cursor, pointer);
+}
+
+.profile-edit-dialog__checkbox-input:focus-visible {
+ outline: var(--focus-indicator-width, 2px) solid var(--solid-ui-color-primary, #7C4DFF);
+ outline-offset: 2px;
+}
+
+.profile-edit-dialog__checkbox-input:disabled {
+ opacity: var(--opacity-disabled, 0.5);
+}
+
+/* The street-address row is a standalone label, so cap its width like the flex rows. */
+.profile-edit-dialog__section fieldset > .profile-edit-dialog__field {
+ max-width: var(--profile-edit-dialog-row-max-width);
+}
+
+.profile-edit-dialog__field-type--wide {
+ flex: 1 1 auto;
+ min-width: 0;
+ width: 100%;
+ max-width: 100%;
+}
+
+.profile-edit-dialog__field-type--contact-point {
+ flex: 0 0 auto;
+ width: auto;
+ max-width: none;
+ align-self: flex-start;
+}
+
+/* Dedicated hook for the social account type select in SocialEditDialog. */
+.profile-edit-dialog__social-account-select {
+ display: block;
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+ box-sizing: border-box;
+ contain: inline-size;
+}
+
+.profile-edit-dialog__resume-organization-type-select {
+ display: block;
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+ box-sizing: border-box;
+}
+
+.profile-edit-dialog__bio-field-container textarea {
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+ min-height: var(--solid-ui-clickable-area, 42px);
+}
diff --git a/src/components/profile-heading/editor/styles/EditDialogs.responsive.css b/src/components/profile-heading/editor/styles/EditDialogs.responsive.css
new file mode 100644
index 00000000..c0ed6dae
--- /dev/null
+++ b/src/components/profile-heading/editor/styles/EditDialogs.responsive.css
@@ -0,0 +1,277 @@
+html[data-layout='mobile'] .profile-edit-dialog__row {
+ max-width: 100%;
+ flex-direction: column;
+ align-items: stretch;
+}
+
+html[data-layout='mobile'] .profile-edit-dialog__row--equal > .profile-edit-dialog__field,
+html[data-layout='mobile'] .profile-edit-dialog__row--equal > .profile-edit-dialog__field-type,
+html[data-layout='mobile'] .profile-edit-dialog__field-type {
+ flex: 1 1 auto;
+}
+
+html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__field--dob {
+ width: 100%;
+ max-width: 100%;
+ min-width: 0;
+ overflow: hidden;
+ align-self: stretch;
+}
+
+html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__row--heading-dob {
+ flex-direction: column;
+ align-items: stretch;
+}
+
+html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__row--heading-dob > .profile-edit-dialog__field,
+html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__row--heading-dob > .profile-edit-dialog__field-type {
+ width: 100%;
+ max-width: 100%;
+ min-width: 0;
+ flex: 1 1 auto;
+}
+
+html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__input--dob {
+ display: block;
+ height: var(--solid-ui-clickable-area, 42px);
+ min-height: var(--solid-ui-clickable-area, 42px);
+ max-height: var(--solid-ui-clickable-area, 42px);
+ box-sizing: border-box;
+ width: 100%;
+ max-width: 100%;
+ min-width: 0;
+ line-height: var(--line-height-base, 1.5);
+}
+
+html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__input--dob::-webkit-date-and-time-value,
+html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__input--dob::-webkit-datetime-edit,
+html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__input--dob::-webkit-datetime-edit-fields-wrapper {
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+ overflow: hidden;
+ text-align: center;
+}
+
+html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__input--dob::-webkit-date-and-time-value {
+ text-align: center;
+ white-space: nowrap;
+}
+
+@media (hover: none) and (pointer: coarse) {
+ /* Native mobile date pickers can overflow the shared field sizing. */
+ html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__input--dob {
+ height: var(--solid-ui-clickable-area, 42px);
+ min-height: var(--solid-ui-clickable-area, 42px);
+ max-height: var(--solid-ui-clickable-area, 42px);
+ line-height: calc(var(--solid-ui-clickable-area, 42px) - 2px);
+ padding-block: 0;
+ width: calc(100% - 24px);
+ max-width: calc(100% - 24px);
+ }
+
+ html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__input--dob::-webkit-date-and-time-value,
+ html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__input--dob::-webkit-datetime-edit,
+ html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__input--dob::-webkit-datetime-edit-fields-wrapper {
+ line-height: calc(var(--solid-ui-clickable-area, 42px) - 2px);
+ }
+
+ html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog--heading .profile-edit-dialog__input--dob::-webkit-calendar-picker-indicator {
+ margin: 0;
+ padding: 0;
+ }
+}
+
+html[data-layout='mobile'] .profile__edit-form {
+ --profile-edit-dialog-mobile-image-button-font-size: var(--solid-ui-font-size-md, 1rem);
+ --profile-edit-dialog-mobile-image-label-font-size: var(--solid-ui-font-size-sm, 0.875rem);
+ --profile-edit-dialog-mobile-image-description-font-size: 0.625rem;
+}
+
+html[data-layout='mobile'] #profile-modal .modal label.profile-edit-dialog__field,
+html[data-layout='mobile'] #profile-modal .modal label.profile-edit-dialog__field-type,
+html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog__field > .label,
+html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog__field-type > .label,
+html[data-layout='mobile'] #profile-modal .modal .profile-edit-dialog__field-description {
+ font-size: var(--solid-ui-font-size-sm, 0.875rem);
+ line-height: 1.5;
+}
+
+html[data-layout='mobile'] .profile-edit-dialog__field input,
+html[data-layout='mobile'] .profile-edit-dialog__field-type input {
+ font-size: var(--solid-ui-font-size-md, 1rem);
+}
+
+html[data-layout='mobile'] .profile-edit-dialog__field-type--contact-point {
+ flex: 1 1 auto;
+ width: 100%;
+ max-width: 100%;
+}
+
+html[data-layout='mobile'] .profile-edit-dialog--heading .profile-edit-dialog__row--contact-point {
+ flex-direction: row;
+ align-items: stretch;
+}
+
+html[data-layout='mobile'] .profile-edit-dialog--heading .profile-edit-dialog__row--contact-point.profile-edit-dialog__row--equal > .profile-edit-dialog__field-type.profile-edit-dialog__field-type--contact-point {
+ flex: 0 0 103px;
+ width: 103px;
+ min-width: 103px;
+ max-width: 103px;
+}
+
+html[data-layout='mobile'] .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo {
+ flex-direction: row;
+ align-items: center;
+ flex-wrap: nowrap;
+ gap: 12px;
+ width: 100%;
+ max-width: 100%;
+}
+
+html[data-layout='mobile'] .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo > header {
+ margin-bottom: 0;
+ flex: 0 0 auto;
+}
+
+html[data-layout='mobile'] .profile-edit-dialog--heading {
+ --profile-edit-dialog-avatar-size: 5rem;
+}
+
+html[data-layout='mobile'] .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile__hero-alt {
+ padding: 0;
+}
+
+html[data-layout='mobile'] .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile__image-frame--fallback .profile__hero-alt {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+html[data-layout='mobile'] .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile-edit-dialog__image-preview {
+ flex: 1 1 auto;
+ min-width: 0;
+ gap: 5px;
+ width: 100%;
+}
+
+html[data-layout='mobile'] .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile-edit-dialog__image-preview-label {
+ font-size: var(--profile-edit-dialog-mobile-image-label-font-size);
+ line-height: 1.25;
+}
+
+html[data-layout='mobile'] .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile-edit-dialog__image-preview-description {
+ font-size: var(--profile-edit-dialog-mobile-image-description-font-size);
+ line-height: 1.4;
+}
+
+html[data-layout='mobile'] .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile-edit-dialog__image-preview-actions {
+ display: grid;
+ grid-template-columns: repeat(2, max-content);
+ gap: 8px;
+ width: 100%;
+ align-items: stretch;
+ justify-content: start;
+}
+
+@media (max-width: 576px) {
+ .profile-edit-dialog__row {
+ max-width: 100%;
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .profile__edit-form {
+ --profile-edit-dialog-mobile-image-button-font-size: var(--solid-ui-font-size-md, 1rem);
+ --profile-edit-dialog-mobile-image-label-font-size: var(--solid-ui-font-size-sm, 0.875rem);
+ --profile-edit-dialog-mobile-image-description-font-size: 0.625rem;
+ }
+
+ .profile-edit-dialog__row--equal > .profile-edit-dialog__field,
+ .profile-edit-dialog__row--equal > .profile-edit-dialog__field-type,
+ .profile-edit-dialog__field-type {
+ flex: 1 1 auto;
+ }
+
+ .profile-edit-dialog__field input,
+ .profile-edit-dialog__field-type input {
+ font-size: var(--solid-ui-font-size-sm, 0.875rem);
+ }
+
+ .profile-edit-dialog__field-type--contact-point {
+ flex: 1 1 auto;
+ width: 100%;
+ max-width: 100%;
+ }
+
+ .profile-edit-dialog--heading .profile-edit-dialog__row--contact-point {
+ flex-direction: row;
+ align-items: stretch;
+ }
+
+ .profile-edit-dialog--heading .profile-edit-dialog__row--contact-point.profile-edit-dialog__row--equal > .profile-edit-dialog__field-type.profile-edit-dialog__field-type--contact-point {
+ flex: 0 0 103px;
+ width: 103px;
+ min-width: 103px;
+ max-width: 103px;
+ }
+
+ .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo {
+ flex-direction: row;
+ align-items: center;
+ flex-wrap: nowrap;
+ gap: 12px;
+ width: 100%;
+ max-width: 100%;
+ }
+
+ .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo > header {
+ margin-bottom: 0;
+ flex: 0 0 auto;
+ }
+
+ .profile__edit-form.profile-edit-dialog--heading {
+ --profile-edit-dialog-avatar-size: 5rem;
+ }
+
+ .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile__hero-alt {
+ padding: 0;
+ }
+
+ .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile__image-frame--fallback .profile__hero-alt {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile-edit-dialog__image-preview {
+ flex: 1 1 auto;
+ min-width: 0;
+ gap: 5px;
+ width: 100%;
+ }
+
+ .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile-edit-dialog__image-preview-label {
+ font-size: var(--profile-edit-dialog-mobile-image-label-font-size);
+ line-height: var(--line-height-tight, 1.25);
+ }
+
+ .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile-edit-dialog__image-preview-description {
+ font-size: var(--profile-edit-dialog-mobile-image-description-font-size);
+ line-height: var(--line-height-base, 1.4);
+ }
+
+ .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile-edit-dialog__image-preview-actions {
+ display: grid;
+ grid-template-columns: repeat(2, max-content);
+ gap: 8px;
+ width: 100%;
+ align-items: stretch;
+ justify-content: start;
+ }
+
+ .profile-edit-dialog--heading .profile-edit-dialog__row--heading-photo .profile-edit-dialog__image-button {
+ width: auto;
+ min-width: 0;
+ }
+}
diff --git a/src/components/profile-heading/editor/textUtils.ts b/src/components/profile-heading/editor/textUtils.ts
new file mode 100644
index 00000000..7c60d455
--- /dev/null
+++ b/src/components/profile-heading/editor/textUtils.ts
@@ -0,0 +1,44 @@
+export function strToUpperCase (str: string) {
+ if (str.trim().length > 0) {
+ const strCase = str.split(' ')
+ for (let i = 0; i < strCase.length; i++) {
+ strCase[i] = strCase[i].charAt(0).toUpperCase() +
+ strCase[i].substring(1)
+ }
+ return strCase.join(' ')
+ }
+ return ''
+}
+
+export function toText (value: unknown): string {
+ if (!value) return ''
+ if (typeof value === 'string') return value
+ if (typeof value === 'object' && 'value' in (value as Record)) {
+ const nested = (value as { value?: unknown }).value
+ return typeof nested === 'string' ? nested : ''
+ }
+ return ''
+}
+
+export function toTypeLabel (value: unknown): string {
+ const raw = toText(value).trim()
+ if (!raw) return ''
+ const withoutAngles = raw.replace(/^<|>$/g, '')
+ const hashParts = withoutAngles.split('#')
+ const lastByHash = hashParts[hashParts.length - 1]
+ const slashParts = lastByHash.split('/')
+ return slashParts[slashParts.length - 1]
+}
+
+export function sanitizeTextValue (value: string): string {
+ return value
+ // Control characters must be removed from user-entered profile values.
+ // eslint-disable-next-line no-control-regex
+ .replace(/[\u0000-\u001F\u007F]/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+}
+
+export function hasNonEmptyText (value: string | undefined): boolean {
+ return Boolean(value && value.trim() !== '')
+}
diff --git a/src/components/profile-heading/editor/texts.ts b/src/components/profile-heading/editor/texts.ts
new file mode 100644
index 00000000..bf5ffd5f
--- /dev/null
+++ b/src/components/profile-heading/editor/texts.ts
@@ -0,0 +1,6 @@
+/* The text refactor was generated by AI Model: GPT-5.4-Copilot. */
+export * from './texts/buttonTexts'
+export * from './texts/dialogTexts'
+export * from './texts/messageTexts'
+export * from './texts/profileTexts'
+export * from './texts/qrCodeTexts'
diff --git a/src/components/profile-heading/editor/texts/buttonTexts.ts b/src/components/profile-heading/editor/texts/buttonTexts.ts
new file mode 100644
index 00000000..40b09675
--- /dev/null
+++ b/src/components/profile-heading/editor/texts/buttonTexts.ts
@@ -0,0 +1,8 @@
+export const addMeToYourFriendsButtonText = 'Add as friend'
+export const logInAddMeToYourFriendsButtonText = 'Log in to add as friend'
+export const addMeToYourContactsButtonText = 'Add as Contact'
+export const logInAddMeToYourContactsButtonText = 'Login to add me to your contacts'
+export const logInToChatWithMeButtonText = 'Login to chat with me'
+export const friendExistsAlreadyButtonText = 'Already a friend'
+export const contactExistsAlreadyButtonText = 'Already part of contacts'
+export const contactExistsAlreadyByNameButtonText = 'Add webID to contact'
diff --git a/src/components/profile-heading/editor/texts/dialogTexts.ts b/src/components/profile-heading/editor/texts/dialogTexts.ts
new file mode 100644
index 00000000..d09dd97c
--- /dev/null
+++ b/src/components/profile-heading/editor/texts/dialogTexts.ts
@@ -0,0 +1,13 @@
+export const editSkillsDialogTitleText = 'Edit Skills'
+export const editLanguagesDialogTitleText = 'Edit Languages'
+export const editContactInfoDialogTitleText = 'Edit More Contacts'
+export const editResumeDialogTitleText = 'Edit Resume'
+export const editEducationDialogTitleText = 'Edit Education'
+export const editProjectsDialogTitleText = 'Add Projects & Communities'
+export const editHeadingDialogTitleText = 'Edit Heading'
+export const editSocialDialogTitleText = 'Edit Social Links'
+export const editBioDialogTitleText = 'Edit Bio'
+export const dialogSubmitLabelText = 'Save Changes'
+export const dialogCancelLabelText = 'Cancel'
+export const pasteEntryButtonTitleText = 'Paste'
+export const deleteEntryButtonTitleText = 'Delete entry'
diff --git a/src/components/profile-heading/editor/texts/messageTexts.ts b/src/components/profile-heading/editor/texts/messageTexts.ts
new file mode 100644
index 00000000..f1a171c4
--- /dev/null
+++ b/src/components/profile-heading/editor/texts/messageTexts.ts
@@ -0,0 +1,43 @@
+export const userNotLoggedInErrorMessage = 'Current user not found! Not logged in?'
+export const friendWasAddedSuccesMessage = 'Friend was added!'
+export const contactWasAddedSuccesMessage = 'Contact was added!'
+export const friendExistsMessage = 'Friend already exists'
+export const contactExistsMessage = 'Contact already exists'
+export const loadingMessage = 'Loading...'
+export const groupIsRequired = 'You need to either pick a group or enter one.'
+export const errorGroupCreation = 'Unable to create the group.'
+export const errorContactCreation = 'Unable to add the contact.'
+export const errorAddressBookCreation = 'Unable to create your address book.'
+export const errorReadingAddressBook = 'There was an error reading your address book.'
+export const errorLoadingContact = 'There was an error loading the contact: '
+export const errorGettingAddressBooks = 'There was an error getting your address books.'
+export const errorNotExistsAddressBookUri = 'Please enter an address book URI.'
+export const errorProcessingUriAddressBook = 'Unable to add your address book.'
+export const errorUnableToDetermineUserWorkspace = 'Unable to determine user workspace for new address book.'
+export const errorAddingContactWebIDToAddressBook = 'Contact webID was not saved.'
+export const ownerLoginRequiredDialogMessageText = 'You need to log in as the profile owner to save contact updates.'
+
+export const saveContactUpdatesFailedMessageText = 'Unable to save your contact information.'
+export const saveHeadingUpdatesFailedMessageText = 'Unable to save your heading information.'
+export const saveLanguageUpdatesFailedMessageText = 'Unable to save your languages.'
+export const saveSkillsUpdatesFailedMessageText = 'Unable to save your skills.'
+export const saveResumeUpdatesFailedMessageText = 'Unable to save your resume.'
+export const saveEducationUpdatesFailedMessageText = 'Unable to save your education history.'
+export const saveProjectsUpdatesFailedMessageText = 'Unable to save your project data.'
+export const unfollowProjectFailedMessageText = 'Unable to unfollow the project.'
+export const saveSocialUpdatesFailedMessageText = 'Unable to save your social accounts.'
+export const saveBioUpdatesFailedMessageText = 'Unable to save your bio.'
+
+export const bioMutationSaveFailedDebugText = 'Bio mutation save failed'
+export const addContactWebIdSaveFailedDebugText = 'Add contact WebID save failed'
+export const contactInfoMutationSaveFailedDebugText = 'Contact info mutation save failed'
+export const educationMutationSaveFailedDebugText = 'Education mutation save failed'
+export const headingMutationSaveFailedDebugText = 'Heading mutation save failed'
+export const languageMutationSaveFailedDebugText = 'Language mutation save failed'
+export const projectsMutationSaveFailedDebugText = 'Projects mutation save failed'
+export const resumeMutationSaveFailedDebugText = 'Resume mutation save failed'
+export const skillsMutationSaveFailedDebugText = 'Skills mutation save failed'
+export const socialMutationSaveFailedDebugText = 'Social mutation save failed'
+export const resumeUpdateEntryNotFoundErrorMessageText = 'Could not find existing resume entry for update. Please reload profile data and try again.'
+export const updaterUnsupportedStoreErrorMessageText = 'Store does not support updates'
+export const fallbackSaveUpdatesErrorMessageText = 'Failed to save updates'
diff --git a/src/components/profile-heading/editor/texts/profileTexts.ts b/src/components/profile-heading/editor/texts/profileTexts.ts
new file mode 100644
index 00000000..b79a42d1
--- /dev/null
+++ b/src/components/profile-heading/editor/texts/profileTexts.ts
@@ -0,0 +1,13 @@
+export const yourContactInformationHeading = 'Your profile'
+export const otherPreferencesHeadingText = 'Other preferences'
+export const communitiesHeadingText = 'Your communities'
+export const resumeHeadingText = 'Resume'
+export const bioHeadingText = 'Bio'
+export const educationHeadingText = 'Education'
+export const skillsHeadingText = 'Skills'
+export const languagesHeadingText = 'Languages'
+export const projectsHeadingText = 'Projects & Communities'
+export const contactInfoHeadingText = 'More Contacts'
+export const contactInfoEmptyHeadingText = 'Contact Info'
+export const socialAccountsHeadingText = 'Follow me on'
+export const friendsHeadingText = 'Friends'
diff --git a/src/components/profile-heading/editor/texts/qrCodeTexts.ts b/src/components/profile-heading/editor/texts/qrCodeTexts.ts
new file mode 100644
index 00000000..bebf3b58
--- /dev/null
+++ b/src/components/profile-heading/editor/texts/qrCodeTexts.ts
@@ -0,0 +1 @@
+export const scanQrToConnectText = 'Add to Contacts'
diff --git a/src/components/profile-heading/editor/ui/dialog.css b/src/components/profile-heading/editor/ui/dialog.css
new file mode 100644
index 00000000..d4f48b63
--- /dev/null
+++ b/src/components/profile-heading/editor/ui/dialog.css
@@ -0,0 +1,163 @@
+@import './dialog.responsive.css';
+
+/* CSS for the accessible modal dialogs created by dialog.ts */
+/* dialog host */
+#profile-modal {
+ --profile-modal-backdrop: var(--overlay-bg-muted, rgba(0, 0, 0, 0.5));
+ --profile-modal-panel-inline-padding: 25px;
+ --profile-modal-panel-block-padding: 20px;
+ --profile-modal-compact-padding: 15px;
+ --profile-modal-button-gap: 15px;
+ --profile-modal-error-padding: 12px;
+ --profile-modal-panel-width: min(600px, calc(100vw - 32px));
+ --profile-modal-panel-max-width: 600px;
+ --profile-modal-panel-max-height: calc(100dvh - 32px);
+ margin: 0;
+ padding: 0;
+ border: none;
+ background: var(--profile-modal-backdrop);
+ max-width: none;
+ max-height: none;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+ display: grid;
+ place-items: center;
+ overscroll-behavior: contain;
+ padding-top: max(25px, env(safe-area-inset-top));
+ padding-right: 0;
+ padding-bottom: max(25px, env(safe-area-inset-bottom));
+ padding-left: 0;
+}
+
+#profile-modal:not([open]) {
+ display: none;
+}
+
+#profile-modal::backdrop {
+ background: var(--profile-modal-backdrop);
+}
+
+#profile-modal .modal {
+ box-sizing: border-box;
+ position: relative;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto auto;
+ margin: 0;
+ background: var(--color-background);
+ border-radius: 5px;
+ max-width: var(--profile-modal-panel-max-width);
+ width: var(--profile-modal-panel-width);
+ max-height: var(--profile-modal-panel-max-height);
+ overflow: hidden;
+ box-shadow: var(--box-shadow-modal, 0 2px 10px rgba(0, 0, 0, 0.3));
+ align-self: center;
+ justify-self: center;
+}
+
+#profile-modal .modal .modal-header {
+ gap: var(--spacing-lg);
+ width: 100%;
+ min-width: 0;
+ padding: var(--profile-modal-panel-block-padding) var(--profile-modal-panel-inline-padding);
+ box-sizing: border-box;
+ border-radius: 5px 5px 0 0;
+ border-bottom: 1px solid var(--solid-ui-color-slate-200, #E2E8F0);
+ background: var(--solid-ui-color-slate-50, #F8FAFC);
+}
+
+#profile-modal .modal #modal-desc {
+ min-height: 0;
+ overflow: auto;
+ overscroll-behavior: contain;
+ width: 100%;
+ min-width: 0;
+ padding: var(--profile-modal-panel-inline-padding);
+ box-sizing: border-box;
+}
+
+#profile-modal .modal #modal-desc:has(> .profile__edit-form--projects) {
+ padding: 10px var(--profile-modal-panel-inline-padding);
+}
+
+#profile-modal .modal #modal-desc:has(> #contacts-addressbook-picker-dialog) {
+ padding-top: 0;
+}
+
+#profile-modal #modal-desc > .profile__edit-form--projects {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+#profile-modal .modal #modal-title {
+ margin: 0;
+ color: var(--color-text-subheading, var(--gray-900, #101828));
+}
+
+#profile-modal .modal:has(#modal-desc > .profile__edit-form--projects) #modal-header-action,
+#profile-modal .modal:has(#modal-desc > .profile__edit-form--contact-info) #modal-header-action {
+ display: none;
+}
+
+/* button container: center buttons horizontally (uses id in markup) */
+#profile-modal #modal-buttons {
+ width: 100%;
+ min-width: 0;
+ padding: var(--profile-modal-panel-block-padding) calc(var(--profile-modal-panel-inline-padding) + var(--spacing-xxs, 0.3125rem)) var(--profile-modal-compact-padding);
+ gap: var(--profile-modal-button-gap);
+ align-self: stretch;
+ box-sizing: border-box;
+ border-top: 1px solid var(--solid-ui-color-slate-200, #E2E8F0);
+ background: var(--color-surface-tertiary, var(--gray-50, #F9FAFB));
+}
+
+#profile-modal #modal-buttons[hidden] {
+ display: none !important;
+}
+
+#profile-modal #modal-buttons > solid-ui-button {
+ width: 10.8rem;
+}
+
+#profile-modal .modal__error-section {
+ margin-top: var(--spacing-sm);
+ margin-bottom: 0;
+ padding: var(--profile-modal-error-padding);
+ border: 1px solid var(--dialog-error-border, var(--pink-700, #ff98b3));
+ border-radius: 5px;
+ background: var(--dialog-error-background, var(--pink-500, #ffdde6));
+ color: var(--solid-ui-color-error, #B00020);
+ text-align: center;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+#profile-modal .modal__error-section[hidden] {
+ display: none !important;
+}
+
+#profile-modal #modal-saving-overlay {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ background: rgba(255, 255, 255, 0.78);
+ z-index: 10;
+}
+
+#profile-modal #modal-saving-overlay[hidden] {
+ display: none;
+}
+
+#profile-modal.modal--saving #modal-desc {
+ opacity: 0.45;
+ pointer-events: none;
+}
+
+#profile-modal .modal__saving-indicator {
+ gap: 0;
+}
diff --git a/src/components/profile-heading/editor/ui/dialog.responsive.css b/src/components/profile-heading/editor/ui/dialog.responsive.css
new file mode 100644
index 00000000..caa4c0b9
--- /dev/null
+++ b/src/components/profile-heading/editor/ui/dialog.responsive.css
@@ -0,0 +1,107 @@
+#profile-modal .modal:has(#modal-desc > .profile__edit-form solid-ui-combobox:focus-within) #modal-desc {
+ padding-bottom: calc(var(--profile-modal-panel-inline-padding) + 30px);
+}
+
+@media (max-width: 576px) {
+ #profile-modal {
+ --profile-modal-panel-max-height: calc(100dvh - 4 * 15px);
+ padding-top: max(15px, env(safe-area-inset-top));
+ padding-right: 0;
+ padding-bottom: max(15px, env(safe-area-inset-bottom));
+ padding-left: 0;
+ place-items: center;
+ }
+
+ #profile-modal .modal {
+ width: var(--profile-modal-panel-width);
+ max-width: var(--profile-modal-panel-max-width);
+ min-width: 0;
+ padding: 0;
+ border-radius: 5px;
+ overscroll-behavior: contain;
+ justify-self: center;
+ align-self: center;
+ margin: 0;
+ }
+
+ #profile-modal .modal .modal-header {
+ gap: 25px;
+ padding: 15px;
+ }
+
+ #profile-modal .modal #modal-title {
+ font-size: var(--solid-ui-font-size-lg, 1.125rem);
+ line-height: 1.4;
+ }
+
+ #profile-modal .modal #modal-desc {
+ padding: 15px 15px 25px;
+ }
+
+ #profile-modal #modal-buttons {
+ flex-direction: row;
+ align-items: center;
+ justify-content: flex-end;
+ gap: var(--profile-modal-button-gap);
+ padding: var(--profile-modal-compact-padding);
+ width: 100%;
+ }
+
+ #profile-modal #modal-buttons > solid-ui-button {
+ flex: 1 0 0;
+ width: auto;
+ min-width: 0;
+ }
+}
+
+html[data-layout='mobile'] #profile-modal {
+ --profile-modal-panel-max-height: calc(100dvh - 4 * 15px);
+ padding-top: max(15px, env(safe-area-inset-top));
+ padding-right: 0;
+ padding-bottom: max(15px, env(safe-area-inset-bottom));
+ padding-left: 0;
+ place-items: center;
+}
+
+html[data-layout='mobile'] #profile-modal .modal {
+ width: var(--profile-modal-panel-width);
+ height: auto;
+ max-height: var(--profile-modal-panel-max-height);
+ max-width: var(--profile-modal-panel-max-width);
+ min-width: 0;
+ padding: 0;
+ border-radius: 5px;
+ overscroll-behavior: contain;
+ justify-self: center;
+ align-self: center;
+ margin: 0;
+}
+
+html[data-layout='mobile'] #profile-modal .modal .modal-header {
+ gap: 25px;
+ padding: 15px;
+}
+
+html[data-layout='mobile'] #profile-modal .modal #modal-title {
+ font-size: var(--solid-ui-font-size-lg, 1.125rem);
+ line-height: 1.4;
+}
+
+html[data-layout='mobile'] #profile-modal .modal #modal-desc {
+ padding: 15px 15px 25px;
+}
+
+html[data-layout='mobile'] #profile-modal #modal-buttons {
+ flex-direction: row;
+ align-items: center;
+ justify-content: flex-end;
+ gap: var(--profile-modal-button-gap);
+ padding: var(--profile-modal-compact-padding);
+ width: 100%;
+}
+
+html[data-layout='mobile'] #profile-modal #modal-buttons > solid-ui-button {
+ flex: 1 0 0;
+ width: auto;
+ min-width: 0;
+}
diff --git a/src/components/profile-heading/editor/ui/dialog.ts b/src/components/profile-heading/editor/ui/dialog.ts
new file mode 100644
index 00000000..5d68a83d
--- /dev/null
+++ b/src/components/profile-heading/editor/ui/dialog.ts
@@ -0,0 +1,656 @@
+import './dialog.css'
+import 'solid-ui/components/button'
+import '~icons/lucide/x'
+import { html, render } from 'lit-html'
+import { createSpinner } from './spinner'
+import { formatDisplayError } from '../utils/errorDisplay'
+
+const closeIcon = html` `
+
+/* Copied from issue-pane, minor typescript adjustments */
+/* Changed modal from div to dialog element */
+let modalDialog: HTMLDialogElement | null = null
+
+const scrollLockState = new WeakMap void) | null
+}>()
+
+function getEventTargetElement (target: EventTarget | null): HTMLElement | null {
+ if (target instanceof HTMLElement) return target
+ if (target instanceof Node) return target.parentElement
+ return null
+}
+
+function getEventPath (event: Event): EventTarget[] {
+ if (typeof event.composedPath === 'function') {
+ return event.composedPath()
+ }
+
+ return event.target ? [event.target] : []
+}
+
+function isDialogScrollRegionTarget (event: Event): boolean {
+ const path = getEventPath(event)
+ const description = modalDialog?.querySelector('#modal-desc')
+
+ if (description && path.includes(description)) {
+ return true
+ }
+
+ if (modalDialog && path.includes(modalDialog)) {
+ return true
+ }
+
+ const targetElement = getEventTargetElement(event.target)
+ if (!targetElement || !modalDialog || !modalDialog.contains(targetElement)) {
+ return false
+ }
+
+ return Boolean(targetElement.closest('#modal-desc'))
+}
+
+function getDocumentScrollLockState (dom: Document) {
+ let state = scrollLockState.get(dom)
+ if (!state) {
+ state = {
+ count: 0,
+ previousHtmlOverflow: dom.documentElement.style.overflow,
+ previousBodyOverflow: dom.body.style.overflow,
+ removeScrollGuard: null
+ }
+ scrollLockState.set(dom, state)
+ }
+ return state
+}
+
+function installDocumentScrollGuard (dom: Document): void {
+ const state = getDocumentScrollLockState(dom)
+ if (state.removeScrollGuard) return
+
+ const preventBackgroundScroll = (event: Event) => {
+ if (!isDialogScrollRegionTarget(event)) {
+ event.preventDefault()
+ }
+ }
+
+ dom.addEventListener('wheel', preventBackgroundScroll, { capture: true, passive: false })
+ dom.addEventListener('touchmove', preventBackgroundScroll, { capture: true, passive: false })
+ state.removeScrollGuard = () => {
+ dom.removeEventListener('wheel', preventBackgroundScroll, true)
+ dom.removeEventListener('touchmove', preventBackgroundScroll, true)
+ state.removeScrollGuard = null
+ }
+}
+
+function lockDocumentScroll (dom: Document): void {
+ const state = getDocumentScrollLockState(dom)
+
+ if (state.count === 0) {
+ state.previousHtmlOverflow = dom.documentElement.style.overflow
+ state.previousBodyOverflow = dom.body.style.overflow
+
+ dom.documentElement.style.overflow = 'hidden'
+ dom.body.style.overflow = 'hidden'
+ installDocumentScrollGuard(dom)
+ }
+
+ state.count += 1
+}
+
+function unlockDocumentScroll (dom: Document): void {
+ const state = getDocumentScrollLockState(dom)
+ if (state.count === 0) return
+
+ state.count -= 1
+ if (state.count > 0) return
+
+ dom.documentElement.style.overflow = state.previousHtmlOverflow
+ dom.body.style.overflow = state.previousBodyOverflow
+ state.removeScrollGuard?.()
+ state.removeScrollGuard = null
+}
+
+function getDialogMountTarget (dom: Document): HTMLElement {
+ return dom.body
+}
+
+type DialogButtonValue = boolean | 'save' | null
+type DialogButton = {
+ label: string,
+ value: DialogButtonValue,
+ primary?: boolean,
+ cancel?: boolean,
+ beforeClose?: () => Promise | boolean
+}
+
+type DialogHeaderAction =
+ | { type: 'close' }
+ | { type: 'none' }
+ | {
+ type: 'button',
+ label: string,
+ ariaLabel?: string,
+ className?: string,
+ onClick?: () => Promise | void
+ }
+
+type DialogElements = {
+ dialog: HTMLDialogElement,
+ title: HTMLHeadingElement,
+ headerAction: HTMLDivElement,
+ description: HTMLDivElement,
+ error: HTMLElement,
+ buttons: HTMLDivElement,
+ savingOverlay: HTMLDivElement
+}
+
+type DialogActionControl = HTMLElement & {
+ disabled?: boolean,
+ label?: string,
+ click: () => void,
+ shadowRoot?: ShadowRoot | null
+}
+
+function isSolidUiButton (control: Element | null): control is DialogActionControl {
+ return control?.tagName === 'SOLID-UI-BUTTON'
+}
+
+function setDialogActionDisabled (control: DialogActionControl | null, disabled: boolean): void {
+ if (!control) return
+ control.disabled = disabled
+ control.toggleAttribute('disabled', disabled)
+}
+
+function setDialogActionLabel (control: DialogActionControl | null, label: string): void {
+ if (!control) return
+
+ if (isSolidUiButton(control)) {
+ control.label = label
+ control.setAttribute('label', label)
+ control.textContent = label
+ return
+ }
+
+ ;(control as HTMLElement).textContent = label
+}
+
+export function getSharedDialogSaveButton (root: ParentNode): DialogActionControl | null {
+ return root.querySelector('#modal-buttons [data-dialog-primary="true"]') as DialogActionControl | null
+}
+
+export function getSharedDialogCancelButton (root: ParentNode): DialogActionControl | null {
+ return root.querySelector('#modal-buttons [data-cancel="true"]') as DialogActionControl | null
+}
+
+function ensureModalDialog (dom: Document): HTMLDialogElement {
+ const mountTarget = getDialogMountTarget(dom)
+
+ // if we previously created a dialog but it was removed from the document
+ // (tests clear body), rebuild it. Checking presence ensures our reference
+ // doesn't point at a detached element.
+ if (modalDialog && dom.body.contains(modalDialog)) {
+ if (modalDialog.parentElement !== mountTarget) {
+ mountTarget.appendChild(modalDialog)
+ }
+ return modalDialog
+ }
+
+ modalDialog = null
+ modalDialog = dom.createElement('dialog')
+ modalDialog.id = 'profile-modal'
+ modalDialog.setAttribute('aria-modal', 'true')
+ modalDialog.setAttribute('aria-labelledby', 'modal-title')
+ modalDialog.setAttribute('aria-describedby', 'modal-desc')
+
+ modalDialog.innerHTML = `
+
+ `
+
+ mountTarget.appendChild(modalDialog)
+ return modalDialog
+}
+
+function getDialogElements (dialog: HTMLDialogElement): DialogElements {
+ return {
+ dialog,
+ title: dialog.querySelector('#modal-title') as HTMLHeadingElement,
+ headerAction: dialog.querySelector('#modal-header-action') as HTMLDivElement,
+ description: dialog.querySelector('#modal-desc') as HTMLDivElement,
+ error: dialog.querySelector('#modal-error') as HTMLElement,
+ buttons: dialog.querySelector('#modal-buttons') as HTMLDivElement,
+ savingOverlay: dialog.querySelector('#modal-saving-overlay') as HTMLDivElement
+ }
+}
+
+function syncDialogErrorDescription (elements: DialogElements): void {
+ const defaultDescription = 'modal-desc'
+ const describedBy = elements.error.hidden || !elements.error.textContent?.trim()
+ ? defaultDescription
+ : `${defaultDescription} modal-error`
+
+ elements.dialog.setAttribute('aria-describedby', describedBy)
+}
+
+function clearModalError (elements: DialogElements): void {
+ elements.error.textContent = ''
+ elements.error.setAttribute('aria-hidden', 'true')
+ elements.error.hidden = true
+ syncDialogErrorDescription(elements)
+}
+
+function setModalError (elements: DialogElements, error: unknown, fallbackMessage?: string): void {
+ elements.error.textContent = formatDisplayError(error, fallbackMessage)
+ elements.error.setAttribute('aria-hidden', 'false')
+ elements.error.hidden = false
+ syncDialogErrorDescription(elements)
+}
+
+function openDialogElement (dialog: HTMLDialogElement): void {
+ lockDocumentScroll(dialog.ownerDocument)
+
+ if (typeof dialog.showModal === 'function') {
+ try {
+ if (!dialog.open) dialog.showModal()
+ return
+ } catch {
+ // Fall back for jsdom or partial dialog implementations.
+ }
+ }
+
+ dialog.setAttribute('open', '')
+}
+
+function closeDialogElement (dialog: HTMLDialogElement): void {
+ try {
+ if (typeof dialog.close === 'function' && dialog.open) {
+ dialog.close()
+ return
+ }
+
+ dialog.removeAttribute('open')
+ } finally {
+ unlockDocumentScroll(dialog.ownerDocument)
+ }
+}
+
+function isMobileDialogLayout (dom: Document): boolean {
+ return Boolean(dom.querySelector('[data-layout=\'mobile\']'))
+}
+
+function isFocusableElement (element: HTMLElement): boolean {
+ if (element.hasAttribute('disabled')) return false
+ if (element.getAttribute('aria-hidden') === 'true') return false
+ if (element.getAttribute('hidden') !== null) return false
+ if (element.tabIndex < 0) return false
+ return true
+}
+
+function findPreferredInitialFocusTarget (container: ParentNode): HTMLElement | null {
+ const focusableControls = container.querySelectorAll([
+ 'input:not([type="hidden"]):not([disabled])',
+ 'textarea:not([disabled])',
+ 'select:not([disabled])',
+ 'solid-ui-select:not([disabled])',
+ 'solid-ui-combobox:not([disabled])',
+ '[contenteditable="true"]',
+ 'button:not([disabled])',
+ 'solid-ui-button:not([disabled])',
+ '[tabindex]:not([tabindex="-1"])'
+ ].join(','))
+
+ for (const match of focusableControls) {
+ if (match instanceof HTMLElement && isFocusableElement(match)) return match
+ }
+
+ return null
+}
+
+function focusInitialDialogTarget (dialog: HTMLDialogElement, description: HTMLDivElement, buttons: HTMLDivElement): void {
+ if (isMobileDialogLayout(dialog.ownerDocument)) {
+ dialog.tabIndex = -1
+ dialog.focus()
+ return
+ }
+
+ const initialTarget = findPreferredInitialFocusTarget(description)
+ if (initialTarget) {
+ initialTarget.focus()
+ if (initialTarget instanceof HTMLInputElement || initialTarget instanceof HTMLTextAreaElement) {
+ initialTarget.select()
+ }
+ return
+ }
+
+ const firstButton = findPreferredInitialFocusTarget(buttons)
+ if (firstButton) {
+ firstButton.focus()
+ }
+}
+
+function collectFormValues (form: HTMLFormElement): InputDialogValues {
+ const data = new FormData(form)
+ const values: InputDialogValues = {}
+ data.forEach((value, key) => {
+ values[key] = String(value)
+ })
+ return values
+}
+
+function openModal ({
+ title,
+ message,
+ buttons,
+ headerAction,
+ dom
+}: {
+ title?: string,
+ message?: string | Node,
+ buttons: DialogButton[],
+ headerAction?: DialogHeaderAction,
+ dom: Document
+}) {
+ const dialog = ensureModalDialog(dom)
+ const elements = getDialogElements(dialog)
+
+ openDialogElement(dialog)
+
+ elements.title.textContent = title || ''
+ elements.description.innerHTML = ''
+ if (typeof message === 'string') {
+ elements.description.textContent = message
+ } else if (message) {
+ elements.description.appendChild(message)
+ }
+
+ elements.buttons.innerHTML = ''
+ clearModalError(elements)
+
+ return new Promise((resolve) => {
+ const finish = (value: DialogButtonValue) => {
+ closeModal(value)
+ resolve(value)
+ }
+
+ const requestCancel = () => {
+ const cancelBtn = getSharedDialogCancelButton(dialog)
+ if (cancelBtn) {
+ cancelBtn.click()
+ return
+ }
+
+ finish(false)
+ }
+
+ dialog.oncancel = (event: Event) => {
+ event.preventDefault()
+ requestCancel()
+ }
+
+ elements.headerAction.innerHTML = ''
+ const resolvedHeaderAction = headerAction || { type: 'close' as const }
+ if (resolvedHeaderAction.type === 'close') {
+ const closeButton = dom.createElement('solid-ui-button') as DialogActionControl
+ closeButton.setAttribute('variant', 'ghost')
+ closeButton.setAttribute('aria-label', 'Close dialog')
+ const iconSlot = dom.createElement('span')
+ iconSlot.setAttribute('slot', 'icon')
+ render(closeIcon, iconSlot)
+ closeButton.appendChild(iconSlot)
+ closeButton.onclick = () => requestCancel()
+ elements.headerAction.appendChild(closeButton)
+ } else if (resolvedHeaderAction.type === 'button') {
+ const actionButton = dom.createElement('solid-ui-button') as DialogActionControl
+ actionButton.setAttribute('variant', 'tertiary')
+ actionButton.className = resolvedHeaderAction.className || ''
+ actionButton.textContent = resolvedHeaderAction.label
+ actionButton.setAttribute('label', resolvedHeaderAction.label)
+ actionButton.setAttribute('aria-label', resolvedHeaderAction.ariaLabel || resolvedHeaderAction.label)
+ actionButton.onclick = async () => {
+ if (resolvedHeaderAction.onClick) {
+ await resolvedHeaderAction.onClick()
+ }
+ }
+ elements.headerAction.appendChild(actionButton)
+ }
+
+ buttons.forEach((btn) => {
+ const b = dom.createElement('solid-ui-button') as DialogActionControl
+ b.setAttribute('label', btn.label)
+ b.setAttribute('variant', btn.primary ? 'primary' : 'secondary')
+ b.textContent = btn.label
+ if (btn.primary) {
+ b.setAttribute('data-dialog-primary', 'true')
+ }
+ if (btn.cancel) {
+ b.setAttribute('data-cancel', 'true')
+ }
+ b.addEventListener('click', async () => {
+ if (btn.beforeClose) {
+ const shouldClose = await btn.beforeClose()
+ if (!shouldClose) return
+ }
+ finish(btn.value)
+ })
+ elements.buttons.appendChild(b)
+ })
+
+ const scheduleInitialFocus = dom.defaultView?.requestAnimationFrame
+ ? (callback: () => void) => dom.defaultView?.requestAnimationFrame(() => callback())
+ : (callback: () => void) => setTimeout(callback, 0)
+
+ scheduleInitialFocus(() => {
+ focusInitialDialogTarget(dialog, elements.description, elements.buttons)
+ })
+ })
+}
+
+function closeModal (_result: DialogButtonValue): void {
+ if (modalDialog) {
+ const elements = getDialogElements(modalDialog)
+ clearModalError(elements)
+ closeDialogElement(modalDialog)
+ modalDialog.oncancel = null
+ const headerActionButton = modalDialog.querySelector('#modal-header-action button') as HTMLButtonElement | null
+ if (headerActionButton) headerActionButton.onclick = null
+ }
+}
+
+export function closeSharedDialog (): void {
+ closeModal(null)
+}
+
+export function alertDialog (message: string, title = 'Information', dom: Document) {
+ return openModal({
+ title,
+ message,
+ buttons: [{ label: 'OK', value: true, primary: true }],
+ dom
+ })
+}
+
+function updateSavingUI (dialog: HTMLDialogElement, submitLabel: string, isSaving: boolean) {
+ const elements = getDialogElements(dialog)
+ const saveButton = getSharedDialogSaveButton(dialog)
+ const cancelButton = getSharedDialogCancelButton(dialog)
+
+ dialog.classList.toggle('modal--saving', isSaving)
+ dialog.setAttribute('aria-busy', String(isSaving))
+ elements.description.toggleAttribute('inert', isSaving)
+
+ if (isSaving) {
+ const activeElement = dialog.ownerDocument.activeElement as HTMLElement | null
+ if (activeElement && dialog.contains(activeElement)) {
+ activeElement.blur()
+ }
+ }
+
+ if (saveButton) {
+ setDialogActionDisabled(saveButton, isSaving)
+ saveButton.setAttribute('aria-busy', String(isSaving))
+ setDialogActionLabel(saveButton, submitLabel)
+ }
+
+ if (cancelButton) {
+ setDialogActionDisabled(cancelButton, isSaving)
+ }
+
+ elements.savingOverlay.hidden = !isSaving
+ if (isSaving) {
+ render(html`
+
+ ${createSpinner()}
+
+ `, elements.savingOverlay)
+ } else {
+ render(html``, elements.savingOverlay)
+ }
+}
+
+export function setSharedDialogSavingState (dom: Document, isSaving: boolean, submitLabel = 'Saving Changes'): void {
+ const dialog = ensureModalDialog(dom)
+ updateSavingUI(dialog, submitLabel, isSaving)
+}
+
+type OpenInputDialogCustom = {
+ title: string,
+ dom: Document,
+ form: HTMLFormElement,
+ submitLabel?: string,
+ cancelLabel?: string,
+ headerAction?: DialogHeaderAction,
+ hideFooterButtons?: boolean,
+ onOpen?: () => void,
+ shouldCloseWithoutSave?: () => Promise | boolean,
+ validate?: () => Promise | string | null,
+ onSave?: () => Promise | void,
+ formatSaveError?: (error: unknown) => string
+}
+
+type InputDialogValues = Record
+/* Function below generated by AI Model: GPT-5.3-Codex */
+/* Prompt: write me a reusable function to open a dialog with a form and return the form values on submit.
+The function should also support validation and display error messages in the dialog.
+It should reuse the OpenModal function for consistency. Done in 2 passes */
+export function openInputDialog (options: OpenInputDialogCustom): Promise {
+ const submitLabel = options.submitLabel || 'Save Changes'
+ const cancelLabel = options.cancelLabel || 'Cancel'
+ const dialog = ensureModalDialog(options.dom)
+ const elements = getDialogElements(dialog)
+
+ const deriveDefaultFieldLabel = (value: string): string => value
+ .replace(/^\s*edit\s+/i, '')
+ .replace(/^\s*add\s+/i, '')
+ .replace(/^\s*create\s+/i, '')
+ .trim()
+
+ const ensureFormControlsHaveAccessibleNames = () => {
+ const titleLabel = deriveDefaultFieldLabel(options.title)
+ if (!titleLabel) return
+
+ const controls = Array.from(options.form.querySelectorAll([
+ 'input:not([type="hidden"])',
+ 'textarea',
+ 'select'
+ ].join(',')))
+
+ if (controls.length !== 1) return
+
+ const [control] = controls
+ const hasExplicitLabel = control.hasAttribute('aria-label') || control.hasAttribute('aria-labelledby')
+ const hasNativeLabel = Boolean(control.closest('label'))
+ if (hasExplicitLabel || hasNativeLabel || control.getAttribute('type') === 'hidden') {
+ return
+ }
+
+ if (!control.getAttribute('aria-label')) {
+ control.setAttribute('aria-label', titleLabel)
+ }
+ }
+
+ ensureFormControlsHaveAccessibleNames()
+
+ const handleSubmit = (event: SubmitEvent) => {
+ event.preventDefault()
+ const saveButton = getSharedDialogSaveButton(dialog)
+ saveButton?.click()
+ }
+
+ options.form.addEventListener('submit', handleSubmit)
+
+ const dialogPromise = openModal({
+ title: options.title,
+ message: options.form,
+ headerAction: options.headerAction,
+ buttons: [
+ { label: cancelLabel, value: null, cancel: true },
+ {
+ label: submitLabel,
+ value: 'save',
+ primary: true,
+ beforeClose: async () => {
+ clearModalError(elements)
+
+ if (options.shouldCloseWithoutSave && await options.shouldCloseWithoutSave()) {
+ return true
+ }
+
+ if (options.validate) {
+ const validationMessage = await options.validate()
+ if (validationMessage) {
+ setModalError(elements, validationMessage)
+ return false
+ }
+ }
+
+ if (!options.onSave) return true
+
+ try {
+ updateSavingUI(dialog, submitLabel, true)
+
+ await options.onSave()
+ return true
+ } catch (error) {
+ updateSavingUI(dialog, submitLabel, false)
+ const fallback = options.formatSaveError ? options.formatSaveError(error) : undefined
+ setModalError(elements, error, fallback)
+ return false
+ }
+ }
+ }
+ ],
+ dom: options.dom
+ })
+
+ const scheduleOpenCallback = options.dom.defaultView?.requestAnimationFrame
+ ? (callback: () => void) => options.dom.defaultView?.requestAnimationFrame(() => callback())
+ : (callback: () => void) => setTimeout(callback, 0)
+
+ if (options.onOpen) {
+ scheduleOpenCallback(options.onOpen)
+ }
+
+ elements.buttons.hidden = Boolean(options.hideFooterButtons)
+
+ return dialogPromise
+ .then((result) => {
+ if (result !== 'save') return null
+ return collectFormValues(options.form)
+ })
+ .finally(() => {
+ elements.buttons.hidden = false
+ updateSavingUI(dialog, submitLabel, false)
+ options.form.removeEventListener('submit', handleSubmit)
+ })
+}
diff --git a/src/components/profile-heading/editor/ui/spinner.ts b/src/components/profile-heading/editor/ui/spinner.ts
new file mode 100644
index 00000000..78dddede
--- /dev/null
+++ b/src/components/profile-heading/editor/ui/spinner.ts
@@ -0,0 +1,8 @@
+import { html, TemplateResult } from 'lit-html'
+
+export function createSpinner (): TemplateResult {
+ return html`
+
+ Saving...
+ `
+}
diff --git a/src/components/profile-heading/editor/utils/errorDisplay.ts b/src/components/profile-heading/editor/utils/errorDisplay.ts
new file mode 100644
index 00000000..eaaccc8a
--- /dev/null
+++ b/src/components/profile-heading/editor/utils/errorDisplay.ts
@@ -0,0 +1,15 @@
+function stringifyUnknown (value: unknown): string {
+ if (typeof value === 'string') return value
+ if (value instanceof Error) return value.message || value.name
+ if (value == null) return ''
+
+ try {
+ return JSON.stringify(value)
+ } catch {
+ return String(value)
+ }
+}
+
+export function formatDisplayError (error: unknown, fallbackMessage?: string): string {
+ return (fallbackMessage || stringifyUnknown(error) || 'Something went wrong.').trim()
+}
diff --git a/src/components/profile-heading/friendActions.ts b/src/components/profile-heading/friendActions.ts
new file mode 100644
index 00000000..bf4f5984
--- /dev/null
+++ b/src/components/profile-heading/friendActions.ts
@@ -0,0 +1,39 @@
+import { authn, store } from 'solid-logic'
+import { st, type NamedNode } from 'rdflib'
+import { ns } from 'solid-ui'
+import { ensureStandardMutationPrefixes } from './editor/sections/shared/rdfMutationHelpers'
+import { formatDisplayError } from './editor/utils/errorDisplay'
+
+export const friendTexts = {
+ add: 'Add as friend',
+ exists: 'Already a friend',
+ success: 'Friend was added!',
+ duplicate: 'Friend already exists',
+ loginRequired: 'Current user not found! Not logged in?'
+}
+
+function requireViewer (viewer: NamedNode): void {
+ if (!authn.currentUser()?.sameTerm(viewer)) {
+ throw new Error(friendTexts.loginRequired)
+ }
+}
+
+export async function checkFriend (viewer: NamedNode, subject: NamedNode): Promise {
+ await store.fetcher.load(viewer)
+ requireViewer(viewer)
+ return store.whether(viewer, ns.foaf('knows'), subject, viewer.doc()) > 0
+}
+
+export async function addFriend (viewer: NamedNode, subject: NamedNode): Promise {
+ requireViewer(viewer)
+ if (await checkFriend(viewer, subject)) throw new Error(friendTexts.duplicate)
+ ensureStandardMutationPrefixes(store)
+ try {
+ await store.updater.update([], [st(viewer, ns.foaf('knows'), subject, viewer.doc())])
+ } catch (error: unknown) {
+ if (formatDisplayError(error).includes('Unauthenticated')) {
+ throw new Error(friendTexts.loginRequired)
+ }
+ throw error
+ }
+}
diff --git a/src/components/profile-heading/index.ts b/src/components/profile-heading/index.ts
new file mode 100644
index 00000000..810ec439
--- /dev/null
+++ b/src/components/profile-heading/index.ts
@@ -0,0 +1,5 @@
+import ProfileHeading from './ProfileHeading'
+
+export { ProfileHeading }
+export type { ProfileHeadingData, ProfileHeadingEditContext, ProfileHeadingPoint } from './ProfileHeading'
+export default ProfileHeading
diff --git a/src/components/profile-heading/profileHeadingData.ts b/src/components/profile-heading/profileHeadingData.ts
new file mode 100644
index 00000000..1bf592a5
--- /dev/null
+++ b/src/components/profile-heading/profileHeadingData.ts
@@ -0,0 +1,163 @@
+import { store } from 'solid-logic'
+import { getViewerRelation } from '../../utils/viewerUtils'
+import { NamedNode, type LiveStore, type Node } from 'rdflib'
+import { ns, utils, widgets } from 'solid-ui'
+import type { AddressDetails, PointDetails } from './editor/sections/contactInfo/types'
+import type { ProfileDetails } from './editor/sections/heading/types'
+import type { ViewerMode } from '../../types'
+
+export async function getViewerMode (subject: NamedNode): Promise {
+ const relation = getViewerRelation(subject)
+ if (relation !== 'owner') return relation
+
+ try {
+ return store.updater?.editable?.(subject.doc()) ? 'owner' : 'anonymous'
+ } catch (error) {
+ console.warn('Failed to check profile edit permissions.', error)
+ return 'anonymous'
+ }
+}
+
+export function presentProfile (subject: NamedNode, profileStore: LiveStore = store): ProfileDetails {
+ const doc = subject.doc()
+ const nickname = profileStore.anyValue(subject, ns.vcard('nickname')) ||
+ profileStore.anyValue(subject, ns.foaf('nick')) || undefined
+ const dateOfBirth = profileStore.anyValue(subject, ns.vcard('bday')) || undefined
+ const imageSrc = widgets.findImage(subject) || undefined
+ const primaryPhone = selectPrimaryPoint(profileStore, subject, ns.vcard('hasTelephone'), doc, 'phone')
+ const primaryEmail = selectPrimaryPoint(profileStore, subject, ns.vcard('hasEmail'), doc, 'email')
+ const jobTitle = selectHeadingJobTitle(profileStore, subject, doc)
+
+ const addressNode = profileStore.any(subject, ns.vcard('hasAddress'), null, doc) || undefined
+ const addressSubject = addressNode as NamedNode | undefined
+ const primaryAddress: AddressDetails | undefined = addressNode
+ ? {
+ entryNode: addressNode,
+ type: profileStore.any(addressSubject, ns.rdf('type'), null, doc) || undefined,
+ streetAddress: profileStore.anyValue(addressSubject, ns.vcard('street-address')) || undefined,
+ locality: profileStore.anyValue(addressSubject, ns.vcard('locality')) || undefined,
+ region: profileStore.anyValue(addressSubject, ns.vcard('region')) || undefined,
+ postalCode: profileStore.anyValue(addressSubject, ns.vcard('postal-code')) || undefined,
+ countryName: profileStore.anyValue(addressSubject, ns.vcard('country-name')) || undefined
+ }
+ : undefined
+ const country = primaryAddress?.countryName
+ const locality = primaryAddress?.locality
+ const subjectPronoun = profileStore.anyValue(subject, ns.solid('preferredSubjectPronoun')) || ''
+ const objectPronoun = profileStore.anyValue(subject, ns.solid('preferredObjectPronoun')) || ''
+ const pronouns = subjectPronoun ? `${subjectPronoun}${objectPronoun ? `/${objectPronoun}` : ''}` : undefined
+
+ return {
+ name: utils.label(subject),
+ nickname,
+ imageSrc,
+ dateOfBirth,
+ jobTitle,
+ primaryPhone,
+ primaryEmail,
+ primaryAddress,
+ location: country && locality ? `${locality}, ${country}` : country || locality || undefined,
+ pronouns,
+ entryNode: subject
+ }
+}
+
+function selectHeadingJobTitle (profileStore: LiveStore, subject: NamedNode, doc: NamedNode): string | undefined {
+ const roles = profileStore.each(null, ns.org('member'), subject, doc)
+ .filter(membership =>
+ !profileStore.holds(
+ membership as NamedNode,
+ ns.rdf('type'),
+ ns.schema('EducationalOccupationalCredential'),
+ doc
+ )
+ )
+ .map(membership => {
+ const membershipNode = membership as NamedNode
+ const types = profileStore.each(membershipNode, ns.rdf('type'), null, doc)
+ const roleType = types.find(type => type.value.startsWith(ns.solid('').value))?.value.split(/[#/]/).pop()?.toLowerCase()
+ const roleText = profileStore.anyValue(membershipNode, ns.vcard('role'), null, doc) || undefined
+ const escoRole = profileStore.any(membershipNode, ns.org('role'), null, doc)
+ const escoRoleName = escoRole
+ ? profileStore.anyValue(escoRole as NamedNode, ns.schema('name'), null, doc) || undefined
+ : undefined
+ const endDate = profileStore.any(membershipNode, ns.schema('endDate'), null, doc)
+ const title = escoRoleName && roleText
+ ? `${escoRoleName} - ${roleText}`
+ : roleText || escoRoleName
+
+ return {
+ title,
+ roleType,
+ isCurrent: !endDate || roleType === 'currentrole'
+ }
+ })
+ const selected = roles.find(role => role.isCurrent) ||
+ roles.find(role => role.roleType !== 'currentrole' && role.roleType !== 'futurerole')
+ return selected?.title
+}
+
+function selectPrimaryPoint (
+ profileStore: LiveStore,
+ subject: NamedNode,
+ predicate: NamedNode,
+ doc: NamedNode,
+ kind: 'email' | 'phone'
+): PointDetails | undefined {
+ const candidates: PointDetails[] = []
+ const nodes = [
+ ...profileStore.statementsMatching(subject, predicate, null, doc),
+ ...profileStore.statementsMatching(subject, predicate)
+ ].map(statement => statement.object as Node)
+ const seen = new Set()
+
+ for (const entryNode of nodes) {
+ if (seen.has(entryNode.value)) continue
+ seen.add(entryNode.value)
+
+ const valueNode = resolvePointValue(profileStore, entryNode, doc, kind)
+ if (!valueNode) continue
+
+ const types = entryNode.termType === 'NamedNode'
+ ? [
+ ...profileStore.each(entryNode as NamedNode, ns.rdf('type'), null, doc),
+ ...profileStore.each(entryNode as NamedNode, ns.rdf('type'))
+ ]
+ : []
+ const type = types[0] || (kind === 'email' ? ns.vcard('Internet') : ns.vcard('Voice'))
+ const candidate = { entryNode, type: type as Node, valueNode }
+ if (types.some(typeNode => isWorkType(typeNode.value))) return candidate
+ candidates.push(candidate)
+ }
+
+ return candidates[0]
+}
+
+function resolvePointValue (
+ profileStore: LiveStore,
+ entryNode: Node,
+ doc: NamedNode,
+ kind: 'email' | 'phone'
+): Node | undefined {
+ if (isContactValue(entryNode.value, kind)) return entryNode
+ if (entryNode.termType !== 'NamedNode') return undefined
+
+ const values = [
+ ...profileStore.each(entryNode as NamedNode, ns.vcard('value'), null, doc),
+ ...profileStore.each(entryNode as NamedNode, ns.vcard('value'))
+ ]
+ return values.find(value => isContactValue(value.value, kind))
+}
+
+function isContactValue (value: string, kind: 'email' | 'phone'): boolean {
+ const normalized = value.trim()
+ if (kind === 'email') {
+ return /^mailto:/i.test(normalized) || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)
+ }
+ return /^tel:/i.test(normalized) || /^[+()\-\s\d]{5,}$/.test(normalized)
+}
+
+function isWorkType (value: string): boolean {
+ const normalized = value.trim().toLowerCase()
+ return normalized === 'work' || normalized.endsWith('#work') || normalized.endsWith('/work')
+}
diff --git a/src/icons/clock.svg b/src/icons/clock.svg
deleted file mode 100644
index ba92e067..00000000
--- a/src/icons/clock.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/src/icons/comment.svg b/src/icons/comment.svg
deleted file mode 100644
index fd7b0d62..00000000
--- a/src/icons/comment.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/src/icons/downArrow.svg b/src/icons/downArrow.svg
deleted file mode 100644
index a765b021..00000000
--- a/src/icons/downArrow.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/src/icons/signOut.svg b/src/icons/signOut.svg
deleted file mode 100644
index c7c9b7c6..00000000
--- a/src/icons/signOut.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/src/icons/signUp.svg b/src/icons/signUp.svg
deleted file mode 100644
index 82a9fab5..00000000
--- a/src/icons/signUp.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/index.ts b/src/index.ts
index 24f2f449..78f65756 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -26,6 +26,7 @@ import {
} from 'pane-registry'
import { createContext } from './outline/context'
import { initMainPage, refreshUI } from './mainPage'
+import { isHandlingBrowserNavigation } from './navigation/browserEvents'
function getOutliner (dom, environment?: RenderEnvironment): OutlineManager {
if (!dom.outlineManager) {
@@ -69,6 +70,7 @@ export {
versionInfo,
initMainPage,
refreshUI,
+ isHandlingBrowserNavigation,
list, // from paneRegistry
paneForIcon, // from paneRegistry
paneForPredicate, // from paneRegistry
diff --git a/src/mainPage/header.ts b/src/mainPage/header.ts
index 4b45bb8e..126fb4d5 100644
--- a/src/mainPage/header.ts
+++ b/src/mainPage/header.ts
@@ -1,7 +1,6 @@
-import { authn } from 'solid-logic'
import { html, render } from 'lit-html'
import type { AccountMenuItem } from 'solid-ui/components/account'
-import type { OutlineManager } from '../outline/manager'
+import type { NavigationController } from '../navigation/controller'
import '~icons/lucide/user'
import '~icons/lucide/users'
@@ -9,36 +8,25 @@ import '~icons/lucide/folder-open'
import '~icons/lucide/layout-dashboard'
import '~icons/lucide/settings-2'
import '../components/header'
-import { getProfilePaneFromURI, getSocialPaneFromURI, getFolderPanesFromURI } from '../utils/paneUtils'
-import { sym } from 'rdflib'
+import { getFolderPanesFromURI } from '../utils/paneUtils'
+import { getCurrentViewer } from '../utils/viewerUtils'
-export async function createHeader (outliner: OutlineManager) {
+export async function createHeader (navigation: NavigationController) {
const existingHeader = document.querySelector('solid-panes-header')
if (existingHeader) {
return existingHeader
}
- const me = authn.currentUser()
-
+ const viewer = await getCurrentViewer()
+ const storagePanes = viewer ? await getFolderPanesFromURI(viewer) : []
const main = document.getElementById('MainContent')
- const outlineView = document.getElementById('OutlineView')
const tmpContainer = document.createElement('div')
-
- function hideNavbar () {
- const navbar = document.querySelector('solid-panes-navbar')
- if (navbar) {
- navbar.classList.add('navbar--hidden')
- }
- }
-
- const storagePanes = me ? await getFolderPanesFromURI(me) : []
const storageMenuItems: AccountMenuItem[] = storagePanes.map(pane => ({
label: html` ${pane.label()}`,
async onSelected () {
- hideNavbar()
- if (me) {
- outliner.GotoSubject(sym(window.location.href), true, pane, true, undefined, outlineView, false)
+ if (await getCurrentViewer()) {
+ await navigation.show({ kind: 'storage', pane, source: 'accountMenu' })
}
}
}))
@@ -46,45 +34,35 @@ export async function createHeader (outliner: OutlineManager) {
{
label: html` Profile`,
async onSelected () {
- hideNavbar()
- if (me) {
- const profilePane = await getProfilePaneFromURI(me)
- outliner.GotoSubject(sym(window.location.href), true, profilePane, true, undefined, outlineView, false)
+ const currentViewer = await getCurrentViewer()
+ if (currentViewer) {
+ await navigation.show({ kind: 'profile', subject: currentViewer.value, source: 'accountMenu' })
}
}
},
{
label: html` Friends`,
async onSelected () {
- hideNavbar()
- if (me) {
- const socialPane = await getSocialPaneFromURI(me)
- outliner.GotoSubject(sym(window.location.href), true, socialPane, true, undefined, outlineView, false)
+ const currentViewer = await getCurrentViewer()
+ if (currentViewer) {
+ await navigation.show({ kind: 'friends', subject: currentViewer.value, source: 'accountMenu' })
}
}
},
...storageMenuItems,
{
label: html` Dashboard`,
- onSelected () {
- hideNavbar()
- if (me) {
- const pane = outliner.context.session.paneRegistry.byName('home')
- if (pane) {
- outliner.GotoSubject(sym(window.location.href), true, pane, true, undefined, outlineView, false)
- }
+ async onSelected () {
+ if (await getCurrentViewer()) {
+ await navigation.show({ kind: 'app', pane: 'home', source: 'accountMenu' })
}
}
},
{
label: html` Preferences`,
- onSelected () {
- hideNavbar()
- if (me) {
- const pane = outliner.context.session.paneRegistry.byName('basicPreferences')
- if (pane) {
- outliner.GotoSubject(sym(window.location.href), true, pane, true, undefined, outlineView, false)
- }
+ async onSelected () {
+ if (await getCurrentViewer()) {
+ await navigation.show({ kind: 'app', pane: 'basicPreferences', source: 'accountMenu' })
}
}
},
diff --git a/src/mainPage/index.ts b/src/mainPage/index.ts
index 17b22876..a7d2b08b 100644
--- a/src/mainPage/index.ts
+++ b/src/mainPage/index.ts
@@ -5,12 +5,13 @@
import { LiveStore, NamedNode } from 'rdflib'
import type { RenderEnvironment } from 'pane-registry'
-import * as paneRegistry from 'pane-registry'
import { getOutliner, OutlineManager } from '../index'
import { createHeader } from './header'
import { createNavbar } from './navbar'
-import { getProfilePaneFromURI } from '../utils/paneUtils'
-import { isWebIdUri } from '../utils/webIdUtils'
+import { navigationFor } from './navigation'
+import { initializeProfileHeading, placeProfileHeading } from './profileHeading'
+import { registerBrowserNavigation } from '../navigation/browserEvents'
+import { clearPaneState } from '../navigation/state'
// Symbol used to stash the last render-relevant env snapshot on the outliner
// so refreshUI can skip a full GotoSubject re-render when nothing changed.
@@ -40,38 +41,29 @@ export async function initMainPage (
environment?: RenderEnvironment
) {
ensureMainContent()
- const outliner = getOutliner(document, environment)
- ;(outliner as any)[LAST_RENDER_ENV_KEY] = renderEnvSignature(environment)
uri = uri || window.location.href
const subject: NamedNode = typeof uri === 'string' ? store.sym(uri) : uri
- const historyPaneName = window.history.state?.paneName
- const historyPane = historyPaneName
- ? paneRegistry.byName(historyPaneName)
- : undefined
- const initialPane = historyPane ??
- (!historyPaneName && isWebIdUri(subject)
- ? await getProfilePaneFromURI(subject)
- : undefined)
-
- outliner.GotoSubject(subject, true, initialPane, true, undefined, undefined, true, false)
+ // A reload shows what the URL is, whatever the history entry last displayed through the navbar
+ // or the account menu: the profile on a profile page, otherwise the resource or container itself.
+ // This is also what Enter on the current URL ends in where the Navigation API is missing (see
+ // NAVIGATION API FALLBACK in navigation/browserEvents.ts): the browser reloads the page.
+ clearPaneState()
+ initializeProfileHeading()
+ const outliner = getOutliner(document, environment)
+ ;(outliner as any)[LAST_RENDER_ENV_KEY] = renderEnvSignature(environment)
+ const navigation = navigationFor(outliner, store)
+ registerBrowserNavigation(navigation)
+ await navigation.show({ kind: 'restore', subject: subject.uri })
- const header = await createHeader(outliner)
- const navbar = await createNavbar(outliner)
+ const header = await createHeader(navigation)
+ const navbar = await createNavbar(navigation)
+ // The navbar went in at the top of the page; the heading belongs above it
+ placeProfileHeading()
return Promise.all([header, navbar])
}
export async function refreshUI (outliner: OutlineManager) {
const store = outliner?.context?.session?.store
- const paneRegistry = outliner?.context?.session?.paneRegistry
- const paneName = window.history.state?.paneName
- const paneUri = window.history.state?.paneUri
- // Panes rendered for a subject other than the page URL (storage/profile/social)
- // must be restored from paneUri, since byName() returns the pane without that subject.
- const usesOwnSubject = paneName === 'profile' || paneName === 'social' || paneName === 'folder'
- const subjectUri = paneUri && usesOwnSubject
- ? paneUri
- : window.document.location.href
- const pane = paneName ? paneRegistry?.byName?.(paneName) : undefined
// Only re-run GotoSubject (full pane re-render) when render-relevant
// environment fields actually changed since the last render.
@@ -80,7 +72,9 @@ export async function refreshUI (outliner: OutlineManager) {
const envChanged = currentSignature !== previousSignature
if (envChanged && store && typeof outliner?.GotoSubject === 'function') {
- outliner.GotoSubject(store.sym(subjectUri), true, pane, true, undefined, undefined, true, false)
+ // Re-render what the current history entry displays without changing the
+ // URL or losing that it came from the account menu.
+ await navigationFor(outliner, store).show({ kind: 'restore' })
;(outliner as any)[LAST_RENDER_ENV_KEY] = currentSignature
}
}
diff --git a/src/mainPage/navbar.ts b/src/mainPage/navbar.ts
index da1f571a..f7607f38 100644
--- a/src/mainPage/navbar.ts
+++ b/src/mainPage/navbar.ts
@@ -1,9 +1,12 @@
import { store } from 'solid-logic'
-import { NamedNode } from 'rdflib'
-import { getSocialPaneFromURI, getProfilePaneFromURI, getFolderPanesFromURI } from '../utils/paneUtils'
import { html, render } from 'lit-html'
-import type { OutlineManager } from '../outline/manager'
-import { isWebIdUri, loadProfileFromURI } from '../utils/webIdUtils'
+import type { NavigationController } from '../navigation/controller'
+import { isOwnWebIdProfile, isWebIdUri } from '../utils/webIdUtils'
+import { deriveNavbarSelection, isNavbarHidden, isNavbarItemSelected, pickNavbarItem } from '../navigation/chrome'
+import { onNavigated } from '../navigation/events'
+import { readNavigationState, type NavigationView } from '../navigation/state'
+import { getPodContext, isSamePod, type PodContext } from '../navigation/podContext'
+import { setNavbarHidden } from '../navigation/navbarVisibility'
import '~icons/lucide/user'
import '~icons/lucide/users'
@@ -13,19 +16,29 @@ import '../components/navbar'
import type { NavbarMenuItem } from '../components/navbar/Navbar'
function showNavbar () {
- const navbar = document.querySelector('solid-panes-navbar')
- if (navbar) {
- navbar.classList.remove('navbar--hidden')
- }
+ setNavbarHidden(isNavbarHidden({
+ viaAccountMenu: false,
+ ownProfile: isOwnWebIdProfile(store.sym(window.location.href))
+ }))
+}
+
+let selectPaneInNavbar: ((paneName: string, paneUri?: string) => void) | undefined
+
+export function selectNavbarPane (paneName: string, paneUri?: string): void {
+ selectPaneInNavbar?.(paneName, paneUri)
}
function createNavItem (
label: string,
+ paneName: string,
+ paneUri: string,
onSelected: () => void,
selected = false
): NavbarMenuItem {
return {
label,
+ paneName,
+ paneUri,
onSelected () {
showNavbar()
return onSelected()
@@ -34,41 +47,39 @@ function createNavItem (
}
}
-async function createNavbarMenuItems (
- outliner: OutlineManager,
- subject: NamedNode,
- outlineView: HTMLElement | null,
- selectedPaneName?: string,
- selectedPaneUri?: string
-): Promise {
- const webId = await loadProfileFromURI(subject)
- const selectedPane = selectedPaneName || (isWebIdUri(subject) ? 'profile' : undefined)
+// The items offered for the pod the page is in. Choosing one displays that pane while the
+// page URL stays as it is.
+function createNavbarMenuItems (
+ context: PodContext,
+ navigation: NavigationController,
+ view: NavigationView
+): NavbarMenuItem[] {
+ const selection = deriveNavbarSelection(view, isWebIdUri(store.sym(window.location.href)))
const menuItems: NavbarMenuItem[] = []
- if (webId) {
+ if (context.owner) {
+ const owner = context.owner
menuItems.push(
- createNavItem('Profile', async () => {
- const profilePane = await getProfilePaneFromURI(webId)
- outliner.GotoSubject(subject, true, profilePane, true, undefined, outlineView)
- }, selectedPane === 'profile'),
- createNavItem('Friends', async () => {
- const socialPane = await getSocialPaneFromURI(webId)
- outliner.GotoSubject(subject, true, socialPane, true, undefined, outlineView)
- }, selectedPane === 'social')
+ createNavItem('Profile', 'profile', owner, () => navigation.show({ kind: 'profile', subject: owner }),
+ isNavbarItemSelected({ paneName: 'profile' }, selection, 0)),
+ createNavItem('Friends', 'social', owner, () => navigation.show({ kind: 'friends', subject: owner }),
+ isNavbarItemSelected({ paneName: 'social' }, selection, 0))
)
}
- const storagePanes = await getFolderPanesFromURI(subject)
- storagePanes.forEach(pane => {
+ context.storagePanes.forEach(pane => {
menuItems.push(
createNavItem(
pane.label(),
- async () => {
- outliner.GotoSubject(subject, true, pane, true, undefined, outlineView)
- },
- selectedPane === pane.paneName &&
- (selectedPaneUri ? selectedPaneUri === pane.subject.value : storagePanes.length === 1)
+ pane.paneName,
+ pane.subject.value,
+ () => navigation.show({ kind: 'storage', pane }),
+ isNavbarItemSelected(
+ { paneName: pane.paneName, paneUri: pane.subject.value },
+ selection,
+ context.storagePanes.length
+ )
)
)
})
@@ -76,25 +87,18 @@ async function createNavbarMenuItems (
return menuItems
}
-export async function createNavbar (outliner: OutlineManager) {
+export async function createNavbar (navigation: NavigationController) {
const existingNavbar = document.querySelector('solid-panes-navbar')
if (existingNavbar) {
- existingNavbar.classList.remove('navbar--hidden')
+ showNavbar()
return existingNavbar
}
- const OutlineView = document.getElementById('OutlineView')
const mainContent = document.getElementById('MainContent')
const tmpContainer = document.createElement('div')
- const uri = window.location.href
- const subject: NamedNode = typeof uri === 'string' ? store.sym(uri) : uri
- const selectedPaneName = window.history.state?.paneName
- const selectedPaneUri = window.history.state?.paneUri
- let menuItems = await createNavbarMenuItems(outliner, subject, OutlineView, selectedPaneName, selectedPaneUri).catch((err) => {
- console.error('Failed to build navbar menu items:', err)
- return []
- })
+ let podContext = await getPodContext(window.location.href)
+ let menuItems = createNavbarMenuItems(podContext, navigation, readNavigationState().view)
function setSelectedItem (selectedItem: NavbarMenuItem) {
menuItems = menuItems.map((menuItem) => ({
@@ -123,6 +127,13 @@ export async function createNavbar (outliner: OutlineManager) {
renderNavbar()
+ selectPaneInNavbar = (paneName, paneUri) => {
+ const item = pickNavbarItem(menuItems, paneName, paneUri)
+ if (!item) return
+ setSelectedItem(item)
+ renderNavbar()
+ }
+
const navbar = tmpContainer.firstElementChild as HTMLElement | null
if (!navbar) {
@@ -135,5 +146,22 @@ export async function createNavbar (outliner: OutlineManager) {
document.body.prepend(navbar)
}
+ showNavbar()
+
+ // The items belong to one pod. When a navigation (a link, back or forward) lands in another
+ // pod, they would offer the wrong profile and storages, so build them again.
+ let rebuildRequest = 0
+ const stopListening = onNavigated(({ url, state }) => {
+ if (!navbar.isConnected) return stopListening()
+
+ const request = ++rebuildRequest
+ getPodContext(url).then(next => {
+ if (request !== rebuildRequest || isSamePod(podContext, next)) return
+ podContext = next
+ menuItems = createNavbarMenuItems(next, navigation, state.view)
+ renderNavbar()
+ }).catch(error => console.error('Failed to rebuild the navbar for the new pod:', error))
+ })
+
return navbar
}
diff --git a/src/mainPage/navigation.ts b/src/mainPage/navigation.ts
new file mode 100644
index 00000000..576b5bdd
--- /dev/null
+++ b/src/mainPage/navigation.ts
@@ -0,0 +1,23 @@
+import type { LiveStore } from 'rdflib'
+import * as paneRegistry from 'pane-registry'
+import type { OutlineManager } from '../outline/manager'
+import { createNavigationController, type NavigationController } from '../navigation/controller'
+import { selectNavbarPane } from './navbar'
+import { refreshProfileHeading } from './profileHeading'
+
+const controllers = new WeakMap()
+
+/** The page's navigation controller for an outliner, created on first use. */
+export function navigationFor (outliner: OutlineManager, store: LiveStore): NavigationController {
+ let controller = controllers.get(outliner)
+ if (!controller) {
+ controller = createNavigationController({
+ store,
+ outliner,
+ byName: paneRegistry.byName,
+ effects: { selectNavbarPane, refreshHeading: refreshProfileHeading }
+ })
+ controllers.set(outliner, controller)
+ }
+ return controller
+}
diff --git a/src/mainPage/profileHeading.css b/src/mainPage/profileHeading.css
new file mode 100644
index 00000000..f0aea43e
--- /dev/null
+++ b/src/mainPage/profileHeading.css
@@ -0,0 +1,4 @@
+#profile-heading-mount {
+ margin-top: 18px;
+ margin-bottom: 20px;
+}
diff --git a/src/mainPage/profileHeading.ts b/src/mainPage/profileHeading.ts
new file mode 100644
index 00000000..084f8fff
--- /dev/null
+++ b/src/mainPage/profileHeading.ts
@@ -0,0 +1,299 @@
+import { authn, authSession, store } from 'solid-logic'
+import { NamedNode } from 'rdflib'
+import type { ProfileDetails } from '../components/profile-heading/editor/sections/heading/types'
+import type { ProfileHeadingEditContext } from '../components/profile-heading/ProfileHeading'
+import { getViewerMode, presentProfile } from '../components/profile-heading/profileHeadingData'
+import type { ViewerMode } from '../types'
+import { loadProfileFromURI } from '../utils/webIdUtils'
+import '../components/profile-heading'
+import './profileHeading.css'
+import { deriveHeading } from '../navigation/chrome'
+import { getPodContext } from '../navigation/podContext'
+import { onNavigated } from '../navigation/events'
+import { readNavigationState } from '../navigation/state'
+
+const PROFILE_HEADING_TAG = 'solid-panes-profile-heading'
+const PROFILE_HEADING_MOUNT_ID = 'profile-heading-mount'
+
+let refreshRequest = 0
+let navigatedListenerRegistered = false
+let authListenersRegistered = false
+let profileSavedListenerRegistered = false
+let authReady: Promise | undefined
+let displayedPaneKey = ''
+let displayedSubjectUri = ''
+let loadingPaneKey = ''
+const hideAnimations = new WeakMap()
+const showAnimations = new WeakMap()
+
+function showHeading (mount: HTMLElement): void {
+ if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ||
+ typeof mount.animate !== 'function') return
+
+ const style = getComputedStyle(mount)
+ const marginTop = style.marginTop || '18px'
+ const marginBottom = style.marginBottom || '20px'
+ const animation = mount.animate([
+ { height: '0px', opacity: 0, marginTop: '0px', marginBottom: '0px', overflow: 'hidden' },
+ {
+ height: `${mount.getBoundingClientRect().height}px`,
+ opacity: 1,
+ marginTop,
+ marginBottom,
+ overflow: 'hidden'
+ }
+ ], { duration: 300, easing: 'ease', fill: 'both' })
+ showAnimations.set(mount, animation)
+ const finish = () => {
+ if (showAnimations.get(mount) !== animation) return
+ showAnimations.delete(mount)
+ animation.cancel()
+ }
+ animation.finished.then(finish, (error: unknown) => {
+ if (showAnimations.get(mount) !== animation) return
+ console.error('Failed to animate profile heading appearance.', error)
+ finish()
+ })
+}
+
+function cancelHeadingHide (mount: HTMLElement): void {
+ const animation = hideAnimations.get(mount)
+ hideAnimations.delete(mount)
+ animation?.cancel()
+ mount.inert = false
+ mount.removeAttribute('aria-hidden')
+}
+
+function hideHeading (mount: HTMLElement): void {
+ if (hideAnimations.has(mount)) return
+
+ const style = getComputedStyle(mount)
+ const height = mount.getBoundingClientRect().height
+ const opacity = style.opacity
+ const marginTop = style.marginTop
+ const marginBottom = style.marginBottom
+ const showing = showAnimations.get(mount)
+ showAnimations.delete(mount)
+ showing?.cancel()
+
+ const finish = () => {
+ mount.hidden = true
+ mount.replaceChildren()
+ }
+ mount.inert = true
+ mount.setAttribute('aria-hidden', 'true')
+
+ if (mount.hidden || !mount.firstElementChild ||
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ||
+ typeof mount.animate !== 'function') {
+ finish()
+ return
+ }
+
+ const animation = mount.animate([
+ {
+ height: `${height}px`,
+ opacity,
+ marginTop,
+ marginBottom,
+ overflow: 'hidden'
+ },
+ { height: '0px', opacity: 0, marginTop: '0px', marginBottom: '0px', overflow: 'hidden' }
+ ], { duration: 300, easing: 'ease', fill: 'forwards' })
+ hideAnimations.set(mount, animation)
+ animation.finished.then(() => {
+ if (hideAnimations.get(mount) !== animation) return
+ finish()
+ hideAnimations.delete(mount)
+ animation.cancel()
+ }, (error: unknown) => {
+ if (hideAnimations.get(mount) !== animation) return
+ console.error('Failed to animate profile heading disappearance.', error)
+ finish()
+ hideAnimations.delete(mount)
+ animation.cancel()
+ })
+}
+
+function refreshAfterAuthChange (): void {
+ refreshProfileHeading(true).catch((error: unknown) => {
+ console.error('Failed to refresh profile heading after an authentication change.', error)
+ })
+}
+
+function ensureMountPoint (): HTMLElement | null {
+ const mainContent = document.getElementById('MainContent')
+ if (!mainContent) return null
+
+ let mount = document.getElementById(PROFILE_HEADING_MOUNT_ID)
+ if (!mount) {
+ mount = document.createElement('div')
+ mount.id = PROFILE_HEADING_MOUNT_ID
+ mount.setAttribute('aria-label', 'Profile heading')
+ }
+
+ const navbar = document.querySelector('solid-panes-navbar')
+ const outlineView = document.getElementById('OutlineView')
+ const insertionPoint = navbar?.parentNode === mainContent
+ ? navbar
+ : outlineView?.parentNode === mainContent
+ ? outlineView
+ : null
+ if (insertionPoint && mount.nextSibling !== insertionPoint) {
+ mainContent.insertBefore(mount, insertionPoint)
+ } else if (!insertionPoint && mount.parentNode !== mainContent) {
+ mainContent.appendChild(mount)
+ }
+ return mount
+}
+
+export async function refreshProfileHeading (force = false): Promise {
+ await authReady
+
+ const pod = await getPodContext(window.location.href)
+ const mount = ensureMountPoint()
+ if (!mount) return
+
+ if (force) {
+ refreshRequest++
+ displayedPaneKey = ''
+ loadingPaneKey = ''
+ }
+
+ const { view, viaAccountMenu } = readNavigationState()
+ const decision = deriveHeading({
+ view,
+ viaAccountMenu,
+ viewer: authn.currentUser()?.value,
+ pageUrl: window.location.href,
+ owner: pod.owner,
+ storageUris: pod.storagePanes.map(pane => pane.subject.value)
+ })
+
+ if (decision.mode === 'none') {
+ refreshRequest++
+ loadingPaneKey = ''
+ displayedPaneKey = ''
+ displayedSubjectUri = ''
+ hideHeading(mount)
+ return
+ }
+
+ const { subjectUri } = decision
+ const isProfilePane = decision.mode === 'full'
+ cancelHeadingHide(mount)
+ const paneKey = `${decision.paneName}:${subjectUri}`
+ if (paneKey === displayedPaneKey || paneKey === loadingPaneKey) return
+
+ const request = ++refreshRequest
+ loadingPaneKey = paneKey
+ let subject: NamedNode
+ let profileData: ProfileDetails
+ let canEdit: boolean
+ let viewerMode: ViewerMode
+ try {
+ subject = await loadProfileFromURI(store.sym(subjectUri))
+ profileData = presentProfile(subject, store)
+ viewerMode = isProfilePane ? await getViewerMode(subject) : 'anonymous'
+ canEdit = viewerMode === 'owner'
+ } catch (error) {
+ if (request === refreshRequest) loadingPaneKey = ''
+ throw error
+ }
+ if (request !== refreshRequest) return
+
+ const heading = (mount.querySelector(PROFILE_HEADING_TAG) ||
+ document.createElement(PROFILE_HEADING_TAG)) as HTMLElement & {
+ profileData?: ProfileDetails
+ editContext?: ProfileHeadingEditContext
+ friendSubject?: NamedNode
+ compact?: boolean
+ updateComplete?: Promise
+ }
+ heading.profileData = profileData
+ const viewer = authn.currentUser()
+ heading.friendSubject = isProfilePane && viewer && !viewer.sameTerm(subject)
+ ? subject
+ : undefined
+ heading.editContext = canEdit
+ ? {
+ subject,
+ profileData,
+ viewerMode,
+ onSaved: () => refreshProfileHeading(true)
+ }
+ : undefined
+ heading.compact = !isProfilePane
+ const appearing = mount.hidden || !mount.firstElementChild
+ mount.hidden = false
+ if (heading.parentNode !== mount) mount.replaceChildren(heading)
+ displayedPaneKey = paneKey
+ displayedSubjectUri = subject.value
+ loadingPaneKey = ''
+ if (appearing) {
+ await heading.updateComplete
+ if (request === refreshRequest && mount.isConnected && !mount.hidden && !hideAnimations.has(mount)) {
+ showHeading(mount)
+ }
+ }
+}
+
+/** Put the heading where it belongs, above the navbar, after the navbar has been added. */
+export function placeProfileHeading (): void {
+ ensureMountPoint()
+}
+
+export function initializeProfileHeading (): void {
+ ensureMountPoint()
+
+ if (!profileSavedListenerRegistered) {
+ document.addEventListener('profile-pane-saved', (event) => {
+ if (!(event instanceof CustomEvent) || event.detail?.subjectUri !== displayedSubjectUri) return
+
+ const heading = document.querySelector(`#${PROFILE_HEADING_MOUNT_ID} ${PROFILE_HEADING_TAG}`)
+ if (heading && event.detail.profileData) {
+ refreshRequest++
+ loadingPaneKey = ''
+ heading.profileData = event.detail.profileData
+ if (heading.editContext) {
+ heading.editContext = { ...heading.editContext, profileData: event.detail.profileData }
+ }
+ return
+ }
+
+ refreshProfileHeading(true).catch((error: unknown) => {
+ console.error('Failed to refresh profile heading after saving profile information.', error)
+ })
+ })
+ profileSavedListenerRegistered = true
+ }
+
+ const readiness = authReady ?? authn.checkUser().then(() => undefined)
+ authReady = readiness
+
+ if (!authListenersRegistered) {
+ authSession.events.on('login', refreshAfterAuthChange)
+ authSession.events.on('sessionRestore', refreshAfterAuthChange)
+ authSession.events.on('logout', refreshAfterAuthChange)
+ authSession.events.on('sessionChange', refreshAfterAuthChange)
+ authSession.events.on('identityReplaced', refreshAfterAuthChange)
+ authListenersRegistered = true
+ }
+
+ // Navigations the page's own navigation does not make, such as links and buttons in solid-ui
+ if (!navigatedListenerRegistered) {
+ onNavigated(() => {
+ refreshProfileHeading().catch((error: unknown) => {
+ console.error('Failed to refresh profile heading.', error)
+ })
+ })
+ navigatedListenerRegistered = true
+ }
+
+ readiness.then(() => refreshProfileHeading(true)).catch((error: unknown) => {
+ console.error('Failed to refresh profile heading.', error)
+ })
+}
diff --git a/src/navigation/browserEvents.ts b/src/navigation/browserEvents.ts
new file mode 100644
index 00000000..b3b2ccf6
--- /dev/null
+++ b/src/navigation/browserEvents.ts
@@ -0,0 +1,86 @@
+/*
+ * What the page does when the browser changes the URL or the history entry without reloading.
+ *
+ * Like a reload, both cases show what the URL is, not what the entry last displayed through the
+ * navbar or the account menu:
+ * - Enter on the current URL (typically one ending in #me)
+ * - back and forward
+ */
+
+import type { NavigationController } from './controller'
+import { clearPaneState } from './state'
+
+interface NavigationEvent extends Event {
+ canIntercept: boolean
+ userInitiated: boolean
+ navigationType: string
+ destination: { url: string }
+ intercept: (options: { handler: () => Promise, scroll: 'manual', focusReset: 'manual' }) => void
+}
+
+interface NavigationApi {
+ addEventListener: (type: 'navigate' | 'currententrychange', listener: (event: NavigationEvent) => void) => void
+}
+
+let navigation: NavigationController | undefined
+let listenersRegistered = false
+
+/**
+ * Whether the main page handles back and forward itself. Packages that used to re-render on
+ * `popstate` (mashlib) ask this and leave it to the main page.
+ */
+export function isHandlingBrowserNavigation (): boolean {
+ return listenersRegistered
+}
+
+function reportError (error: unknown) {
+ console.error('Failed to show the page after browser navigation.', error)
+}
+
+/**
+ * Starts handling Enter, back and forward for a controller. The listeners are added once;
+ * calling this again only changes which controller they use.
+ *
+ * The Navigation API is used where the browser has it; the places that fall back to an older
+ * mechanism are marked NAVIGATION API FALLBACK. See "Browser support" in docs/navigation-design.md.
+ */
+export function registerBrowserNavigation (controller: NavigationController): void {
+ navigation = controller
+ if (listenersRegistered) return
+ listenersRegistered = true
+
+ const showUrl = () => {
+ clearPaneState()
+ return (navigation?.show({ kind: 'restore', forceHeading: true }) ?? Promise.resolve()).catch(reportError)
+ }
+
+ const navigationApi = (window as unknown as { navigation?: NavigationApi }).navigation
+ if (!navigationApi) {
+ // NAVIGATION API FALLBACK (Firefox before 147, Safari before 26.2, older Chromium).
+ // Back and forward use `popstate`, which cannot tell them from in-page `#` links, so a
+ // fragment link also re-renders the page. Enter on the current URL is not handled at all
+ // here: without a fragment the browser reloads the page, and `initMainPage` shows the URL.
+ window.addEventListener('popstate', () => { showUrl() })
+ return
+ }
+
+ // NAVIGATION API: Enter on the current URL (typically one ending in #me) is a same-URL
+ // "replace" navigation; intercepting it shows the URL without reloading. Without the API
+ // there is no equivalent, see the fallback above.
+ navigationApi.addEventListener('navigate', event => {
+ if (!event.canIntercept || !event.userInitiated || event.navigationType !== 'replace' ||
+ event.destination.url !== window.location.href) return
+
+ event.intercept({
+ scroll: 'manual',
+ focusReset: 'manual',
+ handler: showUrl
+ })
+ })
+
+ // NAVIGATION API: unlike the `popstate` fallback above, this does not fire for in-page
+ // fragment links.
+ navigationApi.addEventListener('currententrychange', event => {
+ if (event.navigationType === 'traverse') showUrl()
+ })
+}
diff --git a/src/navigation/chrome.ts b/src/navigation/chrome.ts
new file mode 100644
index 00000000..e122c4bc
--- /dev/null
+++ b/src/navigation/chrome.ts
@@ -0,0 +1,146 @@
+/*
+ * The rules for what the page chrome shows, as pure functions.
+ *
+ * What the user sees depends on the page URL, the pane that was last opened, whether the
+ * account menu opened it, who is logged in and whose pod the URL is in. These functions
+ * decide it from those inputs alone: they read no history, DOM or session, so every rule
+ * can be tested in one place. The heading, navbar, outline and file explorer provider
+ * only apply the result.
+ */
+
+import { type NavigationView } from './state'
+
+/** Registry names of the panes that present a person rather than a resource. */
+export const PERSON_PANES = ['profile', 'social']
+
+export function isPersonPane (paneName?: string): boolean {
+ return paneName !== undefined && PERSON_PANES.includes(paneName)
+}
+
+/** The parts of a navbar item that the rules look at. */
+export interface NavbarItemRef {
+ paneName?: string
+ paneUri?: string
+}
+
+// Profile heading
+
+export type HeadingDecision =
+ | { mode: 'none' }
+ | {
+ /** Full on the profile pane, compact everywhere else. */
+ mode: 'full' | 'compact'
+ /** The profile (WebID or document) to present. */
+ subjectUri: string
+ paneName?: string
+ }
+
+export interface HeadingInput {
+ view: NavigationView
+ viaAccountMenu: boolean
+ /** WebID of the logged-in user. */
+ viewer?: string
+ pageUrl: string
+ /** WebID of the person whose pod the page is in, if known. */
+ owner?: string
+ /** URIs of the storages of that pod. */
+ storageUris: string[]
+}
+
+/**
+ * Profile and friends panes present their own subject. Any other pane shows the owner of
+ * the pod the page is in, unless that is the viewer, or the view came from the account menu
+ * and so shows the viewer's own content.
+ */
+export function deriveHeading (input: HeadingInput): HeadingDecision {
+ const { view, viaAccountMenu, viewer, pageUrl, owner, storageUris } = input
+ const paneName = view.pane
+
+ // Whether the pod owner's navbar has an item for the displayed pane: Profile and Friends
+ // whenever there is an owner, and a storage by its URI (any of them when the view names none).
+ const ownerHasPane = paneName === 'profile' || paneName === 'social'
+ ? owner !== undefined
+ : paneName === 'folder' && storageUris.some(uri => !view.subject || uri === view.subject)
+ const ownerUri = ownerHasPane || !viaAccountMenu ? owner : undefined
+
+ if (!isPersonPane(paneName)) {
+ if (!ownerUri || ownerUri === viewer) return { mode: 'none' }
+ return { mode: 'compact', subjectUri: ownerUri, paneName }
+ }
+
+ return {
+ mode: paneName === 'profile' ? 'full' : 'compact',
+ subjectUri: view.subject || (ownerHasPane ? owner : undefined) || pageUrl,
+ paneName
+ }
+}
+
+// Navbar
+
+export interface NavbarSelection {
+ paneName?: string
+ paneUri?: string
+}
+
+/** What the navbar should have selected: the displayed pane, or Profile on a WebID. */
+export function deriveNavbarSelection (view: NavigationView, pageIsWebId: boolean): NavbarSelection {
+ return {
+ paneName: view.pane ?? (pageIsWebId ? 'profile' : undefined),
+ paneUri: view.subject
+ }
+}
+
+/**
+ * @param storageItemCount how many storage items the navbar has; a single storage is
+ * selected even when the view does not say which one it was
+ */
+export function isNavbarItemSelected (
+ item: NavbarItemRef,
+ selection: NavbarSelection,
+ storageItemCount: number
+): boolean {
+ if (!selection.paneName || item.paneName !== selection.paneName) return false
+ if (item.paneName === 'folder') {
+ return selection.paneUri ? selection.paneUri === item.paneUri : storageItemCount === 1
+ }
+ return true
+}
+
+/** The item to select for a pane: the one for that subject, else the first for that pane. */
+export function pickNavbarItem- (
+ items: Item[],
+ paneName: string,
+ paneUri?: string
+): Item | undefined {
+ const forPane = items.filter(item => item.paneName === paneName)
+ return forPane.find(item => item.paneUri === paneUri) ?? forPane[0]
+}
+
+/** The navbar is not offered on the viewer's own profile, nor over account-menu content. */
+export function isNavbarHidden (input: { viaAccountMenu: boolean, ownProfile: boolean }): boolean {
+ return input.viaAccountMenu || input.ownProfile
+}
+
+// File explorer header
+
+export interface ExplorerHeaderInput {
+ isRootResource: boolean
+ isWebId: boolean
+ /**
+ * The view shows a person's own subject or the viewer's content, not the resource the
+ * page URL points at, so the resource header only fits inside the viewer's own pod.
+ */
+ viewerPodOnly: boolean
+ inViewerPod: boolean
+}
+
+/** Whether the header describing a resource fits the view. */
+export function deriveExplorerHeader (input: ExplorerHeaderInput): boolean {
+ if (input.isRootResource || input.isWebId) return false
+ return !input.viewerPodOnly || input.inViewerPod
+}
+
+/** The header is never shown over a person's profile or friends, whatever the resource. */
+export function explorerHeaderForPane (resourceHeader: boolean, paneName?: string): boolean {
+ return resourceHeader && !isPersonPane(paneName)
+}
diff --git a/src/navigation/controller.ts b/src/navigation/controller.ts
new file mode 100644
index 00000000..3e0d4e16
--- /dev/null
+++ b/src/navigation/controller.ts
@@ -0,0 +1,113 @@
+/*
+ * The one place that displays a view and brings the page chrome in line with it.
+ *
+ * The account menu, the navbar, a page load, Enter in the address bar, back and forward and a
+ * layout change used to each resolve a pane, call `GotoSubject` and then do some of the follow-up
+ * themselves. Now each describes what it wants as an intent, and the follow-up is always the same:
+ * navbar visibility and selection, then the profile heading.
+ */
+
+import type { LiveStore, NamedNode } from 'rdflib'
+import type { PaneDefinition } from 'pane-registry'
+import type { OutlineManager } from '../outline/manager'
+import { getFolderPaneForStorage, getProfilePaneFromURI, getSocialPaneFromURI, type PaneItem } from '../utils/paneUtils'
+import { setNavbarHidden } from './navbarVisibility'
+import { readNavigationState, type NavigationState, type NavigationView } from './state'
+import type { NavigationSource } from './options'
+import { isWebIdUri } from '../utils/webIdUtils'
+
+export type NavigationIntent =
+ /** Display what the current history entry says was displayed, for the page's own URL. */
+ | {
+ kind: 'restore'
+ /** The page subject, when it is not the current URL. */
+ subject?: string
+ /** Redo the heading even if it believes it already shows the right profile. */
+ forceHeading?: boolean
+ }
+ /** A person's profile or friends, by WebID. */
+ | { kind: 'profile' | 'friends', subject: string, source?: NavigationSource }
+ /** A storage, as the navbar or the account menu offers it. */
+ | { kind: 'storage', pane: PaneItem, source?: NavigationSource }
+ /** A pane of the application itself, by registry name (dashboard, preferences). */
+ | { kind: 'app', pane: string, source?: NavigationSource }
+
+export interface NavigationEffects {
+ selectNavbarPane: (paneName: string, paneUri?: string) => void
+ refreshHeading: (force: boolean) => Promise
+}
+
+export interface NavigationDeps {
+ store: LiveStore
+ outliner: OutlineManager
+ byName: (name: string) => PaneDefinition | null | undefined
+ effects: NavigationEffects
+}
+
+export interface NavigationController {
+ /**
+ * Display what the intent asks for. If another intent arrives while this one is still
+ * resolving its pane, only the later one is displayed.
+ */
+ show: (intent: NavigationIntent) => Promise
+}
+
+type DisplayedPane = PaneItem | PaneDefinition | null | undefined
+
+export function createNavigationController (deps: NavigationDeps): NavigationController {
+ const { store, outliner, byName, effects } = deps
+ let latest = 0
+
+ async function paneForView (subject: NamedNode, view: NavigationView): Promise {
+ // These panes render a subject other than the page URL.
+ if (view.subject) {
+ const paneSubject = store.sym(view.subject)
+ if (view.kind === 'profile') return getProfilePaneFromURI(paneSubject)
+ if (view.kind === 'friends') return getSocialPaneFromURI(paneSubject)
+ if (view.kind === 'storage') return getFolderPaneForStorage(paneSubject)
+ }
+ if (view.pane) return byName(view.pane) ?? undefined
+ return isWebIdUri(subject) ? getProfilePaneFromURI(subject) : undefined
+ }
+
+ async function paneFor (intent: NavigationIntent, subject: NamedNode, state: NavigationState): Promise {
+ switch (intent.kind) {
+ case 'restore': return paneForView(subject, state.view)
+ case 'profile': return getProfilePaneFromURI(store.sym(intent.subject))
+ case 'friends': return getSocialPaneFromURI(store.sym(intent.subject))
+ case 'storage': return intent.pane
+ case 'app': return byName(intent.pane)
+ }
+ }
+
+ return {
+ async show (intent) {
+ const request = ++latest
+ const state = readNavigationState()
+ const source: NavigationSource = intent.kind === 'restore'
+ ? (state.viaAccountMenu ? 'accountMenu' : 'default')
+ : intent.source ?? 'default'
+ // The account menu shows the viewer's own content, so the navbar for the page is not offered
+ if (source === 'accountMenu') setNavbarHidden(true)
+
+ const subject = store.sym(intent.kind === 'restore' && intent.subject ? intent.subject : window.location.href)
+ const pane = await paneFor(intent, subject, state)
+ if (request !== latest) return
+
+ // Without a pane the outline would fall back to the page URL's own default view, which
+ // is only right when restoring an entry that displayed nothing in particular.
+ if (!pane && intent.kind !== 'restore') {
+ console.warn('Could not open the requested view for the logged-in user.')
+ return
+ }
+
+ outliner.GotoSubject(subject, true, pane ?? undefined, true, undefined, undefined, { source })
+
+ if (source !== 'accountMenu') {
+ const { view } = readNavigationState()
+ if (view.pane) effects.selectNavbarPane(view.pane, view.subject)
+ }
+ await effects.refreshHeading(intent.kind === 'restore' && intent.forceHeading === true)
+ }
+ }
+}
diff --git a/src/navigation/events.ts b/src/navigation/events.ts
new file mode 100644
index 00000000..8e98bd47
--- /dev/null
+++ b/src/navigation/events.ts
@@ -0,0 +1,30 @@
+import { parseNavigationState, type NavigationState } from './state'
+
+/** Dispatched on the document after a view has been displayed and its history entry written. */
+export const NAVIGATED_EVENT = 'solid-panes:navigated'
+
+export interface NavigatedDetail {
+ /** The page URL after displaying. */
+ url: string
+ /** What the history entry now says was displayed. */
+ state: NavigationState
+}
+
+/**
+ * Whoever calls `GotoSubject` directly (links and buttons in solid-ui, mashlib) gets the same
+ * follow-up as the main page's own navigation, because the page chrome listens for this.
+ */
+export function announceNavigation (doc: Document): void {
+ const detail: NavigatedDetail = {
+ url: doc.location.href,
+ state: parseNavigationState(doc.defaultView?.history.state)
+ }
+ doc.dispatchEvent(new CustomEvent(NAVIGATED_EVENT, { detail }))
+}
+
+/** Calls the listener after each navigation; returns a function that stops listening. */
+export function onNavigated (listener: (detail: NavigatedDetail) => void, doc: Document = document): () => void {
+ const handler = (event: Event) => listener((event as CustomEvent).detail)
+ doc.addEventListener(NAVIGATED_EVENT, handler)
+ return () => doc.removeEventListener(NAVIGATED_EVENT, handler)
+}
diff --git a/src/navigation/navbarVisibility.ts b/src/navigation/navbarVisibility.ts
new file mode 100644
index 00000000..30c3da16
--- /dev/null
+++ b/src/navigation/navbarVisibility.ts
@@ -0,0 +1,6 @@
+const NAVBAR_HIDDEN_CLASS = 'navbar--hidden'
+
+/** Show or hide the navbar, if the page has one. */
+export function setNavbarHidden (hidden: boolean): void {
+ document.querySelector('solid-panes-navbar')?.classList.toggle(NAVBAR_HIDDEN_CLASS, hidden)
+}
diff --git a/src/navigation/options.ts b/src/navigation/options.ts
new file mode 100644
index 00000000..5e93c49a
--- /dev/null
+++ b/src/navigation/options.ts
@@ -0,0 +1,21 @@
+/**
+ * Who asked for a view to be displayed. `GotoSubject` takes it as its seventh argument.
+ *
+ * - `default`: the page's own navigation (URL, navbar, links)
+ * - `accountMenu`: the viewer's own content, shown while the page URL keeps pointing elsewhere
+ */
+export type NavigationSource = 'default' | 'accountMenu'
+
+export interface GotoSubjectOptions {
+ source?: NavigationSource
+}
+
+/**
+ * The seventh argument used to be `showNavbar`: `false` meant "opened by the account menu"
+ * and anything else meant the page's own navigation. That form is still accepted.
+ */
+export function resolveSource (argument?: boolean | GotoSubjectOptions | null): NavigationSource {
+ if (typeof argument === 'object' && argument !== null) return argument.source ?? 'default'
+ if (argument === undefined) return 'default'
+ return argument ? 'default' : 'accountMenu'
+}
diff --git a/src/navigation/podContext.ts b/src/navigation/podContext.ts
new file mode 100644
index 00000000..143963f1
--- /dev/null
+++ b/src/navigation/podContext.ts
@@ -0,0 +1,124 @@
+/*
+ * Whose pod the page is in, and which storages the viewer owns.
+ *
+ * Several parts of the page need this: the navbar builds its items from it, the profile heading
+ * shows the owner, and the outline decides whether the file explorer header fits. Each part is
+ * looked up once and cached, so they no longer each look it up, or read it back from each
+ * other's DOM.
+ */
+
+import { authn } from 'solid-logic'
+import { sym } from 'rdflib'
+import { isWebIdUri, loadProfileFromURI } from '../utils/webIdUtils'
+import { getFolderPanesFromURI, type PaneItem } from '../utils/paneUtils'
+import { getPodStorages } from '../utils/podUtils'
+
+export interface PodContext {
+ /** WebID of the logged-in user. */
+ viewer?: string
+ /** WebID of the person whose pod the page is in; unknown when no profile could be found. */
+ owner?: string
+ /** The storages of that pod, as panes the navbar can open. */
+ storagePanes: PaneItem[]
+ /** The storages the viewer owns. */
+ viewerStorages: string[]
+}
+
+const MAX_REMEMBERED = 500
+
+// What was found depends on different parts of the URL, so each part is remembered on its own:
+// - the owner depends on the document: the profile lookup can end with that document's own
+// foaf:primaryTopic, so two pages of one pod may not share an owner
+// - the storages depend on the pod, derived from the origin and the first path segment
+// - the viewer's storages depend only on who the viewer is
+const owners = new Map>()
+const storages = new Map>()
+const viewerStorages = new Map>()
+
+function podKey (pageUrl: string): string {
+ try {
+ const url = new URL(pageUrl)
+ const [first = ''] = url.pathname.split('/').filter(Boolean)
+ return `${url.origin}|${first}`
+ } catch {
+ return pageUrl
+ }
+}
+
+function documentKey (pageUrl: string): string {
+ return sym(pageUrl).doc().uri
+}
+
+/**
+ * Remembers a lookup, unless it failed: a failure is not kept, so that the next request asks again.
+ */
+function remember (
+ cache: Map>,
+ key: string,
+ what: string,
+ load: () => Promise,
+ fallback: T
+): Promise {
+ const existing = cache.get(key)
+ if (existing) return existing
+
+ const created: Promise = load().catch(error => {
+ console.warn(`Could not load the ${what} for ${key}`, error)
+ if (cache.get(key) === created) cache.delete(key)
+ return fallback
+ })
+ cache.set(key, created)
+ if (cache.size > MAX_REMEMBERED) cache.delete(cache.keys().next().value as string)
+ return created
+}
+
+function ownerOf (pageUrl: string): Promise {
+ return remember(owners, documentKey(pageUrl), 'owner of the pod', async () => {
+ const subject = sym(pageUrl)
+ const webId = await loadProfileFromURI(subject)
+ // The lookup falls back to the URL itself when it finds no profile
+ return webId.uri === subject.uri && !isWebIdUri(subject) ? undefined : webId.value
+ }, undefined)
+}
+
+function storagesOf (pageUrl: string): Promise {
+ return remember(storages, podKey(pageUrl), 'storages of the pod', () => getFolderPanesFromURI(sym(pageUrl)), [])
+}
+
+function storagesOfViewer (viewer: string | undefined): Promise {
+ if (!viewer) return Promise.resolve([])
+ return remember(viewerStorages, viewer, 'storages of the viewer', async () => {
+ return (await getPodStorages(sym(viewer))).map(storage => storage.uri)
+ }, [])
+}
+
+/** The context for the pod a URL is in; the page's own URL by default. */
+export async function getPodContext (pageUrl: string = window.location.href): Promise {
+ const viewer = authn.currentUser()?.value
+ const [owner, storagePanes, ownStorages] = await Promise.all([
+ ownerOf(pageUrl), storagesOf(pageUrl), storagesOfViewer(viewer)
+ ])
+ return { viewer, owner, storagePanes, viewerStorages: ownStorages }
+}
+
+/** Forget what was loaded, for example when tests start over. */
+export function resetPodContexts (): void {
+ owners.clear()
+ storages.clear()
+ viewerStorages.clear()
+}
+
+/** Whether two contexts describe the same pod, so that a navbar built from one fits the other. */
+export function isSamePod (a: PodContext, b: PodContext): boolean {
+ return a.owner === b.owner && a.viewer === b.viewer &&
+ a.storagePanes.map(pane => pane.subject.value).join() === b.storagePanes.map(pane => pane.subject.value).join()
+}
+
+export function isInViewerPod (context: PodContext, url: string): boolean {
+ return context.viewerStorages.some(storage => url.startsWith(storage))
+}
+
+/** Whether the URL lies within one of the viewer's own storages. */
+export async function isPageInViewerPod (url: string): Promise {
+ return isInViewerPod(await getPodContext(url), url)
+}
diff --git a/src/navigation/state.ts b/src/navigation/state.ts
new file mode 100644
index 00000000..7ff3b014
--- /dev/null
+++ b/src/navigation/state.ts
@@ -0,0 +1,96 @@
+/*
+ * What a history entry records about the view that was displayed.
+ *
+ * This is the only module that knows how that is stored in `history.state`; everything
+ * else works with `NavigationState`. The stored fields are `paneName`, `paneUri` and
+ * `viaAccountMenu`, next to whatever other keys the entry carries.
+ */
+
+export type ViewKind = 'default' | 'profile' | 'friends' | 'storage' | 'pane'
+
+export interface NavigationView {
+ kind: ViewKind
+ /** Registry name of the displayed pane. Always set unless the kind is `default`. */
+ pane?: string
+ /** What the pane displays when that is not the page URL (a profile, friends list or storage). */
+ subject?: string
+}
+
+export interface NavigationState {
+ view: NavigationView
+ /** The account menu opened the view: it shows the viewer's own content under the page URL. */
+ viaAccountMenu: boolean
+}
+
+const KIND_BY_PANE: Record = {
+ profile: 'profile',
+ social: 'friends',
+ folder: 'storage'
+}
+
+function asRecord (raw: unknown): Record {
+ return raw !== null && typeof raw === 'object' ? raw as Record : {}
+}
+
+export function viewFromPane (pane?: string, subject?: string): NavigationView {
+ if (!pane) return { kind: 'default' }
+ return { kind: KIND_BY_PANE[pane] ?? 'pane', pane, ...(subject ? { subject } : {}) }
+}
+
+export function parseNavigationState (raw: unknown): NavigationState {
+ const { paneName, paneUri, viaAccountMenu } = asRecord(raw)
+ return {
+ view: viewFromPane(
+ typeof paneName === 'string' && paneName ? paneName : undefined,
+ typeof paneUri === 'string' && paneUri ? paneUri : undefined
+ ),
+ viaAccountMenu: viaAccountMenu === true
+ }
+}
+
+export function readNavigationState (): NavigationState {
+ return parseNavigationState(window.history.state)
+}
+
+export interface HistoryStateChange {
+ /** The pane that is being displayed, and its own subject if it has one. */
+ pane?: { name: string, subject?: string }
+ /** The entry is for a different URL than the current one. */
+ leavingPage: boolean
+ /** The account menu is displaying it. */
+ viaAccountMenu: boolean
+}
+
+/**
+ * The state to store for an entry after displaying something.
+ *
+ * Pane details describe what was displayed, so they must not leak into the entry of
+ * another URL, nor outlive the pane they were saved for. The account-menu marker
+ * keeps a later page load from restoring the viewer's content over the URL's subject.
+ */
+export function nextHistoryState (current: unknown, change: HistoryStateChange): Record {
+ const next: Record = { ...asRecord(current) }
+
+ if (change.pane) {
+ next.paneName = change.pane.name
+ if (typeof change.pane.subject === 'string') next.paneUri = change.pane.subject
+ }
+ if (change.leavingPage && !change.pane) delete next.paneName
+ if ((change.leavingPage && !change.pane) || (change.pane && !change.pane.subject)) delete next.paneUri
+
+ if (change.viaAccountMenu) next.viaAccountMenu = true
+ else delete next.viaAccountMenu
+
+ return next
+}
+
+/** The state without anything about the displayed pane. */
+export function withoutPaneState (raw: unknown): Record {
+ const { paneName, paneUri, viaAccountMenu, ...rest } = asRecord(raw)
+ return rest
+}
+
+/** Forget what the current entry displayed, so the URL alone decides what to show. */
+export function clearPaneState (): void {
+ window.history.replaceState(withoutPaneState(window.history.state), '', window.location.href)
+}
diff --git a/src/outline/manager.js b/src/outline/manager.js
index fe5029bd..19ecea07 100644
--- a/src/outline/manager.js
+++ b/src/outline/manager.js
@@ -12,7 +12,13 @@ import { outlineIcons } from './outlineIcons.js' // @@ chec
import { UserInput } from './userInput.js'
import * as queryByExample from './queryByExample.js'
import { loadContainerRepresentation } from '../utils/podUtils'
-import { isWebIdUri } from '../utils/webIdUtils'
+import { isOwnWebIdProfile, isWebIdUri } from '../utils/webIdUtils'
+import { deriveExplorerHeader, isNavbarHidden } from '../navigation/chrome'
+import { isPageInViewerPod } from '../navigation/podContext'
+import { nextHistoryState } from '../navigation/state'
+import { announceNavigation } from '../navigation/events'
+import { resolveSource } from '../navigation/options'
+import { setNavbarHidden } from '../navigation/navbarVisibility'
import '../components/file-explorer-header'
export default function (context) {
@@ -300,15 +306,8 @@ export default function (context) {
return getOrCreateContainer('OutlineView', 'Resource browser')
}
- function getNavbarElement () {
- return document.querySelector('solid-panes-navbar')
- }
-
- function showSolidPanesNavbar () {
- const navbar = getNavbarElement()
- if (navbar) {
- navbar.classList.remove('navbar--hidden')
- }
+ function showSolidPanesNavbar (subject) {
+ setNavbarHidden(isNavbarHidden({ viaAccountMenu: false, ownProfile: isOwnWebIdProfile(subject) }))
}
/**
@@ -470,7 +469,14 @@ export default function (context) {
provider.relevantPanes = relevantPanes
provider.pane = requiredPane || getPane(relevantPanes, subject)
const isRootResource = !!(subject && subject.uri && subject.site && subject.site().uri === subject.uri)
- provider.showHeader = !isRootResource && !isWebIdUri(subject)
+ const viewerPodOnly = !!options.headerOnlyInViewerPod
+ provider.showHeader = deriveExplorerHeader({
+ isRootResource,
+ isWebId: isWebIdUri(subject),
+ viewerPodOnly,
+ // Only worth looking up when the view does not show the page URL's own resource
+ inViewerPod: viewerPodOnly ? await isPageInViewerPod(document.location.href) : true
+ })
provider.paneRenderOptions = options
provider.soloPane = options.solo
provider.openPane = (paneSubject, paneName) => openPaneInPlace(paneSubject, paneRegistry.byName(paneName))
@@ -1832,6 +1838,58 @@ export default function (context) {
const subject = kb.sym(uri)
this.GotoSubject(subject, true)
}
+ let openPathRequest = 0
+
+ // Opens, one level at a time, the containers from `root` down to `target` (the last
+ // one being the container or resource itself) inside the rows of a rendered container.
+ function openPathWithin (table, root, target) {
+ const request = ++openPathRequest
+ const rootUri = root.uri
+ const targetUri = target.uri.split('#')[0]
+ if (!rootUri.endsWith('/') || !targetUri.startsWith(rootUri) || targetUri === rootUri) return
+
+ const steps = []
+ const parts = targetUri.slice(rootUri.length).split('/')
+ let uri = rootUri
+ parts.forEach((part, index) => {
+ if (!part) return
+ uri += index < parts.length - 1 ? part + '/' : part
+ steps.push(uri)
+ })
+
+ const findRow = stepUri => Array.from(table.querySelectorAll('td.obj[about]'))
+ .find(td => td.getAttribute('about') === kb.sym(stepUri).toNT())
+
+ // The rows only exist once their container has been fetched and rendered
+ const whenRow = stepUri => new Promise(resolve => {
+ const existing = findRow(stepUri)
+ if (existing) return resolve(existing)
+
+ const timer = setTimeout(() => { observer.disconnect(); resolve(null) }, 10000)
+ const observer = new MutationObserver(() => {
+ const row = findRow(stepUri)
+ if (!row && request === openPathRequest) return
+ clearTimeout(timer)
+ observer.disconnect()
+ resolve(row)
+ })
+ observer.observe(table, { childList: true, subtree: true })
+ })
+
+ ;(async () => {
+ for (const stepUri of steps) {
+ const row = await whenRow(stepUri)
+ if (!row || request !== openPathRequest) return
+ if (!row.firstChild || row.firstChild.nodeName !== 'TABLE') {
+ outlineExpand(row, kb.sym(stepUri))
+ }
+ if (stepUri === steps[steps.length - 1] && typeof row.scrollIntoView === 'function') {
+ row.scrollIntoView({ block: 'nearest' })
+ }
+ }
+ })().catch(err => UI.log.warn('outline: could not open ' + target.uri + ' inside ' + rootUri + ': ' + err))
+ }
+
this.GotoURIinit = function (uri) {
const subject = kb.sym(uri)
this.GotoSubject(subject)
@@ -1841,16 +1899,24 @@ export default function (context) {
@param subject -- RDF term for the thing to be presented
@param expand -- flag -- open the subject rather than keep folded closed
- @param pane -- optional -- pane to be used for expanded display
+ @param pane -- optional -- pane to be used for expanded display. When the pane has its own
+ subject (the profile, friends or storage of a person), that subject is displayed
+ instead of `subject`, which only stays the page URL; for a storage the container
+ or resource `subject` points at is opened inside it
@param solo -- optional -- the window will be cleared out and only the subject displayed
@param referer -- optional -- where did we hear about this from anyway?
@param table -- option -- default is an HTML table element in which to put the outline.
- @param showNavbar -- optional -- when false, suppress automatic navbar reveal
+ @param options -- optional -- who asked for the view: `{ source: 'accountMenu' }` for the viewer's
+ own content, which suppresses the automatic navbar reveal and is remembered in
+ the history entry. The old form, `false` for the account menu, is still accepted.
+
+ Afterwards, when `solo`, `solid-panes:navigated` is dispatched on the document.
*/
- this.GotoSubject = function (subject, expand, pane, solo, referrer, table, showNavbar = true) {
+ this.GotoSubject = function (subject, expand, pane, solo, referrer, table, options) {
+ const showNavbar = resolveSource(options) !== 'accountMenu'
const outlineContainer = getOutlineContainer()
if (showNavbar && (!table || table === outlineContainer)) {
- showSolidPanesNavbar()
+ showSolidPanesNavbar(subject)
}
table = table || outlineContainer // if does not exist create a compatible host in the current shell
@@ -1859,25 +1925,34 @@ export default function (context) {
table.style.width = '100%'
}
+ // The URL and history always follow `subject`; what is displayed may differ:
+ // a pane with its own subject (profile, friends or storage of a person) is displayed
+ // for that subject, not for the resource the page URL points at.
+ const displayedSubject = pane && pane.subject && pane.subject.uri
+ ? pane.subject
+ : subject
+
function GotoSubjectDefault () {
const tr = dom.createElement('tr')
tr.classList.add('outlineRow1')
tr.style.verticalAlign = 'top'
table.appendChild(tr)
- const td = thisOutline.outlineObjectTD(subject, undefined, tr)
+ const td = thisOutline.outlineObjectTD(displayedSubject, undefined, tr)
tr.appendChild(td)
return td
}
const td = GotoSubjectDefault()
- if (solo) setUrlBarAndTitle(subject) // dom.title = UI.utils.label(subject) // 'Tabulator: '+ No need to advertize
+ if (solo) setUrlBarAndTitle(displayedSubject) // dom.title = UI.utils.label(subject) // 'Tabulator: '+ No need to advertize
if (expand) {
- outlineExpand(td, subject, {
+ outlineExpand(td, displayedSubject, {
pane,
- solo
+ solo,
+ headerOnlyInViewerPod: !showNavbar || !!(pane && pane.subject && pane.subject.uri)
})
+ if (solo && displayedSubject !== subject) openPathWithin(table, displayedSubject, subject)
}
if (
@@ -1887,16 +1962,19 @@ export default function (context) {
dom.defaultView.history
) {
const currentState = dom.defaultView.history.state || {}
- const paneState = pane ? { paneName: pane.name } : {}
- if (pane && pane.subject && typeof pane.subject.uri === 'string') {
- paneState.paneUri = pane.subject.uri
- }
- const stateObj = { ...currentState, ...paneState }
+ const leavingPage = document.location.href !== subject.uri
+ const stateObj = nextHistoryState(currentState, {
+ pane: pane
+ ? { name: pane.name, subject: pane.subject && typeof pane.subject.uri === 'string' ? pane.subject.uri : undefined }
+ : undefined,
+ leavingPage,
+ viaAccountMenu: !showNavbar
+ })
try {
const currentUrl = new URL(document.location.href)
const targetUrl = new URL(subject.uri, document.location.href)
if (currentUrl.origin === targetUrl.origin) {
- if (document.location.href !== subject.uri) {
+ if (leavingPage) {
dom.defaultView.history.pushState(stateObj, subject.uri, subject.uri)
} else if (JSON.stringify(currentState) !== JSON.stringify(stateObj)) {
dom.defaultView.history.replaceState(stateObj, subject.uri, subject.uri)
@@ -1906,6 +1984,8 @@ export default function (context) {
}
}
+ if (solo) announceNavigation(dom)
+
return subject
}
diff --git a/src/social/socialPane.ts b/src/social/socialPane.ts
index f04c5db7..954508d9 100644
--- a/src/social/socialPane.ts
+++ b/src/social/socialPane.ts
@@ -16,14 +16,12 @@ import { DataBrowserContext } from 'pane-registry'
import { locationIcon } from './icons'
import {
createAllFriendsSection,
- createHeaderSection,
FriendRowRenderers,
createMutualSection,
- createRequestsSection,
- HeaderControls,
- SocialHeaderElement
+ createRequestsSection
} from './socialSections'
-import type { ViewerMode } from './socialSections'
+import type { ViewerMode } from '../types'
+import { getViewerRelation } from '../utils/viewerUtils'
import { FriendshipTriage, loadFriendshipTriage, triageFriends } from './triage'
function applyEnvironmentAttributes (
@@ -225,8 +223,7 @@ export const socialPane = {
const mutualConnections = me && !thisIsYou
? uniqueNodes(common(uniqueFriends, myFriends)).filter(friend => !kb.sameThings(friend, me))
: []
- const mutualFriendCount = me && !thisIsYou ? mutualConnections.length : null
- const viewerMode = getViewerMode(s, me)
+ const viewerMode = getViewerRelation(s, me)
// Do I have a public profile document?
let profile: NamedNode | null = null // This could be SPARQL { ?me foaf:primaryTopic [ a foaf:PersonalProfileDocument ] }
@@ -320,37 +317,10 @@ export const socialPane = {
} // me is defined
// End of you and s
- const shouldShowHeaderAddFriendAction = (mode: ViewerMode) => {
- return mode === 'authenticated' && !thisIsYou && !outgoing
- }
-
const canEditOwnedProfile = viewerMode === 'owner' && editable
const canManageRelationship = viewerMode === 'authenticated' && !thisIsYou && editable && Boolean(profile)
const canModifyFriendsTab = canEditOwnedProfile
- let headerControls: HeaderControls = {
- canEdit: canEditOwnedProfile,
- viewerMode,
- showAddFriendAction: shouldShowHeaderAddFriendAction(viewerMode)
- }
-
- const header = createHeaderSection(context, s, headerControls, {
- friendCount: uniqueFriends.length,
- mutualFriendCount,
- onSelectFriends: function () {
- setActivePanel('all-friends')
- },
- onSelectMutual: typeof mutualFriendCount === 'number'
- ? function () {
- setActivePanel('mutual')
- }
- : undefined
- }, function () {
- return selectProfileData(context, s)
- })
- header.classList.add('social-pane__header-section', 'flex-column')
- socialPane.prepend(header)
-
// div.appendChild(dom.createTextNode(plural(friends.length, 'acquaintance') +'. '))
// ///////////////////////////////////////////// Main block
@@ -547,11 +517,6 @@ export const socialPane = {
refreshRelationshipUi = function () {
rebuildMutualSection()
- headerControls = {
- ...headerControls,
- showAddFriendAction: shouldShowHeaderAddFriendAction(headerControls.viewerMode)
- }
- ;(header as SocialHeaderElement).refreshSocialHeader?.(headerControls)
requestsTriage = triageFriends(kb, s)
requestsSection.refreshRequests(requestsTriage)
setActivePanel(activePanel)
@@ -626,26 +591,11 @@ export const socialPane = {
authn.checkUser()
.then(webId => {
- const confirmedViewerMode = getViewerMode(s, webId)
+ const confirmedViewerMode = getViewerRelation(s, webId)
applyViewerMode(confirmedViewerMode)
- const confirmedCanEditOwnedProfile = confirmedViewerMode === 'owner' && editable
- headerControls = {
- ...headerControls,
- canEdit: confirmedCanEditOwnedProfile,
- viewerMode: confirmedViewerMode,
- showAddFriendAction: shouldShowHeaderAddFriendAction(confirmedViewerMode)
- }
- ;(header as SocialHeaderElement).refreshSocialHeader?.(headerControls)
})
.catch(() => {
applyViewerMode('anonymous')
- headerControls = {
- ...headerControls,
- canEdit: false,
- viewerMode: 'anonymous',
- showAddFriendAction: false
- }
- ;(header as SocialHeaderElement).refreshSocialHeader?.(headerControls)
})
return socialPane
@@ -833,20 +783,3 @@ export function selectProfileData (context: DataBrowserContext, subject: NamedNo
pronouns
}
}
-
-export type { ViewerMode } from './socialSections'
-
-function getViewerMode (subject: NamedNode, currentUser: unknown = authn.currentUser()): ViewerMode {
- const currentUserUri =
- typeof currentUser === 'string'
- ? currentUser
- : typeof currentUser === 'object' && currentUser !== null
- ? ((currentUser as { value?: string, uri?: string }).value ||
- (currentUser as { value?: string, uri?: string }).uri ||
- null)
- : null
- let mode: ViewerMode = 'anonymous'
- if (currentUserUri === subject.value) mode = 'owner'
- if (currentUserUri && currentUserUri !== subject.value) mode = 'authenticated'
- return mode
-}
diff --git a/src/social/socialSections.ts b/src/social/socialSections.ts
index d6bf3aaa..17cc8b88 100644
--- a/src/social/socialSections.ts
+++ b/src/social/socialSections.ts
@@ -5,8 +5,7 @@ import { ns, utils, widgets } from 'solid-ui'
import { appendProfileLinks } from './editProfileDetails'
import { locationIcon, personInCircleIcon as personInCircleIconSvg } from './icons'
import { FriendshipTriage } from './triage'
-
-export type ViewerMode = 'owner' | 'authenticated' | 'anonymous'
+import type { ViewerMode } from '../types'
export type HeaderControls = {
canEdit: boolean,
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 00000000..0f263262
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,3 @@
+export type ViewerMode = 'owner' | 'authenticated' | 'anonymous'
+
+export type Layout = 'mobile' | 'desktop'
diff --git a/src/utils/viewerUtils.ts b/src/utils/viewerUtils.ts
new file mode 100644
index 00000000..50cc7484
--- /dev/null
+++ b/src/utils/viewerUtils.ts
@@ -0,0 +1,16 @@
+import { authn } from 'solid-logic'
+import type { NamedNode } from 'rdflib'
+import type { ViewerMode } from '../types'
+
+// Long-lived UI (e.g. the header) outlives logins, so resolve the viewer on demand.
+export async function getCurrentViewer (): Promise {
+ return authn.currentUser() ?? await authn.checkUser()
+}
+
+// Relation of a viewer to a subject, without checking write access.
+export function getViewerRelation (subject: NamedNode, viewer: unknown = authn.currentUser()): ViewerMode {
+ const { value, uri } = (typeof viewer === 'object' && viewer !== null ? viewer : {}) as { value?: string, uri?: string }
+ const viewerUri = typeof viewer === 'string' ? viewer : value || uri || null
+ if (!viewerUri) return 'anonymous'
+ return viewerUri === subject.value ? 'owner' : 'authenticated'
+}
diff --git a/src/utils/webIdUtils.ts b/src/utils/webIdUtils.ts
index a961dcb5..1f7af259 100644
--- a/src/utils/webIdUtils.ts
+++ b/src/utils/webIdUtils.ts
@@ -5,10 +5,15 @@
import { IndexedFormula, NamedNode, sym } from 'rdflib'
import { ns } from 'solid-ui'
-import { store } from 'solid-logic'
+import { authn, store } from 'solid-logic'
const DEFAULT_PROFILE_PATH = 'profile/card#me'
+export function isOwnWebIdProfile (subject: NamedNode): boolean {
+ const viewer = authn.currentUser()
+ return Boolean(viewer && (viewer.sameTerm(subject) || viewer.doc().sameTerm(subject)))
+}
+
export async function loadProfileFromURI (
uri: NamedNode
): Promise {
diff --git a/test/unit/components/fileExplorerProvider.test.ts b/test/unit/components/fileExplorerProvider.test.ts
new file mode 100644
index 00000000..b8141103
--- /dev/null
+++ b/test/unit/components/fileExplorerProvider.test.ts
@@ -0,0 +1,64 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { store } from 'solid-logic'
+import type { PaneDefinition } from 'pane-registry'
+
+vi.mock('../../../src/utils/podUtils', async (importOriginal) => ({
+ ...await importOriginal(),
+ fetchResourceMetadata: async () => ({ modified: undefined, isPublic: false, canEdit: false, aclUri: undefined })
+}))
+
+import '../../../src/components/file-explorer-header'
+
+afterEach(() => {
+ document.body.replaceChildren()
+})
+
+type Provider = HTMLElement & {
+ context: unknown
+ subjectUri: string
+ pane: Pick
+ showHeader: boolean
+ updateComplete: Promise
+}
+
+async function renderProvider (paneName: string, showHeader = true) {
+ const provider = document.createElement('file-explorer-provider') as Provider
+ provider.context = { dom: document, session: { store } }
+ provider.subjectUri = `${window.location.origin}/container/`
+ provider.pane = { name: paneName }
+ provider.showHeader = showHeader
+ document.body.appendChild(provider)
+ await provider.updateComplete
+ return provider
+}
+
+describe('file explorer provider', () => {
+ it.each(['profile', 'social'])('does not render the file explorer header for the %s pane', async (paneName) => {
+ const provider = await renderProvider(paneName)
+
+ expect(provider.shadowRoot!.querySelector('file-explorer-header')).toBeNull()
+ expect(provider.shadowRoot!.querySelector('slot.pane')).not.toBeNull()
+ })
+
+ it.each(['folder', 'source'])('renders the file explorer header for the %s pane', async (paneName) => {
+ const provider = await renderProvider(paneName)
+
+ expect(provider.shadowRoot!.querySelector('file-explorer-header')).not.toBeNull()
+ })
+
+ it('still honours showHeader being off', async () => {
+ const provider = await renderProvider('folder', false)
+
+ expect(provider.shadowRoot!.querySelector('file-explorer-header')).toBeNull()
+ })
+
+ it('removes the header when the displayed pane changes to the profile pane', async () => {
+ const provider = await renderProvider('folder')
+ expect(provider.shadowRoot!.querySelector('file-explorer-header')).not.toBeNull()
+
+ provider.pane = { name: 'profile' }
+ await provider.updateComplete
+
+ expect(provider.shadowRoot!.querySelector('file-explorer-header')).toBeNull()
+ })
+})
diff --git a/test/unit/components/navbar.test.ts b/test/unit/components/navbar.test.ts
index ae5a8ba8..ac71794d 100644
--- a/test/unit/components/navbar.test.ts
+++ b/test/unit/components/navbar.test.ts
@@ -1,9 +1,29 @@
import { describe, expect, it, vi } from 'vitest'
+import { readFileSync } from 'node:fs'
import '../../../src/components/navbar/Navbar'
import type { NavbarMenuItem } from '../../../src/components/navbar/Navbar'
describe('Navbar', () => {
+ it('owns its hover shadow and stacking styles independently of pane styles', () => {
+ const sheet = new CSSStyleSheet()
+ sheet.replaceSync(readFileSync('src/components/navbar/Navbar.styles.css', 'utf8'))
+ const rules = Array.from(sheet.cssRules).filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule)
+ const host = rules.find(rule => rule.selectorText === ':host')
+ const hiddenHost = rules.find(rule => rule.selectorText === ':host(.navbar--hidden)')
+ const navbar = rules.find(rule => rule.selectorText === '.navbar')
+ const hover = rules.find(rule => rule.selectorText === '.navbar:hover')
+
+ expect(host?.style.getPropertyValue('position')).toBe('relative')
+ expect(host?.style.getPropertyValue('z-index')).toBe('1')
+ expect(host?.style.getPropertyValue('flex-shrink')).toBe('0')
+ expect(host?.style.getPropertyValue('margin-bottom')).toBe('20px')
+ expect(hiddenHost?.style.getPropertyValue('display')).toBe('none')
+ expect(hiddenHost?.style.getPropertyPriority('display')).toBe('important')
+ expect(navbar?.style.getPropertyValue('transition')).toBe('box-shadow 0.2s ease')
+ expect(hover?.style.getPropertyValue('box-shadow')).toBe('0 8px 10px rgba(124, 77, 255, 0.1)')
+ })
+
it('emits solid-ui-select and lets the parent handle async onSelected failures', async () => {
const error = new Error('pane load failed')
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
diff --git a/test/unit/mainPage/header.test.ts b/test/unit/mainPage/header.test.ts
new file mode 100644
index 00000000..bbdecbdb
--- /dev/null
+++ b/test/unit/mainPage/header.test.ts
@@ -0,0 +1,97 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { authn } from 'solid-logic'
+import { sym } from 'rdflib'
+import type { NavigationController } from '../../../src/navigation/controller'
+
+const { getFolderPanes } = vi.hoisted(() => ({ getFolderPanes: vi.fn() }))
+
+vi.mock('../../../src/utils/paneUtils', () => ({ getFolderPanesFromURI: getFolderPanes }))
+
+import { createHeader } from '../../../src/mainPage/header'
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ vi.resetAllMocks()
+ document.body.replaceChildren()
+})
+
+type MenuItem = { onSelected?: () => unknown }
+
+describe('account menu', () => {
+ const viewer = sym('https://viewer.example/profile/card#me')
+
+ async function setup (currentUser: typeof viewer | null, checkUser: typeof viewer | null = null, storages: object[] = []) {
+ const show = vi.fn().mockResolvedValue(undefined)
+ vi.spyOn(authn, 'currentUser').mockReturnValue(currentUser)
+ vi.spyOn(authn, 'checkUser').mockResolvedValue(checkUser)
+ getFolderPanes.mockResolvedValue(storages)
+ const header = await createHeader({ show } as NavigationController) as HTMLElement & { menuItems: MenuItem[] }
+ return { show, header, items: header.menuItems }
+ }
+
+ it('shows the viewer\'s profile, friends, dashboard and preferences as the account menu', async () => {
+ const { show, items } = await setup(viewer)
+ const [profile, friends, dashboard, preferences] = items
+
+ await profile.onSelected?.()
+ await friends.onSelected?.()
+ await dashboard.onSelected?.()
+ await preferences.onSelected?.()
+
+ expect(show.mock.calls.map(call => call[0])).toEqual([
+ { kind: 'profile', subject: viewer.value, source: 'accountMenu' },
+ { kind: 'friends', subject: viewer.value, source: 'accountMenu' },
+ { kind: 'app', pane: 'home', source: 'accountMenu' },
+ { kind: 'app', pane: 'basicPreferences', source: 'accountMenu' }
+ ])
+ })
+
+ it('offers each of the viewer\'s storages, between friends and the dashboard', async () => {
+ const storage = { paneName: 'folder', label: () => 'Storage' }
+ const { show, items } = await setup(viewer, null, [storage])
+
+ expect(items).toHaveLength(5)
+ expect(getFolderPanes).toHaveBeenCalledWith(viewer)
+ await items[2].onSelected?.()
+
+ expect(show).toHaveBeenCalledWith({ kind: 'storage', pane: storage, source: 'accountMenu' })
+ })
+
+ it('loads storage choices for a viewer restored by the initial session check', async () => {
+ const storage = { paneName: 'folder', label: () => 'Storage' }
+ const { show, items } = await setup(null, viewer, [storage])
+
+ expect(getFolderPanes).toHaveBeenCalledWith(viewer)
+ expect(items).toHaveLength(5)
+ await items[2].onSelected?.()
+
+ expect(show).toHaveBeenCalledWith({ kind: 'storage', pane: storage, source: 'accountMenu' })
+ })
+
+ it.each([
+ ['logged in after the header was created', null, viewer, null],
+ ['restored by a session check', null, null, viewer]
+ ])('uses the viewer when it was %s', async (_name, atCreation, later, fromCheck) => {
+ const { show, items } = await setup(atCreation, fromCheck)
+ vi.mocked(authn.currentUser).mockReturnValue(later)
+
+ await items[0].onSelected?.()
+ await items[1].onSelected?.()
+
+ expect(show.mock.calls.map(call => call[0].subject)).toEqual([viewer.value, viewer.value])
+ })
+
+ it('does nothing when nobody is logged in', async () => {
+ const { show, items } = await setup(null, null)
+
+ for (const item of items) await item.onSelected?.()
+
+ expect(show).not.toHaveBeenCalled()
+ })
+
+ it('is created once', async () => {
+ const { header, show } = await setup(viewer)
+
+ expect(await createHeader({ show } as NavigationController)).toBe(header)
+ })
+})
diff --git a/test/unit/mainPage/index.test.ts b/test/unit/mainPage/index.test.ts
new file mode 100644
index 00000000..de044b47
--- /dev/null
+++ b/test/unit/mainPage/index.test.ts
@@ -0,0 +1,329 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { store } from 'solid-logic'
+
+const {
+ getOutliner, byName, getProfilePane, getSocialPane, getFolderPane, refreshHeading, selectNavbarPane, isWebId
+} = vi.hoisted(() => ({
+ getOutliner: vi.fn(),
+ byName: vi.fn(),
+ getProfilePane: vi.fn(),
+ getSocialPane: vi.fn(),
+ getFolderPane: vi.fn(),
+ refreshHeading: vi.fn(),
+ selectNavbarPane: vi.fn(),
+ isWebId: vi.fn()
+}))
+
+vi.mock('../../../src/index', () => ({ getOutliner }))
+vi.mock('pane-registry', () => ({ byName }))
+vi.mock('../../../src/mainPage/header', () => ({ createHeader: vi.fn() }))
+vi.mock('../../../src/mainPage/navbar', () => ({ createNavbar: vi.fn(), selectNavbarPane }))
+vi.mock('../../../src/mainPage/profileHeading', () => ({
+ initializeProfileHeading: vi.fn(),
+ placeProfileHeading: vi.fn(),
+ refreshProfileHeading: refreshHeading
+}))
+vi.mock('../../../src/utils/paneUtils', () => ({
+ getProfilePaneFromURI: getProfilePane,
+ getSocialPaneFromURI: getSocialPane,
+ getFolderPaneForStorage: getFolderPane
+}))
+vi.mock('../../../src/utils/webIdUtils', () => ({ isWebIdUri: isWebId }))
+
+import { initMainPage, refreshUI } from '../../../src/mainPage'
+import { resolveSource } from '../../../src/navigation/options'
+
+const navigationListeners: Record void> = {}
+Object.defineProperty(window, 'navigation', {
+ configurable: true,
+ value: {
+ addEventListener: (type: string, listener: (event: unknown) => void) => {
+ navigationListeners[type] = listener
+ }
+ }
+})
+
+afterEach(() => {
+ vi.resetAllMocks()
+ document.body.replaceChildren()
+ window.history.replaceState({}, '', '/')
+})
+
+const origin = window.location.origin
+const environment = {
+ layout: 'desktop',
+ layoutPreference: 'auto',
+ theme: 'light',
+ inputMode: 'pointer',
+ viewport: { width: 1280, height: 800 }
+}
+const urlProfilePane = {
+ name: 'profile',
+ paneName: 'profile',
+ subject: store.sym('https://other.example/profile/card#me')
+}
+
+function setup (state: object, webId = true) {
+ // Mirrors how the outline manager records the displayed pane in history.state
+ const GotoSubject = vi.fn((_subject, _expand, pane, _solo, _referrer, _table, options) => {
+ const next = { ...window.history.state }
+ if (pane) {
+ next.paneName = pane.name
+ if (pane.subject) next.paneUri = pane.subject.value
+ }
+ if (resolveSource(options) === 'accountMenu') next.viaAccountMenu = true
+ else delete next.viaAccountMenu
+ window.history.replaceState(next, '', window.location.href)
+ })
+ getOutliner.mockReturnValue({ GotoSubject })
+ getProfilePane.mockResolvedValue(urlProfilePane)
+ byName.mockReturnValue({ name: 'folder' })
+ isWebId.mockReturnValue(webId)
+ window.history.replaceState(state, '', webId ? '/other/profile/card#me' : '/other/storage/file.ttl')
+ return GotoSubject
+}
+
+describe('main page load', () => {
+ it('loads the URL\'s profile instead of the pane the account menu showed', async () => {
+ const GotoSubject = setup({
+ paneName: 'folder',
+ paneUri: `${origin}/storage/`,
+ viaAccountMenu: true,
+ other: 'kept'
+ })
+
+ await initMainPage(store)
+
+ expect(getProfilePane).toHaveBeenCalledWith(store.sym(window.location.href))
+ expect(byName).not.toHaveBeenCalled()
+ expect(GotoSubject.mock.calls[0][2]).toBe(urlProfilePane)
+ expect(window.location.pathname).toBe('/other/profile/card')
+ })
+
+ it.each([
+ ['social', 'https://other.example/profile/card#me'],
+ ['folder', 'https://other.example/storage/']
+ ])('always shows the profile when a WebID is reloaded after the %s pane was selected', async (paneName, paneUri) => {
+ const GotoSubject = setup({ paneName, paneUri, other: 'kept' })
+
+ await initMainPage(store)
+
+ expect(getProfilePane).toHaveBeenCalledWith(store.sym(window.location.href))
+ expect(byName).not.toHaveBeenCalled()
+ expect(getSocialPane).not.toHaveBeenCalled()
+ expect(GotoSubject.mock.calls[0][2]).toBe(urlProfilePane)
+ expect(window.history.state).toEqual({
+ paneName: 'profile',
+ paneUri: urlProfilePane.subject.value,
+ other: 'kept'
+ })
+ })
+
+ it.each([
+ ['a storage from the navbar', { paneName: 'folder', paneUri: `${origin}/other/storage/` }],
+ ['friends from the navbar', { paneName: 'social', paneUri: `${origin}/other/profile/card#me` }],
+ ['a pane of the application', { paneName: 'home' }]
+ ])('shows what the URL is on a reload, whatever pane was selected before: %s', async (_name, saved) => {
+ const GotoSubject = setup({ ...saved, other: 'kept' }, false)
+
+ await initMainPage(store)
+
+ expect(getFolderPane).not.toHaveBeenCalled()
+ expect(getSocialPane).not.toHaveBeenCalled()
+ expect(byName).not.toHaveBeenCalled()
+ expect(GotoSubject.mock.calls[0][2]).toBeUndefined()
+ expect(window.history.state).toEqual({ other: 'kept' })
+ })
+
+ it('drops account-menu content on a non-WebID page', async () => {
+ const GotoSubject = setup({
+ paneName: 'folder',
+ paneUri: `${origin}/storage/`,
+ viaAccountMenu: true
+ }, false)
+
+ await initMainPage(store)
+
+ expect(byName).not.toHaveBeenCalled()
+ expect(GotoSubject.mock.calls[0][2]).toBeUndefined()
+ expect(window.history.state).toEqual({})
+ })
+})
+
+describe('browser navigation without a reload', () => {
+ async function setupLoaded (state: object = {}, webId = true) {
+ const GotoSubject = setup(state, webId)
+ await initMainPage(store)
+ GotoSubject.mockClear()
+ refreshHeading.mockClear()
+ getProfilePane.mockClear()
+ selectNavbarPane.mockClear()
+ return GotoSubject
+ }
+
+ function enter (overrides: object = {}) {
+ const intercept = vi.fn()
+ navigationListeners.navigate({
+ canIntercept: true,
+ userInitiated: true,
+ navigationType: 'replace',
+ destination: { url: window.location.href },
+ intercept,
+ ...overrides
+ })
+ return intercept
+ }
+
+ function traverse (navigationType = 'traverse') {
+ navigationListeners.currententrychange({ navigationType })
+ }
+
+ it('shows the URL\'s profile, navbar selection and heading when Enter is pressed on the current URL', async () => {
+ const GotoSubject = await setupLoaded()
+ window.history.replaceState({
+ paneName: 'folder',
+ paneUri: `${origin}/storage/`,
+ viaAccountMenu: true
+ }, '', window.location.href)
+
+ for (const overrides of [
+ { userInitiated: false },
+ { navigationType: 'push' },
+ { canIntercept: false },
+ { destination: { url: `${origin}/elsewhere` } }
+ ]) {
+ expect(enter(overrides)).not.toHaveBeenCalled()
+ }
+
+ const intercept = enter()
+ expect(intercept).toHaveBeenCalledOnce()
+ await intercept.mock.calls[0][0].handler()
+
+ expect(getProfilePane).toHaveBeenCalledWith(store.sym(window.location.href))
+ expect(GotoSubject.mock.calls[0][2]).toBe(urlProfilePane)
+ expect(resolveSource(GotoSubject.mock.calls[0][6])).toBe('default')
+ expect(selectNavbarPane).toHaveBeenCalledWith('profile', urlProfilePane.subject.value)
+ expect(window.history.state).toEqual({ paneName: 'profile', paneUri: urlProfilePane.subject.value })
+ expect(refreshHeading).toHaveBeenCalledWith(true)
+ })
+
+ it('shows a container\'s own default view when Enter is pressed on its URL after a storage was selected', async () => {
+ const GotoSubject = await setupLoaded({}, false)
+ window.history.replaceState({ paneName: 'folder', paneUri: `${origin}/other/` }, '', window.location.href)
+
+ const intercept = enter()
+ expect(intercept).toHaveBeenCalledOnce()
+ await intercept.mock.calls[0][0].handler()
+
+ expect(getFolderPane).not.toHaveBeenCalled()
+ expect(GotoSubject.mock.calls[0][2]).toBeUndefined()
+ expect(window.history.state).toEqual({})
+ expect(selectNavbarPane).not.toHaveBeenCalled()
+ expect(refreshHeading).toHaveBeenCalledWith(true)
+ })
+
+ it.each([
+ ['an account-menu storage', { paneName: 'folder', paneUri: `${origin}/storage/`, viaAccountMenu: true }],
+ ['a storage from the navbar', { paneName: 'folder', paneUri: `${origin}/other/storage/` }],
+ ['friends from the navbar', { paneName: 'social', paneUri: `${origin}/other/profile/card#me` }],
+ ['an account-menu dashboard', { paneName: 'home', viaAccountMenu: true }]
+ ])('shows what the URL is on back or forward, not %s the entry displayed', async (_name, saved) => {
+ const GotoSubject = await setupLoaded()
+ window.history.replaceState({ ...saved, other: 'kept' }, '', window.location.href)
+
+ traverse()
+ await vi.waitFor(() => expect(refreshHeading).toHaveBeenCalledWith(true))
+
+ expect(getFolderPane).not.toHaveBeenCalled()
+ expect(getSocialPane).not.toHaveBeenCalled()
+ expect(getProfilePane).toHaveBeenCalledWith(store.sym(window.location.href))
+ expect(GotoSubject.mock.calls[0][2]).toBe(urlProfilePane)
+ expect(resolveSource(GotoSubject.mock.calls[0][6])).toBe('default')
+ expect(selectNavbarPane).toHaveBeenCalledWith('profile', urlProfilePane.subject.value)
+ expect(window.history.state).toEqual({
+ paneName: 'profile',
+ paneUri: urlProfilePane.subject.value,
+ other: 'kept'
+ })
+ expect(window.location.pathname).toBe('/other/profile/card')
+ })
+
+ it('shows a container\'s own default view on back or forward after a storage was selected', async () => {
+ const GotoSubject = await setupLoaded({}, false)
+ window.history.replaceState({ paneName: 'folder', paneUri: `${origin}/other/` }, '', window.location.href)
+
+ traverse()
+ await vi.waitFor(() => expect(refreshHeading).toHaveBeenCalledWith(true))
+
+ expect(getFolderPane).not.toHaveBeenCalled()
+ expect(GotoSubject.mock.calls[0][2]).toBeUndefined()
+ expect(window.history.state).toEqual({})
+ expect(selectNavbarPane).not.toHaveBeenCalled()
+ })
+
+ it('ignores entry changes that are not back or forward', async () => {
+ const GotoSubject = await setupLoaded()
+
+ traverse('replace')
+ traverse('push')
+ await new Promise(resolve => setTimeout(resolve, 10))
+
+ expect(GotoSubject).not.toHaveBeenCalled()
+ })
+
+ it('only renders the latest when navigating quickly', async () => {
+ const GotoSubject = await setupLoaded()
+ let resolveFirst!: (pane: unknown) => void
+ getProfilePane.mockReturnValueOnce(new Promise(resolve => { resolveFirst = resolve }))
+ traverse()
+ await vi.waitFor(() => expect(getProfilePane).toHaveBeenCalledOnce())
+
+ traverse()
+ await vi.waitFor(() => expect(GotoSubject).toHaveBeenCalledOnce())
+ resolveFirst({ name: 'profile', paneName: 'profile', subject: urlProfilePane.subject })
+ await new Promise(resolve => setTimeout(resolve))
+
+ expect(GotoSubject).toHaveBeenCalledOnce()
+ expect(GotoSubject.mock.calls[0][2]).toBe(urlProfilePane)
+ })
+
+ it('keeps the URL and account-menu view when the render environment changes', async () => {
+ const GotoSubject = await setupLoaded()
+ const navbar = document.createElement('solid-panes-navbar')
+ document.body.appendChild(navbar)
+ const storage = `${origin}/storage/`
+ const storagePane = { name: 'folder', paneName: 'folder', subject: store.sym(storage) }
+ getFolderPane.mockResolvedValue(storagePane)
+ window.history.replaceState({ paneName: 'folder', paneUri: storage, viaAccountMenu: true }, '', window.location.href)
+ const pageUrl = window.location.href
+ const outliner = getOutliner()
+ outliner.context = { session: { store }, environment: { ...environment, layout: 'mobile' } }
+
+ await refreshUI(outliner)
+
+ const [subject, , pane, , , , options] = GotoSubject.mock.calls[0]
+ expect(subject.value).toBe(pageUrl)
+ expect(pane).toBe(storagePane)
+ expect(options).toEqual({ source: 'accountMenu' })
+ expect(window.location.href).toBe(pageUrl)
+ expect(window.history.state).toMatchObject({ paneName: 'folder', paneUri: storage, viaAccountMenu: true })
+ expect(navbar).toHaveClass('navbar--hidden')
+
+ await refreshUI(outliner)
+ expect(GotoSubject).toHaveBeenCalledOnce()
+ })
+
+ it('falls back to popstate when the Navigation API is unavailable', async () => {
+ Reflect.deleteProperty(window, 'navigation')
+ vi.resetModules()
+ const { initMainPage: initWithoutNavigationApi } = await import('../../../src/mainPage')
+ const GotoSubject = setup({})
+ await initWithoutNavigationApi(store)
+ GotoSubject.mockClear()
+ window.history.replaceState({}, '', window.location.href)
+
+ window.dispatchEvent(new PopStateEvent('popstate'))
+ await vi.waitFor(() => expect(GotoSubject).toHaveBeenCalledOnce())
+ expect(GotoSubject.mock.calls[0][2]).toBe(urlProfilePane)
+ })
+})
diff --git a/test/unit/mainPage/navbar.test.ts b/test/unit/mainPage/navbar.test.ts
new file mode 100644
index 00000000..19502786
--- /dev/null
+++ b/test/unit/mainPage/navbar.test.ts
@@ -0,0 +1,260 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { authn } from 'solid-logic'
+import { sym } from 'rdflib'
+import type { NavigationController } from '../../../src/navigation/controller'
+import type { NavbarMenuItem } from '../../../src/components/navbar/Navbar'
+import { getFolderPanesFromURI } from '../../../src/utils/paneUtils'
+import { announceNavigation } from '../../../src/navigation/events'
+import { resetPodContexts } from '../../../src/navigation/podContext'
+
+const { loadProfile } = vi.hoisted(() => ({ loadProfile: vi.fn() }))
+
+vi.mock('../../../src/utils/paneUtils', () => ({
+ getFolderPanesFromURI: vi.fn(async () => [])
+}))
+
+vi.mock('../../../src/utils/webIdUtils', async (importOriginal) => ({
+ ...await importOriginal(),
+ loadProfileFromURI: loadProfile
+}))
+
+import { createNavbar, selectNavbarPane } from '../../../src/mainPage/navbar'
+
+// Without a profile of its own, the lookup answers with the URL it was asked about
+const noProfile = async (subject: ReturnType) => subject
+
+function folderPane (storage: ReturnType) {
+ return {
+ name: 'folder',
+ paneName: 'folder',
+ subject: storage,
+ label: () => 'Storage',
+ render: vi.fn(),
+ shouldGetFocus: () => false,
+ requireQueryButton: false,
+ icon: ''
+ }
+}
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ resetPodContexts()
+ loadProfile.mockReset().mockImplementation(noProfile)
+ vi.mocked(getFolderPanesFromURI).mockResolvedValue([])
+ document.body.replaceChildren()
+ window.history.replaceState({}, '', '/')
+})
+
+describe('page navbar visibility', () => {
+ const navigation = { show: vi.fn().mockResolvedValue(undefined) } as unknown as NavigationController
+
+ beforeEach(() => {
+ loadProfile.mockImplementation(noProfile)
+ })
+
+ it('selects a menu item by pane name and ignores unknown panes', async () => {
+ vi.spyOn(authn, 'currentUser').mockReturnValue(null)
+ window.history.replaceState({ paneName: 'social' }, '', '/other/profile/card#me')
+
+ const navbar = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ const selected = () => navbar.navbarItems.filter(item => item.selected).map(item => item.paneName)
+ expect(selected()).toEqual(['social'])
+
+ selectNavbarPane('profile')
+ expect(selected()).toEqual(['profile'])
+
+ selectNavbarPane('unknown')
+ expect(selected()).toEqual(['profile'])
+ })
+
+ it('selects the storage item matching the pane URI', async () => {
+ const storages = [1, 2].map(index => sym(`${window.location.origin}/other/storage-${index}/`))
+ vi.mocked(getFolderPanesFromURI).mockResolvedValue(storages.map(folderPane))
+ window.history.replaceState({}, '', '/other/profile/card#me')
+
+ const navbar = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ const selected = () => navbar.navbarItems.filter(item => item.selected).map(item => item.paneUri)
+
+ selectNavbarPane('folder', storages[1].value)
+ expect(selected()).toEqual([storages[1].value])
+
+ selectNavbarPane('folder', `${window.location.origin}/unknown/`)
+ expect(selected()).toEqual([storages[0].value])
+ })
+
+ it('associates each storage menu item with its own pane URI', async () => {
+ const subject = sym(`${window.location.origin}/other/profile/card#me`)
+ const storages = [1, 2].map(index => sym(`${window.location.origin}/other/storage-${index}/`))
+ vi.mocked(getFolderPanesFromURI).mockResolvedValue(storages.map(folderPane))
+ window.history.replaceState({
+ paneName: 'folder',
+ paneUri: storages[1].value
+ }, '', subject.value)
+
+ const navbar = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ const storageItems = navbar.navbarItems.filter(item => item.paneName === 'folder')
+
+ expect(storageItems.map(item => item.paneUri)).toEqual(storages.map(storage => storage.value))
+ expect(storageItems.map(item => item.selected)).toEqual([false, true])
+ })
+
+ it.each(['/profile/card#me', '/profile/card'])('hides a new navbar on the own profile URL %s', async (url) => {
+ vi.spyOn(authn, 'currentUser').mockReturnValue(sym(`${window.location.origin}/profile/card#me`))
+ window.history.replaceState({}, '', url)
+
+ const navbar = await createNavbar(navigation)
+
+ expect(navbar).toHaveClass('navbar--hidden')
+ })
+
+ it.each([
+ ['/profile/card#me', false],
+ ['/other/profile/card#me', true],
+ ['/storage/file.ttl', true],
+ ['/profile/card#someone-else', true]
+ ])('keeps the navbar visible for %s when logged in: %s', async (url, loggedIn) => {
+ vi.spyOn(authn, 'currentUser').mockReturnValue(
+ loggedIn ? sym(`${window.location.origin}/profile/card#me`) : null
+ )
+ window.history.replaceState({}, '', url)
+
+ const navbar = await createNavbar(navigation)
+
+ expect(navbar).not.toHaveClass('navbar--hidden')
+ })
+
+ it('updates an existing navbar when moving to and from the own profile', async () => {
+ vi.spyOn(authn, 'currentUser').mockReturnValue(sym(`${window.location.origin}/profile/card#me`))
+ const navbar = document.createElement('solid-panes-navbar')
+ document.body.appendChild(navbar)
+ window.history.replaceState({}, '', '/profile/card#me')
+
+ expect(await createNavbar(navigation)).toBe(navbar)
+ expect(navbar).toHaveClass('navbar--hidden')
+
+ window.history.replaceState({}, '', '/other/profile/card#me')
+ expect(await createNavbar(navigation)).toBe(navbar)
+ expect(navbar).not.toHaveClass('navbar--hidden')
+ })
+
+ it('matches the actual WebID rather than assuming a #me fragment', async () => {
+ vi.spyOn(authn, 'currentUser').mockReturnValue(sym(`${window.location.origin}/identity#person`))
+ window.history.replaceState({}, '', '/identity#person')
+
+ const navbar = await createNavbar(navigation)
+
+ expect(navbar).toHaveClass('navbar--hidden')
+ })
+
+ describe('built from the pod the page is in', () => {
+ it('offers Profile and Friends only when the owner of the pod is known', async () => {
+ window.history.replaceState({}, '', '/other/container/')
+
+ const unknown = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ expect(unknown.navbarItems.map(item => item.paneName)).toEqual([])
+
+ document.body.replaceChildren()
+ resetPodContexts()
+ window.history.replaceState({}, '', '/other/profile/card#me')
+
+ const known = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ expect(known.navbarItems.map(item => item.paneName)).toEqual(['profile', 'social'])
+ })
+
+ it('selects Profile on the WebID, but nothing on the document that holds it', async () => {
+ loadProfile.mockResolvedValue(sym(`${window.location.origin}/timea2/profile/card#me`))
+
+ window.history.replaceState({}, '', '/timea2/profile/card#me')
+ const onWebId = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ expect(onWebId.navbarItems.filter(item => item.selected).map(item => item.paneName)).toEqual(['profile'])
+
+ document.body.replaceChildren()
+ window.history.replaceState({}, '', '/timea2/profile/card')
+ const onDocument = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ expect(onDocument.navbarItems.map(item => item.paneName)).toEqual(['profile', 'social'])
+ expect(onDocument.navbarItems.filter(item => item.selected)).toEqual([])
+ })
+
+ it('selects nothing on other pages of the pod', async () => {
+ loadProfile.mockResolvedValue(sym(`${window.location.origin}/timea2/profile/card#me`))
+ window.history.replaceState({}, '', '/timea2/docs/')
+
+ const navbar = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+
+ expect(navbar.navbarItems.map(item => item.paneName)).toEqual(['profile', 'social'])
+ expect(navbar.navbarItems.filter(item => item.selected)).toEqual([])
+ })
+
+ it('asks the controller to display what is chosen', async () => {
+ const show = vi.fn().mockResolvedValue(undefined)
+ const owner = sym(`${window.location.origin}/a/profile/card#me`)
+ const storage = folderPane(sym(`${window.location.origin}/a/`))
+ loadProfile.mockResolvedValue(owner)
+ vi.mocked(getFolderPanesFromURI).mockResolvedValue([storage])
+ window.history.replaceState({}, '', '/a/docs/first')
+
+ const navbar = await createNavbar({ show } as unknown as NavigationController) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ for (const item of navbar.navbarItems) {
+ navbar.dispatchEvent(new CustomEvent('solid-ui-select', { detail: item, bubbles: true }))
+ }
+
+ await vi.waitFor(() => expect(show).toHaveBeenCalledTimes(3))
+ expect(show.mock.calls.map(call => call[0])).toEqual([
+ { kind: 'profile', subject: owner.value },
+ { kind: 'friends', subject: owner.value },
+ { kind: 'storage', pane: storage }
+ ])
+ })
+
+ describe('when a navigation moves within or between pods', () => {
+ const ownerOf = (pod: string) => sym(`${window.location.origin}/${pod}/profile/card#me`)
+
+ beforeEach(() => {
+ loadProfile.mockImplementation(async (subject: ReturnType) => {
+ const [pod] = new URL(subject.uri).pathname.split('/').filter(Boolean)
+ return ownerOf(pod)
+ })
+ })
+
+ it('builds the items again for the new pod, with the selection of the new entry', async () => {
+ window.history.replaceState({}, '', '/a/profile/card#me')
+ const navbar = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ expect(navbar.navbarItems.map(item => item.paneUri)).toEqual([ownerOf('a').value, ownerOf('a').value])
+
+ window.history.replaceState({ paneName: 'social', paneUri: ownerOf('b').value }, '', '/b/docs/')
+ announceNavigation(document)
+
+ await vi.waitFor(() => {
+ expect(navbar.navbarItems.map(item => item.paneUri)).toEqual([ownerOf('b').value, ownerOf('b').value])
+ })
+ expect(navbar.navbarItems.filter(item => item.selected).map(item => item.paneName)).toEqual(['social'])
+ })
+
+ it('keeps the items for another page in the same pod', async () => {
+ window.history.replaceState({}, '', '/a/profile/card#me')
+ const navbar = await createNavbar(navigation) as HTMLElement & { navbarItems: NavbarMenuItem[] }
+ const items = navbar.navbarItems
+
+ window.history.replaceState({}, '', '/a/docs/')
+ announceNavigation(document)
+ await new Promise(resolve => setTimeout(resolve, 20))
+
+ expect(navbar.navbarItems).toBe(items)
+ })
+
+ it('stops following navigations once the navbar is gone', async () => {
+ window.history.replaceState({}, '', '/a/profile/card#me')
+ await createNavbar(navigation)
+ document.body.replaceChildren()
+ loadProfile.mockClear()
+ resetPodContexts()
+
+ window.history.replaceState({}, '', '/b/docs/')
+ announceNavigation(document)
+ await new Promise(resolve => setTimeout(resolve, 20))
+
+ expect(loadProfile).not.toHaveBeenCalled()
+ })
+ })
+ })
+})
diff --git a/test/unit/mainPage/profileHeading.test.ts b/test/unit/mainPage/profileHeading.test.ts
new file mode 100644
index 00000000..48f96837
--- /dev/null
+++ b/test/unit/mainPage/profileHeading.test.ts
@@ -0,0 +1,918 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { authn, authSession, store } from 'solid-logic'
+import { lit, sym } from 'rdflib'
+import { ns } from 'solid-ui'
+import type { ProfileHeadingEditContext } from '../../../src/components/profile-heading/ProfileHeading'
+
+const { getViewerModeMock, createHeadingEditDialogMock, pod } = vi.hoisted(() => ({
+ getViewerModeMock: vi.fn(),
+ createHeadingEditDialogMock: vi.fn(),
+ // What the page knows about the pod it is in
+ pod: { current: { owner: undefined as string | undefined, storagePanes: [] as Array<{ subject: { value: string } }>, viewerStorages: [] as string[] } }
+}))
+
+vi.mock('../../../src/navigation/podContext', async (importOriginal) => ({
+ ...await importOriginal(),
+ getPodContext: async () => pod.current
+}))
+
+vi.mock('../../../src/components/profile-heading/editor/sections/heading/HeadingEditDialog', () => ({
+ createHeadingEditDialog: createHeadingEditDialogMock
+}))
+
+vi.mock('../../../src/components/profile-heading/profileHeadingData', async (importOriginal) => {
+ const actual = await importOriginal()
+
+ return {
+ ...actual,
+ getViewerMode: getViewerModeMock
+ }
+})
+
+vi.mock('../../../src/utils/webIdUtils', async () => {
+ const actual = await vi.importActual('../../../src/utils/webIdUtils')
+ return {
+ ...actual,
+ loadProfileFromURI: async (subject: ReturnType) => subject
+ }
+})
+
+import { initializeProfileHeading, placeProfileHeading, refreshProfileHeading } from '../../../src/mainPage/profileHeading'
+import { announceNavigation } from '../../../src/navigation/events'
+
+function podOf (owner: string | undefined, ...storages: string[]) {
+ pod.current = { owner, storagePanes: storages.map(value => ({ subject: { value } })), viewerStorages: [] }
+}
+
+afterEach(() => {
+ document.body.replaceChildren()
+ window.history.replaceState({}, '', '/')
+ podOf(undefined)
+})
+
+describe('page-level profile heading', () => {
+ it('renders for the active profile pane after concurrent refresh requests', async () => {
+ const subject = sym('https://profile-heading.example/profile/card#me')
+ store.add(subject, ns.vcard('fn'), lit('Profile Heading Test'), subject.doc())
+
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ const navbar = document.createElement('solid-panes-navbar')
+ const outline = document.createElement('table')
+ outline.id = 'OutlineView'
+ mainContent.append(navbar, outline)
+ document.body.appendChild(mainContent)
+ window.history.replaceState({
+ paneName: 'profile',
+ paneUri: subject.value
+ }, '', '/')
+
+ await Promise.all([refreshProfileHeading(), refreshProfileHeading()])
+
+ const mount = document.getElementById('profile-heading-mount')
+ const heading = mount?.querySelector('solid-panes-profile-heading') as (HTMLElement & {
+ profileData?: { name: string }
+ updateComplete?: Promise
+ }) | null
+ await heading?.updateComplete
+
+ expect(mount?.hidden).toBe(false)
+ expect(mainContent.firstElementChild).toBe(mount)
+ expect(heading?.profileData?.name).toBe('Profile Heading Test')
+ expect(heading?.shadowRoot?.querySelector('h1')?.textContent).toBe('Profile Heading Test')
+ expect(heading?.shadowRoot?.querySelector('.image-frame')).toHaveClass('image-frame--fallback')
+ expect(heading?.shadowRoot?.querySelector('.hero-fallback icon-lucide-circle-user-round')).not.toBeNull()
+ expect(heading?.shadowRoot?.querySelector('.profile__heading-edit-action')).toBeNull()
+ })
+
+ it('opens the heading editor from Edit and announces the save to other views', async () => {
+ const subject = sym('https://edit-flow.example/profile/card#me')
+ const profileData = { entryNode: subject, name: 'Profile Heading Test' }
+ const onSaved = vi.fn()
+ createHeadingEditDialogMock.mockReset().mockImplementation(
+ async (_event, _store, _subject, _profile, _viewerMode, saved) => saved()
+ )
+ const heading = document.createElement('solid-panes-profile-heading') as HTMLElement & {
+ profileData?: typeof profileData
+ editContext?: unknown
+ updateComplete?: Promise
+ }
+ heading.profileData = profileData
+ heading.editContext = { subject, profileData, viewerMode: 'owner', onSaved }
+ document.body.appendChild(heading)
+ await heading.updateComplete
+
+ const headingSaved = vi.fn()
+ document.addEventListener('profile-heading-saved', headingSaved)
+ try {
+ const desktopEditButton = heading.shadowRoot?.querySelector('.desktop-edit-button')
+ expect(desktopEditButton).toHaveAttribute('variant', 'tertiary')
+ const mobileEditButton = heading.shadowRoot?.querySelector('.mobile-edit-button')
+ expect(mobileEditButton).toHaveAttribute('variant', 'ghost')
+ expect(mobileEditButton?.querySelector('icon-lucide-pencil')).not.toBeNull()
+ desktopEditButton?.click()
+
+ await vi.waitFor(() => expect(onSaved).toHaveBeenCalledOnce())
+ expect(createHeadingEditDialogMock).toHaveBeenCalledWith(
+ expect.any(Event), store, subject, profileData, 'owner', expect.any(Function)
+ )
+ expect(headingSaved).toHaveBeenCalledOnce()
+ expect((headingSaved.mock.calls[0][0] as CustomEvent).detail).toEqual({ subjectUri: subject.value })
+ } finally {
+ document.removeEventListener('profile-heading-saved', headingSaved)
+ }
+ })
+
+ it('does not announce a save when the editor is closed without saving', async () => {
+ const subject = sym('https://edit-flow-cancel.example/profile/card#me')
+ const profileData = { entryNode: subject, name: 'Profile Heading Test' }
+ const onSaved = vi.fn()
+ createHeadingEditDialogMock.mockReset().mockResolvedValue(undefined)
+ const heading = document.createElement('solid-panes-profile-heading') as HTMLElement & {
+ profileData?: typeof profileData
+ editContext?: unknown
+ updateComplete?: Promise
+ }
+ heading.profileData = profileData
+ heading.editContext = { subject, profileData, viewerMode: 'owner', onSaved }
+ document.body.appendChild(heading)
+ await heading.updateComplete
+
+ const headingSaved = vi.fn()
+ document.addEventListener('profile-heading-saved', headingSaved)
+ try {
+ heading.shadowRoot?.querySelector('.desktop-edit-button')?.click()
+ await vi.waitFor(() => expect(createHeadingEditDialogMock).toHaveBeenCalledOnce())
+ expect(headingSaved).not.toHaveBeenCalled()
+ expect(onSaved).not.toHaveBeenCalled()
+ } finally {
+ document.removeEventListener('profile-heading-saved', headingSaved)
+ }
+ })
+
+ it('renders Lucide icons for profile detail rows', async () => {
+ const heading = document.createElement('solid-panes-profile-heading') as HTMLElement & {
+ profileData?: {
+ name: string
+ dateOfBirth: string
+ location: string
+ primaryPhone: { valueNode: ReturnType }
+ primaryEmail: { valueNode: ReturnType }
+ }
+ updateComplete?: Promise
+ }
+ heading.profileData = {
+ name: 'Profile Heading Test',
+ dateOfBirth: '2001-02-03',
+ location: 'Example City',
+ primaryPhone: { valueNode: lit('tel:+123456789') },
+ primaryEmail: { valueNode: lit('mailto:person@example.com') }
+ }
+ document.body.appendChild(heading)
+ await heading.updateComplete
+
+ expect(heading.shadowRoot?.querySelector('.detail-icon icon-lucide-cake')).not.toBeNull()
+ expect(heading.shadowRoot?.querySelector('.detail-icon icon-lucide-map-pin')).not.toBeNull()
+ expect(heading.shadowRoot?.querySelector('.detail-icon icon-lucide-phone')).not.toBeNull()
+ expect(heading.shadowRoot?.querySelector('.detail-icon icon-lucide-mail')).not.toBeNull()
+ })
+
+ it('rechecks owner edit access after a restored session', async () => {
+ getViewerModeMock.mockReset()
+ const subject = sym('https://profile-heading.example/profile/card#me')
+ let sessionReady = false
+ const checkUser = vi.spyOn(authn, 'checkUser').mockImplementation(async () => {
+ sessionReady = true
+ return subject
+ })
+ getViewerModeMock.mockImplementation(async () => sessionReady ? 'owner' : 'anonymous')
+
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ document.body.appendChild(mainContent)
+ window.history.replaceState({
+ paneName: 'profile',
+ paneUri: subject.value
+ }, '', '/')
+
+ initializeProfileHeading()
+ await refreshProfileHeading(true)
+ expect(checkUser).toHaveBeenCalledOnce()
+
+ const getHeading = () => document.querySelector('solid-panes-profile-heading') as (HTMLElement & {
+ editContext?: unknown
+ updateComplete?: Promise
+ }) | null
+
+ await vi.waitFor(async () => {
+ await getHeading()?.updateComplete
+ expect(getHeading()?.editContext).toBeTruthy()
+ })
+
+ sessionReady = false
+ authSession.events.emit('logout')
+
+ await vi.waitFor(async () => {
+ await getHeading()?.updateComplete
+ expect(getHeading()?.editContext).toBeUndefined()
+ })
+
+ checkUser.mockRestore()
+ })
+
+ it('hides extra information and editing in compact mode, then restores the full heading', async () => {
+ const heading = document.createElement('solid-panes-profile-heading') as HTMLElement & {
+ profileData: {
+ name: string
+ jobTitle: string
+ pronouns: string
+ dateOfBirth: string
+ location: string
+ primaryEmail: { valueNode: ReturnType }
+ }
+ compact: boolean
+ editContext: {
+ subject: ReturnType
+ profileData: { entryNode: ReturnType; name: string }
+ viewerMode: 'owner'
+ onSaved: () => void
+ }
+ updateComplete: Promise
+ }
+ heading.profileData = {
+ name: 'Compact Heading Test',
+ jobTitle: 'Engineer',
+ pronouns: 'she/her',
+ dateOfBirth: '2000-01-02',
+ location: 'Paris, France',
+ primaryEmail: { valueNode: sym('mailto:compact@example.com') }
+ }
+ heading.editContext = {
+ subject: sym('https://compact-heading.example/profile/card#me'),
+ profileData: {
+ entryNode: sym('https://compact-heading.example/profile/card#me'),
+ name: 'Compact Heading Test'
+ },
+ viewerMode: 'owner',
+ onSaved: vi.fn()
+ }
+ document.body.appendChild(heading)
+ await heading.updateComplete
+ const avatar = heading.shadowRoot?.querySelector('.image-frame')
+ const details = heading.shadowRoot?.querySelector('.details-collapse')
+ expect(details).toHaveAttribute('aria-hidden', 'false')
+ expect(heading.shadowRoot?.querySelector('.pronouns')).not.toHaveAttribute('hidden')
+
+ heading.compact = true
+ await heading.updateComplete
+ expect(heading).toHaveAttribute('compact')
+ expect(details).toHaveAttribute('aria-hidden', 'true')
+ expect(details).toHaveAttribute('inert')
+ expect(heading.shadowRoot?.querySelector('.pronouns')).toHaveAttribute('hidden')
+ expect(heading.shadowRoot?.querySelector('.profile__heading-edit-action')).toBeNull()
+ expect(heading.shadowRoot?.querySelector('.name')?.textContent).toBe('Compact Heading Test')
+ expect(heading.shadowRoot?.querySelector('.role')?.textContent).toBe('Engineer')
+ expect(heading.shadowRoot?.querySelector('.image-frame')).toBe(avatar)
+
+ heading.compact = false
+ await heading.updateComplete
+ expect(heading).not.toHaveAttribute('compact')
+ expect(details).not.toHaveAttribute('inert')
+ expect(details).toHaveAttribute('aria-hidden', 'false')
+ expect(heading.shadowRoot?.querySelector('.pronouns')).not.toHaveAttribute('hidden')
+ expect(heading.shadowRoot?.querySelector('.profile__heading-edit-action')).not.toBeNull()
+ expect(heading.shadowRoot?.querySelector('.image-frame')).toBe(avatar)
+ })
+
+ it('only enables heading editing on the profile pane', async () => {
+ getViewerModeMock.mockReset().mockResolvedValue('owner')
+ const subject = sym('https://profile-heading.example/profile/card#me')
+ store.add(subject, ns.vcard('fn'), lit('Profile Heading Test'), subject.doc())
+
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ document.body.appendChild(mainContent)
+
+ window.history.replaceState({
+ paneName: 'social',
+ paneUri: subject.value
+ }, '', '/')
+ await refreshProfileHeading(true)
+
+ const heading = document.querySelector('solid-panes-profile-heading') as (HTMLElement & {
+ editContext?: unknown
+ compact?: boolean
+ updateComplete?: Promise
+ }) | null
+ await heading?.updateComplete
+ expect(heading?.editContext).toBeUndefined()
+ expect(heading?.compact).toBe(true)
+ expect(heading?.shadowRoot?.querySelector('.profile__heading-edit-action')).toBeNull()
+
+ window.history.replaceState({
+ paneName: 'profile',
+ paneUri: subject.value
+ }, '', '/')
+ await refreshProfileHeading(true)
+ await heading?.updateComplete
+
+ expect(heading?.editContext).toBeTruthy()
+ expect(heading?.compact).toBe(false)
+ expect(heading?.shadowRoot?.querySelector('.profile__heading-edit-action')).not.toBeNull()
+
+ window.history.replaceState({
+ paneName: 'social',
+ paneUri: subject.value
+ }, '', '/')
+ await refreshProfileHeading()
+ await heading?.updateComplete
+
+ expect(document.querySelector('solid-panes-profile-heading')).toBe(heading)
+ expect(heading?.compact).toBe(true)
+ expect(heading?.editContext).toBeUndefined()
+ })
+
+ it('refreshes the heading job title when the displayed profile saves resume changes', async () => {
+ const subject = sym('https://resume-heading.example/profile/card#me')
+ const membership = sym('https://resume-heading.example/profile/card#role')
+ const doc = subject.doc()
+ store.add(subject, ns.vcard('fn'), lit('Resume Heading Test'), doc)
+ store.add(membership, ns.org('member'), subject, doc)
+ store.add(membership, ns.rdf('type'), ns.solid('CurrentRole'), doc)
+ store.add(membership, ns.vcard('role'), lit('Engineer'), doc)
+ getViewerModeMock.mockResolvedValue('owner')
+
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ const profilePane = document.createElement('div')
+ mainContent.appendChild(profilePane)
+ document.body.appendChild(mainContent)
+ window.history.replaceState({
+ paneName: 'profile',
+ paneUri: subject.value
+ }, '', '/')
+ initializeProfileHeading()
+ await refreshProfileHeading(true)
+
+ const heading = document.querySelector('solid-panes-profile-heading') as HTMLElement & {
+ updateComplete: Promise
+ }
+ await heading.updateComplete
+ expect(heading.shadowRoot?.querySelector('.role')?.textContent).toBe('Engineer')
+
+ store.removeMany(membership, ns.vcard('role'), null, doc)
+ store.add(membership, ns.vcard('role'), lit('Staff Engineer'), doc)
+
+ profilePane.dispatchEvent(new CustomEvent('profile-pane-saved', {
+ bubbles: true,
+ composed: true,
+ detail: { subjectUri: 'https://unrelated.example/profile/card#me' }
+ }))
+ await Promise.resolve()
+ expect(heading.shadowRoot?.querySelector('.role')?.textContent).toBe('Engineer')
+
+ profilePane.dispatchEvent(new CustomEvent('profile-pane-saved', {
+ bubbles: true,
+ composed: true,
+ detail: { subjectUri: subject.value }
+ }))
+ await vi.waitFor(async () => {
+ await heading.updateComplete
+ expect(heading.shadowRoot?.querySelector('.role')?.textContent).toBe('Staff Engineer')
+ })
+
+ store.removeMany(membership, ns.vcard('role'), null, doc)
+ profilePane.dispatchEvent(new CustomEvent('profile-pane-saved', {
+ bubbles: true,
+ composed: true,
+ detail: { subjectUri: subject.value }
+ }))
+ await vi.waitFor(async () => {
+ await heading.updateComplete
+ expect(heading.shadowRoot?.querySelector('.role')).toBeNull()
+ })
+ })
+
+ it('refreshes heading contact details after More contacts saves additions, edits, and removals', async () => {
+ const subject = sym('https://contacts-heading.example/profile/card#me')
+ const doc = subject.doc()
+ const phone = sym(`${doc.value}#phone`)
+ const email = sym(`${doc.value}#email`)
+ const address = sym(`${doc.value}#address`)
+ store.add(subject, ns.vcard('fn'), lit('Contacts Heading Test'), doc)
+ getViewerModeMock.mockResolvedValue('owner')
+
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ const profilePane = document.createElement('div')
+ mainContent.appendChild(profilePane)
+ document.body.appendChild(mainContent)
+ window.history.replaceState({
+ paneName: 'profile',
+ paneUri: subject.value
+ }, '', '/')
+ initializeProfileHeading()
+ await refreshProfileHeading(true)
+
+ const heading = document.querySelector('solid-panes-profile-heading') as HTMLElement & {
+ updateComplete: Promise
+ }
+ const values = () => Array.from(
+ heading.shadowRoot?.querySelectorAll('.detail-value') ?? [],
+ element => element.textContent
+ )
+ const notifySaved = () => profilePane.dispatchEvent(new CustomEvent('profile-pane-saved', {
+ bubbles: true,
+ composed: true,
+ detail: { subjectUri: subject.value }
+ }))
+ await heading.updateComplete
+ expect(values()).toEqual([])
+
+ store.add(subject, ns.vcard('hasTelephone'), phone, doc)
+ store.add(phone, ns.vcard('value'), sym('tel:+123456789'), doc)
+ store.add(phone, ns.rdf('type'), ns.vcard('Work'), doc)
+ store.add(subject, ns.vcard('hasEmail'), email, doc)
+ store.add(email, ns.vcard('value'), sym('mailto:work@example.com'), doc)
+ store.add(email, ns.rdf('type'), ns.vcard('Work'), doc)
+ store.add(subject, ns.vcard('hasAddress'), address, doc)
+ store.add(address, ns.vcard('locality'), lit('Paris'), doc)
+ store.add(address, ns.vcard('country-name'), lit('France'), doc)
+ notifySaved()
+ await vi.waitFor(async () => {
+ await heading.updateComplete
+ expect(values()).toEqual(['Paris, France', '+123456789', 'work@example.com'])
+ })
+
+ store.removeMany(phone, ns.vcard('value'), null, doc)
+ store.add(phone, ns.vcard('value'), sym('tel:+987654321'), doc)
+ store.removeMany(email, ns.vcard('value'), null, doc)
+ store.add(email, ns.vcard('value'), sym('mailto:updated@example.com'), doc)
+ store.removeMany(address, ns.vcard('locality'), null, doc)
+ store.add(address, ns.vcard('locality'), lit('Lyon'), doc)
+ notifySaved()
+ await vi.waitFor(async () => {
+ await heading.updateComplete
+ expect(values()).toEqual(['Lyon, France', '+987654321', 'updated@example.com'])
+ })
+
+ store.removeMany(subject, ns.vcard('hasTelephone'), null, doc)
+ store.removeMany(subject, ns.vcard('hasEmail'), null, doc)
+ store.removeMany(subject, ns.vcard('hasAddress'), null, doc)
+ notifySaved()
+ await vi.waitFor(async () => {
+ await heading.updateComplete
+ expect(values()).toEqual([])
+ })
+ })
+
+ it.each(['owner', 'anonymous'] as const)('uses fresh saved data for the heading and editor in %s mode', async (viewerMode) => {
+ const subject = sym('https://saved-contacts.example/profile/card#me')
+ store.add(subject, ns.vcard('fn'), lit('Saved Contacts Test'), subject.doc())
+ store.add(subject, ns.vcard('hasEmail'), sym('mailto:old@example.com'), subject.doc())
+ getViewerModeMock.mockResolvedValue(viewerMode)
+ createHeadingEditDialogMock.mockReset().mockResolvedValue(undefined)
+
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ document.body.appendChild(mainContent)
+ window.history.replaceState({
+ paneName: 'profile',
+ paneUri: subject.value
+ }, '', '/')
+ initializeProfileHeading()
+ await refreshProfileHeading(true)
+ const heading = document.querySelector('solid-panes-profile-heading') as HTMLElement & {
+ profileData?: unknown
+ editContext?: ProfileHeadingEditContext
+ updateComplete: Promise
+ }
+ await heading.updateComplete
+ expect(heading.shadowRoot?.textContent).toContain('old@example.com')
+ const previousEditContext = heading.editContext
+ const profileData = {
+ entryNode: subject,
+ name: 'Saved Contacts Test',
+ primaryPhone: { valueNode: sym('tel:+123456789') },
+ primaryEmail: { valueNode: sym('mailto:new@example.com') },
+ location: 'Lyon, France',
+ jobTitle: 'Staff Engineer'
+ }
+
+ mainContent.dispatchEvent(new CustomEvent('profile-pane-saved', {
+ bubbles: true,
+ composed: true,
+ detail: {
+ subjectUri: subject.value,
+ profileData
+ }
+ }))
+ await heading.updateComplete
+ expect(heading.shadowRoot?.textContent).toContain('new@example.com')
+ expect(heading.shadowRoot?.textContent).toContain('+123456789')
+ expect(heading.shadowRoot?.textContent).toContain('Lyon, France')
+ expect(heading.shadowRoot?.querySelector('.role')?.textContent).toBe('Staff Engineer')
+ expect(heading.shadowRoot?.textContent).not.toContain('old@example.com')
+ expect(heading.profileData).toBe(profileData)
+ if (viewerMode === 'owner') {
+ expect(previousEditContext).toBeDefined()
+ expect(heading.editContext).not.toBe(previousEditContext)
+ expect(heading.editContext).toEqual({ ...previousEditContext, profileData })
+ expect(heading.editContext?.profileData).toBe(profileData)
+ expect(heading.editContext?.onSaved).toBe(previousEditContext?.onSaved)
+
+ heading.shadowRoot?.querySelector('.desktop-edit-button')?.click()
+ await vi.waitFor(() => expect(createHeadingEditDialogMock).toHaveBeenCalledOnce())
+ expect(createHeadingEditDialogMock).toHaveBeenCalledWith(
+ expect.any(Event), store, subject, profileData, viewerMode, expect.any(Function)
+ )
+ } else {
+ expect(heading.editContext).toBeUndefined()
+ expect(heading.shadowRoot?.querySelector('.desktop-edit-button')).toBeNull()
+ }
+ })
+
+ it('keeps another person\'s heading visible on their storage', async () => {
+ const subject = sym('https://another-profile-heading.example/profile/card#me')
+ const storage = sym('https://profile-heading.example/storage/')
+ const viewer = sym('https://viewer.example/profile/card#me')
+ store.add(subject, ns.vcard('fn'), lit('Another Person'), subject.doc())
+ podOf(subject.value, storage.value)
+
+ const currentUser = vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ document.body.appendChild(mainContent)
+ window.history.replaceState({
+ paneName: 'folder',
+ paneUri: storage.value
+ }, '', '/')
+
+ await refreshProfileHeading(true)
+
+ const mount = document.getElementById('profile-heading-mount')
+ const heading = mount?.querySelector('solid-panes-profile-heading') as (HTMLElement & {
+ profileData?: { name: string }
+ editContext?: unknown
+ compact?: boolean
+ updateComplete?: Promise
+ }) | null
+ await heading?.updateComplete
+
+ expect(mount?.hidden).toBe(false)
+ expect(heading?.profileData?.name).toBe('Another Person')
+ expect(heading?.editContext).toBeUndefined()
+ expect(heading?.compact).toBe(true)
+ expect(heading?.shadowRoot?.querySelector('.profile__heading-edit-action')).toBeNull()
+
+ currentUser.mockRestore()
+ })
+
+ it('hides another person\'s heading when the account menu opens the viewer\'s storage', async () => {
+ const subject = sym(`${window.location.origin}/other/profile/card#me`)
+ const viewer = sym(`${window.location.origin}/profile/card#me`)
+ const viewerStorage = `${window.location.origin}/storage/`
+ const ownStorages = [1, 2].map(index => `${window.location.origin}/other/storage-${index}/`)
+ podOf(subject.value, ...ownStorages)
+ const currentUser = vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ document.body.appendChild(mainContent)
+
+ try {
+ window.history.replaceState({ paneName: 'profile', paneUri: subject.value }, '', subject.value)
+ await refreshProfileHeading(true)
+ const mount = document.getElementById('profile-heading-mount')!
+ expect(mount.hidden).toBe(false)
+
+ window.history.replaceState({ paneName: 'folder', paneUri: viewerStorage, viaAccountMenu: true }, '', subject.value)
+ await refreshProfileHeading()
+
+ expect(window.location.href).toBe(subject.value)
+ expect(mount.hidden).toBe(true)
+ expect(mount.firstElementChild).toBeNull()
+
+ // The same storage opened another way is just a view of a page in their pod
+ window.history.replaceState({ paneName: 'folder', paneUri: viewerStorage }, '', subject.value)
+ await refreshProfileHeading()
+ expect(mount.hidden).toBe(false)
+
+ window.history.replaceState({ paneName: 'folder', paneUri: ownStorages[1] }, '', subject.value)
+ await refreshProfileHeading()
+ expect(mount.hidden).toBe(false)
+ expect(mount.firstElementChild).not.toBeNull()
+ } finally {
+ currentUser.mockRestore()
+ }
+ })
+
+ it('shows a compact heading for the owner on a container in someone else\'s pod', async () => {
+ const other = sym(`${window.location.origin}/other/profile/card#me`)
+ const viewer = sym(`${window.location.origin}/profile/card#me`)
+ store.add(other, ns.vcard('fn'), lit('Pod Owner'), other.doc())
+ podOf(other.value)
+ const currentUser = vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ document.body.appendChild(mainContent)
+
+ try {
+ for (const state of [{}, { paneName: 'folder' }, { paneName: 'source' }]) {
+ window.history.replaceState(state, '', '/other/container/')
+ await refreshProfileHeading(true)
+
+ const mount = document.getElementById('profile-heading-mount')!
+ const heading = mount.querySelector('solid-panes-profile-heading') as HTMLElement & {
+ profileData?: { name: string }
+ editContext?: unknown
+ friendSubject?: unknown
+ compact?: boolean
+ updateComplete: Promise
+ }
+ await heading.updateComplete
+ expect(mount.hidden).toBe(false)
+ expect(heading.profileData?.name).toBe('Pod Owner')
+ expect(heading.compact).toBe(true)
+ expect(heading.editContext).toBeUndefined()
+ expect(heading.friendSubject).toBeUndefined()
+ }
+ } finally {
+ currentUser.mockRestore()
+ }
+ })
+
+ it('shows the heading even when the navbar does not exist yet', async () => {
+ const other = sym(`${window.location.origin}/other/profile/card#me`)
+ store.add(other, ns.vcard('fn'), lit('Early Owner'), other.doc())
+ podOf(other.value)
+ const currentUser = vi.spyOn(authn, 'currentUser').mockReturnValue(sym(`${window.location.origin}/profile/card#me`))
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ document.body.appendChild(mainContent)
+ window.history.replaceState({}, '', '/other/container/')
+
+ try {
+ expect(document.querySelector('solid-panes-navbar')).toBeNull()
+ await refreshProfileHeading(true)
+
+ expect(document.getElementById('profile-heading-mount')?.hidden).toBe(false)
+ } finally {
+ currentUser.mockRestore()
+ }
+ })
+
+ it.each([
+ ['the viewer\'s own pod', {}, 'viewer'],
+ ['an account-menu view', { paneName: 'home', viaAccountMenu: true }, 'other'],
+ ['an account-menu storage view', { paneName: 'folder', paneUri: 'https://x.example/s/', viaAccountMenu: true }, 'other'],
+ ['a pod without a known owner', {}, 'unknown']
+ ])('hides the heading on a container for %s', async (_name, state, owner) => {
+ const other = sym(`${window.location.origin}/other/profile/card#me`)
+ const viewer = sym(`${window.location.origin}/profile/card#me`)
+ const currentUser = vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ document.body.appendChild(mainContent)
+ podOf(owner === 'viewer' ? viewer.value : owner === 'other' ? other.value : undefined)
+
+ try {
+ window.history.replaceState(state, '', '/other/container/')
+ await refreshProfileHeading(true)
+
+ expect(document.getElementById('profile-heading-mount')?.hidden).toBe(true)
+ } finally {
+ currentUser.mockRestore()
+ }
+ })
+
+ it('hides the mount for panes other than profile and social', async () => {
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ document.body.appendChild(mainContent)
+ window.history.replaceState({ paneName: 'folder' }, '', '/')
+
+ await refreshProfileHeading()
+
+ expect(document.getElementById('profile-heading-mount')?.hidden).toBe(true)
+ })
+
+ it.each(['profile', 'social'])('animates appearance on %s from a pane without a heading', async (paneName) => {
+ const subject = sym(`https://show-${paneName}.example/profile/card#me`)
+ getViewerModeMock.mockResolvedValue('owner')
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ document.body.appendChild(mainContent)
+ window.history.replaceState({ paneName: 'folder' }, '', '/')
+ await refreshProfileHeading(true)
+ const mount = document.getElementById('profile-heading-mount')!
+ let complete!: () => void
+ const finished = new Promise(resolve => { complete = resolve })
+ const cancel = vi.fn()
+ const animate = vi.fn(() => ({ finished, cancel }))
+ Object.defineProperty(mount, 'animate', { value: animate })
+ window.history.replaceState({ paneName, paneUri: subject.value }, '', '/')
+ await refreshProfileHeading()
+ await refreshProfileHeading()
+
+ expect(animate).toHaveBeenCalledOnce()
+ expect(animate).toHaveBeenCalledWith([
+ expect.objectContaining({ height: '0px', opacity: 0, marginTop: '0px', marginBottom: '0px' }),
+ expect.objectContaining({ opacity: 1, marginTop: '18px', marginBottom: '20px' })
+ ], { duration: 300, easing: 'ease', fill: 'both' })
+ const heading = mount.firstElementChild as HTMLElement & { compact: boolean }
+ expect(heading.compact).toBe(paneName !== 'profile')
+ expect(heading.shadowRoot?.querySelector('.profile-heading')).not.toBeNull()
+ expect(mount.hidden).toBe(false)
+ expect(mount.inert).toBe(false)
+ complete()
+ await finished
+ expect(cancel).toHaveBeenCalledOnce()
+ expect(mount.firstElementChild).toBe(heading)
+ expect(mount.hidden).toBe(false)
+ })
+
+ it('cancels appearance when navigating away before it finishes', async () => {
+ const subject = sym('https://cancel-show.example/profile/card#me')
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ document.body.appendChild(mainContent)
+ window.history.replaceState({ paneName: 'folder' }, '', '/')
+ await refreshProfileHeading(true)
+ const mount = document.getElementById('profile-heading-mount')!
+ let completeShow!: () => void
+ let completeHide!: () => void
+ const shown = new Promise(resolve => { completeShow = resolve })
+ const hidden = new Promise(resolve => { completeHide = resolve })
+ const cancelShow = vi.fn()
+ const animate = vi.fn()
+ .mockReturnValueOnce({ finished: shown, cancel: cancelShow })
+ .mockReturnValueOnce({ finished: hidden, cancel: vi.fn() })
+ Object.defineProperty(mount, 'animate', { value: animate })
+ window.history.replaceState({ paneName: 'social', paneUri: subject.value }, '', '/')
+ await refreshProfileHeading()
+ window.history.replaceState({ paneName: 'folder' }, '', '/')
+ await refreshProfileHeading()
+ expect(cancelShow).toHaveBeenCalledOnce()
+ expect(animate).toHaveBeenCalledTimes(2)
+ completeShow()
+ await shown
+ expect(mount.hidden).toBe(false)
+ completeHide()
+ await hidden
+ expect(mount.hidden).toBe(true)
+ expect(mount.firstElementChild).toBeNull()
+ })
+
+ it.each(['profile', 'social'])('animates disappearance from %s before removing the heading', async (paneName) => {
+ const subject = sym(`https://hide-${paneName}.example/profile/card#me`)
+ getViewerModeMock.mockResolvedValue('owner')
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ document.body.appendChild(mainContent)
+ window.history.replaceState({ paneName, paneUri: subject.value }, '', '/')
+ await refreshProfileHeading(true)
+
+ const mount = document.getElementById('profile-heading-mount')!
+ const heading = mount.firstElementChild
+ let complete!: () => void
+ const finished = new Promise(resolve => { complete = resolve })
+ const cancel = vi.fn()
+ const animate = vi.fn(() => ({ finished, cancel }))
+ Object.defineProperty(mount, 'animate', { value: animate })
+ window.history.replaceState({ paneName: 'folder' }, '', '/')
+ await refreshProfileHeading()
+ await refreshProfileHeading()
+
+ expect(animate).toHaveBeenCalledOnce()
+ expect(animate).toHaveBeenCalledWith(expect.arrayContaining([
+ expect.objectContaining({ height: '0px', opacity: 0, marginTop: '0px', marginBottom: '0px' })
+ ]), { duration: 300, easing: 'ease', fill: 'forwards' })
+ expect(mount.hidden).toBe(false)
+ expect(mount.firstElementChild).toBe(heading)
+ expect(mount.inert).toBe(true)
+ expect(mount).toHaveAttribute('aria-hidden', 'true')
+
+ complete()
+ await finished
+ expect(mount.hidden).toBe(true)
+ expect(mount.firstElementChild).toBeNull()
+ expect(cancel).toHaveBeenCalledOnce()
+ })
+
+ it('cancels disappearance when navigating back before it finishes', async () => {
+ const subject = sym('https://cancel-hide.example/profile/card#me')
+ getViewerModeMock.mockResolvedValue('owner')
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ document.body.appendChild(mainContent)
+ window.history.replaceState({ paneName: 'profile', paneUri: subject.value }, '', '/')
+ await refreshProfileHeading(true)
+
+ const mount = document.getElementById('profile-heading-mount')!
+ const heading = mount.firstElementChild
+ let complete!: () => void
+ const finished = new Promise(resolve => { complete = resolve })
+ const cancel = vi.fn()
+ Object.defineProperty(mount, 'animate', { value: vi.fn(() => ({ finished, cancel })) })
+ window.history.replaceState({ paneName: 'folder' }, '', '/')
+ await refreshProfileHeading()
+ window.history.replaceState({ paneName: 'social', paneUri: subject.value }, '', '/')
+ await refreshProfileHeading()
+ expect(cancel).toHaveBeenCalledOnce()
+ expect(mount.inert).toBe(false)
+ expect(mount).not.toHaveAttribute('aria-hidden')
+
+ complete()
+ await finished
+ expect(mount.hidden).toBe(false)
+ expect(mount.firstElementChild).toBe(heading)
+ })
+
+ it('skips appearance and disappearance animations when reduced motion is requested', async () => {
+ const subject = sym('https://reduced-hide.example/profile/card#me')
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ document.body.appendChild(mainContent)
+ window.history.replaceState({ paneName: 'social', paneUri: subject.value }, '', '/')
+ await refreshProfileHeading(true)
+ const mount = document.getElementById('profile-heading-mount')!
+ const animate = vi.fn()
+ Object.defineProperty(mount, 'animate', { value: animate })
+ vi.stubGlobal('matchMedia', vi.fn(() => ({
+ matches: true,
+ media: '(prefers-reduced-motion: reduce)',
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn()
+ })))
+ try {
+ window.history.replaceState({ paneName: 'folder' }, '', '/')
+ await refreshProfileHeading()
+ expect(animate).not.toHaveBeenCalled()
+ expect(mount.hidden).toBe(true)
+ expect(mount.firstElementChild).toBeNull()
+ window.history.replaceState({ paneName: 'social', paneUri: subject.value }, '', '/')
+ await refreshProfileHeading()
+ expect(animate).not.toHaveBeenCalled()
+ expect(mount.hidden).toBe(false)
+ expect(mount.firstElementChild).not.toBeNull()
+ } finally {
+ vi.unstubAllGlobals()
+ }
+ })
+
+ describe('following navigations', () => {
+ async function setup () {
+ const subject = sym('https://announced.example/profile/card#me')
+ store.add(subject, ns.vcard('fn'), lit('Announced Person'), subject.doc())
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ document.body.appendChild(mainContent)
+ initializeProfileHeading()
+ await refreshProfileHeading(true)
+ const mount = document.getElementById('profile-heading-mount')!
+ expect(mount.hidden).toBe(true)
+ return { subject, mainContent, mount }
+ }
+
+ it('refreshes when a navigation is announced, whoever made it', async () => {
+ const { subject, mount } = await setup()
+
+ window.history.replaceState({ paneName: 'profile', paneUri: subject.value }, '', '/')
+ announceNavigation(document)
+
+ await vi.waitFor(() => expect(mount.hidden).toBe(false))
+ const heading = mount.querySelector('solid-panes-profile-heading') as HTMLElement & { profileData?: { name: string } }
+ expect(heading.profileData?.name).toBe('Announced Person')
+ })
+
+ it('does not look at what the page has been changed to without a navigation', async () => {
+ const { subject, mainContent, mount } = await setup()
+ window.history.replaceState({ paneName: 'profile', paneUri: subject.value }, '', '/')
+ announceNavigation(document)
+ await vi.waitFor(() => expect(mount.hidden).toBe(false))
+
+ window.history.replaceState({}, '', '/')
+ mainContent.appendChild(document.createElement('div'))
+ window.dispatchEvent(new PopStateEvent('popstate'))
+ await new Promise(resolve => setTimeout(resolve, 20))
+
+ expect(mount.hidden).toBe(false)
+ })
+
+ it('places the heading above a navbar that was added after it', async () => {
+ const { mainContent, mount } = await setup()
+ const navbar = document.createElement('solid-panes-navbar')
+ mainContent.insertBefore(navbar, mainContent.firstChild)
+ expect(mount.nextElementSibling).not.toBe(navbar)
+
+ placeProfileHeading()
+
+ expect(mount.nextElementSibling).toBe(navbar)
+ expect(mainContent.firstElementChild).toBe(mount)
+ })
+ })
+})
diff --git a/test/unit/mainPage/profileHeadingData.test.ts b/test/unit/mainPage/profileHeadingData.test.ts
new file mode 100644
index 00000000..5a684fd9
--- /dev/null
+++ b/test/unit/mainPage/profileHeadingData.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it, vi } from 'vitest'
+import { authn, store } from 'solid-logic'
+import { lit, sym } from 'rdflib'
+import { ns } from 'solid-ui'
+import { getViewerMode, presentProfile } from '../../../src/components/profile-heading/profileHeadingData'
+
+describe('local profile heading presentation', () => {
+ it('presents profile and contact data from the local RDF store', () => {
+ const subject = sym('https://local-profile-heading.example/profile/card#me')
+ const doc = subject.doc()
+ const email = sym('https://local-profile-heading.example/profile/card#email')
+ const address = sym('https://local-profile-heading.example/profile/card#address')
+ const membership = sym('https://local-profile-heading.example/profile/card#role')
+
+ store.add(subject, ns.vcard('fn'), lit('Local Heading'), doc)
+ store.add(subject, ns.vcard('nickname'), lit('Local Nickname'), doc)
+ store.add(subject, ns.vcard('bday'), lit('2000-01-02'), doc)
+ store.add(subject, ns.solid('preferredSubjectPronoun'), lit('they'), doc)
+ store.add(subject, ns.solid('preferredObjectPronoun'), lit('them'), doc)
+ store.add(subject, ns.vcard('hasEmail'), email, doc)
+ store.add(email, ns.vcard('value'), lit('person@example.com'), doc)
+ store.add(email, ns.rdf('type'), ns.vcard('Work'), doc)
+ store.add(subject, ns.vcard('hasAddress'), address, doc)
+ store.add(address, ns.vcard('locality'), lit('Example City'), doc)
+ store.add(address, ns.vcard('country-name'), lit('Example Country'), doc)
+ store.add(membership, ns.org('member'), subject, doc)
+ store.add(membership, ns.rdf('type'), ns.solid('CurrentRole'), doc)
+ store.add(membership, ns.vcard('role'), lit('Engineer'), doc)
+
+ const profile = presentProfile(subject, store)
+
+ expect(profile.name).toBe('Local Heading')
+ expect(profile.nickname).toBe('Local Nickname')
+ expect(profile.dateOfBirth).toBe('2000-01-02')
+ expect(profile.pronouns).toBe('they/them')
+ expect(profile.jobTitle).toBe('Engineer')
+ expect(profile.primaryEmail?.valueNode.value).toBe('person@example.com')
+ expect(profile.location).toBe('Example City, Example Country')
+ expect(profile.entryNode).toBe(subject)
+ })
+
+ it('determines anonymous and authenticated viewing modes locally', async () => {
+ const subject = sym('https://local-viewer-mode.example/profile/card#me')
+ const currentUser = vi.spyOn(authn, 'currentUser')
+
+ try {
+ currentUser.mockReturnValue(null)
+ await expect(getViewerMode(subject)).resolves.toBe('anonymous')
+
+ currentUser.mockReturnValue(sym('https://another-user.example/profile/card#me'))
+ await expect(getViewerMode(subject)).resolves.toBe('authenticated')
+ } finally {
+ currentUser.mockRestore()
+ }
+ })
+})
diff --git a/test/unit/mainPage/profileHeadingEditor.test.ts b/test/unit/mainPage/profileHeadingEditor.test.ts
new file mode 100644
index 00000000..c7e8c664
--- /dev/null
+++ b/test/unit/mainPage/profileHeadingEditor.test.ts
@@ -0,0 +1,111 @@
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
+import { authn, store } from 'solid-logic'
+import { lit, sym } from 'rdflib'
+import { ns } from 'solid-ui'
+import { createHeadingEditDialog, type ProfileDetails } from '../../../src/components/profile-heading/editor'
+import { refreshProfileHeading } from '../../../src/mainPage/profileHeading'
+import {
+ getSharedDialogCancelButton,
+ getSharedDialogSaveButton
+} from '../../../src/components/profile-heading/editor/ui/dialog'
+
+const attachInternalsDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'attachInternals')
+
+vi.mock('../../../src/utils/webIdUtils', () => ({
+ loadProfileFromURI: async (subject: ReturnType) => subject
+}))
+
+beforeAll(() => {
+ Object.defineProperty(HTMLElement.prototype, 'attachInternals', {
+ configurable: true,
+ value: () => ({
+ setFormValue: () => undefined,
+ setValidity: () => undefined,
+ checkValidity: () => true,
+ reportValidity: () => true,
+ validity: {},
+ validationMessage: '',
+ willValidate: false,
+ states: new Set()
+ })
+ })
+})
+
+afterEach(() => {
+ document.body.replaceChildren()
+ window.history.replaceState({}, '', '/')
+ vi.restoreAllMocks()
+})
+
+afterAll(() => {
+ if (attachInternalsDescriptor) {
+ Object.defineProperty(HTMLElement.prototype, 'attachInternals', attachInternalsDescriptor)
+ } else {
+ Reflect.deleteProperty(HTMLElement.prototype, 'attachInternals')
+ }
+})
+
+describe('local profile heading editor', () => {
+ it('opens the local dialog when the owner clicks Edit on the mounted heading', async () => {
+ const subject = sym('https://local-heading-editor.example/profile/card#owner')
+ store.add(subject, ns.vcard('fn'), lit('Heading Owner'), subject.doc())
+ vi.spyOn(authn, 'currentUser').mockReturnValue(subject)
+ vi.spyOn(store.updater, 'editable').mockReturnValue(true)
+ const consoleError = vi.spyOn(console, 'error')
+
+ const mainContent = document.createElement('main')
+ mainContent.id = 'MainContent'
+ document.body.appendChild(mainContent)
+ window.history.replaceState({ paneName: 'profile', paneUri: subject.value }, '', '/')
+ await refreshProfileHeading(true)
+
+ const heading = document.querySelector }>('solid-panes-profile-heading')
+ await heading?.updateComplete
+ const editButton = heading?.shadowRoot?.querySelector('.desktop-edit-button')
+ expect(editButton).not.toBeNull()
+ editButton?.click()
+
+ await vi.waitFor(() => {
+ expect(document.querySelector('#profile-modal')).toHaveAttribute('open')
+ expect(document.querySelector('input[name="name"]')?.value).toBe('Heading Owner')
+ })
+ expect(consoleError).not.toHaveBeenCalled()
+
+ getSharedDialogCancelButton(document)?.click()
+ await vi.waitFor(() => {
+ expect(document.querySelector('#profile-modal')).not.toHaveAttribute('open')
+ })
+ })
+
+ it('validates phone input and closes through the local dialog controls', async () => {
+ const subject = sym('https://local-heading-editor.example/profile/card#me')
+ const profileData: ProfileDetails = {
+ entryNode: subject,
+ name: 'Jane Doe'
+ }
+ const resultPromise = createHeadingEditDialog(
+ new MouseEvent('click', { bubbles: true, composed: true }),
+ store,
+ subject,
+ profileData,
+ 'owner'
+ )
+
+ await vi.waitFor(() => {
+ expect(document.querySelector('input[name="phone-value"]')).not.toBeNull()
+ })
+
+ const phoneInput = document.querySelector('input[name="phone-value"]')!
+ phoneInput.value = '555 123 4567'
+ phoneInput.dispatchEvent(new Event('input', { bubbles: true }))
+ getSharedDialogSaveButton(document)?.click()
+
+ await vi.waitFor(() => {
+ expect(document.querySelector('#modal-error')?.textContent)
+ .toBe('Phone Number 1 should contain only numbers.')
+ })
+
+ getSharedDialogCancelButton(document)?.click()
+ await resultPromise
+ })
+})
diff --git a/test/unit/mainPage/profileHeadingFriends.test.ts b/test/unit/mainPage/profileHeadingFriends.test.ts
new file mode 100644
index 00000000..5ef31ae5
--- /dev/null
+++ b/test/unit/mainPage/profileHeadingFriends.test.ts
@@ -0,0 +1,269 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { authn, store } from 'solid-logic'
+import { lit, st, sym } from 'rdflib'
+import { ns } from 'solid-ui'
+import ProfileHeading from '../../../src/components/profile-heading/ProfileHeading'
+import { refreshProfileHeading } from '../../../src/mainPage/profileHeading'
+
+vi.mock('../../../src/utils/webIdUtils', () => ({
+ loadProfileFromURI: async (subject: ReturnType) => subject
+}))
+
+const viewer = sym('https://friends-viewer.example/profile/card#me')
+const subject = sym('https://friends-subject.example/profile/card#me')
+
+beforeEach(() => {
+ vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
+ vi.spyOn(store.fetcher, 'load').mockResolvedValue(new Response())
+ vi.spyOn(store, 'whether').mockReturnValue(0)
+ vi.spyOn(store.updater, 'update').mockResolvedValue(undefined)
+})
+
+afterEach(() => {
+ document.body.replaceChildren()
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+ window.history.replaceState({}, '', '/')
+})
+
+async function createHeading (friendSubject = subject) {
+ const heading = document.createElement('solid-panes-profile-heading') as ProfileHeading
+ heading.profileData = { name: 'Friend Test' }
+ heading.friendSubject = friendSubject
+ document.body.appendChild(heading)
+ await vi.waitFor(() => expect(heading.friendState).not.toBe('loading'))
+ await heading.updateComplete
+ return heading
+}
+
+function button (heading: ProfileHeading) {
+ return heading.shadowRoot?.querySelector('.profile__btn-friends')
+}
+
+describe('profile heading friends', () => {
+ it.each(['success', 'error'])('dismisses %s feedback after exactly 10 seconds', async (result) => {
+ const heading = await createHeading()
+ if (result === 'error') {
+ vi.mocked(store.updater.update).mockRejectedValueOnce(new Error('Write denied'))
+ }
+ vi.useFakeTimers()
+ button(heading)?.click()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(heading.shadowRoot?.querySelector('.friend-message')).not.toBeNull()
+ await vi.advanceTimersByTimeAsync(9999)
+ expect(heading.shadowRoot?.querySelector('.friend-message')).not.toBeNull()
+ await vi.advanceTimersByTimeAsync(1)
+ expect(heading.shadowRoot?.querySelector('.friend-message')).toBeNull()
+ expect(heading.friendState).toBe(result === 'success' ? 'exists' : 'error')
+ })
+
+ it('restarts the dismissal timer for new feedback', async () => {
+ const heading = await createHeading()
+ vi.mocked(store.updater.update).mockRejectedValueOnce(new Error('Write denied'))
+ vi.useFakeTimers()
+ button(heading)?.click()
+ await vi.advanceTimersByTimeAsync(5000)
+ button(heading)?.click()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(heading.friendMessage).toBe('Friend was added!')
+ await vi.advanceTimersByTimeAsync(5000)
+ expect(heading.friendMessage).toBe('Friend was added!')
+ await vi.advanceTimersByTimeAsync(5000)
+ expect(heading.friendMessage).toBe('')
+ })
+
+ it('clears the dismissal timer when disconnected', async () => {
+ const heading = await createHeading()
+ vi.useFakeTimers()
+ button(heading)?.click()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(vi.getTimerCount()).toBe(1)
+ heading.remove()
+ expect(vi.getTimerCount()).toBe(0)
+ expect(heading.friendMessage).toBe('')
+ })
+
+ it('ignores a pending friend check after disconnection', async () => {
+ let finish: (() => void) | undefined
+ vi.mocked(store.fetcher.load).mockImplementationOnce(() => new Promise(resolve => {
+ finish = () => resolve(new Response())
+ }))
+ const heading = document.createElement('solid-panes-profile-heading') as ProfileHeading
+ heading.profileData = { name: 'Pending' }
+ heading.friendSubject = subject
+ document.body.appendChild(heading)
+ await heading.updateComplete
+ heading.remove()
+ vi.mocked(store.whether).mockReturnValue(1)
+ finish?.()
+ await new Promise(resolve => setTimeout(resolve, 0))
+ expect(heading.friendState).toBe('loading')
+ })
+
+ it('ignores a pending friend addition after disconnection', async () => {
+ const heading = await createHeading()
+ let finish: (() => void) | undefined
+ vi.mocked(store.updater.update).mockImplementationOnce(() => new Promise(resolve => {
+ finish = () => resolve(undefined)
+ }))
+ vi.useFakeTimers()
+ button(heading)?.click()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(heading.friendState).toBe('saving')
+ heading.remove()
+ finish?.()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(heading.friendState).toBe('saving')
+ expect(heading.friendMessage).toBe('')
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it('rechecks an interrupted friend request when reconnected', async () => {
+ const heading = await createHeading()
+ let finish: (() => void) | undefined
+ vi.mocked(store.updater.update).mockImplementationOnce(() => new Promise(resolve => {
+ finish = () => resolve(undefined)
+ }))
+ button(heading)?.click()
+ await vi.waitFor(() => expect(store.updater.update).toHaveBeenCalled())
+ heading.remove()
+ vi.mocked(store.whether).mockReturnValue(1)
+ finish?.()
+ document.body.appendChild(heading)
+ await vi.waitFor(() => expect(heading.friendState).toBe('exists'))
+ expect(heading.friendMessage).toBe('')
+ })
+
+ it('checks the viewer profile and adds foaf:knows in that document', async () => {
+ const heading = await createHeading()
+ expect(store.fetcher.load).toHaveBeenCalledWith(viewer)
+ expect(button(heading)?.textContent).toBe('Add as friend')
+ expect(button(heading)?.querySelector('icon-lucide-user-round-plus')).toHaveAttribute('slot', 'icon')
+ button(heading)?.click()
+ button(heading)?.click()
+ await vi.waitFor(() => expect(heading.friendState).toBe('exists'))
+ await heading.updateComplete
+ expect(button(heading)?.querySelector('icon-lucide-user-round-plus')).toBeNull()
+ expect(store.updater.update).toHaveBeenCalledExactlyOnceWith(
+ [], [st(viewer, ns.foaf('knows'), subject, viewer.doc())]
+ )
+ expect(button(heading)).toHaveAttribute('disabled')
+ expect(button(heading)?.textContent).toBe('Already a friend')
+ const message = heading.shadowRoot?.querySelector('.friend-message')
+ expect(message).toHaveAttribute('role', 'status')
+ expect(message?.textContent).toContain('Friend was added!')
+ expect(heading.shadowRoot?.activeElement).toBe(message)
+ })
+
+ it('disables the button for an existing friend', async () => {
+ vi.mocked(store.whether).mockReturnValue(1)
+ const heading = await createHeading()
+ expect(button(heading)).toHaveAttribute('disabled')
+ expect(button(heading)?.textContent).toBe('Already a friend')
+ expect(button(heading)?.querySelector('icon-lucide-user-round-plus')).toBeNull()
+ button(heading)?.dispatchEvent(new Event('click'))
+ expect(store.updater.update).not.toHaveBeenCalled()
+ })
+
+ it('shows update errors and supports retry without stale feedback', async () => {
+ vi.mocked(store.updater.update).mockRejectedValueOnce(new Error('Write denied'))
+ const heading = await createHeading()
+ button(heading)?.click()
+ await vi.waitFor(() => expect(heading.friendState).toBe('error'))
+ await heading.updateComplete
+ expect(heading.shadowRoot?.querySelector('[role="alert"]')?.textContent).toContain('Write denied')
+ button(heading)?.click()
+ await vi.waitFor(() => expect(heading.friendState).toBe('exists'))
+ await heading.updateComplete
+ expect(heading.shadowRoot?.querySelector('[role="alert"]')).toBeNull()
+ expect(heading.friendMessage).toBe('Friend was added!')
+ })
+
+ it('shows load errors rather than enabling an unchecked action', async () => {
+ vi.mocked(store.fetcher.load).mockRejectedValueOnce(new Error('Profile unavailable'))
+ const heading = await createHeading()
+ expect(heading.friendState).toBe('error')
+ expect(heading.shadowRoot?.querySelector('[role="alert"]')?.textContent).toContain('Profile unavailable')
+ expect(store.updater.update).not.toHaveBeenCalled()
+ })
+
+ it('maps unauthenticated update errors to the original login message', async () => {
+ vi.mocked(store.updater.update).mockRejectedValueOnce(new Error('Unauthenticated'))
+ const heading = await createHeading()
+ button(heading)?.click()
+ await vi.waitFor(() => expect(heading.friendState).toBe('error'))
+ expect(heading.friendMessage).toBe('Current user not found! Not logged in?')
+ })
+
+ it('rejects a friendship that was added after the initial check', async () => {
+ const heading = await createHeading()
+ vi.mocked(store.whether).mockReturnValue(1)
+ button(heading)?.click()
+ await vi.waitFor(() => expect(heading.friendState).toBe('error'))
+ expect(heading.friendMessage).toBe('Friend already exists')
+ expect(store.updater.update).not.toHaveBeenCalled()
+ })
+
+ it('does not write if the viewer logs out while loading their profile', async () => {
+ const heading = await createHeading()
+ vi.mocked(store.fetcher.load).mockImplementationOnce(async () => {
+ vi.mocked(authn.currentUser).mockReturnValue(null)
+ return new Response()
+ })
+ button(heading)?.click()
+ await vi.waitFor(() => expect(heading.friendState).toBe('error'))
+ expect(store.updater.update).not.toHaveBeenCalled()
+ })
+
+ it('ignores a stale friend check after navigation', async () => {
+ let finish: (() => void) | undefined
+ vi.mocked(store.fetcher.load).mockImplementationOnce(() => new Promise(resolve => {
+ finish = () => resolve(new Response())
+ }))
+ const heading = document.createElement('solid-panes-profile-heading') as ProfileHeading
+ heading.profileData = { name: 'First' }
+ heading.friendSubject = subject
+ document.body.appendChild(heading)
+ await heading.updateComplete
+ heading.friendSubject = sym('https://second-friend.example/#me')
+ await vi.waitFor(() => expect(heading.friendState).toBe('available'))
+ vi.mocked(store.whether).mockReturnValue(1)
+ finish?.()
+ await Promise.resolve()
+ await heading.updateComplete
+ expect(heading.friendState).toBe('available')
+ })
+
+ it('places friends before Edit when both actions are provided', async () => {
+ const heading = await createHeading()
+ heading.editContext = {
+ subject,
+ profileData: { name: 'Friend Test', entryNode: subject },
+ viewerMode: 'owner',
+ onSaved: vi.fn()
+ }
+ await heading.updateComplete
+ expect(heading.shadowRoot?.querySelector('.profile__actions solid-ui-button')).toBe(button(heading))
+ expect(heading.shadowRoot?.querySelector('.desktop-edit-button')).not.toBeNull()
+ })
+
+ it.each([
+ ['profile', viewer, subject, true],
+ ['profile', viewer, viewer, false],
+ ['profile', null, subject, false],
+ ['social', viewer, subject, false],
+ ['profile', viewer, sym('https://new-friend.example/#me'), true]
+ ])('gates the action for pane %s, viewer %s, subject %s', async (paneName, me, viewed, visible) => {
+ vi.mocked(authn.currentUser).mockReturnValue(me)
+ store.add(viewed, ns.vcard('fn'), lit('Gating Test'), viewed.doc())
+ const main = document.createElement('main')
+ main.id = 'MainContent'
+ document.body.appendChild(main)
+ window.history.replaceState({ paneName, paneUri: viewed.value }, '', '/')
+ await refreshProfileHeading(true)
+ const heading = main.querySelector('solid-panes-profile-heading')
+ await heading?.updateComplete
+ expect(Boolean(button(heading!))).toBe(visible)
+ expect(Boolean(heading?.friendSubject)).toBe(visible)
+ })
+})
diff --git a/test/unit/mainPage/profileHeadingImage.test.ts b/test/unit/mainPage/profileHeadingImage.test.ts
new file mode 100644
index 00000000..08a29276
--- /dev/null
+++ b/test/unit/mainPage/profileHeadingImage.test.ts
@@ -0,0 +1,28 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import type ProfileHeading from '../../../src/components/profile-heading/ProfileHeading'
+import '../../../src/components/profile-heading/ProfileHeading'
+
+afterEach(() => {
+ document.body.replaceChildren()
+})
+
+describe('profile heading image fallback', () => {
+ it('retries an image source that failed earlier once the source changes and returns', async () => {
+ const heading = document.createElement('solid-panes-profile-heading') as ProfileHeading
+ heading.profileData = { name: 'Image Test', imageSrc: 'https://example.org/a.png' }
+ document.body.appendChild(heading)
+ await heading.updateComplete
+
+ heading.shadowRoot?.querySelector('img')?.dispatchEvent(new Event('error'))
+ await heading.updateComplete
+ expect(heading.shadowRoot?.querySelector('img')).toBeNull()
+
+ heading.profileData = { name: 'Image Test', imageSrc: 'https://example.org/b.png' }
+ await heading.updateComplete
+ expect(heading.shadowRoot?.querySelector('img')?.getAttribute('src')).toBe('https://example.org/b.png')
+
+ heading.profileData = { name: 'Image Test', imageSrc: 'https://example.org/a.png' }
+ await heading.updateComplete
+ expect(heading.shadowRoot?.querySelector('img')?.getAttribute('src')).toBe('https://example.org/a.png')
+ })
+})
diff --git a/test/unit/mainPage/reload.test.ts b/test/unit/mainPage/reload.test.ts
new file mode 100644
index 00000000..9aaf5e5d
--- /dev/null
+++ b/test/unit/mainPage/reload.test.ts
@@ -0,0 +1,241 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { authn, authSession, store } from 'solid-logic'
+import { lit, sym } from 'rdflib'
+import { ns } from 'solid-ui'
+
+const { getViewerModeMock, loadProfile, outlinerHolder, byName } = vi.hoisted(() => ({
+ getViewerModeMock: vi.fn(),
+ loadProfile: vi.fn(),
+ outlinerHolder: { current: undefined as undefined | { GotoSubject: (...args: unknown[]) => void } },
+ byName: (name: string) => ({
+ name,
+ icon: '',
+ label: () => name,
+ render: (subject: { value: string }) => {
+ const div = document.createElement('div')
+ div.textContent = `${name} pane for ${subject.value}`
+ return div
+ }
+ })
+}))
+
+vi.mock('pane-registry', () => ({ byName }))
+
+vi.mock('../../../src/components/profile-heading/profileHeadingData', async (importOriginal) => ({
+ ...await importOriginal(),
+ getViewerMode: getViewerModeMock
+}))
+vi.mock('../../../src/utils/webIdUtils', async (importOriginal) => ({
+ ...await importOriginal(),
+ loadProfileFromURI: loadProfile
+}))
+vi.mock('../../../src/utils/podUtils', async (importOriginal) => ({
+ ...await importOriginal(),
+ getPodStorages: async () => [],
+ loadContainerRepresentation: async () => undefined
+}))
+vi.mock('../../../src/mainPage/header', () => ({ createHeader: vi.fn() }))
+vi.mock('../../../src/index', async () => {
+ const { default: OutlineManager } = await import('../../../src/outline/manager')
+ const paneRegistry = { list: [], byName }
+ return {
+ getOutliner: () => {
+ const outliner = new OutlineManager({ dom: document, session: { store, paneRegistry } })
+ outlinerHolder.current = outliner
+ return outliner
+ }
+ }
+})
+
+import { initMainPage } from '../../../src/mainPage'
+import { resetPodContexts } from '../../../src/navigation/podContext'
+
+// Without a profile of its own, the lookup answers with the URL it was asked about
+const noProfile = async (subject: ReturnType) => subject
+
+beforeEach(() => {
+ loadProfile.mockImplementation(noProfile)
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ resetPodContexts()
+ document.body.replaceChildren()
+ window.history.replaceState({}, '', '/')
+})
+
+describe('page reload of a WebID', () => {
+ it.each([
+ ['after using the account menu', (webId: string) => ({
+ paneName: 'folder',
+ paneUri: `${window.location.origin}/storage/`,
+ viaAccountMenu: true
+ })],
+ ['after selecting Friends in the navbar', (webId: string) => ({ paneName: 'social', paneUri: webId })]
+ ])('shows the profile with the profile selected in the navbar and its heading %s', async (_name, savedState) => {
+ const viewer = sym(`${window.location.origin}/profile/card#me`)
+ const other = sym(`${window.location.origin}/other/profile/card#me`)
+ store.add(other, ns.vcard('fn'), lit('Someone Else'), other.doc())
+ vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
+ vi.spyOn(authn, 'checkUser').mockResolvedValue(viewer)
+ vi.spyOn(authSession.events, 'on').mockImplementation(() => {})
+ getViewerModeMock.mockResolvedValue('visitor')
+ window.history.replaceState(savedState(other.value), '', other.value)
+
+ await initMainPage(store)
+ const mount = document.getElementById('profile-heading-mount')!
+ await vi.waitFor(() => expect(mount.querySelector('solid-panes-profile-heading')).not.toBeNull())
+ const heading = mount.querySelector('solid-panes-profile-heading') as HTMLElement & {
+ profileData?: { name: string }
+ }
+ const navbar = document.querySelector('solid-panes-navbar') as HTMLElement & {
+ navbarItems: Array<{ paneName?: string, selected?: boolean }>
+ }
+
+ expect(mount.nextElementSibling).toBe(navbar)
+ expect(window.location.href).toBe(other.value)
+ expect(window.history.state).toMatchObject({ paneName: 'profile', paneUri: other.value })
+ expect(window.history.state).not.toHaveProperty('viaAccountMenu')
+ expect(mount.hidden).toBe(false)
+ expect(heading.profileData?.name).toBe('Someone Else')
+ expect(navbar).not.toHaveClass('navbar--hidden')
+ expect(navbar.navbarItems.filter(item => item.selected).map(item => item.paneName)).toEqual(['profile'])
+ })
+})
+
+describe('page reload of the document that holds a WebID', () => {
+ it('shows the document and its content like any other resource, not the profile', async () => {
+ const viewer = sym(`${window.location.origin}/profile/card#me`)
+ const webId = sym(`${window.location.origin}/other/profile/card#me`)
+ loadProfile.mockResolvedValue(webId)
+ vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
+ vi.spyOn(authn, 'checkUser').mockResolvedValue(viewer)
+ vi.spyOn(authSession.events, 'on').mockReturnValue(authSession.events)
+ window.history.replaceState({ paneName: 'social', paneUri: webId.value }, '', webId.doc().uri)
+
+ await initMainPage(store)
+ const provider = await vi.waitFor(() => {
+ const element = document.querySelector('file-explorer-provider')
+ expect(element).not.toBeNull()
+ return element!
+ })
+ const navbar = document.querySelector('solid-panes-navbar') as HTMLElement & {
+ navbarItems: Array<{ paneName?: string, selected?: boolean }>
+ }
+
+ const mount = document.getElementById('profile-heading-mount')!
+ await vi.waitFor(() => expect(mount.querySelector('solid-panes-profile-heading')).not.toBeNull())
+ expect(mount.hidden).toBe(false)
+ expect(navbar).not.toHaveClass('navbar--hidden')
+ expect(window.location.href).toBe(webId.doc().uri)
+ expect(provider.subjectUri).toBe(webId.doc().uri)
+ expect(provider.showHeader).toBe(true)
+ expect(provider.pane?.name).not.toBe('profile')
+ expect(window.history.state?.paneName).toBeUndefined()
+ expect(navbar.navbarItems.filter(item => item.selected)).toEqual([])
+ })
+
+ it('leaves other pages of the pod showing what they show', async () => {
+ const viewer = sym(`${window.location.origin}/profile/card#me`)
+ loadProfile.mockResolvedValue(sym(`${window.location.origin}/other/profile/card#me`))
+ vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
+ vi.spyOn(authn, 'checkUser').mockResolvedValue(viewer)
+ vi.spyOn(authSession.events, 'on').mockReturnValue(authSession.events)
+ window.history.replaceState({}, '', '/other/docs/')
+
+ await initMainPage(store)
+ const navbar = document.querySelector('solid-panes-navbar') as HTMLElement & {
+ navbarItems: Array<{ selected?: boolean }>
+ }
+
+ expect(window.history.state?.paneName).toBeUndefined()
+ expect(navbar.navbarItems.filter(item => item.selected)).toEqual([])
+ })
+
+ it.each([
+ ['a container', '/other/docs/'],
+ ['a resource', '/other/docs/notes.ttl']
+ ])('shows %s with its file explorer header, even after a storage was selected', async (_name, url) => {
+ const viewer = sym(`${window.location.origin}/profile/card#me`)
+ loadProfile.mockResolvedValue(sym(`${window.location.origin}/other/profile/card#me`))
+ vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
+ vi.spyOn(authn, 'checkUser').mockResolvedValue(viewer)
+ vi.spyOn(authSession.events, 'on').mockReturnValue(authSession.events)
+ window.history.replaceState({ paneName: 'folder', paneUri: `${window.location.origin}/other/` }, '', url)
+
+ await initMainPage(store)
+ const provider = await vi.waitFor(() => {
+ const element = document.querySelector('file-explorer-provider')
+ expect(element).not.toBeNull()
+ return element!
+ })
+
+ expect(provider.subjectUri).toBe(`${window.location.origin}${url}`)
+ expect(provider.showHeader).toBe(true)
+ expect(window.history.state?.paneName).toBeUndefined()
+ expect(window.location.pathname).toBe(url)
+ })
+})
+
+describe('a link into another pod, made by a package that does not use the controller', () => {
+ const ownerOf = (pod: string) => sym(`${window.location.origin}/${pod}/profile/card#me`)
+
+ it('brings the heading and the navbar along to the other pod', async () => {
+ const viewer = sym(`${window.location.origin}/profile/card#me`)
+ store.add(ownerOf('a'), ns.vcard('fn'), lit('Owner A'), ownerOf('a').doc())
+ store.add(ownerOf('b'), ns.vcard('fn'), lit('Owner B'), ownerOf('b').doc())
+ loadProfile.mockImplementation(async (subject: ReturnType) => {
+ const [pod] = new URL(subject.uri).pathname.split('/').filter(Boolean)
+ return ownerOf(pod)
+ })
+ vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
+ vi.spyOn(authn, 'checkUser').mockResolvedValue(viewer)
+ vi.spyOn(authSession.events, 'on').mockReturnValue(authSession.events)
+ getViewerModeMock.mockResolvedValue('visitor')
+ window.history.replaceState({}, '', ownerOf('a').uri)
+
+ await initMainPage(store)
+ const mount = document.getElementById('profile-heading-mount')!
+ const navbar = document.querySelector('solid-panes-navbar') as HTMLElement & { navbarItems: Array<{ paneUri?: string }> }
+ const heading = () => mount.querySelector('solid-panes-profile-heading') as HTMLElement & { profileData?: { name: string }, compact?: boolean }
+ await vi.waitFor(() => expect(heading()?.profileData?.name).toBe('Owner A'))
+ expect(navbar.navbarItems.map(item => item.paneUri)).toEqual([ownerOf('a').value, ownerOf('a').value])
+
+ // As the links and buttons in solid-ui do
+ outlinerHolder.current!.GotoSubject(store.sym(`${window.location.origin}/b/docs/`), true, undefined, true)
+
+ await vi.waitFor(() => expect(heading()?.profileData?.name).toBe('Owner B'))
+ expect(heading().compact).toBe(true)
+ await vi.waitFor(() => {
+ expect(navbar.navbarItems.map(item => item.paneUri)).toEqual([ownerOf('b').value, ownerOf('b').value])
+ })
+ expect(window.location.pathname).toBe('/b/docs/')
+ })
+
+ it('takes the navbar back to the first pod when going back', async () => {
+ const viewer = sym(`${window.location.origin}/profile/card#me`)
+ store.add(ownerOf('a'), ns.vcard('fn'), lit('Owner A'), ownerOf('a').doc())
+ store.add(ownerOf('b'), ns.vcard('fn'), lit('Owner B'), ownerOf('b').doc())
+ loadProfile.mockImplementation(async (subject: ReturnType) => {
+ const [pod] = new URL(subject.uri).pathname.split('/').filter(Boolean)
+ return ownerOf(pod)
+ })
+ vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
+ vi.spyOn(authn, 'checkUser').mockResolvedValue(viewer)
+ vi.spyOn(authSession.events, 'on').mockReturnValue(authSession.events)
+ getViewerModeMock.mockResolvedValue('visitor')
+ window.history.replaceState({}, '', `${window.location.origin}/a/`)
+
+ await initMainPage(store)
+ const navbar = document.querySelector('solid-panes-navbar') as HTMLElement & { navbarItems: Array<{ paneUri?: string }> }
+ const owners = () => navbar.navbarItems.map(item => item.paneUri)
+ await vi.waitFor(() => expect(owners()).toEqual([ownerOf('a').value, ownerOf('a').value]))
+
+ outlinerHolder.current!.GotoSubject(store.sym(`${window.location.origin}/b/docs/`), true, undefined, true)
+ await vi.waitFor(() => expect(owners()).toEqual([ownerOf('b').value, ownerOf('b').value]))
+
+ window.history.back()
+ await vi.waitFor(() => expect(window.location.pathname).toBe('/a/'))
+ await vi.waitFor(() => expect(owners()).toEqual([ownerOf('a').value, ownerOf('a').value]))
+ })
+})
diff --git a/test/unit/navigation/browserEvents.test.ts b/test/unit/navigation/browserEvents.test.ts
new file mode 100644
index 00000000..e9431dab
--- /dev/null
+++ b/test/unit/navigation/browserEvents.test.ts
@@ -0,0 +1,159 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { NavigationController } from '../../../src/navigation/controller'
+
+type Listener = (event: unknown) => void
+
+const listeners: Record = {}
+let show: ReturnType
+let controller: NavigationController
+
+function defineNavigationApi () {
+ Object.defineProperty(window, 'navigation', {
+ configurable: true,
+ value: { addEventListener: (type: string, listener: Listener) => { listeners[type] = listener } }
+ })
+}
+
+// The module keeps the listeners it added, so each test starts from a fresh copy of it
+async function freshModule () {
+ vi.resetModules()
+ return import('../../../src/navigation/browserEvents')
+}
+
+function enter (overrides: object = {}) {
+ const intercept = vi.fn()
+ listeners.navigate({
+ canIntercept: true,
+ userInitiated: true,
+ navigationType: 'replace',
+ destination: { url: window.location.href },
+ intercept,
+ ...overrides
+ })
+ return intercept
+}
+
+const traverse = (navigationType = 'traverse') => listeners.currententrychange({ navigationType })
+const nextTask = () => new Promise(resolve => setTimeout(resolve, 5))
+
+beforeEach(() => {
+ for (const key of Object.keys(listeners)) delete listeners[key]
+ show = vi.fn().mockResolvedValue(undefined)
+ controller = { show } as unknown as NavigationController
+ defineNavigationApi()
+ window.history.replaceState({ paneName: 'folder', paneUri: 'https://pod.example/', viaAccountMenu: true, other: 'kept' }, '', '/page')
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ Reflect.deleteProperty(window, 'navigation')
+ window.history.replaceState(null, '', '/')
+})
+
+describe('Enter on the current URL', () => {
+ it('shows what the URL is, after forgetting what the entry displayed', async () => {
+ const { registerBrowserNavigation } = await freshModule()
+ registerBrowserNavigation(controller)
+
+ const intercept = enter()
+ expect(intercept).toHaveBeenCalledOnce()
+ expect(intercept.mock.calls[0][0]).toMatchObject({ scroll: 'manual', focusReset: 'manual' })
+ await intercept.mock.calls[0][0].handler()
+
+ expect(window.history.state).toEqual({ other: 'kept' })
+ expect(show).toHaveBeenCalledWith({ kind: 'restore', forceHeading: true })
+ })
+
+ it.each([
+ ['not started by the user', { userInitiated: false }],
+ ['not a replacement of the entry', { navigationType: 'push' }],
+ ['one that cannot be intercepted', { canIntercept: false }],
+ ['to another URL', { destination: { url: 'https://elsewhere.example/' } }]
+ ])('leaves a navigation alone when it is %s', async (_name, overrides) => {
+ const { registerBrowserNavigation } = await freshModule()
+ registerBrowserNavigation(controller)
+
+ expect(enter(overrides)).not.toHaveBeenCalled()
+ expect(show).not.toHaveBeenCalled()
+ expect(window.history.state).toMatchObject({ paneName: 'folder' })
+ })
+})
+
+describe('back and forward', () => {
+ it('shows what the URL is, after forgetting what the entry displayed', async () => {
+ const { registerBrowserNavigation } = await freshModule()
+ registerBrowserNavigation(controller)
+
+ traverse()
+ await nextTask()
+
+ expect(window.history.state).toEqual({ other: 'kept' })
+ expect(show).toHaveBeenCalledWith({ kind: 'restore', forceHeading: true })
+ })
+
+ it.each(['replace', 'push', 'reload'])('ignores an entry change of type %s', async (type) => {
+ const { registerBrowserNavigation } = await freshModule()
+ registerBrowserNavigation(controller)
+
+ traverse(type)
+ await nextTask()
+
+ expect(show).not.toHaveBeenCalled()
+ expect(window.history.state).toMatchObject({ paneName: 'folder' })
+ })
+})
+
+describe('without the Navigation API', () => {
+ beforeEach(() => {
+ Reflect.deleteProperty(window, 'navigation')
+ })
+
+ it('shows what the URL is on popstate', async () => {
+ const { registerBrowserNavigation } = await freshModule()
+ registerBrowserNavigation(controller)
+
+ window.dispatchEvent(new PopStateEvent('popstate'))
+ await nextTask()
+
+ expect(window.history.state).toEqual({ other: 'kept' })
+ expect(show).toHaveBeenCalledOnce()
+ })
+})
+
+describe('whether the page handles browser navigation', () => {
+ it('says no before the listeners are registered and yes afterwards', async () => {
+ const { registerBrowserNavigation, isHandlingBrowserNavigation } = await freshModule()
+ expect(isHandlingBrowserNavigation()).toBe(false)
+
+ registerBrowserNavigation(controller)
+
+ expect(isHandlingBrowserNavigation()).toBe(true)
+ })
+})
+
+describe('registering', () => {
+ it('adds the listeners once, and uses the controller registered last', async () => {
+ const { registerBrowserNavigation } = await freshModule()
+ const addEventListener = vi.spyOn(window.navigation as never as { addEventListener: () => void }, 'addEventListener')
+ const second = { show: vi.fn().mockResolvedValue(undefined) }
+
+ registerBrowserNavigation(controller)
+ registerBrowserNavigation(second as unknown as NavigationController)
+
+ expect(addEventListener).toHaveBeenCalledTimes(2)
+ await enter().mock.calls[0][0].handler()
+ expect(show).not.toHaveBeenCalled()
+ expect(second.show).toHaveBeenCalledOnce()
+ })
+
+ it('reports a failure to show the page instead of leaving it unhandled', async () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ show.mockRejectedValue(new Error('no pane'))
+ const { registerBrowserNavigation } = await freshModule()
+ registerBrowserNavigation(controller)
+
+ await enter().mock.calls[0][0].handler()
+
+ expect(error).toHaveBeenCalledWith('Failed to show the page after browser navigation.', expect.any(Error))
+ })
+})
diff --git a/test/unit/navigation/chrome.test.ts b/test/unit/navigation/chrome.test.ts
new file mode 100644
index 00000000..4136ff2e
--- /dev/null
+++ b/test/unit/navigation/chrome.test.ts
@@ -0,0 +1,206 @@
+import { describe, expect, it } from 'vitest'
+import {
+ deriveExplorerHeader,
+ deriveHeading,
+ deriveNavbarSelection,
+ explorerHeaderForPane,
+ isNavbarHidden,
+ isNavbarItemSelected,
+ isPersonPane,
+ pickNavbarItem,
+ type HeadingInput
+} from '../../../src/navigation/chrome'
+import { viewFromPane } from '../../../src/navigation/state'
+
+const viewerUri = 'https://pod.example/me/profile/card#me'
+const ownerUri = 'https://pod.example/owner/profile/card#me'
+const pageUrl = 'https://pod.example/owner/container/'
+
+const ownerStorage = 'https://pod.example/owner/'
+
+function heading (overrides: Partial & { pane?: string, subject?: string } = {}) {
+ const { pane, subject, ...rest } = overrides
+ return deriveHeading({
+ view: viewFromPane(pane, subject),
+ viaAccountMenu: false,
+ viewer: viewerUri,
+ pageUrl,
+ owner: ownerUri,
+ storageUris: [ownerStorage],
+ ...rest
+ })
+}
+
+describe('isPersonPane', () => {
+ it.each([['profile', true], ['social', true], ['folder', false], ['home', false], [undefined, false]])(
+ '%s -> %s', (pane, expected) => {
+ expect(isPersonPane(pane)).toBe(expected)
+ }
+ )
+})
+
+describe('deriveHeading', () => {
+ describe('profile and friends panes present their own subject', () => {
+ it('is full for the profile pane and compact for friends', () => {
+ expect(heading({ pane: 'profile', subject: ownerUri })).toEqual({ mode: 'full', subjectUri: ownerUri, paneName: 'profile' })
+ expect(heading({ pane: 'social', subject: ownerUri })).toEqual({ mode: 'compact', subjectUri: ownerUri, paneName: 'social' })
+ })
+
+ it('presents the saved subject, else the navbar entry\'s profile, else the page', () => {
+ const other = 'https://pod.example/saved/profile/card#me'
+ expect(heading({ pane: 'profile', subject: other })).toMatchObject({ subjectUri: other })
+ expect(heading({ pane: 'profile' })).toMatchObject({ subjectUri: ownerUri })
+ expect(heading({ pane: 'profile', owner: undefined })).toMatchObject({ subjectUri: pageUrl })
+ })
+
+ it('does not depend on who is logged in or where the view came from', () => {
+ const own = heading({ pane: 'profile', subject: viewerUri, viewer: viewerUri, viaAccountMenu: true })
+
+ expect(own).toEqual({ mode: 'full', subjectUri: viewerUri, paneName: 'profile' })
+ })
+ })
+
+ describe('other panes show the owner of the pod the page is in', () => {
+ it.each([undefined, 'folder', 'source', 'dataContents'])('shows a compact heading for pane %s', pane => {
+ expect(heading({ pane })).toEqual({ mode: 'compact', subjectUri: ownerUri, paneName: pane })
+ })
+
+ it('is hidden when the owner is the viewer', () => {
+ expect(heading({ viewer: ownerUri })).toEqual({ mode: 'none' })
+ })
+
+ it('shows the owner to a visitor who is not logged in, but nothing without an owner', () => {
+ expect(heading({ viewer: undefined })).toMatchObject({ mode: 'compact' })
+ expect(heading({ viewer: undefined, owner: undefined })).toEqual({ mode: 'none' })
+ })
+
+ it('is hidden for account-menu views, which show the viewer\'s content', () => {
+ expect(heading({ pane: 'home', viaAccountMenu: true })).toEqual({ mode: 'none' })
+ expect(heading({ viaAccountMenu: true })).toEqual({ mode: 'none' })
+ })
+
+ it('is hidden for the viewer\'s storage opened from the account menu', () => {
+ expect(heading({ pane: 'folder', subject: 'https://pod.example/me/', viaAccountMenu: true })).toEqual({ mode: 'none' })
+ })
+
+ it('is hidden for the viewer\'s own storage opened from the account menu even when the pod has storages', () => {
+ expect(heading({ pane: 'folder', subject: 'https://pod.example/me/', viaAccountMenu: true, storageUris: [ownerStorage] }))
+ .toEqual({ mode: 'none' })
+ })
+
+ it('shows the owner for a storage of the pod, whichever way it was opened', () => {
+ expect(heading({ pane: 'folder', subject: ownerStorage, viaAccountMenu: true, viewer: viewerUri }))
+ .toEqual({ mode: 'compact', subjectUri: ownerUri, paneName: 'folder' })
+ expect(heading({ pane: 'folder', viaAccountMenu: true }))
+ .toEqual({ mode: 'compact', subjectUri: ownerUri, paneName: 'folder' })
+ })
+
+ it('shows the owner for a storage the pod does not list, unless the account menu opened it', () => {
+ expect(heading({ pane: 'folder', subject: 'https://pod.example/elsewhere/' }))
+ .toEqual({ mode: 'compact', subjectUri: ownerUri, paneName: 'folder' })
+ expect(heading({ pane: 'folder', subject: 'https://pod.example/elsewhere/', viaAccountMenu: true }))
+ .toEqual({ mode: 'none' })
+ })
+
+ it('is hidden without a known owner of the pod', () => {
+ expect(heading({ owner: undefined })).toEqual({ mode: 'none' })
+ expect(heading({ owner: undefined, storageUris: [] })).toEqual({ mode: 'none' })
+ })
+ })
+})
+
+describe('navbar', () => {
+ describe('deriveNavbarSelection', () => {
+ it('selects the displayed pane and its subject', () => {
+ expect(deriveNavbarSelection(viewFromPane('folder', 'https://pod.example/s/'), false))
+ .toEqual({ paneName: 'folder', paneUri: 'https://pod.example/s/' })
+ })
+
+ it('selects Profile on a WebID, and nothing on other pages', () => {
+ expect(deriveNavbarSelection(viewFromPane(), true)).toEqual({ paneName: 'profile', paneUri: undefined })
+ expect(deriveNavbarSelection(viewFromPane(), false)).toEqual({ paneName: undefined, paneUri: undefined })
+ })
+
+ it('prefers the displayed pane over the WebID default', () => {
+ expect(deriveNavbarSelection(viewFromPane('social', ownerUri), true).paneName).toBe('social')
+ })
+ })
+
+ describe('isNavbarItemSelected', () => {
+ const storage = { paneName: 'folder', paneUri: 'https://pod.example/a/' }
+
+ it('selects profile and friends by pane alone', () => {
+ expect(isNavbarItemSelected({ paneName: 'profile' }, { paneName: 'profile' }, 0)).toBe(true)
+ expect(isNavbarItemSelected({ paneName: 'social' }, { paneName: 'profile' }, 0)).toBe(false)
+ })
+
+ it('selects a storage by its URI', () => {
+ expect(isNavbarItemSelected(storage, { paneName: 'folder', paneUri: storage.paneUri }, 2)).toBe(true)
+ expect(isNavbarItemSelected(storage, { paneName: 'folder', paneUri: 'https://pod.example/b/' }, 2)).toBe(false)
+ })
+
+ it('selects a storage without a URI only when it is the single one', () => {
+ expect(isNavbarItemSelected(storage, { paneName: 'folder' }, 1)).toBe(true)
+ expect(isNavbarItemSelected(storage, { paneName: 'folder' }, 2)).toBe(false)
+ })
+
+ it('selects nothing when nothing is displayed', () => {
+ expect(isNavbarItemSelected({ paneName: 'profile' }, {}, 0)).toBe(false)
+ })
+ })
+
+ describe('pickNavbarItem', () => {
+ const items = [
+ { paneName: 'profile', paneUri: 'p' },
+ { paneName: 'folder', paneUri: 'a' },
+ { paneName: 'folder', paneUri: 'b' }
+ ]
+
+ it('prefers the item for that subject, else the first for that pane', () => {
+ expect(pickNavbarItem(items, 'folder', 'b')).toBe(items[2])
+ expect(pickNavbarItem(items, 'folder', 'unknown')).toBe(items[1])
+ expect(pickNavbarItem(items, 'folder')).toBe(items[1])
+ })
+
+ it('finds nothing for a pane the navbar does not have', () => {
+ expect(pickNavbarItem(items, 'social')).toBeUndefined()
+ })
+ })
+
+ describe('isNavbarHidden', () => {
+ it.each([
+ [false, false, false],
+ [true, false, true],
+ [false, true, true],
+ [true, true, true]
+ ])('account menu %s, own profile %s -> hidden %s', (viaAccountMenu, ownProfile, hidden) => {
+ expect(isNavbarHidden({ viaAccountMenu, ownProfile })).toBe(hidden)
+ })
+ })
+})
+
+describe('file explorer header', () => {
+ const resource = { isRootResource: false, isWebId: false, viewerPodOnly: false, inViewerPod: false }
+
+ it('is shown for an ordinary resource', () => {
+ expect(deriveExplorerHeader(resource)).toBe(true)
+ })
+
+ it('is never shown for root resources or WebIDs', () => {
+ expect(deriveExplorerHeader({ ...resource, isRootResource: true, inViewerPod: true })).toBe(false)
+ expect(deriveExplorerHeader({ ...resource, isWebId: true, inViewerPod: true })).toBe(false)
+ })
+
+ it('is shown for views of the viewer\'s own content only inside the viewer\'s pod', () => {
+ expect(deriveExplorerHeader({ ...resource, viewerPodOnly: true, inViewerPod: false })).toBe(false)
+ expect(deriveExplorerHeader({ ...resource, viewerPodOnly: true, inViewerPod: true })).toBe(true)
+ })
+
+ it('is never shown over profile or friends panes', () => {
+ expect(explorerHeaderForPane(true, 'profile')).toBe(false)
+ expect(explorerHeaderForPane(true, 'social')).toBe(false)
+ expect(explorerHeaderForPane(true, 'folder')).toBe(true)
+ expect(explorerHeaderForPane(true, undefined)).toBe(true)
+ expect(explorerHeaderForPane(false, 'folder')).toBe(false)
+ })
+})
diff --git a/test/unit/navigation/controller.test.ts b/test/unit/navigation/controller.test.ts
new file mode 100644
index 00000000..d5539b57
--- /dev/null
+++ b/test/unit/navigation/controller.test.ts
@@ -0,0 +1,270 @@
+import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
+import { store } from 'solid-logic'
+import type { OutlineManager } from '../../../src/outline/manager'
+import {
+ createNavigationController,
+ type NavigationController,
+ type NavigationDeps,
+ type NavigationEffects
+} from '../../../src/navigation/controller'
+import { resolveSource } from '../../../src/navigation/options'
+
+const { getProfilePane, getSocialPane, getFolderPane, isWebId } = vi.hoisted(() => ({
+ getProfilePane: vi.fn(),
+ getSocialPane: vi.fn(),
+ getFolderPane: vi.fn(),
+ isWebId: vi.fn()
+}))
+
+vi.mock('../../../src/utils/paneUtils', () => ({
+ getProfilePaneFromURI: getProfilePane,
+ getSocialPaneFromURI: getSocialPane,
+ getFolderPaneForStorage: getFolderPane
+}))
+vi.mock('../../../src/utils/webIdUtils', () => ({ isWebIdUri: isWebId }))
+
+const owner = 'https://pod.example/timea/profile/card#me'
+const profilePane = { name: 'profile', subject: store.sym(owner) }
+const socialPane = { name: 'social', subject: store.sym(owner) }
+const storagePane = { name: 'folder', paneName: 'folder', subject: store.sym('https://pod.example/timea/') }
+const homePane = { name: 'home' }
+
+let calls: string[]
+let GotoSubject: Mock<(...args: any[]) => void>
+let selectNavbarPane: Mock
+let refreshHeading: Mock
+let byName: Mock
+let controller: NavigationController
+let navbar: HTMLElement
+
+beforeEach(() => {
+ calls = []
+ // Records the displayed pane in history.state, as the outline manager does
+ GotoSubject = vi.fn((_subject, _expand, pane, _solo, _referrer, _table, options) => {
+ calls.push('GotoSubject')
+ const next: Record = { ...window.history.state }
+ if (pane) {
+ next.paneName = pane.name
+ if (pane.subject) next.paneUri = pane.subject.value
+ }
+ if (resolveSource(options) === 'accountMenu') next.viaAccountMenu = true
+ else delete next.viaAccountMenu
+ window.history.replaceState(next, '', window.location.href)
+ })
+ selectNavbarPane = vi.fn(() => { calls.push('select') })
+ refreshHeading = vi.fn(async () => { calls.push('heading') })
+ byName = vi.fn((name: string) => (name === 'home' ? homePane as never : null))
+ controller = createNavigationController({
+ store,
+ outliner: { GotoSubject } as unknown as OutlineManager,
+ byName,
+ effects: { selectNavbarPane, refreshHeading }
+ })
+ navbar = document.createElement('solid-panes-navbar')
+ document.body.appendChild(navbar)
+
+ getProfilePane.mockResolvedValue(profilePane)
+ getSocialPane.mockResolvedValue(socialPane)
+ getFolderPane.mockResolvedValue(storagePane)
+ isWebId.mockReturnValue(false)
+ window.history.replaceState({}, '', '/timea2/docs/')
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ vi.resetAllMocks()
+ document.body.replaceChildren()
+ window.history.replaceState(null, '', '/')
+})
+
+const sourceOfCall = (index = 0) => resolveSource(GotoSubject.mock.calls[index][6])
+
+describe('showing a profile, friends or storage', () => {
+ it('displays the person\'s pane for the page URL, then selects it in the navbar and refreshes the heading', async () => {
+ await controller.show({ kind: 'profile', subject: owner })
+
+ expect(getProfilePane).toHaveBeenCalledWith(store.sym(owner))
+ expect(GotoSubject).toHaveBeenCalledWith(
+ store.sym(window.location.href), true, profilePane, true, undefined, undefined, { source: 'default' }
+ )
+ expect(selectNavbarPane).toHaveBeenCalledWith('profile', owner)
+ expect(refreshHeading).toHaveBeenCalledWith(false)
+ expect(calls).toEqual(['GotoSubject', 'select', 'heading'])
+ expect(navbar).not.toHaveClass('navbar--hidden')
+ })
+
+ it('does the same for friends and for a storage', async () => {
+ await controller.show({ kind: 'friends', subject: owner })
+ await controller.show({ kind: 'storage', pane: storagePane as never })
+
+ expect(GotoSubject.mock.calls.map(call => call[2])).toEqual([socialPane, storagePane])
+ expect(selectNavbarPane.mock.calls).toEqual([['social', owner], ['folder', 'https://pod.example/timea/']])
+ })
+
+ it('displays a pane of the application by its registry name', async () => {
+ await controller.show({ kind: 'app', pane: 'home' })
+
+ expect(byName).toHaveBeenCalledWith('home')
+ expect(GotoSubject.mock.calls[0][2]).toBe(homePane)
+ expect(selectNavbarPane).toHaveBeenCalledWith('home', undefined)
+ })
+})
+
+describe('when the account menu asks', () => {
+ it('hides the navbar, remembers the source and does not select anything in it', async () => {
+ await controller.show({ kind: 'profile', subject: owner, source: 'accountMenu' })
+
+ expect(sourceOfCall()).toBe('accountMenu')
+ expect(window.history.state).toMatchObject({ viaAccountMenu: true })
+ expect(navbar).toHaveClass('navbar--hidden')
+ expect(selectNavbarPane).not.toHaveBeenCalled()
+ expect(refreshHeading).toHaveBeenCalledWith(false)
+ })
+
+ it('hides the navbar before the pane has been resolved', async () => {
+ let finish!: (pane: unknown) => void
+ getProfilePane.mockReturnValue(new Promise(resolve => { finish = resolve }))
+
+ const shown = controller.show({ kind: 'profile', subject: owner, source: 'accountMenu' })
+
+ expect(navbar).toHaveClass('navbar--hidden')
+ expect(GotoSubject).not.toHaveBeenCalled()
+ finish(profilePane)
+ await shown
+ })
+
+ it('shows the navbar again for the page\'s own navigation', async () => {
+ navbar.classList.add('navbar--hidden')
+
+ await controller.show({ kind: 'friends', subject: owner })
+ // The outline manager reveals the navbar; the fake outliner here does not, so only check intent
+ expect(sourceOfCall()).toBe('default')
+ })
+})
+
+describe('when a pane cannot be built', () => {
+ it('displays nothing rather than the page URL\'s default view, and says so', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ getProfilePane.mockResolvedValue(null)
+
+ await controller.show({ kind: 'profile', subject: owner, source: 'accountMenu' })
+
+ expect(GotoSubject).not.toHaveBeenCalled()
+ expect(refreshHeading).not.toHaveBeenCalled()
+ expect(warn).toHaveBeenCalledOnce()
+ })
+
+ it('treats an unknown application pane the same way', async () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+
+ await controller.show({ kind: 'app', pane: 'unknown' })
+
+ expect(GotoSubject).not.toHaveBeenCalled()
+ })
+})
+
+describe('restoring a history entry', () => {
+ it('displays what the current entry displays', async () => {
+ window.history.replaceState({ paneName: 'social', paneUri: owner }, '', '/timea2/docs/')
+
+ await controller.show({ kind: 'restore' })
+
+ expect(getSocialPane).toHaveBeenCalledWith(store.sym(owner))
+ expect(GotoSubject.mock.calls[0][2]).toBe(socialPane)
+ expect(selectNavbarPane).toHaveBeenCalledWith('social', owner)
+ expect(refreshHeading).toHaveBeenCalledWith(false)
+ })
+
+ it.each([
+ ['home', 'a pane of the application', homePane],
+ ['source', 'a pane the registry does not know', undefined]
+ ])('restores the registry pane %s without a subject of its own (%s)', async (name, _what, expected) => {
+ byName.mockImplementation((requested: string) => requested === 'home' ? homePane as never : null)
+ window.history.replaceState({ paneName: name }, '', '/timea2/docs/')
+
+ await controller.show({ kind: 'restore' })
+
+ expect(GotoSubject.mock.calls[0][2]).toBe(expected)
+ })
+
+ it('shows the profile on a WebID whose entry displayed nothing', async () => {
+ isWebId.mockReturnValue(true)
+ window.history.replaceState({}, '', '/timea/profile/card')
+
+ await controller.show({ kind: 'restore' })
+
+ expect(isWebId).toHaveBeenCalledWith(store.sym(`${window.location.origin}/timea/profile/card`))
+ expect(getProfilePane).toHaveBeenCalledWith(store.sym(`${window.location.origin}/timea/profile/card`))
+ expect(GotoSubject.mock.calls[0][2]).toBe(profilePane)
+ })
+
+ it('shows the page\'s default view when nothing was displayed and it is not a WebID', async () => {
+ await controller.show({ kind: 'restore' })
+
+ expect(GotoSubject.mock.calls[0][2]).toBeUndefined()
+ expect(selectNavbarPane).not.toHaveBeenCalled()
+ expect(refreshHeading).toHaveBeenCalledWith(false)
+ })
+
+ it('still shows the default view when the entry names a pane that cannot be built', async () => {
+ getFolderPane.mockResolvedValue(null)
+ window.history.replaceState({ paneName: 'folder', paneUri: 'https://pod.example/timea/' }, '', '/timea2/docs/')
+
+ await controller.show({ kind: 'restore' })
+
+ expect(GotoSubject.mock.calls[0][2]).toBeUndefined()
+ })
+
+ it('uses the subject it is given instead of the page URL', async () => {
+ isWebId.mockReturnValue(true)
+
+ await controller.show({ kind: 'restore', subject: 'https://other.example/profile/card#me' })
+
+ expect(isWebId).toHaveBeenCalledWith(store.sym('https://other.example/profile/card#me'))
+ expect(GotoSubject.mock.calls[0][0]).toEqual(store.sym('https://other.example/profile/card#me'))
+ })
+
+ it('redoes the heading only when asked to', async () => {
+ await controller.show({ kind: 'restore', forceHeading: true })
+ await controller.show({ kind: 'restore' })
+
+ expect(refreshHeading.mock.calls).toEqual([[true], [false]])
+ })
+})
+
+describe('when intents overlap', () => {
+ it('displays only the latest while an earlier one is still resolving its pane', async () => {
+ let finishFirst!: (pane: unknown) => void
+ getProfilePane.mockReturnValueOnce(new Promise(resolve => { finishFirst = resolve }))
+
+ const first = controller.show({ kind: 'profile', subject: owner })
+ const second = controller.show({ kind: 'friends', subject: owner })
+ await second
+ finishFirst(profilePane)
+ await first
+
+ expect(GotoSubject).toHaveBeenCalledOnce()
+ expect(GotoSubject.mock.calls[0][2]).toBe(socialPane)
+ expect(refreshHeading).toHaveBeenCalledOnce()
+ })
+
+ it('lets an account-menu choice win over a restore that was already under way', async () => {
+ let finishRestore!: (pane: unknown) => void
+ getProfilePane.mockReturnValueOnce(new Promise(resolve => { finishRestore = resolve }))
+ isWebId.mockReturnValue(true)
+
+ const restore = controller.show({ kind: 'restore' })
+ await controller.show({ kind: 'storage', pane: storagePane as never, source: 'accountMenu' })
+ finishRestore(profilePane)
+ await restore
+
+ expect(GotoSubject.mock.calls.map(call => call[2])).toEqual([storagePane])
+ })
+
+ it('does not hold up an intent because of an earlier one that already finished', async () => {
+ await controller.show({ kind: 'profile', subject: owner })
+ await controller.show({ kind: 'friends', subject: owner })
+
+ expect(GotoSubject).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/test/unit/navigation/events.test.ts b/test/unit/navigation/events.test.ts
new file mode 100644
index 00000000..a2781851
--- /dev/null
+++ b/test/unit/navigation/events.test.ts
@@ -0,0 +1,45 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { announceNavigation, NAVIGATED_EVENT, onNavigated, type NavigatedDetail } from '../../../src/navigation/events'
+
+afterEach(() => {
+ window.history.replaceState(null, '', '/')
+})
+
+describe('navigation events', () => {
+ it('announces the page URL and what the entry displays', () => {
+ window.history.replaceState({ paneName: 'folder', paneUri: 'https://a.example/', viaAccountMenu: true }, '', '/some/page')
+ const seen: NavigatedDetail[] = []
+ document.addEventListener(NAVIGATED_EVENT, event => seen.push((event as CustomEvent).detail), { once: true })
+
+ announceNavigation(document)
+
+ expect(seen).toEqual([{
+ url: `${window.location.origin}/some/page`,
+ state: { view: { kind: 'storage', pane: 'folder', subject: 'https://a.example/' }, viaAccountMenu: true }
+ }])
+ })
+
+ it('announces an entry that displays nothing in particular', () => {
+ const listener = vi.fn()
+ const stop = onNavigated(listener)
+
+ announceNavigation(document)
+
+ expect(listener).toHaveBeenCalledWith({
+ url: window.location.href,
+ state: { view: { kind: 'default' }, viaAccountMenu: false }
+ })
+ stop()
+ })
+
+ it('stops calling a listener once it has been removed', () => {
+ const listener = vi.fn()
+ const stop = onNavigated(listener)
+
+ announceNavigation(document)
+ stop()
+ announceNavigation(document)
+
+ expect(listener).toHaveBeenCalledOnce()
+ })
+})
diff --git a/test/unit/navigation/options.test.ts b/test/unit/navigation/options.test.ts
new file mode 100644
index 00000000..4b673a36
--- /dev/null
+++ b/test/unit/navigation/options.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest'
+import { resolveSource } from '../../../src/navigation/options'
+
+describe('resolveSource', () => {
+ it.each([
+ [undefined, 'default'],
+ [{}, 'default'],
+ [{ source: 'default' }, 'default'],
+ [{ source: 'accountMenu' }, 'accountMenu']
+ ] as const)('reads the options %j as %s', (argument, expected) => {
+ expect(resolveSource(argument)).toBe(expected)
+ })
+
+ it('still reads the old showNavbar boolean', () => {
+ expect(resolveSource(true)).toBe('default')
+ expect(resolveSource(false)).toBe('accountMenu')
+ })
+
+ it('reads any other falsy value like the old default parameter did', () => {
+ expect(resolveSource(null)).toBe('accountMenu')
+ })
+})
diff --git a/test/unit/navigation/podContext.test.ts b/test/unit/navigation/podContext.test.ts
new file mode 100644
index 00000000..4d03f0bf
--- /dev/null
+++ b/test/unit/navigation/podContext.test.ts
@@ -0,0 +1,213 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { sym } from 'rdflib'
+import { authn } from 'solid-logic'
+import type { PaneItem } from '../../../src/utils/paneUtils'
+
+const { loadProfile, getFolderPanes, getStorages } = vi.hoisted(() => ({
+ loadProfile: vi.fn(),
+ getFolderPanes: vi.fn(),
+ getStorages: vi.fn()
+}))
+
+vi.mock('../../../src/utils/webIdUtils', async (importOriginal) => ({
+ ...await importOriginal(),
+ loadProfileFromURI: loadProfile
+}))
+vi.mock('../../../src/utils/paneUtils', () => ({ getFolderPanesFromURI: getFolderPanes }))
+vi.mock('../../../src/utils/podUtils', () => ({ getPodStorages: getStorages }))
+
+import {
+ getPodContext,
+ isInViewerPod,
+ isPageInViewerPod,
+ isSamePod,
+ resetPodContexts,
+ type PodContext
+} from '../../../src/navigation/podContext'
+
+const owner = sym('https://pod.example/timea/profile/card#me')
+const viewer = sym('https://pod.example/other/profile/card#me')
+const storage = (uri: string) => ({ subject: sym(uri), paneName: 'folder' }) as unknown as PaneItem
+
+beforeEach(() => {
+ resetPodContexts()
+ loadProfile.mockResolvedValue(owner)
+ getFolderPanes.mockResolvedValue([storage('https://pod.example/timea/')])
+ getStorages.mockResolvedValue([sym('https://pod.example/other/')])
+ vi.spyOn(authn, 'currentUser').mockReturnValue(viewer)
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ vi.resetAllMocks()
+})
+
+describe('getPodContext', () => {
+ it('describes the pod the page is in and the viewer\'s own storages', async () => {
+ const context = await getPodContext('https://pod.example/timea/docs/')
+
+ expect(context).toMatchObject({
+ viewer: viewer.value,
+ owner: owner.value,
+ viewerStorages: ['https://pod.example/other/']
+ })
+ expect(context.storagePanes.map(pane => pane.subject.value)).toEqual(['https://pod.example/timea/'])
+ })
+
+ it('looks up the storages of a pod and of the viewer once, however many of its pages are asked about', async () => {
+ await Promise.all([
+ getPodContext('https://pod.example/timea/a'),
+ getPodContext('https://pod.example/timea/b/c'),
+ getPodContext('https://pod.example/timea/')
+ ])
+
+ expect(getFolderPanes).toHaveBeenCalledOnce()
+ expect(getStorages).toHaveBeenCalledOnce()
+ })
+
+ it('looks up the owner once per document, whatever fragment is asked about', async () => {
+ await Promise.all([
+ getPodContext('https://pod.example/timea/a'),
+ getPodContext('https://pod.example/timea/a#one'),
+ getPodContext('https://pod.example/timea/a#two')
+ ])
+ await getPodContext('https://pod.example/timea/b')
+
+ expect(loadProfile).toHaveBeenCalledTimes(2)
+ })
+
+ it('gives each page its own owner, since the lookup can end with that page\'s own primary topic', async () => {
+ const alice = sym('https://example.org/people/alice#me')
+ const bob = sym('https://example.org/people/bob#me')
+ loadProfile.mockImplementation(async (subject: typeof alice) => subject.uri.includes('alice') ? alice : bob)
+
+ const first = await getPodContext('https://example.org/people/alice')
+ const second = await getPodContext('https://example.org/people/bob')
+
+ expect([first.owner, second.owner]).toEqual([alice.value, bob.value])
+ expect(isSamePod(first, second)).toBe(false)
+ })
+
+ it('looks up the storages again for another pod, on the same host or another one', async () => {
+ await getPodContext('https://pod.example/timea/')
+ await getPodContext('https://pod.example/timea2/')
+ await getPodContext('https://elsewhere.example/timea/')
+
+ expect(getFolderPanes).toHaveBeenCalledTimes(3)
+ expect(loadProfile).toHaveBeenCalledTimes(3)
+ })
+
+ it('looks up the viewer\'s storages once per viewer, and the rest is unaffected by who is logged in', async () => {
+ await getPodContext('https://pod.example/timea/')
+ vi.mocked(authn.currentUser).mockReturnValue(null)
+
+ const loggedOut = await getPodContext('https://pod.example/timea/')
+
+ expect(loggedOut.viewer).toBeUndefined()
+ expect(loggedOut.viewerStorages).toEqual([])
+ expect(loggedOut.owner).toBe(owner.value)
+ expect(loadProfile).toHaveBeenCalledOnce()
+ expect(getStorages).toHaveBeenCalledOnce()
+ })
+
+ it('does not remember an unbounded number of documents', async () => {
+ for (let index = 0; index < 520; index++) await getPodContext(`https://pod.example/timea/doc-${index}`)
+ loadProfile.mockClear()
+
+ await getPodContext('https://pod.example/timea/doc-519')
+ expect(loadProfile).not.toHaveBeenCalled()
+ await getPodContext('https://pod.example/timea/doc-0')
+ expect(loadProfile).toHaveBeenCalledOnce()
+ })
+
+ it('uses the page URL by default', async () => {
+ window.history.replaceState({}, '', '/timea/docs/')
+
+ await getPodContext()
+
+ expect(loadProfile).toHaveBeenCalledWith(sym(window.location.href))
+ window.history.replaceState({}, '', '/')
+ })
+
+ describe('when a lookup finds no profile', () => {
+ it('does not take the URL itself for the owner', async () => {
+ const page = sym('https://pod.example/files/notes.ttl')
+ loadProfile.mockResolvedValue(page)
+
+ expect((await getPodContext(page.uri)).owner).toBeUndefined()
+ })
+
+ it('does take a WebID for its own owner', async () => {
+ loadProfile.mockResolvedValue(owner)
+
+ expect((await getPodContext(owner.uri)).owner).toBe(owner.value)
+ })
+ })
+
+ describe('when a lookup fails', () => {
+ beforeEach(() => {
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ })
+
+ it('still describes what could be loaded', async () => {
+ loadProfile.mockRejectedValue(new Error('offline'))
+
+ const context = await getPodContext('https://pod.example/timea/')
+
+ expect(context.owner).toBeUndefined()
+ expect(context.storagePanes).toHaveLength(1)
+ })
+
+ it('asks again next time instead of remembering the gap', async () => {
+ loadProfile.mockRejectedValueOnce(new Error('offline'))
+
+ await getPodContext('https://pod.example/timea/')
+ const retried = await getPodContext('https://pod.example/timea/')
+
+ expect(retried.owner).toBe(owner.value)
+ expect(loadProfile).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not look up the viewer\'s storages when nobody is logged in', async () => {
+ vi.mocked(authn.currentUser).mockReturnValue(null)
+ getStorages.mockRejectedValue(new Error('not called'))
+
+ const context = await getPodContext('https://pod.example/timea/')
+
+ expect(context.viewerStorages).toEqual([])
+ expect(getStorages).not.toHaveBeenCalled()
+ })
+ })
+})
+
+describe('what a context says about a URL', () => {
+ const context: PodContext = {
+ viewer: viewer.value,
+ owner: owner.value,
+ storagePanes: [],
+ viewerStorages: ['https://pod.example/other/']
+ }
+
+ it.each([
+ ['https://pod.example/other/', true],
+ ['https://pod.example/other/docs/file.ttl', true],
+ ['https://pod.example/timea/docs/', false],
+ ['https://pod.example/others/', false]
+ ])('puts %s in the viewer\'s pod: %s', (url, expected) => {
+ expect(isInViewerPod(context, url)).toBe(expected)
+ })
+
+ it('compares pods by owner, viewer and storages', () => {
+ const same = { ...context, storagePanes: [storage('https://pod.example/timea/')] }
+
+ expect(isSamePod(same, { ...same })).toBe(true)
+ expect(isSamePod(same, { ...same, owner: 'https://elsewhere.example/profile/card#me' })).toBe(false)
+ expect(isSamePod(same, { ...same, viewer: undefined })).toBe(false)
+ expect(isSamePod(same, { ...same, storagePanes: [] })).toBe(false)
+ })
+
+ it('answers for a page URL directly', async () => {
+ expect(await isPageInViewerPod('https://pod.example/other/docs/')).toBe(true)
+ expect(await isPageInViewerPod('https://pod.example/timea/')).toBe(false)
+ })
+})
diff --git a/test/unit/navigation/state.test.ts b/test/unit/navigation/state.test.ts
new file mode 100644
index 00000000..d220067c
--- /dev/null
+++ b/test/unit/navigation/state.test.ts
@@ -0,0 +1,140 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ clearPaneState,
+ nextHistoryState,
+ parseNavigationState,
+ readNavigationState,
+ viewFromPane,
+ withoutPaneState
+} from '../../../src/navigation/state'
+
+afterEach(() => {
+ window.history.replaceState(null, '', '/')
+})
+
+describe('viewFromPane', () => {
+ it.each([
+ [undefined, undefined, { kind: 'default' }],
+ ['profile', 'https://a.example/card#me', { kind: 'profile', pane: 'profile', subject: 'https://a.example/card#me' }],
+ ['social', 'https://a.example/card#me', { kind: 'friends', pane: 'social', subject: 'https://a.example/card#me' }],
+ ['folder', 'https://a.example/', { kind: 'storage', pane: 'folder', subject: 'https://a.example/' }],
+ ['folder', undefined, { kind: 'storage', pane: 'folder' }],
+ ['home', undefined, { kind: 'pane', pane: 'home' }],
+ ['source', 'https://a.example/file', { kind: 'pane', pane: 'source', subject: 'https://a.example/file' }]
+ ])('describes pane %s with subject %s', (pane, subject, expected) => {
+ expect(viewFromPane(pane, subject)).toEqual(expected)
+ })
+})
+
+describe('parseNavigationState', () => {
+ it.each([null, undefined, 'text', 42, {}])('reads %j as nothing displayed', raw => {
+ expect(parseNavigationState(raw)).toEqual({ view: { kind: 'default' }, viaAccountMenu: false })
+ })
+
+ it('reads the stored pane fields', () => {
+ expect(parseNavigationState({ paneName: 'social', paneUri: 'https://a.example/card#me', other: 1 })).toEqual({
+ view: { kind: 'friends', pane: 'social', subject: 'https://a.example/card#me' },
+ viaAccountMenu: false
+ })
+ })
+
+ it('ignores empty or mistyped pane fields', () => {
+ expect(parseNavigationState({ paneName: '', paneUri: 'x' }).view).toEqual({ kind: 'default' })
+ expect(parseNavigationState({ paneName: 'profile', paneUri: 5 }).view).toEqual({ kind: 'profile', pane: 'profile' })
+ })
+
+ it('only trusts the account-menu marker when it is exactly true', () => {
+ expect(parseNavigationState({ viaAccountMenu: true }).viaAccountMenu).toBe(true)
+ expect(parseNavigationState({ viaAccountMenu: 'yes' }).viaAccountMenu).toBe(false)
+ })
+
+ it('reads the current history entry', () => {
+ window.history.replaceState({ paneName: 'folder', paneUri: 'https://a.example/', viaAccountMenu: true }, '', '/')
+
+ expect(readNavigationState()).toEqual({
+ view: { kind: 'storage', pane: 'folder', subject: 'https://a.example/' },
+ viaAccountMenu: true
+ })
+ })
+})
+
+describe('nextHistoryState', () => {
+ const storage = { name: 'folder', subject: 'https://a.example/' }
+
+ it('records the displayed pane and its subject', () => {
+ expect(nextHistoryState({}, { pane: storage, leavingPage: false, viaAccountMenu: false })).toEqual({
+ paneName: 'folder',
+ paneUri: 'https://a.example/'
+ })
+ })
+
+ it('keeps other keys of the entry', () => {
+ expect(nextHistoryState({ other: 'kept' }, { pane: storage, leavingPage: false, viaAccountMenu: false }))
+ .toMatchObject({ other: 'kept', paneName: 'folder' })
+ })
+
+ it('treats a missing or non-object entry as empty', () => {
+ for (const current of [null, undefined, 'text']) {
+ expect(nextHistoryState(current, { leavingPage: false, viaAccountMenu: false })).toEqual({})
+ }
+ })
+
+ it('drops a saved pane URI when the new pane has no subject of its own', () => {
+ expect(nextHistoryState({ paneName: 'folder', paneUri: 'https://a.example/' }, {
+ pane: { name: 'home' }, leavingPage: false, viaAccountMenu: false
+ })).toEqual({ paneName: 'home' })
+ expect(nextHistoryState({ paneUri: 'https://a.example/' }, {
+ pane: { name: 'home', subject: '' }, leavingPage: false, viaAccountMenu: false
+ })).toEqual({ paneName: 'home' })
+ })
+
+ it('keeps the pane when the same page is rendered again without one', () => {
+ const current = { paneName: 'profile', paneUri: 'https://a.example/card#me' }
+
+ expect(nextHistoryState(current, { leavingPage: false, viaAccountMenu: false })).toEqual(current)
+ })
+
+ it('does not carry pane details into the entry of a different page', () => {
+ expect(nextHistoryState(
+ { paneName: 'profile', paneUri: 'https://a.example/card#me', other: 'kept' },
+ { leavingPage: true, viaAccountMenu: false }
+ )).toEqual({ other: 'kept' })
+ })
+
+ it('records a pane for a different page', () => {
+ expect(nextHistoryState({ paneName: 'profile' }, { pane: storage, leavingPage: true, viaAccountMenu: false }))
+ .toEqual({ paneName: 'folder', paneUri: 'https://a.example/' })
+ })
+
+ it('marks and unmarks account-menu views', () => {
+ expect(nextHistoryState({}, { pane: storage, leavingPage: false, viaAccountMenu: true }))
+ .toMatchObject({ viaAccountMenu: true })
+ expect(nextHistoryState({ viaAccountMenu: true }, { pane: storage, leavingPage: false, viaAccountMenu: false }))
+ .not.toHaveProperty('viaAccountMenu')
+ })
+
+ it('does not change the entry it was given', () => {
+ const current = { paneName: 'profile', viaAccountMenu: true }
+
+ nextHistoryState(current, { leavingPage: true, viaAccountMenu: false })
+
+ expect(current).toEqual({ paneName: 'profile', viaAccountMenu: true })
+ })
+})
+
+describe('forgetting the displayed pane', () => {
+ it('removes only the pane fields', () => {
+ expect(withoutPaneState({ paneName: 'x', paneUri: 'y', viaAccountMenu: true, other: 'kept' })).toEqual({ other: 'kept' })
+ expect(withoutPaneState(null)).toEqual({})
+ })
+
+ it('clears them from the current entry without changing the URL', () => {
+ window.history.replaceState({ paneName: 'folder', paneUri: 'https://a.example/', viaAccountMenu: true, other: 1 }, '', '/some/page')
+
+ clearPaneState()
+
+ expect(window.history.state).toEqual({ other: 1 })
+ expect(window.location.pathname).toBe('/some/page')
+ expect(readNavigationState()).toEqual({ view: { kind: 'default' }, viaAccountMenu: false })
+ })
+})
diff --git a/test/unit/outline/manager.test.ts b/test/unit/outline/manager.test.ts
index 693663a4..3e060812 100644
--- a/test/unit/outline/manager.test.ts
+++ b/test/unit/outline/manager.test.ts
@@ -1,4 +1,12 @@
-import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { authn, store } from 'solid-logic'
+
+const { isInViewerPodMock } = vi.hoisted(() => ({ isInViewerPodMock: vi.fn() }))
+
+vi.mock('../../../src/navigation/podContext', async (importOriginal) => ({
+ ...await importOriginal(),
+ isPageInViewerPod: isInViewerPodMock
+}))
vi.mock('solid-ui', async () => {
const actual = await vi.importActual('solid-ui')
@@ -12,6 +20,7 @@ vi.mock('solid-ui', async () => {
})
import OutlineManager from '../../../src/outline/manager'
+import { onNavigated, type NavigatedDetail } from '../../../src/navigation/events'
import { lit, NamedNode, sym, blankNode } from 'rdflib'
import { findByText, getByText } from '@testing-library/dom'
@@ -32,6 +41,352 @@ const mockPaneRegistry = {
}
describe('manager', () => {
+ describe('navbar visibility during navigation', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ document.body.replaceChildren()
+ })
+
+ it.each([
+ ['https://owner.example/profile/card#me', true],
+ ['https://owner.example/profile/card', true],
+ ['https://owner.example/storage/file.ttl', false],
+ ['https://visitor.example/profile/card#me', false]
+ ])('sets navbar visibility for %s', (uri, hidden) => {
+ vi.spyOn(authn, 'currentUser').mockReturnValue(sym('https://owner.example/profile/card#me'))
+ const navbar = document.createElement('solid-panes-navbar')
+ navbar.classList.toggle('navbar--hidden', !hidden)
+ document.body.appendChild(navbar)
+ const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } })
+
+ manager.GotoSubject(sym(uri), false)
+
+ expect(navbar.classList.contains('navbar--hidden')).toBe(hidden)
+ })
+
+ it('preserves account-menu hiding when automatic reveal is suppressed', () => {
+ vi.spyOn(authn, 'currentUser').mockReturnValue(sym('https://owner.example/profile/card#me'))
+ const navbar = document.createElement('solid-panes-navbar')
+ navbar.classList.add('navbar--hidden')
+ document.body.appendChild(navbar)
+ const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } })
+
+ manager.GotoSubject(sym('https://visitor.example/profile/card#me'), false, undefined, false, undefined, undefined, false)
+
+ expect(navbar).toHaveClass('navbar--hidden')
+ })
+ })
+
+ describe('account-menu history state', () => {
+ afterEach(() => {
+ document.body.replaceChildren()
+ window.history.replaceState({}, '', '/')
+ })
+
+ it.each([
+ ['the options form', { source: 'accountMenu' }, true],
+ ['the old boolean form', false, true],
+ ['default options', {}, false],
+ ['the old default', true, false],
+ ['no options', undefined, false]
+ ])('marks the entry as opened by the account menu with %s: %s', (_name, options, marked) => {
+ const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } })
+ const pane = { ...MockPane, subject: sym(`${window.location.origin}/profile/card#me`) }
+ window.history.replaceState({ viaAccountMenu: !marked }, '', '/')
+
+ manager.GotoSubject(sym(window.location.href), false, pane, true, undefined, undefined, options as never)
+
+ expect(Boolean(window.history.state.viaAccountMenu)).toBe(marked)
+ })
+
+ describe('announcing the navigation', () => {
+ it('announces once after a solo display, with the entry it wrote', () => {
+ const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } })
+ const pane = { ...MockPane, subject: sym(`${window.location.origin}/profile/card#me`) }
+ const seen: NavigatedDetail[] = []
+ const stop = onNavigated(detail => seen.push(detail))
+
+ manager.GotoSubject(sym(window.location.href), false, pane, true, undefined, undefined, { source: 'accountMenu' })
+ stop()
+
+ expect(seen).toEqual([{
+ url: window.location.href,
+ state: {
+ view: { kind: 'pane', pane: 'mock', subject: `${window.location.origin}/profile/card#me` },
+ viaAccountMenu: true
+ }
+ }])
+ })
+
+ it('announces the new URL when a different subject is displayed', () => {
+ const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } })
+ const target = sym(`${window.location.origin}/elsewhere/`)
+ const listener = vi.fn()
+ const stop = onNavigated(listener)
+
+ manager.GotoSubject(target, false, undefined, true)
+ stop()
+
+ expect(listener).toHaveBeenCalledWith({
+ url: target.uri,
+ state: { view: { kind: 'default' }, viaAccountMenu: false }
+ })
+ })
+
+ it('does not announce displays inside the page that do not change what is shown as a whole', () => {
+ const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } })
+ const listener = vi.fn()
+ const stop = onNavigated(listener)
+
+ manager.GotoSubject(sym(window.location.href), false, MockPane, false, undefined, document.createElement('table'))
+ stop()
+
+ expect(listener).not.toHaveBeenCalled()
+ })
+ })
+
+ it('marks account-menu navigation and clears the mark on other navigation', () => {
+ const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } })
+ const pane = { ...MockPane, subject: sym(`${window.location.origin}/profile/card#me`) }
+ const subject = sym(`${window.location.origin}/other/profile/card#me`)
+
+ manager.GotoSubject(subject, false, pane, true, undefined, undefined, false)
+ expect(window.history.state).toMatchObject({ paneName: 'mock', viaAccountMenu: true })
+ expect(window.location.href).toBe(subject.uri)
+
+ manager.GotoSubject(subject, false, pane, true)
+ expect(window.history.state).toMatchObject({ paneName: 'mock' })
+ expect(window.history.state).not.toHaveProperty('viaAccountMenu')
+ })
+
+ it('displays the account-menu pane\'s own subject while the URL stays on the page subject', () => {
+ const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } })
+ const container = sym(`${window.location.origin}/other/container/`)
+ const profile = sym(`${window.location.origin}/profile/card#me`)
+ window.history.replaceState({}, '', container.uri)
+ const table = document.createElement('table')
+ const pane = { ...MockPane, subject: profile }
+
+ manager.GotoSubject(container, false, pane, true, undefined, table, false)
+
+ expect(table.querySelector('td.obj')).toHaveAttribute('about', `<${profile.uri}>`)
+ expect(window.location.href).toBe(container.uri)
+ expect(window.history.state).toMatchObject({ paneName: 'mock', paneUri: profile.uri, viaAccountMenu: true })
+ })
+
+ it('displays a navbar pane\'s own subject, and the page subject for panes without one', () => {
+ const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } })
+ const page = sym(`${window.location.origin}/other/container/sub/`)
+ const storage = sym(`${window.location.origin}/other/`)
+ window.history.replaceState({}, '', page.uri)
+
+ const navbarTable = document.createElement('table')
+ manager.GotoSubject(page, false, { ...MockPane, subject: storage }, true, undefined, navbarTable)
+ expect(navbarTable.querySelector('td.obj')).toHaveAttribute('about', `<${storage.uri}>`)
+ expect(window.location.href).toBe(page.uri)
+ expect(window.history.state).toMatchObject({ paneName: 'mock', paneUri: storage.uri })
+
+ const homeTable = document.createElement('table')
+ manager.GotoSubject(page, false, MockPane, true, undefined, homeTable, false)
+ expect(homeTable.querySelector('td.obj')).toHaveAttribute('about', `<${page.uri}>`)
+ })
+
+ describe('opening the URL inside a storage', () => {
+ const root = `${window.location.origin}/other/`
+
+ // A folder pane that lists known children as expandable rows, like the real one
+ function setup (children: Record) {
+ const folderPane = {
+ name: 'folder',
+ icon: '',
+ label: () => 'Contents',
+ render: (subject: { uri: string }, context: { getOutliner: (dom: Document) => OutlineManager, dom: Document }) => {
+ const div = document.createElement('div')
+ const rows = document.createElement('table')
+ for (const child of children[subject.uri] ?? []) {
+ const tr = document.createElement('tr')
+ tr.appendChild(context.getOutliner(context.dom).outlineObjectTD(sym(child), undefined, undefined, undefined))
+ rows.appendChild(tr)
+ }
+ div.appendChild(rows)
+ return div
+ }
+ }
+ const manager: OutlineManager = new OutlineManager({
+ dom: document,
+ getOutliner: () => manager,
+ session: { paneRegistry: { list: [folderPane], byName: () => folderPane }, store }
+ })
+ return { manager, folderPane }
+ }
+
+ async function opened (target: string, children: Record) {
+ const { manager, folderPane } = setup(children)
+ window.history.replaceState({}, '', target)
+ const table = document.createElement('table')
+ document.body.appendChild(table)
+ manager.GotoSubject(sym(target), true, { ...folderPane, subject: sym(root) }, true, undefined, table)
+ return table
+ }
+
+ const isExpanded = (table: HTMLElement, uri: string) =>
+ table.querySelector(`td.obj[about="<${uri}>"]`)?.firstElementChild?.nodeName === 'TABLE'
+
+ const tree = {
+ [root]: [`${root}a/`, `${root}z/`],
+ [`${root}a/`]: [`${root}a/b/`, `${root}a/other.ttl`],
+ [`${root}a/b/`]: [`${root}a/b/file.ttl`]
+ }
+
+ it('expands each level down to a container in the URL, leaving siblings closed', async () => {
+ const table = await opened(`${root}a/b/`, tree)
+
+ await vi.waitFor(() => {
+ expect(isExpanded(table, `${root}a/`)).toBe(true)
+ expect(isExpanded(table, `${root}a/b/`)).toBe(true)
+ })
+ expect(isExpanded(table, `${root}z/`)).toBe(false)
+ expect(table.querySelector(`td.obj[about="<${root}a/other.ttl>"]`)).not.toBeNull()
+ expect(window.location.href).toBe(`${root}a/b/`)
+ })
+
+ it('opens the containers of a resource in the URL', async () => {
+ const table = await opened(`${root}a/b/file.ttl`, tree)
+
+ await vi.waitFor(() => {
+ expect(isExpanded(table, `${root}a/`)).toBe(true)
+ expect(isExpanded(table, `${root}a/b/`)).toBe(true)
+ })
+ })
+
+ it('opens nothing when the URL is the storage itself or outside it', async () => {
+ const atRoot = await opened(root, tree)
+ await new Promise(resolve => setTimeout(resolve, 50))
+ expect(isExpanded(atRoot, `${root}a/`)).toBe(false)
+
+ const outside = await opened(`${window.location.origin}/elsewhere/a/`, tree)
+ await new Promise(resolve => setTimeout(resolve, 50))
+ expect(isExpanded(outside, `${root}a/`)).toBe(false)
+ })
+ })
+
+ it('renders the account-menu profile for the viewer without a file explorer header, even on another person\'s container URL', async () => {
+ const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry, store } })
+ const container = sym(`${window.location.origin}/other/container/`)
+ const profile = sym(`${window.location.origin}/me/profile/card#me`)
+ window.history.replaceState({}, '', container.uri)
+ const table = document.createElement('table')
+ document.body.appendChild(table)
+ const pane = { ...MockPane, name: 'profile', paneName: 'profile', subject: profile }
+
+ manager.GotoSubject(container, true, pane, true, undefined, table, false)
+ const provider = await vi.waitFor(() => {
+ const element = table.querySelector('file-explorer-provider')
+ expect(element).not.toBeNull()
+ return element!
+ })
+
+ expect(provider.subjectUri).toBe(profile.uri)
+ expect(provider.showHeader).toBe(false)
+ expect(table).toHaveTextContent(`Mock Pane for ${profile.uri}`)
+ expect(window.location.href).toBe(container.uri)
+ })
+
+ describe('file explorer header', () => {
+ const container = sym(`${window.location.origin}/other/folder/file.ttl`)
+
+ beforeEach(() => {
+ isInViewerPodMock.mockReset()
+ })
+ async function headerShown (showNavbar: boolean, pane: object = MockPane) {
+ const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry, store } })
+ window.history.replaceState({}, '', container.uri)
+ const table = document.createElement('table')
+ document.body.appendChild(table)
+ manager.GotoSubject(container, true, pane, true, undefined, table, showNavbar)
+ const provider = await vi.waitFor(() => {
+ const element = table.querySelector('file-explorer-provider')
+ expect(element).not.toBeNull()
+ return element!
+ })
+ return provider.showHeader
+ }
+
+ it('is hidden for account-menu views when the URL is not in the viewer\'s pod', async () => {
+ isInViewerPodMock.mockResolvedValue(false)
+
+ expect(await headerShown(false)).toBe(false)
+ expect(await headerShown(false, { ...MockPane, subject: sym(`${window.location.origin}/me/storage/docs/`) })).toBe(false)
+ expect(isInViewerPodMock).toHaveBeenCalledWith(container.uri)
+ })
+
+ it('is kept for account-menu views when the URL is in the viewer\'s pod', async () => {
+ isInViewerPodMock.mockResolvedValue(true)
+
+ expect(await headerShown(false)).toBe(true)
+ })
+
+ it('is not affected for navbar navigation to a pane without its own subject', async () => {
+ isInViewerPodMock.mockResolvedValue(false)
+
+ expect(await headerShown(true)).toBe(true)
+ expect(isInViewerPodMock).not.toHaveBeenCalled()
+ })
+
+ it('is hidden for navbar navigation to a pane with its own subject outside the viewer\'s pod', async () => {
+ isInViewerPodMock.mockResolvedValue(false)
+ const storage = { ...MockPane, subject: sym(`${window.location.origin}/other/storage/`) }
+
+ expect(await headerShown(true, storage)).toBe(false)
+ expect(isInViewerPodMock).toHaveBeenCalledWith(container.uri)
+ })
+
+ it('is kept for navbar navigation to a pane with its own subject inside the viewer\'s pod', async () => {
+ isInViewerPodMock.mockResolvedValue(true)
+ const storage = { ...MockPane, subject: sym(`${window.location.origin}/other/storage/`) }
+
+ expect(await headerShown(true, storage)).toBe(true)
+ })
+ })
+
+ it('does not carry pane details into the entry of a different subject', () => {
+ const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } })
+ const first = sym(`${window.location.origin}/a/profile/card#me`)
+ const second = sym(`${window.location.origin}/b/profile/card#me`)
+ window.history.replaceState({ other: 'kept' }, '', first.uri)
+ manager.GotoSubject(first, false, { ...MockPane, subject: first }, true)
+ const entries = window.history.length
+
+ manager.GotoSubject(second, false, undefined, true)
+
+ expect(window.history.length).toBe(entries + 1)
+ expect(window.location.href).toBe(second.uri)
+ expect(window.history.state).toEqual({ other: 'kept' })
+ })
+
+ it('keeps pane details when the same subject is rendered again without a pane', () => {
+ const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } })
+ const subject = sym(`${window.location.origin}/a/profile/card#me`)
+ window.history.replaceState({}, '', subject.uri)
+ manager.GotoSubject(subject, false, { ...MockPane, subject }, true)
+
+ manager.GotoSubject(subject, false, undefined, true)
+
+ expect(window.history.state).toEqual({ paneName: 'mock', paneUri: subject.uri })
+ })
+
+ it('replaces a saved pane URI when the new pane does not render another subject', () => {
+ const manager = new OutlineManager({ dom: document, session: { paneRegistry: mockPaneRegistry } })
+ const subject = sym(`${window.location.origin}/a/profile/card#me`)
+ window.history.replaceState({}, '', subject.uri)
+ manager.GotoSubject(subject, false, { ...MockPane, subject: sym(`${window.location.origin}/storage/`) }, true)
+
+ manager.GotoSubject(subject, false, { ...MockPane, name: 'other' }, true)
+
+ expect(window.history.state).toEqual({ paneName: 'other' })
+ })
+ })
+
describe('outline object td', () => {
describe('for a named node', () => {
let result
diff --git a/vite.config.mts b/vite.config.mts
index b9def924..e3272f70 100644
--- a/vite.config.mts
+++ b/vite.config.mts
@@ -16,6 +16,8 @@ if (isWatch && build && Array.isArray(build.rolldownOptions?.output)) {
build.rolldownOptions.output = build.rolldownOptions.output.filter(
(o: { format?: string }) => o.format === 'es',
)
+
+ build.watch = { exclude: ['**/dist/**'] }
}
type ConcretePlugin = Extract