diff --git a/src/gui/src/UI/Dashboard/shareAvatar.js b/src/gui/src/UI/Dashboard/shareAvatar.js
new file mode 100644
index 000000000..7a77bcb14
--- /dev/null
+++ b/src/gui/src/UI/Dashboard/shareAvatar.js
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+// The initial-and-color avatar the share modal shows next to each person.
+
+/**
+ * Stable, name-derived hue so a person keeps the same color across reopenings.
+ *
+ * @param {string} [name]
+ * @returns {number} hue in [0, 360)
+ */
+export const avatarHue = (name) => {
+ let hue = 0;
+ for ( const char of String(name ?? '') ) {
+ hue = (hue * 31 + char.codePointAt(0)) % 360;
+ }
+ return hue;
+};
+
+/**
+ * The name's first character, uppercased; `?` when there is no name.
+ *
+ * @param {string} [name]
+ * @returns {string}
+ */
+export const avatarInitial = (name) => {
+ const trimmed = String(name ?? '').trim();
+ // Iterating takes a whole code point; charAt(0) halves an emoji into tofu.
+ for ( const char of trimmed ) return char.toUpperCase();
+ return '?';
+};
diff --git a/src/gui/src/UI/Dashboard/shareAvatar.test.js b/src/gui/src/UI/Dashboard/shareAvatar.test.js
new file mode 100644
index 000000000..75965dc53
--- /dev/null
+++ b/src/gui/src/UI/Dashboard/shareAvatar.test.js
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest';
+import { avatarHue, avatarInitial } from './shareAvatar.js';
+
+describe('avatarHue', () => {
+ it('gives the same person the same color every time', () => {
+ expect(avatarHue('juan')).toBe(avatarHue('juan'));
+ });
+
+ it('stays a usable hue', () => {
+ for ( const name of ['a', 'juan', 'someone@example.com', '𝒥'.repeat(40)] ) {
+ const hue = avatarHue(name);
+ expect(Number.isInteger(hue)).toBe(true);
+ expect(hue).toBeGreaterThanOrEqual(0);
+ expect(hue).toBeLessThan(360);
+ }
+ });
+
+ it('tells different people apart', () => {
+ expect(avatarHue('ann')).not.toBe(avatarHue('bob'));
+ });
+
+ it('handles a missing name instead of throwing', () => {
+ expect(avatarHue(undefined)).toBe(0);
+ expect(avatarHue(null)).toBe(0);
+ expect(avatarHue('')).toBe(0);
+ });
+});
+
+describe('avatarInitial', () => {
+ it('uppercases the first letter', () => {
+ expect(avatarInitial('juan')).toBe('J');
+ expect(avatarInitial('Ann')).toBe('A');
+ });
+
+ it('ignores surrounding whitespace', () => {
+ expect(avatarInitial(' ann ')).toBe('A');
+ });
+
+ it('falls back to ? when there is no name', () => {
+ // A pending invite has no username until the recipient joins.
+ expect(avatarInitial('')).toBe('?');
+ expect(avatarInitial(' ')).toBe('?');
+ expect(avatarInitial(undefined)).toBe('?');
+ expect(avatarInitial(null)).toBe('?');
+ });
+
+ it('keeps an astral first character whole', () => {
+ // charAt(0) would return half a surrogate pair and render as tofu.
+ expect(avatarInitial('😀 team')).toBe('😀');
+ });
+});
diff --git a/src/gui/src/UI/UIWindowShare.js b/src/gui/src/UI/UIWindowShare.js
index 98f0de4b7..1f6986371 100644
--- a/src/gui/src/UI/UIWindowShare.js
+++ b/src/gui/src/UI/UIWindowShare.js
@@ -23,31 +23,7 @@ import path from '../lib/path.js';
import { owner_of_path } from '../helpers/path_owner.js';
import { invalidate_shared_roots } from '../helpers/shared_access.js';
import { icons } from '../helpers/actionIcons.js';
-
-// Offered when granting. The API accepts `see` and `list` too, but they are a
-// developer-level distinction with no place in this dialog — a row already set
-// to one is shown as-is rather than quietly rounded up to `read`.
-const MODES = ['read', 'write', 'manage'];
-
-// Already HTML-safe: `i18n()` encodes what it returns, and an unencoded mode
-// from the API is encoded here. Encoding a label again turns the `&` in
-// "Can edit & share" into a literal `&`.
-const mode_label = (mode) => {
- if ( mode === 'write' ) return i18n('share_access_write');
- if ( mode === 'manage' ) return i18n('share_access_manage');
- if ( mode === 'read' ) return i18n('share_access_read');
- return html_encode(mode);
-};
-
-const options_for = (current) => {
- const modes = MODES.includes(current) ? MODES : [current, ...MODES];
- return modes
- .map(
- (mode) =>
- ``,
- )
- .join('');
-};
+import { mode_label, options_for } from '../helpers/share_modes.js';
/**
* Sharing dialog for one file or directory.
diff --git a/src/gui/src/helpers/share_modes.js b/src/gui/src/helpers/share_modes.js
new file mode 100644
index 000000000..5261a9cd3
--- /dev/null
+++ b/src/gui/src/helpers/share_modes.js
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+// Access levels the sharing dialogs offer, shared so the two can't drift.
+
+// The API also accepts `see` and `list`, a developer-level distinction with no
+// place in these dialogs; a row already set to one is shown as-is.
+export const MODES = ['read', 'write', 'manage'];
+
+/**
+ * Human-readable label for an access mode, ready to drop into HTML.
+ *
+ * Already HTML-safe — encoding it again renders the `&` in "Can edit & share".
+ *
+ * @param {string} mode
+ * @returns {string} HTML-safe label
+ */
+export const mode_label = (mode) => {
+ if ( mode === 'write' ) return i18n('share_access_write');
+ if ( mode === 'manage' ) return i18n('share_access_manage');
+ if ( mode === 'read' ) return i18n('share_access_read');
+ return html_encode(mode);
+};
+
+/**
+ * `