From 471e5b197539676e397128be90a9f40aa8d917ed Mon Sep 17 00:00:00 2001 From: Otto Richter Date: Wed, 25 Dec 2024 03:56:39 +0100 Subject: [PATCH 1/5] New repo: Clean up and improve CSS - drop custom layout rules for this page - move form-related content to form.css - extend new form CSS to add gap between labels and input fields --- web_src/css/base.css | 91 -------------------------------- web_src/css/form.css | 122 ++++++++++++++++++++++++++++++++++++++----- web_src/css/repo.css | 4 -- 3 files changed, 110 insertions(+), 107 deletions(-) diff --git a/web_src/css/base.css b/web_src/css/base.css index fc3200d7da..dd761a1b75 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -652,97 +652,6 @@ img.ui.avatar, background: var(--color-active); } -.ui.form .fields.error .field textarea, -.ui.form .fields.error .field select, -.ui.form .fields.error .field input:not([type]), -.ui.form .fields.error .field input[type="date"], -.ui.form .fields.error .field input[type="datetime-local"], -.ui.form .fields.error .field input[type="email"], -.ui.form .fields.error .field input[type="number"], -.ui.form .fields.error .field input[type="password"], -.ui.form .fields.error .field input[type="search"], -.ui.form .fields.error .field input[type="tel"], -.ui.form .fields.error .field input[type="time"], -.ui.form .fields.error .field input[type="text"], -.ui.form .fields.error .field input[type="file"], -.ui.form .fields.error .field input[type="url"], -.ui.form .fields.error .field .ui.dropdown, -.ui.form .fields.error .field .ui.dropdown .item, -.ui.form .field.error .ui.dropdown, -.ui.form .field.error .ui.dropdown .text, -.ui.form .field.error .ui.dropdown .item, -.ui.form .field.error textarea, -.ui.form .field.error select, -.ui.form .field.error input:not([type]), -.ui.form .field.error input[type="date"], -.ui.form .field.error input[type="datetime-local"], -.ui.form .field.error input[type="email"], -.ui.form .field.error input[type="number"], -.ui.form .field.error input[type="password"], -.ui.form .field.error input[type="search"], -.ui.form .field.error input[type="tel"], -.ui.form .field.error input[type="time"], -.ui.form .field.error input[type="text"], -.ui.form .field.error input[type="file"], -.ui.form .field.error input[type="url"], -.ui.form .field.error select:focus, -.ui.form .field.error input:not([type]):focus, -.ui.form .field.error input[type="date"]:focus, -.ui.form .field.error input[type="datetime-local"]:focus, -.ui.form .field.error input[type="email"]:focus, -.ui.form .field.error input[type="number"]:focus, -.ui.form .field.error input[type="password"]:focus, -.ui.form .field.error input[type="search"]:focus, -.ui.form .field.error input[type="tel"]:focus, -.ui.form .field.error input[type="time"]:focus, -.ui.form .field.error input[type="text"]:focus, -.ui.form .field.error input[type="file"]:focus, -.ui.form .field.error input[type="url"]:focus { - background-color: var(--color-error-bg); - border-color: var(--color-error-border); - color: var(--color-error-text); -} - -.ui.form .fields.error .field .ui.dropdown, -.ui.form .field.error .ui.dropdown, -.ui.form .fields.error .field .ui.dropdown:hover, -.ui.form .field.error .ui.dropdown:hover { - border-color: var(--color-error-border) !important; -} - -.ui.form .fields.error .field .ui.dropdown .menu .item:hover, -.ui.form .field.error .ui.dropdown .menu .item:hover { - background-color: var(--color-error-bg-hover); -} - -.ui.form .fields.error .field .ui.dropdown .menu .active.item, -.ui.form .field.error .ui.dropdown .menu .active.item { - background-color: var(--color-error-bg-active) !important; -} - -.ui.form .fields.error .dropdown .menu, -.ui.form .field.error .dropdown .menu { - border-color: var(--color-error-border) !important; -} - -input:-webkit-autofill, -input:-webkit-autofill:focus, -input:-webkit-autofill:hover, -input:-webkit-autofill:active, -.ui.form .field.field input:-webkit-autofill, -.ui.form .field.field input:-webkit-autofill:focus, -.ui.form .field.field input:-webkit-autofill:hover, -.ui.form .field.field input:-webkit-autofill:active { - -webkit-background-clip: text; - -webkit-text-fill-color: var(--color-text); - box-shadow: 0 0 0 100px var(--color-primary-light-6) inset !important; - border-color: var(--color-primary-light-4) !important; -} - -.ui.form .field.muted { - opacity: var(--opacity-disabled); -} - .text.primary { color: var(--color-primary) !important; } diff --git a/web_src/css/form.css b/web_src/css/form.css index fb9364db45..bf50114344 100644 --- a/web_src/css/form.css +++ b/web_src/css/form.css @@ -18,6 +18,11 @@ fieldset label:has(input[type="number"]) { font-weight: var(--font-weight-medium); } +/* override inline style on custom input elements */ +fieldset label .ui.dropdown { + width: 100% !important; +} + fieldset .help { font-weight: var(--font-weight-normal); } @@ -27,9 +32,17 @@ fieldset .help { padding-bottom: 0; } -fieldset input[type="checkbox"], -fieldset input[type="radio"] { +fieldset label > input, +fieldset label > textarea, +fieldset label > .ui.dropdown, +fieldset label + .ui.dropdown { + margin-top: 0.28rem !important; +} + +fieldset label > input[type="checkbox"], +fieldset label > input[type="radio"] { margin-right: 0.75em; + margin-top: 0 !important; vertical-align: initial !important; /* overrides a semantic.css rule, remove when obsolete */ } @@ -142,6 +155,101 @@ textarea:focus, color: var(--color-input-text); } +/* error messages */ +fieldset label.error textarea, +fieldset label.error select, +fieldset label.error input, +.ui.form .fields.error .field textarea, +.ui.form .fields.error .field select, +.ui.form .fields.error .field input:not([type]), +.ui.form .fields.error .field input[type="date"], +.ui.form .fields.error .field input[type="datetime-local"], +.ui.form .fields.error .field input[type="email"], +.ui.form .fields.error .field input[type="number"], +.ui.form .fields.error .field input[type="password"], +.ui.form .fields.error .field input[type="search"], +.ui.form .fields.error .field input[type="tel"], +.ui.form .fields.error .field input[type="time"], +.ui.form .fields.error .field input[type="text"], +.ui.form .fields.error .field input[type="file"], +.ui.form .fields.error .field input[type="url"], +.ui.form .fields.error .field .ui.dropdown, +.ui.form .fields.error .field .ui.dropdown .item, +.ui.form .field.error .ui.dropdown, +.ui.form .field.error .ui.dropdown .text, +.ui.form .field.error .ui.dropdown .item, +.ui.form .field.error textarea, +.ui.form .field.error select, +.ui.form .field.error input:not([type]), +.ui.form .field.error input[type="date"], +.ui.form .field.error input[type="datetime-local"], +.ui.form .field.error input[type="email"], +.ui.form .field.error input[type="number"], +.ui.form .field.error input[type="password"], +.ui.form .field.error input[type="search"], +.ui.form .field.error input[type="tel"], +.ui.form .field.error input[type="time"], +.ui.form .field.error input[type="text"], +.ui.form .field.error input[type="file"], +.ui.form .field.error input[type="url"], +.ui.form .field.error select:focus, +.ui.form .field.error input:not([type]):focus, +.ui.form .field.error input[type="date"]:focus, +.ui.form .field.error input[type="datetime-local"]:focus, +.ui.form .field.error input[type="email"]:focus, +.ui.form .field.error input[type="number"]:focus, +.ui.form .field.error input[type="password"]:focus, +.ui.form .field.error input[type="search"]:focus, +.ui.form .field.error input[type="tel"]:focus, +.ui.form .field.error input[type="time"]:focus, +.ui.form .field.error input[type="text"]:focus, +.ui.form .field.error input[type="file"]:focus, +.ui.form .field.error input[type="url"]:focus { + background-color: var(--color-error-bg); + border-color: var(--color-error-border); + color: var(--color-error-text); +} + +.ui.form .fields.error .field .ui.dropdown, +.ui.form .field.error .ui.dropdown, +.ui.form .fields.error .field .ui.dropdown:hover, +.ui.form .field.error .ui.dropdown:hover { + border-color: var(--color-error-border) !important; +} + +.ui.form .fields.error .field .ui.dropdown .menu .item:hover, +.ui.form .field.error .ui.dropdown .menu .item:hover { + background-color: var(--color-error-bg-hover); +} + +.ui.form .fields.error .field .ui.dropdown .menu .active.item, +.ui.form .field.error .ui.dropdown .menu .active.item { + background-color: var(--color-error-bg-active) !important; +} + +.ui.form .fields.error .dropdown .menu, +.ui.form .field.error .dropdown .menu { + border-color: var(--color-error-border) !important; +} + +input:-webkit-autofill, +input:-webkit-autofill:focus, +input:-webkit-autofill:hover, +input:-webkit-autofill:active, +.ui.form .field.field input:-webkit-autofill, +.ui.form .field.field input:-webkit-autofill:focus, +.ui.form .field.field input:-webkit-autofill:hover, +.ui.form .field.field input:-webkit-autofill:active { + -webkit-background-clip: text; + -webkit-text-fill-color: var(--color-text); + box-shadow: 0 0 0 100px var(--color-primary-light-6) inset !important; + border-color: var(--color-primary-light-4) !important; +} + +.ui.form .field.muted { + opacity: var(--opacity-disabled); +} + .ui.form .field > label, .ui.form .inline.fields > label, .ui.form .inline.fields .field > label, @@ -400,14 +508,12 @@ textarea:focus, .repository.new.fork form .header { padding-left: 280px !important; } - .repository.new.repo form .inline.field > label, .repository.new.migrate form .inline.field > label, .repository.new.fork form .inline.field > label { text-align: right; width: 250px !important; word-wrap: break-word; } - .repository.new.repo form .help, .repository.new.migrate form .help, .repository.new.fork form .help { margin-left: 265px !important; @@ -417,10 +523,8 @@ textarea:focus, .repository.new.fork form .optional .title { margin-left: 250px !important; } - .repository.new.repo form .inline.field > input, .repository.new.migrate form .inline.field > input, .repository.new.fork form .inline.field > input, - .repository.new.repo form .inline.field > textarea, .repository.new.migrate form .inline.field > textarea, .repository.new.fork form .inline.field > textarea { width: 50%; @@ -440,7 +544,6 @@ textarea:focus, } } -.repository.new.repo form .dropdown .text, .repository.new.migrate form .dropdown .text, .repository.new.fork form .dropdown .text { margin-right: 0 !important; @@ -453,7 +556,6 @@ textarea:focus, text-align: center; } -.repository.new.repo form .selection.dropdown, .repository.new.migrate form .selection.dropdown, .repository.new.fork form .selection.dropdown, .repository.new.fork form .field a { @@ -490,10 +592,6 @@ textarea:focus, } } -.repository.new.repo .ui.form .selection.dropdown:not(.owner) { - width: 50% !important; -} - @media (max-width: 767.98px) { .repository.new.repo .ui.form .selection.dropdown:not(.owner) { width: 100% !important; diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 484cdf1c85..675e5565bd 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -4,10 +4,6 @@ user-select: none; } -.repository .owner.dropdown { - min-width: 40% !important; -} - .repository .unicode-escaped .escaped-code-point[data-escaped]::before { visibility: visible; content: attr(data-escaped); From 81599155e817e00bd0588df4cb201d1081104388 Mon Sep 17 00:00:00 2001 From: Otto Richter Date: Wed, 25 Dec 2024 03:58:07 +0100 Subject: [PATCH 2/5] New repo: Rework basic settings - separate template - ensure correct labelling of elements - drop additional required indicators for field that already have browser semantics (the icon has colour contrast issues anyway), especially as the first dropdown cannot be left empty --- templates/repo/create.tmpl | 52 ++------------------------------ templates/repo/create_basic.tmpl | 45 +++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 49 deletions(-) create mode 100644 templates/repo/create_basic.tmpl diff --git a/templates/repo/create.tmpl b/templates/repo/create.tmpl index df4288a2f2..79ed22d78e 100644 --- a/templates/repo/create.tmpl +++ b/templates/repo/create.tmpl @@ -16,56 +16,10 @@

{{ctx.Locale.TrN .MaxCreationLimit "repo.form.reach_limit_of_creation_1" "repo.form.reach_limit_of_creation_n" .MaxCreationLimit}}

{{end}} -
- - - {{ctx.Locale.Tr "repo.owner_helper"}} -
+
+ {{template "repo/create_basic" .}} +
-
- - - {{ctx.Locale.Tr "repo.repo_name_helper"}} -
-
- -
- - -
- {{if .IsForcedPrivate}} - {{ctx.Locale.Tr "repo.visibility_helper_forced"}} - {{end}} - {{ctx.Locale.Tr "repo.visibility_description"}} -
-
- - -

diff --git a/templates/repo/create_advanced.tmpl b/templates/repo/create_advanced.tmpl new file mode 100644 index 0000000000..c0274701f8 --- /dev/null +++ b/templates/repo/create_advanced.tmpl @@ -0,0 +1,45 @@ + + +{{$supportedFormatsLength := len .SupportedObjectFormats}} +{{/* Only offer object format selection if there is an actual choice */}} +{{if ge $supportedFormatsLength 2}} + +{{else}} + +{{end}} + + + + diff --git a/templates/repo/create_basic.tmpl b/templates/repo/create_basic.tmpl index 78a4e8b957..0396629fef 100644 --- a/templates/repo/create_basic.tmpl +++ b/templates/repo/create_basic.tmpl @@ -1,5 +1,7 @@ -
diff --git a/templates/repo/create_init.tmpl b/templates/repo/create_init.tmpl new file mode 100644 index 0000000000..729b44c8e6 --- /dev/null +++ b/templates/repo/create_init.tmpl @@ -0,0 +1,56 @@ + + +
+ + + + + {{$supportedReadmesLength := len .Readmes}} + {{/* Only offer README selection if there is an actual choice */}} + {{if ge $supportedReadmesLength 2}} + + {{else}} + + {{end}} +
diff --git a/tests/integration/repo_generate_test.go b/tests/integration/repo_generate_test.go index 4bd9f32119..bcee0df417 100644 --- a/tests/integration/repo_generate_test.go +++ b/tests/integration/repo_generate_test.go @@ -43,7 +43,7 @@ func assertRepoCreateForm(t *testing.T, htmlDoc *HTMLDoc, owner *user_model.User // the template menu is loaded client-side, so don't assert the option exists assert.Equal(t, templateID, htmlDoc.GetInputValueByName("repo_template"), "Unexpected repo_template selection") - for _, name := range []string{"issue_labels", "gitignores", "license", "readme", "object_format_name"} { + for _, name := range []string{"issue_labels", "gitignores", "license", "object_format_name"} { htmlDoc.AssertDropdownHasOptions(t, name) } } From 8d829a97b21650f51f820834b6f92e9aa3855140 Mon Sep 17 00:00:00 2001 From: Otto Richter Date: Fri, 27 Dec 2024 22:57:24 +0100 Subject: [PATCH 5/5] tests(e2e): Test new repo dialog and behaviour - screenshots and basic accessibility scan of collapsed and expanded sections - the dropdowns do not pass the accessibility checks, but I haven't found an easy fix - I manually confirmed the dropdown behaviour via orca and firefox, though --- tests/e2e/repo-new.test.e2e.ts | 134 +++++++++++++++++++++++++++++++++ tests/e2e/shared/forms.ts | 3 + tests/e2e/utils_e2e.ts | 15 ++++ 3 files changed, 152 insertions(+) create mode 100644 tests/e2e/repo-new.test.e2e.ts diff --git a/tests/e2e/repo-new.test.e2e.ts b/tests/e2e/repo-new.test.e2e.ts new file mode 100644 index 0000000000..c9cc29ad56 --- /dev/null +++ b/tests/e2e/repo-new.test.e2e.ts @@ -0,0 +1,134 @@ +// @watch start +// templates/repo/create**.tmpl +// web_src/css/{form,repo}.css +// @watch end + +import {expect} from '@playwright/test'; +import {test, dynamic_id, save_visual, login_user, login} from './utils_e2e.ts'; +import {validate_form} from './shared/forms.ts'; + +test.beforeAll(async ({browser}, workerInfo) => { + await login_user(browser, workerInfo, 'user2'); +}); + +test('New repo: invalid', async ({browser}, workerInfo) => { + const page = await login({browser}, workerInfo); + const response = await page.goto('/repo/create'); + expect(response?.status()).toBe(200); + // check that relevant form content is hidden or available + await expect(page.getByRole('group', {name: 'Use a template You can select'}).getByRole('combobox')).toBeVisible(); + await expect(page.getByText('.gitignore Select .gitignore')).toBeHidden(); + await expect(page.getByText('Labels Select a label set')).toBeHidden(); + await validate_form({page}, 'fieldset'); + await save_visual(page); + + await page.getByLabel('Repository name').fill('*invalid'); + await page.getByRole('button', {name: 'Create repository'}).click(); + await expect(page.getByText('Repository name should contain only alphanumeric')).toBeVisible(); + await save_visual(page); +}); + +test('New repo: initialize', async ({browser}, workerInfo) => { + const page = await login({browser}, workerInfo); + const response = await page.goto('/repo/create'); + expect(response?.status()).toBe(200); + // check that relevant form content is hidden or available + await expect(page.getByRole('group', {name: 'Use a template You can select'}).getByRole('combobox')).toBeVisible(); + await expect(page.getByText('.gitignore Select .gitignore')).toBeHidden(); + // fill initialization section + await page.getByText('Start the Git history with').click(); + await page.getByText('Select .gitignore templates').click(); + await page.getByLabel('.gitignore Select .gitignore').fill('Go'); + await page.getByRole('option', {name: 'Go', exact: true}).click(); + await page.keyboard.press('Escape'); + await page.getByLabel('License Select a license file').click(); + await page.getByRole('option', {name: 'MIT', exact: true}).click(); + await page.keyboard.press('Escape'); + // add advanced settings + await page.getByText('Click to expand').click(); + await page.getByPlaceholder('master').fill('main'); + await page.getByLabel('Make repository a template').check(); + + await validate_form({page}, 'fieldset'); + await save_visual(page); + const reponame = dynamic_id(); + await page.getByLabel('Repository name').fill(reponame); + await page.getByRole('button', {name: 'Create repository'}).click(); + await expect(page.getByRole('link', {name: '.gitignore'})).toBeVisible(); + await expect(page.getByRole('link', {name: 'LICENSE', exact: true})).toBeVisible(); + if (!workerInfo.project.name.includes('Mobile')) { + await expect(page.getByText('Template', {exact: true})).toBeVisible(); + } + await save_visual(page); +}); + +test('New repo: initialize later', async ({browser}, workerInfo) => { + const page = await login({browser}, workerInfo); + const response = await page.goto('/repo/create'); + expect(response?.status()).toBe(200); + + const reponame = dynamic_id(); + await page.getByLabel('Repository name').fill(reponame); + await page.getByPlaceholder('Enter short description').fill(`Description for repo ${reponame}`); + await page.getByText('Click to expand').click(); + await page.getByPlaceholder('master').fill('devbranch'); + await validate_form({page}, 'fieldset'); + await page.getByRole('button', {name: 'Create repository'}).click(); + expect(page.url()).toBe(`http://localhost:3003/user2/${reponame}`); + await expect(page.getByRole('link', {name: 'New file'})).toBeVisible(); + await expect(page.getByRole('heading', {name: 'Creating a new repository on'})).toBeVisible(); + await save_visual(page); + + // add a README + await page.getByRole('link', {name: 'New file'}).click(); + // wait for loading spinner to disappear + // Otherwise, filling the filename might not populate the tree_path form field or preview tab + // The editor has race conditions, likely related to https://codeberg.org/forgejo/forgejo/issues/3371 + await expect(page.locator('.is-loading')).toBeHidden(); + await page.locator('.view-lines').click(); + await page.keyboard.type('# Heading\n\nHello Forgejo!'); + await page.getByPlaceholder('Name your fileā€¦').fill('README.md'); + await expect(page.getByText('Preview')).toBeVisible(); + await page.getByPlaceholder('Add ""').fill('My first commit message'); + await page.getByRole('button', {name: 'Commit changes'}).click(); + expect(page.url()).toBe(`http://localhost:3003/user2/${reponame}/src/branch/devbranch/README.md`); + await expect(page.getByRole('link', {name: 'My first commit message'})).toBeVisible(); + await expect(page.getByText('Hello Forgejo!')).toBeVisible(); + await save_visual(page); +}); + +test('New repo: from template', async ({browser}, workerInfo) => { + test.skip(['Mobile Safari', 'webkit'].includes(workerInfo.project.name), 'WebKit browsers seem to have CORS issues with localhost here.'); + const page = await login({browser}, workerInfo); + const response = await page.goto('/repo/create'); + expect(response?.status()).toBe(200); + + const reponame = dynamic_id(); + await page.getByRole('group', {name: 'Use a template You can select'}).getByRole('combobox').click(); + await page.getByRole('option', {name: 'user27/template1'}).click(); + await page.getByText('Git content (Default branch)').click(); + await save_visual(page); + await page.getByLabel('Repository name').fill(reponame); + await page.getByRole('button', {name: 'Create repository'}).click(); + await expect(page.getByRole('link', {name: `${reponame}.log`})).toBeVisible(); + await save_visual(page); +}); + +test('New repo: label set', async ({browser}, workerInfo) => { + const page = await login({browser}, workerInfo); + await page.goto('/repo/create'); + + const reponame = dynamic_id(); + await page.getByText('Click to expand').click(); + await page.getByLabel('Labels Select a label set').click(); + await page.getByRole('option', {name: 'Advanced (Kind/Bug, Kind/'}).click(); + // close dropdown via unrelated click + await page.getByText('You can select an existing').click(); + await save_visual(page); + await page.getByLabel('Repository name').fill(reponame); + await page.getByRole('button', {name: 'Create repository'}).click(); + await page.goto(`/user2/${reponame}/issues`); + await page.getByRole('link', {name: 'Labels'}).click(); + await expect(page.getByText('Kind/Bug Something is not')).toBeVisible(); + await save_visual(page); +}); diff --git a/tests/e2e/shared/forms.ts b/tests/e2e/shared/forms.ts index 99ad5a0a6d..fc608489b0 100644 --- a/tests/e2e/shared/forms.ts +++ b/tests/e2e/shared/forms.ts @@ -7,6 +7,9 @@ export async function validate_form({page}: {page: Page}, scope: 'form' | 'field 'span[data-tooltip-content', // exclude weird non-semantic HTML disabled content '.disabled', + // legacy dropdowns don't use semantic HTML yet, + // avoid using these where possible + '.ui.dropdown', ]; await accessibilityCheck({page}, [scope], excludedElements, []); diff --git a/tests/e2e/utils_e2e.ts b/tests/e2e/utils_e2e.ts index 09189e6826..31fc999fb0 100644 --- a/tests/e2e/utils_e2e.ts +++ b/tests/e2e/utils_e2e.ts @@ -81,6 +81,14 @@ export async function save_visual(page: Page) { await page.locator('.flex-item-body > relative-time').filter({hasText: /now|minute/}).evaluateAll((nodes) => { for (const node of nodes) node.outerHTML = 'relative time in repo'; }); + // dynamically generated UUIDs + await page.getByText('dyn-id-').evaluateAll((nodes) => { + for (const node of nodes) node.innerHTML = node.innerHTML.replaceAll(/dyn-id-[a-f0-9-]+/g, 'dynamic-id'); + }); + // repeat above, work around https://github.com/microsoft/playwright/issues/34152 + await page.getByText('dyn-id-').evaluateAll((nodes) => { + for (const node of nodes) node.innerHTML = node.innerHTML.replaceAll(/dyn-id-[a-f0-9-]+/g, 'dynamic-id'); + }); await page.locator('relative-time').evaluateAll((nodes) => { for (const node of nodes) node.outerHTML = 'time element'; }); @@ -97,6 +105,8 @@ export async function save_visual(page: Page) { page.locator('#repo_migrating'), // update order of recently created repos is not fully deterministic page.locator('.flex-item-main').filter({hasText: 'relative time in repo'}), + // dynamic IDs in fixed-size inputs + page.locator('input[value*="dyn-id-"]'), ], }); } @@ -122,3 +132,8 @@ export async function create_temp_user(browser: Browser, workerInfo: TestInfo, r return {context: await login_user(browser, workerInfo, username), username}; } + +// returns a random string with a pattern that can be filtered for screenshots automatically +export function dynamic_id() { + return `dyn-id-${globalThis.crypto.randomUUID()}`; +}