From 869b89293edfa1feabc2b52c794dcf65a55421d6 Mon Sep 17 00:00:00 2001 From: Hanrim <148833226+MerciHanrim@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:14:00 +0900 Subject: [PATCH] feat(about): the third-party open-source licenses, in the About dialog (v0.18.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #301, the second of its two pull requests. Version 0.18.0. - About Loop Studio gains a "Third-party open-source licenses" button that turns the same dialog into its licence view, not a second modal. The view says first that these are the notices of the third-party software Loop Studio includes and that they cover those components only, not Loop Studio itself; then the build's own notices, unchanged and in English, in a scrollable, focusable
that the browser can
search, select and copy. Back to About returns focus to the button; Escape,
the backdrop and x close the whole dialog, and the next opening starts on
About. Loading and a failed read have their own states, with Try again.
- src/licenses/notices.ts reads the text: the same-origin
THIRD_PARTY_NOTICES.txt on the web and PWA builds (precached by the PWA, so
it opens offline; a non-plain-text answer such as a host's index.html
fallback is refused), and template.content.textContent in the portable
file. The web and PWA builds also offer the file in a new tab with
rel="noopener noreferrer"; the portable file has no separate file and no
link. The text is a React text child; a new check, check:licence-screen,
parses the loader, the view and the dialog with the TypeScript parser and
fails on any HTML sink (dangerouslySetInnerHTML, innerHTML / outerHTML,
insertAdjacentHTML, document.write, createContextualFragment, DOMParser,
setHTMLUnsafe) or a loader that does not read the template's content.
- The dev server serves THIRD_PARTY_NOTICES.txt too (thirdPartyNoticesDev):
the web build's text rebuilt in memory from the committed manifest and the
installed packages (noticesFromManifest), with every licence file, NOTICE
file and the whole text checked against the manifest's SHA-256; nothing is
written to disk or fetched. A unit test rebuilds all three sections and
compares them with the manifest.
- e2e: on the dev server, the production bundle, the PWA with the network
down and the portable file, the text on screen is one text node whose
SHA-256 is the manifest's for that build; the file link opens the same
bytes with no opener (offline from the precache on the PWA); a failed read,
Back, Escape, 18 languages and a 320 px phone are covered. The PWA test
server now serves .txt as text/plain, as Cloudflare Pages does.
- The About dialog's links take the dialog's ink, underlined: the browser's
default link blue was unreadable on the dark panel.
- Declared user-facing: version 0.18.0 and release note release:0.18.0, three
lines; nine new interface strings; all in 18 languages, 16 without native
review. The per-language copy tests move their pinned counts by those
twelve keys, as each release does, and declare the loanwords Italian and
Dutch use (open source, software, copyright) and two Spanish homographs;
pt-PT stays under its quarter bound. scripts/arrow-units.json records the
view's external-link icon.
- Docs: docs/guided-tour.md §GT7.1, licenses/README.md, and README's
release section (it still said "Latest — v0.15.3") and Copyright section;
CHANGELOG.md gains v0.16.0 to v0.18.0, which it did not have.
---
.changes/licence-screen.json | 1 +
.github/workflows/ci.yml | 2 +
CHANGELOG.md | 47 +++++
README.md | 65 +++----
docs/guided-tour.md | 25 +++
e2e/dist.spec.ts | 20 +++
e2e/licenses.spec.ts | 170 ++++++++++++++++++
e2e/portable-file.spec.ts | 13 ++
e2e/pwa.spec.ts | 27 +++
e2e/support/licenses.ts | 49 +++++
e2e/support/pwa-serve.mjs | 2 +
e2e/whats-new.spec.ts | 2 +-
licenses/README.md | 6 +
package-lock.json | 4 +-
package.json | 3 +-
scripts/arrow-units.json | 3 +-
scripts/check-licence-screen.mjs | 33 ++++
scripts/licence-screen-rules.mjs | 51 ++++++
scripts/licence-screen-rules.test.mjs | 51 ++++++
scripts/third-party-notices.test.mjs | 28 +++
scripts/third-party-notices/core.mjs | 61 +++++++
scripts/third-party-notices/vite-plugin.d.mts | 3 +
scripts/third-party-notices/vite-plugin.mjs | 40 +++++
src/components/AboutDialog.tsx | 102 ++++++++---
src/components/LicensesView.tsx | 76 ++++++++
src/i18n/es419Copy.test.ts | 2 +
src/i18n/itCopy.test.ts | 9 +-
src/i18n/locales/ar/ui.ts | 12 ++
src/i18n/locales/de/ui.ts | 12 ++
src/i18n/locales/en/ui.ts | 12 ++
src/i18n/locales/es-419/ui.ts | 12 ++
src/i18n/locales/es-ES/ui.ts | 12 ++
src/i18n/locales/fr/ui.ts | 12 ++
src/i18n/locales/it/ui.ts | 12 ++
src/i18n/locales/ja/ui.ts | 12 ++
src/i18n/locales/ko/ui.ts | 12 ++
src/i18n/locales/nl/ui.ts | 12 ++
src/i18n/locales/pt-BR/ui.ts | 12 ++
src/i18n/locales/pt-PT/ui.ts | 12 ++
src/i18n/locales/ru/ui.ts | 12 ++
src/i18n/locales/th/ui.ts | 12 ++
src/i18n/locales/tr/ui.ts | 12 ++
src/i18n/locales/vi/ui.ts | 12 ++
src/i18n/locales/zh-Hans/ui.ts | 12 ++
src/i18n/locales/zh-Hant/ui.ts | 12 ++
src/i18n/nlCopy.test.ts | 8 +-
src/i18n/ptPtCopy.test.ts | 13 +-
src/i18n/ruCopy.test.ts | 3 +-
src/i18n/thCopy.test.ts | 10 +-
src/i18n/trCopy.test.ts | 2 +-
src/i18n/viCopy.test.ts | 10 +-
src/index.css | 63 +++++++
src/licenses/notices.test.ts | 88 +++++++++
src/licenses/notices.ts | 70 ++++++++
src/releaseNotes/releaseNotes.ts | 8 +
vite.config.ts | 5 +-
56 files changed, 1309 insertions(+), 82 deletions(-)
create mode 100644 .changes/licence-screen.json
create mode 100644 e2e/licenses.spec.ts
create mode 100644 e2e/support/licenses.ts
create mode 100644 scripts/check-licence-screen.mjs
create mode 100644 scripts/licence-screen-rules.mjs
create mode 100644 scripts/licence-screen-rules.test.mjs
create mode 100644 src/components/LicensesView.tsx
create mode 100644 src/licenses/notices.test.ts
create mode 100644 src/licenses/notices.ts
diff --git a/.changes/licence-screen.json b/.changes/licence-screen.json
new file mode 100644
index 00000000..6507017b
--- /dev/null
+++ b/.changes/licence-screen.json
@@ -0,0 +1 @@
+{ "type": "user-facing", "releaseNoteId": "release:0.18.0" }
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3aa4b5bc..37b7ec00 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -74,6 +74,8 @@ jobs:
run: npm run check:storage-port
- name: Share crypto — key handling and encryption stay in one module, with fixed parameters
run: npm run check:share-crypto
+ - name: Licence screen — the third-party notices are shown as text, never through an HTML sink
+ run: npm run check:licence-screen
- name: Change declaration — a change that ships says whether it is user-facing, with its version and release note
run: npm run check:change-declaration
env:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b04c1cde..5179fb4f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,53 @@ All notable Loop Studio releases, newest first. Behavioral changes are pinned
in versioned spec documents (see the [README](README.md#technical-reference));
this file is the narrative history, not the contract.
+## v0.18.0 — 2026-10-05
+
+The third-party open-source licenses, inside the app (issue #301).
+
+- **About Loop Studio has a Third-party open-source licenses button.** It turns the same dialog into a licence view, not a second dialog: the view says first that these are the notices of the third-party software Loop Studio includes and that they cover those components only, not Loop Studio itself, then shows every component's full licence text, in its original English, in a scrollable block that can be searched, selected and copied. Back to About returns to the button; Escape closes the dialog.
+- **The text is the build's own notices file**, the one every build has carried since the previous change: the web and installed builds read `THIRD_PARTY_NOTICES.txt` from their own origin (the installed app has it precached, so it opens offline) and also offer it as a text file in a new tab; the single-file version reads the copy inside its own HTML. It is shown as text, never as HTML, and a new check, `check:licence-screen`, fails if that ever changes. Loading and a failed read have their own states, with Try again.
+- **The dev server serves the same text**, rebuilt from the committed manifest and the installed packages, and checked against the manifest byte for byte. On the dev server, the production bundle, the installed app offline and the single-file version, the end-to-end tests compare the text on screen with the manifest's SHA-256.
+- **The About dialog's links are readable in the dark theme**: they had the browser's default blue.
+
+**No migration.** Nine new interface strings and three release-note lines in 18 languages, 16 of them without native review. The informational `meta.tool` string is now `loop-studio/0.18.0`.
+
+## v0.17.2 — 2026-10-05
+
+A fix release. The Temporary session button in the toolbar was a 20 px pill with 10 px grey text and read as a status badge.
+
+- **It is drawn as the menu buttons beside it**: 28 px tall, the 8 px control radius, 12 px text, the primary ink on the raised face, their hover boundary and their keyboard focus. Its orange border still marks a temporary session at rest. Nothing in how sessions switch or save changed.
+
+**No migration.** The informational `meta.tool` string is now `loop-studio/0.17.2`.
+
+## v0.17.1 — 2026-10-04
+
+A fix release (issue #301). Share links used a bundled compression library whose origin the repository's record does not establish.
+
+- **Share links are compressed and decompressed only with the browser's own Compression Streams**; the bundled code is removed. Every browser the build targets has them. Links the removed code made still open, and the link format, limits and opening order are unchanged.
+- **A page without them** makes no plain or protected link, and opening a link there shows one sentence, asks for no password and leaves the open diagram untouched.
+
+**No migration.** The informational `meta.tool` string is now `loop-studio/0.17.1`.
+
+## v0.17.0 — 2026-10-04
+
+Optional password protection for share links (issue #300).
+
+- **Protect with a password**, unticked by default: the diagram is sealed inside the link with AES-256-GCM under a key derived from the password (PBKDF2-HMAC-SHA-256, 600,000 iterations), with Web Crypto only (`docs/specs/SEMANTICS-P.md`). A plain link is created and opened exactly as before.
+- **Opening a protected link** checks its structure first, removes the fragment, then asks for the password; nothing from the shared diagram is drawn before a correct one. A wrong password and altered link data give the same message. Loop Studio does not store or transmit the password or the key, and a lost password cannot be recovered.
+
+**No migration.** The informational `meta.tool` string is now `loop-studio/0.17.0`.
+
+## v0.16.0 — 2026-10-04
+
+Storage on a shared computer (issue #297).
+
+- **A storage gate**: before anything stored is read, Loop Studio asks whether this is a personal browser or a shared computer. A temporary session neither reads nor saves stored work, author information or settings; only the start-up choice can be stored. The single-file version asks every time and never remembers.
+- **A Storage and privacy area in Settings**: the storage mode, the restore toggle, switching to the other kind of session, deleting the stored work and resetting everything Loop Studio keeps. A failed deletion is never shown as done.
+- **A temporary session shows a standing button** with export and the ways out, warns before a reload would lose work, and is asked before the installed app restarts. The share dialog says that the link itself contains the entire document.
+
+**No migration.** The informational `meta.tool` string is now `loop-studio/0.16.0`.
+
## v0.15.3 — 2026-10-03
A fix release. The symbols on the buttons were characters an operating-system font drew, so a button looked different from Windows to iPhone, and on iPhone some of them were colour emoji.
diff --git a/README.md b/README.md
index 2d4753c2..0dc8eac6 100644
--- a/README.md
+++ b/README.md
@@ -140,53 +140,42 @@ Additional feature-specific design documents (localization, mobile, module
system, large-graph readability, simulation playback, edge routing, data
import, …) live under [`docs/`](docs/).
-## Latest — v0.15.3
+## Latest — v0.18.0
-A fix release: the buttons' symbols are the app's own icons.
+The third-party open-source licenses, inside the app.
-- **Every functional icon is drawn by the app**, not by an operating-system font, so Play,
- the lock, the menu arrows and the rest look the same on Windows, iPhone and everywhere else
-- **Names are words**: no symbol is read out before a label, and the Reset and Step buttons
- have names
+- **About Loop Studio → Third-party open-source licenses** shows the full license text of
+ every third-party component the build includes, in its original English, in the same
+ dialog; Back returns to About
+- **Offline too**: the installed app has the text precached, and the single-file version
+ carries it inside the file; the web and installed builds also open it as a text file
+- **Loop Studio's own notice is unchanged**: the licenses cover the included components only
-## v0.15.2
+## v0.17.2
-A fix release: the theme you chose comes back when the app starts.
+A fix release: the Temporary session button looks like the menu buttons beside it.
-- **The saved theme is applied at start-up**, without opening Settings; an unreadable value
- follows the system theme
-- **No light flash before a dark start**: the theme is read in the page head, before anything
- is painted
+- **The same height, corners, text size and colours** as the toolbar's menu buttons, and
+ their hover and keyboard focus; its orange border still marks a temporary session
-## v0.15.1
+## v0.17.1
-A fix release: the playback bar keeps to its space at narrow widths.
+A fix release: share links use the browser's own compression.
-- **On a phone**, the run bar no longer covers the bottom of the canvas when its buttons wrap
- to a second row; the Timeline sheet, the More sheet and the zoom buttons stay above it
- whatever its height
-- **In a narrow desktop window**, the playback controls are no longer cut off at the bottom
- of the window
+- **Share links are compressed with the browser's built-in Compression Streams**; the
+ bundled compression code is removed. Existing links still open and the link format is
+ unchanged
+- **A browser without them** makes no link and says so, and the open diagram is kept
-## v0.15.0
+## v0.17.0
-- **An update notice** — after an update, a browser that has used Loop Studio before sees
- one line naming the new version; it takes no focus, changes nothing and never times out
-- **What's new, in the Help menu** — every release note, newest first, in all eighteen
- languages, offline; a `New` marker stays until the newest entry has been opened
-- **A clearer Help menu** — grouped by purpose, with items named for what they do:
- `Restart the tour`, `Turn contextual tips back on`
-- **A Timeline series selector** — choose which series the chart draws; a document with no
- saved choice draws the first eight instead of every line
-- **A refreshed light theme** — one calm palette, rounder panels and a clearer keyboard
- focus indicator; the dark theme is unchanged
+- **Password-protected share links** — an optional password encrypts the diagram inside
+ the link, in the browser; the password is asked for before anything from the diagram is
+ shown, and a lost password cannot be recovered. A plain link is still the default
-No migration: a v0.14.0 file opens unchanged. From this release on, a visible change ships
-with its version and its release note in the same change — see
-[`docs/release-notes.md`](docs/release-notes.md).
-
-See [`CHANGELOG.md`](CHANGELOG.md) for the full v0.15.2, v0.15.1 and v0.15.0 notes, the
-v0.14.0 and v0.13.0 releases and every earlier one.
+See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.16.0 (the
+storage gate, temporary sessions and the Storage and privacy area), the v0.15 releases and
+every earlier one.
## Credits
@@ -199,3 +188,7 @@ publicly documented academic work on game-economy diagrams.
## Copyright
Copyright © 2026 Hanrim. All rights reserved.
+
+Loop Studio includes third-party open-source components. Their licenses are in the app
+(About Loop Studio → Third-party open-source licenses) and in every build
+([`licenses/README.md`](licenses/README.md)); they cover those components only.
diff --git a/docs/guided-tour.md b/docs/guided-tour.md
index eaf51684..9a49e24f 100644
--- a/docs/guided-tour.md
+++ b/docs/guided-tour.md
@@ -427,6 +427,31 @@ Loop Studio는 독립 프로젝트이며 Machinations.io와
- No animation requirement; respects `prefers-reduced-motion` and `forced-colors`
like every other dialog.
+**The licence view** (issue #301, v0.18.0)
+
+- Below the non-affiliation sentence, a button, `Third-party open-source
+ licenses` / `제3자 오픈소스 라이선스` (key `about.licenses`), turns the SAME
+ dialog into its licence view: no second modal. The title becomes that name;
+ focus moves to `Back to About`, which returns to About and puts focus back on
+ the button. `Escape`, the backdrop and × still close the whole dialog, and
+ the next opening starts on About.
+- The view says first, in the UI language, that these are the notices of the
+ third-party software Loop Studio includes, that they cover those components
+ only and not Loop Studio itself, and that each licence is shown in its
+ original English. Loop Studio's own copyright line is unchanged.
+- The notices are the build's own `THIRD_PARTY_NOTICES.txt`, untranslated, in a
+ scrollable, focusable `` that the browser can search,
+ select and copy. They are a React text child, never HTML
+ (`npm run check:licence-screen`, and [`licenses/README.md`](../licenses/README.md)).
+- Web and PWA read the same-origin file when the view opens (the PWA has it
+ precached, so it opens offline) and offer `Open as a text file`, a new tab
+ with `rel="noopener noreferrer"`. The portable file reads the text from its
+ own `` and has no file link. The dev
+ server answers the file with the web build's text, rebuilt from the
+ committed manifest.
+- Loading and a failed read have their own states; `Try again` reads again,
+ and `Back to About` works in every state. Nothing is stored.
+
## GT8. Localization
All copy is in the localization catalog under a `tour.*` namespace, EN canonical
diff --git a/e2e/dist.spec.ts b/e2e/dist.spec.ts
index 622d8a0a..a4b98eb8 100644
--- a/e2e/dist.spec.ts
+++ b/e2e/dist.spec.ts
@@ -4,6 +4,7 @@ import { expect, test } from './support/loop'
import { capturedExports, installProbe, pathProbe } from './support/mc'
import { expectOneVersionStory, readAboutVersion, readNewestShown } from './support/whatsNew'
import { RELEASE_NOTES } from '../src/releaseNotes/releaseNotes'
+import { expectShownNoticesAre, manifestNoticesSha, openAbout, openLicences, sha256 } from './support/licenses'
// Runs under playwright.dist.config.ts: production `npm run build` served by
// `vite preview` at the root `/` — the shape Cloudflare Pages serves. No
@@ -675,3 +676,22 @@ test.describe('production build: a password-protected share link', () => {
expect(bad).toEqual([])
})
})
+
+// ── issue #301 — the third-party licences, as the production build serves them
+test.describe('production build — the third-party open-source licenses', () => {
+ test('About shows the built THIRD_PARTY_NOTICES.txt as text, and its file link opens the same bytes in a new tab', async ({ page, context }) => {
+ const { bad } = await openProd(page)
+ await openAbout(page)
+ const dlg = await openLicences(page)
+ await expectShownNoticesAre(page, 'web')
+ const link = dlg.locator('[data-licenses-file]')
+ await expect(link).toHaveAttribute('rel', /\bnoopener\b/)
+ const [tab] = await Promise.all([context.waitForEvent('page'), link.click()])
+ await tab.waitForLoadState()
+ expect(new URL(tab.url()).pathname).toBe('/THIRD_PARTY_NOTICES.txt')
+ expect(await tab.evaluate(() => window.opener)).toBeNull()
+ expect(sha256(await (await tab.request.get(tab.url())).text())).toBe(manifestNoticesSha('web'))
+ await tab.close()
+ expect(bad, 'no failed or cross-origin requests').toEqual([])
+ })
+})
diff --git a/e2e/licenses.spec.ts b/e2e/licenses.spec.ts
new file mode 100644
index 00000000..f99d5929
--- /dev/null
+++ b/e2e/licenses.spec.ts
@@ -0,0 +1,170 @@
+import type { Page } from '@playwright/test'
+import { expect, openApp, test } from './support/loop'
+import { aboutDialog, expectShownNoticesAre, manifestNoticesSha, openAbout, openLicences, sha256 } from './support/licenses'
+
+// Issue #301 — the About dialog's licence view, on the dev server. The dev
+// server answers THIRD_PARTY_NOTICES.txt with the web build's text rebuilt
+// from the committed manifest (scripts/third-party-notices/vite-plugin.mjs
+// `thirdPartyNoticesDev`), so the screen must show exactly the bytes the
+// manifest pins for the web build. The production bundle, the PWA (offline)
+// and the portable file are covered in dist.spec.ts, pwa.spec.ts and
+// portable-file.spec.ts.
+
+const NOTICES = '**/THIRD_PARTY_NOTICES.txt'
+
+test.describe('the licence view in About', () => {
+ test('shows the web build’s notices as text, says first that they cover third-party components, and Back returns to About', async ({ page }) => {
+ await openApp(page)
+ const dlg = await openAbout(page)
+ const button = dlg.locator('[data-about-licenses]')
+ await expect(button).toHaveText('Third-party open-source licenses')
+ await openLicences(page)
+ // the same dialog, now titled with the view's name; focus on Back
+ await expect(page.locator('.mcdlg--about')).toHaveCount(1)
+ await expect(dlg.locator('.mcdlg__head')).toContainText('Third-party open-source licenses')
+ await expect(dlg.locator('[data-licenses-back]')).toBeFocused()
+ await expect(dlg.locator('.licenses__lead')).toContainText('They cover those components only, not Loop Studio itself.')
+ await expectShownNoticesAre(page, 'web')
+ // the text: left to right, English, scrollable, focusable, selectable
+ const pre = dlg.locator('[data-licenses-text]')
+ expect(await pre.evaluate((el) => ({ dir: el.getAttribute('dir'), lang: el.getAttribute('lang'), tab: el.tabIndex, label: el.getAttribute('aria-label'), scrolls: el.scrollHeight > el.clientHeight, overflow: getComputedStyle(el).overflowY, select: getComputedStyle(el).userSelect }))).toEqual({ dir: 'ltr', lang: 'en', tab: 0, label: 'License texts', scrolls: true, overflow: 'auto', select: 'text' })
+ // selecting it gives the text back (what a copy would take). MEASURED:
+ // Chrome's selection drops the text's one final newline and nothing else
+ const selected = await pre.evaluate((el) => {
+ const r = document.createRange()
+ r.selectNodeContents(el)
+ const s = getSelection()!
+ s.removeAllRanges()
+ s.addRange(r)
+ return s.toString()
+ })
+ expect(sha256(`${selected}\n`)).toBe(manifestNoticesSha('web'))
+ // the file link: same origin, a new tab, no opener
+ const link = dlg.locator('[data-licenses-file]')
+ await expect(link).toHaveAttribute('href', new URL('THIRD_PARTY_NOTICES.txt', page.url()).href)
+ await expect(link).toHaveAttribute('target', '_blank')
+ await expect(link).toHaveAttribute('rel', /\bnoopener\b/)
+ // Back: About again, focus on the button that left it
+ await dlg.locator('[data-licenses-back]').click()
+ await expect(dlg).toHaveAttribute('data-about-view', 'about')
+ await expect(dlg.locator('[data-about-licenses]')).toBeFocused()
+ await expect(dlg).toContainText('Copyright © 2026 Hanrim. All rights reserved.')
+ })
+
+ test('the file link opens the same text in a new tab, with no opener', async ({ page, context }) => {
+ await openApp(page)
+ await openAbout(page)
+ const dlg = await openLicences(page)
+ await expectShownNoticesAre(page, 'web')
+ const [tab] = await Promise.all([context.waitForEvent('page'), dlg.locator('[data-licenses-file]').click()])
+ await tab.waitForLoadState()
+ expect(new URL(tab.url()).pathname).toBe('/THIRD_PARTY_NOTICES.txt')
+ expect(await tab.evaluate(() => window.opener)).toBeNull()
+ const body = await (await tab.request.get(tab.url())).text()
+ expect(sha256(body)).toBe(manifestNoticesSha('web'))
+ await tab.close()
+ })
+
+ test('Escape closes the whole dialog from the licence view, and it opens on About next time', async ({ page }) => {
+ await openApp(page)
+ await openAbout(page)
+ await openLicences(page)
+ await page.keyboard.press('Escape')
+ await expect(aboutDialog(page)).toHaveCount(0)
+ await expect(page.locator('[data-tour="help-trigger"]')).toBeFocused()
+ await openAbout(page)
+ await expect(aboutDialog(page)).toHaveAttribute('data-about-view', 'about')
+ })
+
+ test('a read that fails has its own state; About stays usable, and Try again reads again', async ({ page, errors }) => {
+ await openApp(page)
+ let fail = true
+ await page.route(NOTICES, (route) => (fail ? route.fulfill({ status: 503, contentType: 'text/plain', body: 'unavailable' }) : route.continue()))
+ await openAbout(page)
+ const dlg = await openLicences(page)
+ await expect(dlg.locator('.licenses')).toHaveAttribute('data-licenses-state', 'error')
+ await expect(dlg.locator('[role="alert"]')).toContainText('The license texts could not be loaded.')
+ await expect(dlg.locator('[data-licenses-text]')).toHaveCount(0)
+ // Back still works, and the licences button opens the view again
+ await dlg.locator('[data-licenses-back]').click()
+ await expect(dlg).toHaveAttribute('data-about-view', 'about')
+ await openLicences(page)
+ await expect(dlg.locator('.licenses')).toHaveAttribute('data-licenses-state', 'error')
+ fail = false
+ await dlg.locator('[data-licenses-retry]').click()
+ await expectShownNoticesAre(page, 'web')
+ // the two refused reads log their 503; nothing else is expected
+ expect(errors.filter((e) => !/Failed to load resource: the server responded with a status of 503/.test(e))).toEqual([])
+ expect(errors).toHaveLength(2)
+ errors.length = 0
+ })
+
+ test('a host that answers with the app page instead is a failure, not shown as notices', async ({ page }) => {
+ await openApp(page)
+ await page.route(NOTICES, (route) => route.fulfill({ status: 200, contentType: 'text/html', body: 'Loop Studio ' }))
+ await openAbout(page)
+ const dlg = await openLicences(page)
+ await expect(dlg.locator('.licenses')).toHaveAttribute('data-licenses-state', 'error')
+ })
+
+ test('the dev server’s text is the manifest’s web text, byte for byte', async ({ page }) => {
+ await openApp(page)
+ const res = await page.request.get('/THIRD_PARTY_NOTICES.txt')
+ expect(res.status()).toBe(200)
+ expect(res.headers()['content-type']).toMatch(/^text\/plain/)
+ expect(sha256(await res.text())).toBe(manifestNoticesSha('web'))
+ })
+
+ test('in all 18 languages the button and the title read the catalog’s name, and the text stays English, left to right', async ({ page }) => {
+ await openApp(page)
+ const codes = await page.evaluate(async () => (await import('/src/i18n/registry.ts')).enabledLocales().map((l) => l.code).filter((c) => !/-X[A-Z]$/.test(c)))
+ expect(codes).toHaveLength(18)
+ const setLocale = async (code: string) => {
+ await page.evaluate((c) => (window as unknown as { __loop: { i18n: { getState: () => { setLocale: (c: string) => void } } } }).__loop.i18n.getState().setLocale(c), code)
+ await expect.poll(() => page.evaluate(() => document.documentElement.lang)).toBe(code)
+ }
+ const name = (page: Page) => page.evaluate(() => (window as unknown as { __loop: { i18n: { getState: () => { activeCatalog: Record } } } }).__loop.i18n.getState().activeCatalog['about.licenses'])
+ for (const code of codes) {
+ await setLocale(code)
+ const expected = await name(page)
+ expect(expected, `${code} has a name for the licence view`).toBeTruthy()
+ if (code === 'ko') expect(expected).toBe('제3자 오픈소스 라이선스')
+ await page.locator('[data-tour="help-trigger"]').click()
+ await page.locator('[role="menu"] [role="menuitem"]').last().click()
+ const dlg = aboutDialog(page)
+ await expect(dlg.locator('[data-about-licenses]')).toHaveText(expected!)
+ await openLicences(page)
+ await expect(dlg.locator('.mcdlg__head > span')).toHaveText(expected!)
+ const pre = dlg.locator('[data-licenses-text]')
+ await expect(pre).toBeVisible()
+ expect(await pre.evaluate((el) => [el.getAttribute('dir'), el.getAttribute('lang'), el.scrollWidth <= el.clientWidth])).toEqual(['ltr', 'en', true])
+ await page.keyboard.press('Escape')
+ await expect(dlg).toHaveCount(0)
+ }
+ })
+})
+
+test.describe('on a phone', () => {
+ test.use({ viewport: { width: 320, height: 640 }, isMobile: true, hasTouch: true })
+
+ test('the More sheet’s About opens the same licence view; nothing is cut off and the page does not scroll sideways', async ({ page }) => {
+ await openApp(page)
+ await page.locator('.mob-more').click()
+ const help = page.locator('.sheet__row', { hasText: /^Help/ })
+ await help.focus()
+ await page.keyboard.press('Enter')
+ const about = page.locator('.sheet .sheet__row', { hasText: /^About Loop Studio$/ })
+ await about.focus()
+ await page.keyboard.press('Enter')
+ const dlg = aboutDialog(page)
+ await expect(dlg).toBeVisible()
+ await openLicences(page)
+ await expectShownNoticesAre(page, 'web')
+ const fit = await page.evaluate(() => {
+ const d = document.querySelector('.mcdlg--about')!.getBoundingClientRect()
+ const pre = document.querySelector('[data-licenses-text]') as HTMLElement
+ return { dialogInside: d.left >= 0 && d.right <= window.innerWidth, preNoSideways: pre.scrollWidth <= pre.clientWidth, page: document.documentElement.scrollWidth - document.documentElement.clientWidth }
+ })
+ expect(fit).toEqual({ dialogInside: true, preNoSideways: true, page: 0 })
+ })
+})
diff --git a/e2e/portable-file.spec.ts b/e2e/portable-file.spec.ts
index d1f8b978..36f28aec 100644
--- a/e2e/portable-file.spec.ts
+++ b/e2e/portable-file.spec.ts
@@ -8,6 +8,7 @@ import { expectOneVersionStory, readAboutVersion, readNewestShown, seedWhatsNewS
import { RELEASE_NOTES } from '../src/releaseNotes/releaseNotes'
import { LEGACY_SHARE_VECTORS } from '../src/model/shareLegacy.fixture'
import { PORTABLE_TEMPLATE_ID, portableTemplate, sha256 } from '../scripts/third-party-notices/core.mjs'
+import { expectShownNoticesAre, openAbout, openLicences } from './support/licenses'
// SLICE-2 §5–§6: the portable single-file build opened from file://. No dev
// server, no window.__loop bridge (production build) — driven entirely through
@@ -210,6 +211,18 @@ test.describe('portable file://', () => {
}
}, html)
expect(parsed).toEqual({ elements: ['html', 'head', 'body', 'template'], kinds: [TEXT_NODE], inside: 0, text: HOSTILE })
+ // the About dialog's licence view reads the same template and shows it as
+ // text; the file has no separate notices file, so there is no file link
+ await page.goto(portableUrl())
+ await answerPortableGate(page)
+ await expect(page.locator('.toolbar')).toBeVisible()
+ await page.locator('.tour-card').waitFor({ timeout: 15_000 })
+ await page.getByRole('button', { name: 'Skip' }).first().click()
+ await openAbout(page)
+ const dlg = await openLicences(page)
+ await expectShownNoticesAre(page, 'portable')
+ await expect(dlg.locator('[data-licenses-file]')).toHaveCount(0)
+ await page.keyboard.press('Escape')
// and a live document: nothing in it runs
await page.setContent(html)
expect(await page.evaluate(() => ({ pwned: (window as unknown as { __pwned?: number }).__pwned ?? null, scripts: document.scripts.length, imgs: document.images.length }))).toEqual({ pwned: null, scripts: 0, imgs: 0 })
diff --git a/e2e/pwa.spec.ts b/e2e/pwa.spec.ts
index 40d1fad6..30b02a35 100644
--- a/e2e/pwa.spec.ts
+++ b/e2e/pwa.spec.ts
@@ -2,6 +2,7 @@ import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { expectOneVersionStory, readAboutVersion, readNewestShown, seedWhatsNewSeen } from './support/whatsNew'
import { RELEASE_NOTES } from '../src/releaseNotes/releaseNotes'
+import { expectShownNoticesAre, manifestNoticesSha, openAbout, openLicences, sha256 } from './support/licenses'
// Runs under playwright.pwa.config.ts — three pre-built `--mode pwa` generations
// (stamps pwagenA/B/C) served by e2e/support/pwa-serve.mjs, which switches which
@@ -142,6 +143,32 @@ test('offline cold boot: app + deterministic Step work with the network down', a
await context.setOffline(false)
})
+// issue #301 — the precached THIRD_PARTY_NOTICES.txt, with the network down:
+// the About dialog's licence view shows it, and its file link opens it
+test('offline: the third-party licences open in About and as their file, from the precache', async ({ page, context }) => {
+ await setGen(page, 'a')
+ await installAndControl(page)
+ expect(await swPrecache(page)).toContain('THIRD_PARTY_NOTICES.txt')
+ await context.setOffline(true)
+
+ const p2 = await context.newPage()
+ await p2.goto('/')
+ await expect(p2.locator('.canvas .react-flow')).toBeVisible()
+ expect(await controller(p2)).not.toBeNull()
+ await openAbout(p2)
+ const dlg = await openLicences(p2)
+ await expectShownNoticesAre(p2, 'pwa')
+ const [tab] = await Promise.all([context.waitForEvent('page'), dlg.locator('[data-licenses-file]').click()])
+ await tab.waitForLoadState()
+ expect(new URL(tab.url()).pathname).toBe('/THIRD_PARTY_NOTICES.txt')
+ const body = await tab.evaluate(() => document.body.innerText)
+ expect(body.startsWith('Loop Studio\nCopyright © 2026 Hanrim. All rights reserved.')).toBe(true)
+ expect(sha256(await tab.evaluate(async () => (await fetch(location.href)).text()))).toBe(manifestNoticesSha('pwa'))
+ await tab.close()
+
+ await context.setOffline(false)
+})
+
test('offline Monte-Carlo: the precached worker chunk runs a real distribution', async ({
page,
context,
diff --git a/e2e/support/licenses.ts b/e2e/support/licenses.ts
new file mode 100644
index 00000000..6e5e93fb
--- /dev/null
+++ b/e2e/support/licenses.ts
@@ -0,0 +1,49 @@
+import { createHash } from 'node:crypto'
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { expect, type Locator, type Page } from '@playwright/test'
+
+// Issue #301 — the licence view in the About dialog, shared by the dev, the
+// production-bundle, the PWA and the portable specs. What the screen shows is
+// compared with the SHA-256 the committed manifest pins for that build.
+
+type Build = 'web' | 'pwa' | 'portable'
+
+export const manifestNoticesSha = (build: Build): string =>
+ (JSON.parse(readFileSync(resolve('licenses/third-party-manifest.json'), 'utf8')) as { builds: Record }).builds[build].noticesSha256
+
+export const sha256 = (text: string): string => createHash('sha256').update(text, 'utf8').digest('hex')
+
+export const aboutDialog = (page: Page): Locator => page.locator('.mcdlg--about')
+
+/** Help ▸ About Loop Studio (desktop) */
+export async function openAbout(page: Page): Promise {
+ await page.locator('[data-tour="help-trigger"]').click()
+ await page.getByRole('menuitem', { name: 'About Loop Studio' }).click()
+ const dlg = aboutDialog(page)
+ await expect(dlg).toBeVisible()
+ await expect(dlg).toHaveAttribute('data-about-view', 'about')
+ return dlg
+}
+
+/** About ▸ Third-party open-source licenses: the same dialog, its licence view */
+export async function openLicences(page: Page): Promise {
+ const dlg = aboutDialog(page)
+ await dlg.locator('[data-about-licenses]').click()
+ await expect(dlg).toHaveAttribute('data-about-view', 'licenses')
+ return dlg
+}
+
+/** the shown notices: the 's child node types and its text */
+export async function shownNotices(page: Page): Promise<{ kinds: number[]; text: string }> {
+ const pre = aboutDialog(page).locator('[data-licenses-text]')
+ await expect(pre).toBeVisible()
+ return pre.evaluate((el) => ({ kinds: [...el.childNodes].map((n) => n.nodeType), text: el.textContent ?? '' }))
+}
+
+/** the shown text is one text node whose bytes are the manifest's for `build` */
+export async function expectShownNoticesAre(page: Page, build: Build): Promise {
+ const shown = await shownNotices(page)
+ expect(shown.kinds, 'the notices are one text node').toEqual([3])
+ expect(sha256(shown.text)).toBe(manifestNoticesSha(build))
+}
diff --git a/e2e/support/pwa-serve.mjs b/e2e/support/pwa-serve.mjs
index 324a0c1c..0767d55d 100644
--- a/e2e/support/pwa-serve.mjs
+++ b/e2e/support/pwa-serve.mjs
@@ -45,6 +45,8 @@ const MIME = {
'.woff2': 'font/woff2',
'.map': 'application/json',
'.ico': 'image/x-icon',
+ // issue #301 - THIRD_PARTY_NOTICES.txt, as Cloudflare Pages serves it
+ '.txt': 'text/plain; charset=utf-8',
}
const server = createServer(async (req, res) => {
diff --git a/e2e/whats-new.spec.ts b/e2e/whats-new.spec.ts
index 9dbd941f..56bce9d8 100644
--- a/e2e/whats-new.spec.ts
+++ b/e2e/whats-new.spec.ts
@@ -239,7 +239,7 @@ test.describe('closing the notice and opening the panel are different things', (
expect(text).not.toContain('text unavailable')
}
}
- expect(RELEASE_NOTES.map((n) => n.version)).toEqual(['0.17.2', '0.17.1', '0.17.0', '0.16.0', '0.15.3', '0.15.2', '0.15.1', '0.15.0', '0.14.0'])
+ expect(RELEASE_NOTES.map((n) => n.version)).toEqual(['0.18.0', '0.17.2', '0.17.1', '0.17.0', '0.16.0', '0.15.3', '0.15.2', '0.15.1', '0.15.0', '0.14.0'])
await page.keyboard.press('Escape')
await expect(panel).toHaveCount(0)
diff --git a/licenses/README.md b/licenses/README.md
index 308d47fb..c90d932d 100644
--- a/licenses/README.md
+++ b/licenses/README.md
@@ -7,6 +7,9 @@ Loop Studio ships third-party code and fonts. Every build carries their notices:
| web (`npm run build`) | `dist/THIRD_PARTY_NOTICES.txt` |
| PWA (`npm run build:pwa`, and the Cloudflare Production build) | `THIRD_PARTY_NOTICES.txt` in the output, precached by the service worker |
| portable (`npm run build:portable`) | inside the single HTML file, in `` |
+| dev server (`npm run dev`) | `/THIRD_PARTY_NOTICES.txt`, the web build's text rebuilt in memory from `third-party-manifest.json` and the installed packages |
+
+In the app they are in **About Loop Studio → Third-party open-source licenses** (v0.18.0): the same dialog shows the build's text, unchanged and in English, with a link to the file on the web and PWA builds ([`docs/guided-tour.md`](../docs/guided-tour.md) §GT7.1).
## The files here
@@ -24,6 +27,8 @@ Licence texts are third-party input. Wherever Loop Studio shows them, including
The rules are in `scripts/third-party-notices/core.mjs` (`portableTemplate`, `readPortableTemplate`); a hostile text is tested there and in a real browser in `e2e/portable-file.spec.ts`.
+The licence view keeps this contract: `src/licenses/notices.ts` reads the file or the template, and `src/components/LicensesView.tsx` shows the text in a `` as a text node. `npm run check:licence-screen` parses those files (and `AboutDialog.tsx`) and fails on any HTML sink: `dangerouslySetInnerHTML`, `innerHTML` / `outerHTML`, `insertAdjacentHTML`, `document.write`, `createContextualFragment`, `DOMParser`, `setHTMLUnsafe`; it also requires the loader to read the template through `.content.textContent`. The e2e specs compare the text the screen shows with the manifest's SHA-256 on the dev server, the production bundle, the PWA offline and the portable file.
+
## When a build fails on the notices
A dependency was added, removed or upgraded, or its licence files changed. Run, offline:
@@ -45,6 +50,7 @@ The update itself refuses to write when a component breaks a rule:
## The checks
+- The dev server's file comes from `thirdPartyNoticesDev` in the same plugin module: `noticesFromManifest` finds every package of the manifest's web section in `node_modules` by name and version, checks each licence and NOTICE file's SHA-256 and the whole text's, and serves it from memory or answers 500 with the reason. `scripts/third-party-notices.test.mjs` rebuilds all three sections and compares them with the manifest.
- The build step is `scripts/third-party-notices/vite-plugin.mjs`. It reads the module graph, Vite's own `build.license` list (as evidence, then removed from the output), the fonts named by CSS, and the registry. Its rules are in `scripts/third-party-notices/core.mjs`, tested in `scripts/third-party-notices.test.mjs`.
- `npm run check:third-party-notices` runs after the three builds. It checks that the notices are in each output, match the manifest, are precached by the PWA, and that every service-worker item's marker is present. It also checks that the portable copy is escaped and pinned to the same bytes as the web file, and that neither the notices nor the manifest hold a local absolute path.
diff --git a/package-lock.json b/package-lock.json
index cd8e4ded..d42f2439 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "loop-studio",
- "version": "0.17.2",
+ "version": "0.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loop-studio",
- "version": "0.17.2",
+ "version": "0.18.0",
"dependencies": {
"@fontsource/ibm-plex-mono": "^5.3.0",
"@fontsource/ibm-plex-sans": "^5.3.0",
diff --git a/package.json b/package.json
index e946b1e8..8bf19c3c 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "loop-studio",
"private": true,
- "version": "0.17.2",
+ "version": "0.18.0",
"type": "module",
"engines": {
"node": ">=22.12.0"
@@ -33,6 +33,7 @@
"check:snapshot-policy": "node scripts/check-snapshot-policy.mjs",
"check:storage-port": "node scripts/check-storage-port.mjs",
"check:share-crypto": "node scripts/check-share-crypto.mjs",
+ "check:licence-screen": "node scripts/check-licence-screen.mjs",
"check:third-party-notices": "node scripts/check-third-party-notices.mjs dist web && node scripts/check-third-party-notices.mjs dist-portable portable && node scripts/check-third-party-notices.mjs dist-pwa pwa",
"licenses:update": "node scripts/third-party-notices/update.mjs",
"check:change-declaration": "node scripts/check-change-declaration.mjs",
diff --git a/scripts/arrow-units.json b/scripts/arrow-units.json
index e1c59de4..23454437 100644
--- a/scripts/arrow-units.json
+++ b/scripts/arrow-units.json
@@ -6,7 +6,7 @@
"mirroring": 4,
"jsxSites": 1,
"keepLiterals": 1,
- "iconSites": 11,
+ "iconSites": 12,
"catalogKeysMirror": 9,
"catalogKeysKeep": 1,
"catalogPairsPerLocale": 10,
@@ -63,6 +63,7 @@
},
"sites": [
{ "file": "src/components/HelpMenu.tsx", "unit": "external-link", "count": 1 },
+ { "file": "src/components/LicensesView.tsx", "unit": "external-link", "count": 1 },
{ "file": "src/components/dataImport/DataImportWizard.tsx", "unit": "external-link", "count": 1 },
{ "file": "src/components/dataImport/DataImportWizard.tsx", "unit": "submenu", "count": 1 },
{ "file": "src/components/mobile/MobileMoreMenu.tsx", "unit": "external-link", "count": 1 },
diff --git a/scripts/check-licence-screen.mjs b/scripts/check-licence-screen.mjs
new file mode 100644
index 00000000..01559f06
--- /dev/null
+++ b/scripts/check-licence-screen.mjs
@@ -0,0 +1,33 @@
+// Issue #301 - the licence view shows the third-party notices as text.
+//
+// node scripts/check-licence-screen.mjs
+//
+// The rule is in `licence-screen-rules.mjs`. The files are the loader and the
+// view that touch the notices text; every one must exist (a renamed file is a
+// failure, not a pass), parse cleanly, use no HTML sink, and the loader must
+// read the portable template through `.content.textContent`.
+import fs from 'node:fs'
+import path from 'node:path'
+import { checkLicenceSource, readsTemplateContentText } from './licence-screen-rules.mjs'
+
+const ROOT = path.resolve(import.meta.dirname, '..')
+const FILES = ['src/licenses/notices.ts', 'src/components/LicensesView.tsx', 'src/components/AboutDialog.tsx']
+const LOADER = 'src/licenses/notices.ts'
+
+const problems = []
+for (const rel of FILES) {
+ const abs = path.join(ROOT, rel)
+ if (!fs.existsSync(abs)) {
+ problems.push(`${rel}: missing - the check covers it by name; update FILES if it moved`)
+ continue
+ }
+ const text = fs.readFileSync(abs, 'utf8')
+ problems.push(...checkLicenceSource(rel, text))
+ if (rel === LOADER && !readsTemplateContentText(rel, text)) problems.push(`${rel}: the portable template is not read through .content.textContent`)
+}
+for (const p of problems) console.error(` FAIL ${p}`)
+if (problems.length) {
+ console.error('\n The licence view must show the notices as text. See licenses/README.md.')
+ process.exit(1)
+}
+console.log(` ok ${FILES.length} files show the third-party notices as text, and the loader reads the template's content`)
diff --git a/scripts/licence-screen-rules.mjs b/scripts/licence-screen-rules.mjs
new file mode 100644
index 00000000..7e0448e7
--- /dev/null
+++ b/scripts/licence-screen-rules.mjs
@@ -0,0 +1,51 @@
+// Issue #301 - the rule behind `scripts/check-licence-screen.mjs`, as a pure
+// function so `scripts/licence-screen-rules.test.mjs` can feed it made-up
+// sources. The third-party notices are third-party text: the licence view and
+// its loader show them as TEXT. Nothing in those files may hand a string to
+// the HTML parser:
+//
+// - the JSX attribute `dangerouslySetInnerHTML`
+// - `innerHTML` / `outerHTML`, read or written, by name or as `['innerHTML']`
+// - `insertAdjacentHTML`, `document.write` / `writeln`,
+// `createContextualFragment`, `DOMParser`, `setHTMLUnsafe`, `parseHTMLUnsafe`
+//
+// and the loader reads the portable template through `.content.textContent`.
+// A file that does not parse cleanly is a problem, never "clean".
+import ts from 'typescript'
+
+const HTML_SINKS = new Set(['innerHTML', 'outerHTML', 'insertAdjacentHTML', 'write', 'writeln', 'createContextualFragment', 'setHTMLUnsafe', 'parseHTMLUnsafe'])
+const HTML_CONSTRUCTORS = new Set(['DOMParser'])
+
+/** the problems in one source file; `name` is used for messages and the parser's JSX mode */
+export function checkLicenceSource(name, text) {
+ const kind = name.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
+ const sf = ts.createSourceFile(name, text, ts.ScriptTarget.Latest, true, kind)
+ if (sf.parseDiagnostics?.length) return [`${name}: did not parse cleanly - refusing to call it clean`]
+ const problems = []
+ const at = (node) => `${name}:${sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1}`
+ const visit = (node) => {
+ if (ts.isJsxAttribute(node) && node.name.getText(sf) === 'dangerouslySetInnerHTML') problems.push(`${at(node)}: dangerouslySetInnerHTML - the notices are shown as text`)
+ if (ts.isPropertyAccessExpression(node) && HTML_SINKS.has(node.name.text)) {
+ // `.write` / `.writeln` only on `document`
+ const isDocWrite = node.name.text === 'write' || node.name.text === 'writeln'
+ if (!isDocWrite || node.expression.getText(sf) === 'document') problems.push(`${at(node)}: .${node.name.text} hands a string to the HTML parser`)
+ }
+ if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression) && HTML_SINKS.has(node.argumentExpression.text)) problems.push(`${at(node)}: ['${node.argumentExpression.text}'] hands a string to the HTML parser`)
+ if ((ts.isNewExpression(node) || ts.isCallExpression(node)) && ts.isIdentifier(node.expression) && HTML_CONSTRUCTORS.has(node.expression.text)) problems.push(`${at(node)}: ${node.expression.text} parses HTML`)
+ ts.forEachChild(node, visit)
+ }
+ visit(sf)
+ return problems
+}
+
+/** does this source read some `.content.textContent` (the portable template's text)? */
+export function readsTemplateContentText(name, text) {
+ const sf = ts.createSourceFile(name, text, ts.ScriptTarget.Latest, true, name.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
+ let found = false
+ const visit = (node) => {
+ if (ts.isPropertyAccessExpression(node) && node.name.text === 'textContent' && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'content') found = true
+ if (!found) ts.forEachChild(node, visit)
+ }
+ visit(sf)
+ return found
+}
diff --git a/scripts/licence-screen-rules.test.mjs b/scripts/licence-screen-rules.test.mjs
new file mode 100644
index 00000000..1c7b9185
--- /dev/null
+++ b/scripts/licence-screen-rules.test.mjs
@@ -0,0 +1,51 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { checkLicenceSource, readsTemplateContentText } from './licence-screen-rules.mjs'
+
+// Issue #301 — the licence view shows the third-party notices as text. Each
+// way of handing a string to the HTML parser has a case that must fail.
+
+const ok = (src, name = 'a.tsx') => expect(checkLicenceSource(name, src)).toEqual([])
+const bad = (src, re, name = 'a.tsx') => expect(checkLicenceSource(name, src).join('\n')).toMatch(re)
+
+describe('the licence view and loader show the notices as text', () => {
+ it('a text child, textContent and a template content read are fine', () => {
+ ok('export const V = ({ text }: { text: string }) => {text}')
+ ok('const t = document.getElementById("x") as HTMLTemplateElement; export const s = t.content.textContent', 'b.ts')
+ ok('export function w(el: HTMLElement, s: string) { el.textContent = s }', 'b.ts')
+ })
+ it('dangerouslySetInnerHTML fails', () => {
+ bad('export const V = ({ html }: { html: string }) => ', /dangerouslySetInnerHTML/)
+ })
+ it.each([
+ ['el.innerHTML = s', /\.innerHTML/],
+ ['const x = el.innerHTML', /\.innerHTML/],
+ ['el.outerHTML = s', /\.outerHTML/],
+ ['el["innerHTML"] = s', /\['innerHTML'\]/],
+ ['el.insertAdjacentHTML("beforeend", s)', /insertAdjacentHTML/],
+ ['document.write(s)', /\.write/],
+ ['document.createRange().createContextualFragment(s)', /createContextualFragment/],
+ ['new DOMParser().parseFromString(s, "text/html")', /DOMParser parses HTML/],
+ ['el.setHTMLUnsafe(s)', /setHTMLUnsafe/],
+ ])('%s fails', (stmt, re) => {
+ bad(`declare const el: any; declare const s: string; ${stmt}`, re, 'b.ts')
+ })
+ it('a write() that is not document.write is not an HTML sink', () => {
+ ok('declare const stream: { write(s: string): void }; stream.write("x")', 'b.ts')
+ })
+ it('a file that does not parse is a problem, never clean', () => {
+ bad('export const V = () => {text', /did not parse cleanly/)
+ })
+ it('the loader must read a template through .content.textContent', () => {
+ expect(readsTemplateContentText('b.ts', 'declare const t: HTMLTemplateElement; t.content.textContent')).toBe(true)
+ expect(readsTemplateContentText('b.ts', 'declare const t: HTMLTemplateElement; t.textContent')).toBe(false)
+ })
+ it('the real files pass', () => {
+ const root = path.resolve(import.meta.dirname, '..')
+ for (const rel of ['src/licenses/notices.ts', 'src/components/LicensesView.tsx', 'src/components/AboutDialog.tsx']) {
+ expect(checkLicenceSource(rel, fs.readFileSync(path.join(root, rel), 'utf8'))).toEqual([])
+ }
+ expect(readsTemplateContentText('notices.ts', fs.readFileSync(path.join(root, 'src/licenses/notices.ts'), 'utf8'))).toBe(true)
+ })
+})
diff --git a/scripts/third-party-notices.test.mjs b/scripts/third-party-notices.test.mjs
index a51d7042..97ccec08 100644
--- a/scripts/third-party-notices.test.mjs
+++ b/scripts/third-party-notices.test.mjs
@@ -7,11 +7,13 @@ import {
copyrightLines,
diffSection,
escapeHtml,
+ findPackageDir,
injectPortableTemplate,
judgeLicence,
LOCAL_PATH,
manifestSection,
normaliseText,
+ noticesFromManifest,
packageDirOf,
parseSpdx,
portableTemplate,
@@ -247,6 +249,32 @@ describe('the committed manifest', () => {
expect(LOCAL_PATH.test(' /home/runner/work')).toBe(true)
expect(LOCAL_PATH.test('https://github.com/vitejs/vite')).toBe(false)
})
+ // the dev server serves this rebuild: it must be the build's text, byte for byte
+ it.each([['web'], ['pwa'], ['portable']])('%s: the text rebuilt from the manifest and node_modules is the build text the manifest pins', (build) => {
+ const text = noticesFromManifest(manifest.builds[build], ROOT)
+ expect(sha256(text)).toBe(manifest.builds[build].noticesSha256)
+ })
+ it('the rebuild fails closed: another licence hash, a missing NOTICE, a version not installed, a changed copyright line', () => {
+ const copy = () => JSON.parse(JSON.stringify(manifest.builds.web))
+ const a = copy()
+ a.entries.find((e) => e.name === 'react').licenceFiles[0].sha256 = '0'.repeat(64)
+ expect(() => noticesFromManifest(a, ROOT)).toThrow(/react@.*LICENSE is not the text the manifest pins/)
+ const b = copy()
+ b.entries.find((e) => e.name === 'react').noticeFiles = [{ file: 'NOTICE', sha256: '1'.repeat(64) }]
+ expect(() => noticesFromManifest(b, ROOT)).toThrow(/NOTICE files are not the ones/)
+ const c = copy()
+ c.entries.find((e) => e.name === 'classcat').version = '9.9.9'
+ expect(() => noticesFromManifest(c, ROOT)).toThrow(/classcat@9\.9\.9: not installed/)
+ const d = copy()
+ d.entries.find((e) => e.name === 'classcat').copyright = ['Copyright (c) Someone Else']
+ expect(() => noticesFromManifest(d, ROOT)).toThrow(/rebuilt notices .* are not the manifest's/)
+ })
+ it('finds the second, nested copy of a package by its version', () => {
+ const v4 = findPackageDir(ROOT, 'zustand', '4.5.7')
+ const v5 = findPackageDir(ROOT, 'zustand', '5.0.15')
+ expect(v4 && v5 && v4 !== v5).toBe(true)
+ expect(findPackageDir(ROOT, 'zustand', '0.0.1')).toBeNull()
+ })
it('pins the same notices bytes for the web and portable builds', () => {
expect(manifest.builds.portable.noticesSha256).toBe(manifest.builds.web.noticesSha256)
})
diff --git a/scripts/third-party-notices/core.mjs b/scripts/third-party-notices/core.mjs
index 54bc8a34..4dbb5f20 100644
--- a/scripts/third-party-notices/core.mjs
+++ b/scripts/third-party-notices/core.mjs
@@ -109,6 +109,67 @@ export function packageDirOf(id) {
return p.slice(0, i + '/node_modules/'.length) + name
}
+/**
+ * The installed directory of `name` at exactly `version`: `node_modules/`,
+ * or one level down (`node_modules//node_modules/`, the way npm
+ * nests a second version). Null when no copy has that version.
+ */
+export function findPackageDir(root, name, version) {
+ const nm = path.join(root, 'node_modules')
+ const isIt = (dir) => {
+ try {
+ return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version === version
+ } catch {
+ return false
+ }
+ }
+ const direct = path.join(nm, ...name.split('/'))
+ if (isIt(direct)) return direct
+ for (const a of fs.readdirSync(nm)) {
+ const parents = a.startsWith('@') ? fs.readdirSync(path.join(nm, a)).map((b) => path.join(nm, a, b)) : [path.join(nm, a)]
+ for (const parent of parents) {
+ const nested = path.join(parent, 'node_modules', ...name.split('/'))
+ if (isIt(nested)) return nested
+ }
+ }
+ return null
+}
+
+/**
+ * A build's notices text rebuilt from its manifest section and the installed
+ * packages, without the bundler - what the dev server serves (it has no module
+ * graph to read). Every package is found by name AND version, every licence
+ * and NOTICE file must have the SHA-256 the manifest pins, and the whole text
+ * must have the manifest's `noticesSha256`. Throws on any difference: the dev
+ * text is the build's text or nothing.
+ */
+export function noticesFromManifest(section, root) {
+ const entries = section.entries.map((m) => {
+ const who = `${m.name}@${m.version}`
+ const dir = findPackageDir(root, m.name, m.version)
+ if (!dir) throw new Error(`${who}: not installed at that version`)
+ const p = readPackage(dir, { files: m.licenceFiles.map((f) => f.file) })
+ const licenceFiles = p.licenceFiles.map((l, i) => {
+ if (l.sha256 !== m.licenceFiles[i].sha256) throw new Error(`${who}: ${l.file} is not the text the manifest pins`)
+ return { ...l, ...(m.licenceFiles[i].covers !== undefined ? { covers: m.licenceFiles[i].covers } : {}) }
+ })
+ if (JSON.stringify(p.noticeFiles.map((n) => ({ file: n.file, sha256: n.sha256 }))) !== JSON.stringify(m.noticeFiles)) throw new Error(`${who}: its NOTICE files are not the ones the manifest pins`)
+ return {
+ name: m.name,
+ version: m.version,
+ spdx: m.spdx,
+ ...(m.licenceSummary !== undefined ? { licenceSummary: m.licenceSummary } : {}),
+ source: m.source,
+ licenceFiles,
+ noticeFiles: p.noticeFiles,
+ copyright: m.copyright,
+ }
+ })
+ const text = renderNotices(entries)
+ if (sha256(text) !== section.noticesSha256) throw new Error(`the rebuilt notices (SHA-256 ${sha256(text).slice(0, 12)}) are not the manifest's (${section.noticesSha256.slice(0, 12)})`)
+ return text
+}
+
/** every problem with a set of entries: licence, licence text, copyright */
export function checkEntries(entries, reviews = {}) {
const problems = []
diff --git a/scripts/third-party-notices/vite-plugin.d.mts b/scripts/third-party-notices/vite-plugin.d.mts
index 9d1781c3..c283d968 100644
--- a/scripts/third-party-notices/vite-plugin.d.mts
+++ b/scripts/third-party-notices/vite-plugin.d.mts
@@ -5,3 +5,6 @@ export declare const VITE_LICENSE_JSON: string
/** Issue #301 - writes THIRD_PARTY_NOTICES for one build flavour and fails the build on a manifest mismatch. */
export declare function thirdPartyNotices(options: { flavour: 'web' | 'pwa' | 'portable'; root?: string }): Plugin
+
+/** Issue #301 - the dev server's THIRD_PARTY_NOTICES.txt: the web build's text rebuilt from the manifest, from memory. */
+export declare function thirdPartyNoticesDev(options?: { root?: string }): Plugin
diff --git a/scripts/third-party-notices/vite-plugin.mjs b/scripts/third-party-notices/vite-plugin.mjs
index c3573107..82518e6a 100644
--- a/scripts/third-party-notices/vite-plugin.mjs
+++ b/scripts/third-party-notices/vite-plugin.mjs
@@ -28,6 +28,7 @@ import {
diffSection,
injectPortableTemplate,
manifestSection,
+ noticesFromManifest,
packageDirOf,
readPackage,
renderNotices,
@@ -35,6 +36,45 @@ import {
export const VITE_LICENSE_JSON = '.vite/third-party-licenses.json'
+/**
+ * The dev server's THIRD_PARTY_NOTICES.txt. A dev server has no finished
+ * module graph to read, so it serves the web build's text rebuilt from the
+ * committed manifest and the installed packages (`noticesFromManifest`), from
+ * memory - nothing is written to disk, nothing is fetched. The rebuild checks
+ * every licence file's SHA-256 and the whole text's against the manifest, so
+ * the dev text is the build's text byte for byte, or a 500 that says why.
+ */
+export function thirdPartyNoticesDev({ root = process.cwd() } = {}) {
+ const manifestPath = path.join(root, 'licenses', 'third-party-manifest.json')
+ let cache = null
+ const text = () => {
+ const stamp = fs.statSync(manifestPath).mtimeMs
+ if (!cache || cache.stamp !== stamp) cache = { stamp, text: noticesFromManifest(JSON.parse(fs.readFileSync(manifestPath, 'utf8')).builds.web, root) }
+ return cache.text
+ }
+ return {
+ name: 'loop-studio:third-party-notices-dev',
+ apply: 'serve',
+ configureServer(server) {
+ server.middlewares.use((req, res, next) => {
+ if (req.method !== 'GET' && req.method !== 'HEAD') return next()
+ if (new URL(req.url ?? '/', 'http://dev').pathname !== `/${NOTICES_FILE}`) return next()
+ try {
+ const body = text()
+ res.statusCode = 200
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8')
+ res.setHeader('Cache-Control', 'no-store')
+ res.end(req.method === 'HEAD' ? undefined : body)
+ } catch (err) {
+ res.statusCode = 500
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8')
+ res.end(`${NOTICES_FILE} could not be rebuilt from licenses/third-party-manifest.json: ${err.message}\n`)
+ }
+ })
+ },
+ }
+}
+
export function thirdPartyNotices({ flavour, root = process.cwd() }) {
const manifestPath = path.join(root, 'licenses', 'third-party-manifest.json')
const registry = JSON.parse(fs.readFileSync(path.join(root, 'licenses', 'registry.json'), 'utf8'))
diff --git a/src/components/AboutDialog.tsx b/src/components/AboutDialog.tsx
index 0e1aa34d..c24f0c71 100644
--- a/src/components/AboutDialog.tsx
+++ b/src/components/AboutDialog.tsx
@@ -1,7 +1,8 @@
-import { useId, useRef } from 'react'
+import { useEffect, useId, useRef, useState } from 'react'
import { useT } from '../i18n'
import { useDialogFocus } from './useDialogFocus'
import { DialogScrim } from './DialogScrim'
+import { LicensesView } from './LicensesView'
import { Icon } from '../ui/icons'
// docs/guided-tour.md §GT7.1 — a small, static, read-only "About Loop Studio"
@@ -15,6 +16,11 @@ import { Icon } from '../ui/icons'
// SHA come from the same globals as the toolbar stamp. The GitHub link points
// at the project repository; its visible text and accessible name are keyed
// (`about.repo` / `about.repoAria`), the href is fixed. It opens in a new tab.
+//
+// Issue #301 — "Third-party open-source licenses" turns the SAME dialog into
+// the licence view (LicensesView), no second modal: Back returns to About and
+// puts focus back on that button; Escape, the backdrop and × still close the
+// whole dialog. Every opening starts on About.
export const REPO_URL = 'https://github.com/MerciHanrim/loop-studio'
@@ -24,49 +30,99 @@ type Props = {
returnFocusTo?: () => HTMLElement | null | undefined
}
+type View = 'about' | 'licenses'
+
export function AboutDialog({ open, onClose, returnFocusTo }: Props) {
const t = useT()
const ref = useRef(null)
const titleId = useId()
+ const [view, setView] = useState('about')
+ const licensesRef = useRef(null)
+ const backRef = useRef(null)
+ // where focus goes once the view has changed: Back on entering, the
+ // licences button on returning (null = leave focus alone)
+ const focusNext = useRef<'back' | 'licenses' | null>(null)
useDialogFocus(open, ref, onClose, returnFocusTo)
+
+ useEffect(() => {
+ const target = focusNext.current === 'back' ? backRef.current : focusNext.current === 'licenses' ? licensesRef.current : null
+ focusNext.current = null
+ target?.focus()
+ }, [view])
+
+ // a closed dialog opens on About next time
+ const [wasOpen, setWasOpen] = useState(open)
+ if (open !== wasOpen) {
+ setWasOpen(open)
+ if (!open) setView('about')
+ }
+
if (!open) return null
+ const licenses = view === 'licenses'
return (
e.stopPropagation()}
>
- Loop Studio
+ {licenses ? t('about.licenses') : 'Loop Studio'}
-
-
- v{__APP_VERSION__}
- {__BUILD_SHA__ ? ` · build ${__BUILD_SHA__}` : ''}
-
-
- {t('about.createdBy')} Hanrim
-
-
- {t('about.repo')}
-
-
- Copyright © 2026 Hanrim. All rights reserved.
- {t('about.notAffiliated')}
-
+ {licenses ? (
+
+ {
+ focusNext.current = 'licenses'
+ setView('about')
+ }}
+ />
+
+ ) : (
+
+
+ v{__APP_VERSION__}
+ {__BUILD_SHA__ ? ` · build ${__BUILD_SHA__}` : ''}
+
+
+ {t('about.createdBy')} Hanrim
+
+
+ {t('about.repo')}
+
+
+ Copyright © 2026 Hanrim. All rights reserved.
+ {t('about.notAffiliated')}
+
+
+
+
+ )}
)
diff --git a/src/components/LicensesView.tsx b/src/components/LicensesView.tsx
new file mode 100644
index 00000000..9790f69a
--- /dev/null
+++ b/src/components/LicensesView.tsx
@@ -0,0 +1,76 @@
+import { useEffect, useState, type RefObject } from 'react'
+import { useT } from '../i18n'
+import { loadNotices, noticesFileUrl, noticesHaveFile } from '../licenses/notices'
+import { ArrowIcon } from '../ui/icons'
+
+// Issue #301 - the About dialog's licence view: the third-party notices of
+// this build (src/licenses/notices.ts), in the dialog itself, not a second
+// modal. The lead says first that these are the notices of the third-party
+// components, not a licence of Loop Studio. The text is the notices file's,
+// unchanged and untranslated, in a scrollable, focusable that the
+// browser can search, select and copy. It is a React text child: never
+// `innerHTML` / `dangerouslySetInnerHTML` (scripts/check-licence-screen.mjs).
+// Loading and a failed read have their own states; Back stays usable in both.
+
+type State = { kind: 'loading' } | { kind: 'ready'; text: string } | { kind: 'error' }
+
+export function LicensesView({ backRef, onBack }: { backRef: RefObject; onBack: () => void }) {
+ const t = useT()
+ const [state, setState] = useState({ kind: 'loading' })
+ const [attempt, setAttempt] = useState(0)
+
+ useEffect(() => {
+ let live = true
+ loadNotices().then(
+ (text) => {
+ if (live) setState({ kind: 'ready', text })
+ },
+ () => {
+ if (live) setState({ kind: 'error' })
+ },
+ )
+ return () => {
+ live = false
+ }
+ }, [attempt])
+
+ return (
+
+
+ {t('licenses.lead')}
+ {noticesHaveFile() ? (
+
+ {t('licenses.openFile')}
+
+ ) : null}
+ {state.kind === 'loading' ? (
+
+ {t('licenses.loading')}
+
+ ) : null}
+ {state.kind === 'error' ? (
+
+ {t('licenses.error')}
+
+
+ ) : null}
+ {state.kind === 'ready' ? (
+
+ {state.text}
+
+ ) : null}
+
+ )
+}
diff --git a/src/i18n/es419Copy.test.ts b/src/i18n/es419Copy.test.ts
index f47c8573..bcae8e41 100644
--- a/src/i18n/es419Copy.test.ts
+++ b/src/i18n/es419Copy.test.ts
@@ -238,6 +238,8 @@ describe('es-419 copy — mechanical review', () => {
// issue #297: a browser name, a file format, and two Spanish/English
// homographs (`accidental`, `personal`) in the storage gate and area
'Chrome HTML accidental personal ' +
+ // issue #301: two Spanish/English homographs in the licence view
+ 'original software ' +
// issue #300: the protocol the password dialogs name
'HTTPS'
).split(' '),
diff --git a/src/i18n/itCopy.test.ts b/src/i18n/itCopy.test.ts
index f322c8c8..f0c29421 100644
--- a/src/i18n/itCopy.test.ts
+++ b/src/i18n/itCopy.test.ts
@@ -147,6 +147,9 @@ const SURVIVING: ReadonlyArray = [
// issue #300 - password protection for share links: `password` is the word
// Italian uses, HTTPS is a protocol name
['password protection for share links (issue #300)', ['password', 'https']],
+ // issue #301 - the licence view: `open source`, `software` and `copyright`
+ // are the words Italian uses for these things
+ ['the third-party licence view (issue #301)', ['open', 'source', 'software', 'copyright']],
]
const DECLARED = new Set(SURVIVING.flatMap(([, ws]) => ws))
@@ -199,15 +202,15 @@ const IDENTICAL_TO_EN: ReadonlyArray = [
// ------------------------------------------------------------------ shape
describe('it copy — the surfaces exist and are complete', () => {
it('has exactly the base key set', () => {
- expect(KEYS).toHaveLength(984) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines)
+ expect(KEYS).toHaveLength(996) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines)
expect(Object.keys(IT).sort()).toEqual([...KEYS].sort())
})
it('covers all four runtime surfaces, at the measured sizes', () => {
const per: Record = {}
for (const r of RUNTIME) per[r.surface.split('/')[0]!] = (per[r.surface.split('/')[0]!] ?? 0) + 1
- expect(per).toEqual({ catalog: 984, template: 196, frame: 7, module: 19 })
- expect(RUNTIME).toHaveLength(1206)
+ expect(per).toEqual({ catalog: 996, template: 196, frame: 7, module: 19 })
+ expect(RUNTIME).toHaveLength(1218)
})
it('EVERY row has a non-empty ENGLISH side — the vacuity guard', () => {
diff --git a/src/i18n/locales/ar/ui.ts b/src/i18n/locales/ar/ui.ts
index b40db819..9d013353 100644
--- a/src/i18n/locales/ar/ui.ts
+++ b/src/i18n/locales/ar/ui.ts
@@ -395,6 +395,15 @@ const ui = {
'about.repo': 'مستودع GitHub',
'about.repoAria': 'مستودع Loop Studio على GitHub',
'about.notAffiliated': 'Loop Studio مشروع مستقل وغير تابع لـ Machinations.io ولا معتمد منها.',
+ 'about.licenses': 'تراخيص المصادر المفتوحة للجهات الخارجية',
+ 'licenses.back': 'رجوع',
+ 'licenses.lead': 'هذه إشعارات البرامج التابعة لجهات خارجية المضمّنة في Loop Studio. وهي تخص تلك المكوّنات وحدها، لا Loop Studio نفسه. ويُعرض نص كل ترخيص كاملًا بلغته الإنجليزية الأصلية.',
+ 'licenses.openFile': 'فتح كملف نصي',
+ 'licenses.openFileAria': 'فتح نصوص التراخيص كملف نصي في علامة تبويب جديدة',
+ 'licenses.loading': 'جارٍ تحميل نصوص التراخيص…',
+ 'licenses.error': 'تعذّر تحميل نصوص التراخيص.',
+ 'licenses.retry': 'إعادة المحاولة',
+ 'licenses.textLabel': 'نصوص التراخيص',
'hint.close': 'تجاهل هذه الملاحظة',
'hint.emptyCanvas.body': 'ابدأ من قالب، أو اسحب أنواع العقد من اللوحة الجانبية.',
'hint.mc.body': 'يشغّل مونت كارلو النموذج مرات كثيرة ويعرض مدى من النتائج، لا تنبؤًا واحدًا.',
@@ -562,6 +571,9 @@ const ui = {
'whatsNew.v0172.size': 'أصبح زر الجلسة المؤقتة أعلى النافذة بنفس ارتفاع أزرار القوائم المجاورة له وبزوايا مستديرة مثلها.',
'whatsNew.v0172.text': 'صار نص الزر أكبر وأسهل في القراءة.',
'whatsNew.v0172.same': 'يحتفظ الزر بإطاره البرتقالي ويفتح القائمة نفسها، ولم تتغير طريقة عمل الجلسة المؤقتة.',
+ 'whatsNew.v0180.licenses': 'أصبحت نافذة «عن Loop Studio» تضم صفحة لتراخيص المصادر المفتوحة للجهات الخارجية، فيها النص الكامل لترخيص كل مكوّن في التطبيق.',
+ 'whatsNew.v0180.offline': 'تُفتح نصوص التراخيص دون اتصال بالشبكة في التطبيق المثبّت وفي نسخة الملف الواحد.',
+ 'whatsNew.v0180.own': 'لم يتغير إشعار حقوق النشر الخاص بـ Loop Studio نفسه، وهذه التراخيص تخص المكوّنات التابعة لجهات خارجية المضمّنة فقط.',
} as const
export default ui
diff --git a/src/i18n/locales/de/ui.ts b/src/i18n/locales/de/ui.ts
index 57029cde..d9b19978 100644
--- a/src/i18n/locales/de/ui.ts
+++ b/src/i18n/locales/de/ui.ts
@@ -421,6 +421,15 @@ const ui = {
'about.repoAria': 'GitHub-Repository von Loop Studio',
'about.notAffiliated':
'Loop Studio ist ein unabhängiges Projekt und steht weder in Verbindung mit Machinations.io noch wird es von dort unterstützt.',
+ 'about.licenses': 'Open-Source-Lizenzen von Drittanbietern',
+ 'licenses.back': 'Zurück',
+ 'licenses.lead': 'Hier stehen die Hinweise zur Software von Drittanbietern, die Loop Studio enthält. Sie gelten nur für diese Komponenten, nicht für Loop Studio selbst. Jede Lizenz ist vollständig im englischen Originaltext wiedergegeben.',
+ 'licenses.openFile': 'Als Textdatei öffnen',
+ 'licenses.openFileAria': 'Die Lizenztexte als Textdatei in einem neuen Tab öffnen',
+ 'licenses.loading': 'Lizenztexte werden geladen…',
+ 'licenses.error': 'Die Lizenztexte konnten nicht geladen werden.',
+ 'licenses.retry': 'Erneut versuchen',
+ 'licenses.textLabel': 'Lizenztexte',
'hint.close': 'Diesen Hinweis ausblenden',
'hint.emptyCanvas.body':
'Mit einer Vorlage beginnen oder Knotenarten aus der linken Leiste hereinziehen.',
@@ -597,6 +606,9 @@ const ui = {
'whatsNew.v0172.size': 'Die Schaltfläche für die temporäre Sitzung oben im Fenster hat jetzt dieselbe Höhe und dieselben abgerundeten Ecken wie die Menüschaltflächen daneben.',
'whatsNew.v0172.text': 'Ihre Beschriftung ist größer und besser lesbar.',
'whatsNew.v0172.same': 'Sie behält ihren orangefarbenen Rahmen und öffnet dasselbe Menü; an der Funktionsweise einer temporären Sitzung hat sich nichts geändert.',
+ 'whatsNew.v0180.licenses': 'Über Loop Studio enthält jetzt eine Seite mit den Open-Source-Lizenzen von Drittanbietern und dem vollständigen Lizenztext jeder Komponente der App.',
+ 'whatsNew.v0180.offline': 'Die Lizenztexte öffnen sich ohne Netzwerkverbindung in der installierten App und in der Version als einzelne Datei.',
+ 'whatsNew.v0180.own': 'Der Urheberrechtshinweis von Loop Studio selbst ist unverändert; diese Lizenzen gelten nur für die enthaltenen Komponenten von Drittanbietern.',
} satisfies Record
export default ui
diff --git a/src/i18n/locales/en/ui.ts b/src/i18n/locales/en/ui.ts
index 87de1300..58c76f7f 100644
--- a/src/i18n/locales/en/ui.ts
+++ b/src/i18n/locales/en/ui.ts
@@ -343,6 +343,15 @@ const ui = {
'about.repo': 'GitHub repository',
'about.repoAria': 'Loop Studio GitHub repository',
'about.notAffiliated': 'Loop Studio is an independent project and is not affiliated with or endorsed by Machinations.io.',
+ 'about.licenses': 'Third-party open-source licenses',
+ 'licenses.back': 'Back to About',
+ 'licenses.lead': 'These are the notices for the third-party software included in Loop Studio. They cover those components only, not Loop Studio itself. Each license is reproduced in full in its original English.',
+ 'licenses.openFile': 'Open as a text file',
+ 'licenses.openFileAria': 'Open the license texts as a text file in a new tab',
+ 'licenses.loading': 'Loading the license texts…',
+ 'licenses.error': 'The license texts could not be loaded.',
+ 'licenses.retry': 'Try again',
+ 'licenses.textLabel': 'License texts',
'hint.close': 'Dismiss this note',
'hint.emptyCanvas.body': 'Start from a Template, or drag node types in from the left panel.',
'hint.mc.body': 'Monte Carlo runs the model many times and shows a spread of outcomes, not a single prediction.',
@@ -506,6 +515,9 @@ const ui = {
'whatsNew.v0172.size': 'The Temporary session button at the top of the window now has the same height and rounded corners as the menu buttons beside it.',
'whatsNew.v0172.text': 'Its label is larger and easier to read.',
'whatsNew.v0172.same': 'It keeps its orange border and opens the same menu; how a temporary session works has not changed.',
+ 'whatsNew.v0180.licenses': 'About Loop Studio now has a Third-party open-source licenses page with the full license text of every component the app includes.',
+ 'whatsNew.v0180.offline': 'The license texts open without a network connection in the installed app and in the single-file version.',
+ 'whatsNew.v0180.own': 'Loop Studio’s own copyright notice is unchanged; these licenses cover the included third-party components only.',
} as const
export type UiKey = keyof typeof ui
diff --git a/src/i18n/locales/es-419/ui.ts b/src/i18n/locales/es-419/ui.ts
index 0203532c..1da9e0d8 100644
--- a/src/i18n/locales/es-419/ui.ts
+++ b/src/i18n/locales/es-419/ui.ts
@@ -357,6 +357,15 @@ const ui = {
'about.repo': 'Repositorio de GitHub',
'about.repoAria': 'Repositorio de Loop Studio en GitHub',
'about.notAffiliated': 'Loop Studio es un proyecto independiente y no está afiliado a Machinations.io ni cuenta con su respaldo.',
+ 'about.licenses': 'Licencias de código abierto de terceros',
+ 'licenses.back': 'Volver',
+ 'licenses.lead': 'Estos son los avisos del software de terceros incluido en Loop Studio. Se aplican solo a esos componentes, no a Loop Studio. Cada licencia se reproduce completa en su versión original en inglés.',
+ 'licenses.openFile': 'Abrir como archivo de texto',
+ 'licenses.openFileAria': 'Abrir los textos de las licencias como archivo de texto en una pestaña nueva',
+ 'licenses.loading': 'Cargando los textos de las licencias…',
+ 'licenses.error': 'No se pudieron cargar los textos de las licencias.',
+ 'licenses.retry': 'Reintentar',
+ 'licenses.textLabel': 'Textos de las licencias',
'hint.close': 'Descartar esta nota',
'hint.emptyCanvas.body': 'Empiece desde una Plantilla, o arrastre tipos de nodo desde el panel izquierdo.',
'hint.mc.body': 'Monte Carlo ejecuta el modelo muchas veces y muestra un rango de resultados, no una sola predicción.',
@@ -520,6 +529,9 @@ const ui = {
'whatsNew.v0172.size': 'El botón de sesión temporal, en la parte superior de la ventana, ahora tiene la misma altura y las mismas esquinas redondeadas que los botones de menú que lo acompañan.',
'whatsNew.v0172.text': 'Su texto es más grande y más fácil de leer.',
'whatsNew.v0172.same': 'Conserva su borde naranja y abre el mismo menú; el funcionamiento de una sesión temporal no ha cambiado.',
+ 'whatsNew.v0180.licenses': 'Acerca de Loop Studio ahora incluye una página de licencias de código abierto de terceros con el texto completo de la licencia de cada componente de la aplicación.',
+ 'whatsNew.v0180.offline': 'Los textos de las licencias se abren sin conexión a internet en la aplicación instalada y en la versión de un solo archivo.',
+ 'whatsNew.v0180.own': 'El aviso de derechos de autor de Loop Studio no ha cambiado; estas licencias solo se aplican a los componentes de terceros incluidos.',
} as const
export type UiKey = keyof typeof ui
diff --git a/src/i18n/locales/es-ES/ui.ts b/src/i18n/locales/es-ES/ui.ts
index 3707bcec..454f52d1 100644
--- a/src/i18n/locales/es-ES/ui.ts
+++ b/src/i18n/locales/es-ES/ui.ts
@@ -371,6 +371,15 @@ const ui = {
'about.repo': 'Repositorio de GitHub',
'about.repoAria': 'Repositorio de Loop Studio en GitHub',
'about.notAffiliated': 'Loop Studio es un proyecto independiente y no está afiliado a Machinations.io ni cuenta con su respaldo.',
+ 'about.licenses': 'Licencias de código abierto de terceros',
+ 'licenses.back': 'Volver',
+ 'licenses.lead': 'Estos son los avisos del software de terceros incluido en Loop Studio. Se aplican solo a esos componentes, no a Loop Studio. Cada licencia se reproduce completa en su versión original en inglés.',
+ 'licenses.openFile': 'Abrir como archivo de texto',
+ 'licenses.openFileAria': 'Abrir los textos de las licencias como archivo de texto en una pestaña nueva',
+ 'licenses.loading': 'Cargando los textos de las licencias…',
+ 'licenses.error': 'No se pudieron cargar los textos de las licencias.',
+ 'licenses.retry': 'Reintentar',
+ 'licenses.textLabel': 'Textos de las licencias',
'hint.close': 'Descartar esta nota',
'hint.emptyCanvas.body': 'Empiece desde una Plantilla, o arrastre tipos de nodo desde el panel izquierdo.',
'hint.mc.body': 'Monte Carlo ejecuta el modelo muchas veces y muestra un rango de resultados, no una sola predicción.',
@@ -534,6 +543,9 @@ const ui = {
'whatsNew.v0172.size': 'El botón de sesión temporal, en la parte superior de la ventana, ahora tiene la misma altura y las mismas esquinas redondeadas que los botones de menú que lo acompañan.',
'whatsNew.v0172.text': 'Su texto es más grande y más fácil de leer.',
'whatsNew.v0172.same': 'Conserva su borde naranja y abre el mismo menú; el funcionamiento de una sesión temporal no ha cambiado.',
+ 'whatsNew.v0180.licenses': 'Acerca de Loop Studio ahora incluye una página de licencias de código abierto de terceros con el texto completo de la licencia de cada componente de la aplicación.',
+ 'whatsNew.v0180.offline': 'Los textos de las licencias se abren sin conexión a internet en la aplicación instalada y en la versión de un solo archivo.',
+ 'whatsNew.v0180.own': 'El aviso de derechos de autor de Loop Studio no ha cambiado; estas licencias solo se aplican a los componentes de terceros incluidos.',
} as const
export type UiKey = keyof typeof ui
diff --git a/src/i18n/locales/fr/ui.ts b/src/i18n/locales/fr/ui.ts
index 3b2d9b68..5984fb80 100644
--- a/src/i18n/locales/fr/ui.ts
+++ b/src/i18n/locales/fr/ui.ts
@@ -414,6 +414,15 @@ const ui = {
'about.repoAria': 'Dépôt GitHub de Loop Studio',
'about.notAffiliated':
'Loop Studio est un projet indépendant, sans affiliation avec Machinations.io ni approbation de sa part.',
+ 'about.licenses': 'Licences open source de tiers',
+ 'licenses.back': 'Retour',
+ 'licenses.lead': 'Voici les mentions des logiciels tiers inclus dans Loop Studio. Elles ne concernent que ces composants, et non Loop Studio lui-même. Chaque licence est reproduite en entier, dans sa version anglaise d’origine.',
+ 'licenses.openFile': 'Ouvrir en fichier texte',
+ 'licenses.openFileAria': 'Ouvrir les textes des licences en fichier texte dans un nouvel onglet',
+ 'licenses.loading': 'Chargement des textes des licences…',
+ 'licenses.error': 'Les textes des licences n’ont pas pu être chargés.',
+ 'licenses.retry': 'Réessayer',
+ 'licenses.textLabel': 'Textes des licences',
'hint.close': 'Masquer cette note',
'hint.emptyCanvas.body':
'Partez d’un modèle, ou faites glisser des types de nœuds depuis le panneau de gauche.',
@@ -591,6 +600,9 @@ const ui = {
'whatsNew.v0172.size': 'Le bouton Session temporaire, en haut de la fenêtre, a désormais la même hauteur et les mêmes coins arrondis que les boutons de menu voisins.',
'whatsNew.v0172.text': 'Son libellé est plus grand et plus facile à lire.',
'whatsNew.v0172.same': 'Il garde sa bordure orange et ouvre le même menu, et le fonctionnement d’une session temporaire n’a pas changé.',
+ 'whatsNew.v0180.licenses': 'À propos de Loop Studio comporte désormais une page Licences open source de tiers, avec le texte complet de la licence de chaque composant inclus dans l’application.',
+ 'whatsNew.v0180.offline': 'Les textes des licences s’ouvrent sans connexion réseau dans l’application installée et dans la version en un seul fichier.',
+ 'whatsNew.v0180.own': 'La mention de droits d’auteur de Loop Studio est inchangée et ces licences ne concernent que les composants tiers inclus.',
} satisfies Record
export default ui
diff --git a/src/i18n/locales/it/ui.ts b/src/i18n/locales/it/ui.ts
index d548e247..2cabae9f 100644
--- a/src/i18n/locales/it/ui.ts
+++ b/src/i18n/locales/it/ui.ts
@@ -373,6 +373,15 @@ const ui = {
'about.repo': 'Repository GitHub',
'about.repoAria': 'Repository GitHub di Loop Studio',
'about.notAffiliated': 'Loop Studio è un progetto indipendente e non è affiliato né approvato da Machinations.io.',
+ 'about.licenses': 'Licenze open source di terze parti',
+ 'licenses.back': 'Indietro',
+ 'licenses.lead': 'Queste sono le note sul software di terze parti incluso in Loop Studio. Valgono solo per quei componenti, non per Loop Studio stesso. Ogni licenza è riportata per intero nel testo originale inglese.',
+ 'licenses.openFile': 'Apri come file di testo',
+ 'licenses.openFileAria': 'Apri i testi delle licenze come file di testo in una nuova scheda',
+ 'licenses.loading': 'Caricamento dei testi delle licenze…',
+ 'licenses.error': 'Impossibile caricare i testi delle licenze.',
+ 'licenses.retry': 'Riprova',
+ 'licenses.textLabel': 'Testi delle licenze',
'hint.close': 'Ignora questa nota',
'hint.emptyCanvas.body': 'Parti da un Modello, oppure trascina i tipi di nodo dal pannello a sinistra.',
'hint.mc.body': 'Monte Carlo esegue il modello molte volte e mostra una gamma di esiti, non una previsione singola.',
@@ -536,6 +545,9 @@ const ui = {
'whatsNew.v0172.size': 'Il pulsante della sessione temporanea, in alto nella finestra, ha ora la stessa altezza e gli stessi angoli arrotondati dei pulsanti di menu accanto.',
'whatsNew.v0172.text': 'La sua scritta è più grande e più facile da leggere.',
'whatsNew.v0172.same': 'Mantiene il bordo arancione e apre lo stesso menu; il funzionamento di una sessione temporanea non è cambiato.',
+ 'whatsNew.v0180.licenses': 'Informazioni su Loop Studio ora include una pagina con le licenze open source di terze parti e il testo completo della licenza di ogni componente dell’app.',
+ 'whatsNew.v0180.offline': 'I testi delle licenze si aprono senza connessione di rete nell’app installata e nella versione in un unico file.',
+ 'whatsNew.v0180.own': 'La nota di copyright di Loop Studio stesso non è cambiata; queste licenze valgono solo per i componenti di terze parti inclusi.',
} satisfies Record
export default ui
diff --git a/src/i18n/locales/ja/ui.ts b/src/i18n/locales/ja/ui.ts
index 087ad535..0ed6c042 100644
--- a/src/i18n/locales/ja/ui.ts
+++ b/src/i18n/locales/ja/ui.ts
@@ -375,6 +375,15 @@ const ui = {
'about.repoAria': 'Loop Studio の GitHub リポジトリ',
'about.notAffiliated':
'Loop Studio は独立したプロジェクトであり、Machinations.io とは提携しておらず、その承認も受けていません。',
+ 'about.licenses': 'サードパーティのオープンソースライセンス',
+ 'licenses.back': '戻る',
+ 'licenses.lead': 'Loop Studio に含まれるサードパーティ製ソフトウェアの表示です。これらは各コンポーネントにのみ適用され、Loop Studio 自体には適用されません。各ライセンスの原文を英語のまま全文掲載しています。',
+ 'licenses.openFile': 'テキストファイルとして開く',
+ 'licenses.openFileAria': 'ライセンス原文を新しいタブでテキストファイルとして開く',
+ 'licenses.loading': 'ライセンス原文を読み込んでいます…',
+ 'licenses.error': 'ライセンス原文を読み込めませんでした。',
+ 'licenses.retry': 'もう一度試す',
+ 'licenses.textLabel': 'ライセンス原文',
'hint.close': 'この注記を閉じる',
'hint.emptyCanvas.body': 'テンプレートから始めるか、左のパネルからノードの種類をドラッグしてください。',
'hint.mc.body': 'モンテカルロはモデルを何度も実行し、単一の予測ではなく結果の広がりを示します。',
@@ -541,6 +550,9 @@ const ui = {
'whatsNew.v0172.size': '画面上部の一時セッションのボタンが、隣のメニューボタンと同じ高さと角の形になりました。',
'whatsNew.v0172.text': 'ボタンの文字が大きくなり、読みやすくなりました。',
'whatsNew.v0172.same': 'オレンジ色の枠と開くメニューはそのままで、一時セッションの動作も変わっていません。',
+ 'whatsNew.v0180.licenses': 'Loop Studio の概要画面にサードパーティのオープンソースライセンスのページが加わり、アプリに含まれるすべてのコンポーネントのライセンス原文を確認できます。',
+ 'whatsNew.v0180.offline': 'インストールしたアプリと単一ファイル版では、ネットワークに接続していなくてもライセンス原文を開けます。',
+ 'whatsNew.v0180.own': 'Loop Studio 自体の著作権表示は変わっていません。これらのライセンスは、含まれているサードパーティ製コンポーネントにのみ適用されます。',
} satisfies Record
export default ui
diff --git a/src/i18n/locales/ko/ui.ts b/src/i18n/locales/ko/ui.ts
index 8e558e8b..ac74ab12 100644
--- a/src/i18n/locales/ko/ui.ts
+++ b/src/i18n/locales/ko/ui.ts
@@ -346,6 +346,15 @@ const ui = {
'about.repo': 'GitHub 저장소',
'about.repoAria': 'Loop Studio GitHub 저장소',
'about.notAffiliated': 'Loop Studio는 독립 프로젝트이며 Machinations.io와 제휴하거나 보증받은 프로젝트가 아닙니다.',
+ 'about.licenses': '제3자 오픈소스 라이선스',
+ 'licenses.back': 'Loop Studio 정보로 돌아가기',
+ 'licenses.lead': 'Loop Studio에 포함된 제3자 소프트웨어의 고지입니다. 이 고지는 해당 구성 요소에만 적용되며 Loop Studio 자체에는 적용되지 않습니다. 각 라이선스 원문을 영어 그대로 모두 싣습니다.',
+ 'licenses.openFile': '텍스트 파일로 열기',
+ 'licenses.openFileAria': '라이선스 원문을 새 탭에서 텍스트 파일로 열기',
+ 'licenses.loading': '라이선스 원문을 불러오는 중…',
+ 'licenses.error': '라이선스 원문을 불러오지 못했습니다.',
+ 'licenses.retry': '다시 시도',
+ 'licenses.textLabel': '라이선스 원문',
'hint.close': '이 안내 닫기',
'hint.emptyCanvas.body': '템플릿으로 시작하거나, 왼쪽 패널에서 노드 유형을 끌어다 놓으세요.',
'hint.mc.body': '몬테카를로는 모델을 여러 번 실행해 결과의 분포를 보여줍니다. 단일 예측이 아닙니다.',
@@ -509,6 +518,9 @@ const ui = {
'whatsNew.v0172.size': '화면 위쪽의 임시 세션 버튼이 옆의 메뉴 버튼과 같은 높이와 모서리 모양이 되었습니다.',
'whatsNew.v0172.text': '버튼 글자가 커져 더 읽기 쉬워졌습니다.',
'whatsNew.v0172.same': '주황색 테두리와 버튼을 누르면 열리는 메뉴는 그대로이며, 임시 세션이 작동하는 방식도 바뀌지 않았습니다.',
+ 'whatsNew.v0180.licenses': 'Loop Studio 정보에 제3자 오픈소스 라이선스 화면이 생겨, 앱에 포함된 모든 구성 요소의 라이선스 원문을 볼 수 있습니다.',
+ 'whatsNew.v0180.offline': '설치한 앱과 단일 파일 버전에서는 네트워크 연결 없이도 라이선스 원문이 열립니다.',
+ 'whatsNew.v0180.own': 'Loop Studio 자체의 저작권 표시는 바뀌지 않았으며, 이 라이선스는 포함된 제3자 구성 요소에만 적용됩니다.',
} satisfies Record
export default ui
diff --git a/src/i18n/locales/nl/ui.ts b/src/i18n/locales/nl/ui.ts
index 066f01d2..dec575c5 100644
--- a/src/i18n/locales/nl/ui.ts
+++ b/src/i18n/locales/nl/ui.ts
@@ -353,6 +353,15 @@ const ui = {
'about.repo': 'GitHub-repository',
'about.repoAria': 'GitHub-repository van Loop Studio',
'about.notAffiliated': 'Loop Studio is een onafhankelijk project en is niet verbonden aan of onderschreven door Machinations.io.',
+ 'about.licenses': 'Opensourcelicenties van derden',
+ 'licenses.back': 'Terug',
+ 'licenses.lead': 'Dit zijn de vermeldingen voor de software van derden die in Loop Studio zit. Ze gelden alleen voor die onderdelen, niet voor Loop Studio zelf. Elke licentie staat er volledig in, in de oorspronkelijke Engelse tekst.',
+ 'licenses.openFile': 'Openen als tekstbestand',
+ 'licenses.openFileAria': 'De licentieteksten als tekstbestand openen in een nieuw tabblad',
+ 'licenses.loading': 'Licentieteksten laden…',
+ 'licenses.error': 'De licentieteksten konden niet worden geladen.',
+ 'licenses.retry': 'Opnieuw proberen',
+ 'licenses.textLabel': 'Licentieteksten',
'hint.close': 'Deze melding sluiten',
'hint.emptyCanvas.body': 'Begin met een Sjabloon, of sleep soorten knooppunten uit het linkerpaneel naar binnen.',
'hint.mc.body': 'Monte Carlo voert het model vaak uit en toont een spreiding van uitkomsten, niet één voorspelling.',
@@ -517,6 +526,9 @@ const ui = {
'whatsNew.v0172.size': 'De knop voor de tijdelijke sessie bovenaan het venster heeft nu dezelfde hoogte en dezelfde afgeronde hoeken als de menuknoppen ernaast.',
'whatsNew.v0172.text': 'Het opschrift is groter en beter leesbaar.',
'whatsNew.v0172.same': 'De knop houdt zijn oranje rand en opent hetzelfde menu; hoe een tijdelijke sessie werkt, is niet veranderd.',
+ 'whatsNew.v0180.licenses': 'Over Loop Studio heeft nu een pagina met opensourcelicenties van derden, met de volledige licentietekst van elk onderdeel van de app.',
+ 'whatsNew.v0180.offline': 'De licentieteksten openen zonder netwerkverbinding in de geïnstalleerde app en in de versie als één bestand.',
+ 'whatsNew.v0180.own': 'De auteursrechtvermelding van Loop Studio zelf is niet veranderd; deze licenties gelden alleen voor de meegeleverde onderdelen van derden.',
} as const
export default ui
diff --git a/src/i18n/locales/pt-BR/ui.ts b/src/i18n/locales/pt-BR/ui.ts
index 992bbbd5..19af9cf8 100644
--- a/src/i18n/locales/pt-BR/ui.ts
+++ b/src/i18n/locales/pt-BR/ui.ts
@@ -359,6 +359,15 @@ const ui = {
'about.repo': 'Repositório no GitHub',
'about.repoAria': 'Repositório do Loop Studio no GitHub',
'about.notAffiliated': 'O Loop Studio é um projeto independente e não tem vínculo com a Machinations.io nem é endossado por ela.',
+ 'about.licenses': 'Licenças de código aberto de terceiros',
+ 'licenses.back': 'Voltar',
+ 'licenses.lead': 'Estes são os avisos do software de terceiros incluído no Loop Studio. Eles valem apenas para esses componentes, não para o próprio Loop Studio. Cada licença é reproduzida na íntegra, no original em inglês.',
+ 'licenses.openFile': 'Abrir como arquivo de texto',
+ 'licenses.openFileAria': 'Abrir os textos das licenças como arquivo de texto em uma nova aba',
+ 'licenses.loading': 'Carregando os textos das licenças…',
+ 'licenses.error': 'Não foi possível carregar os textos das licenças.',
+ 'licenses.retry': 'Tentar novamente',
+ 'licenses.textLabel': 'Textos das licenças',
'hint.close': 'Dispensar este aviso',
'hint.emptyCanvas.body': 'Comece por um template ou arraste tipos de nó do painel da esquerda.',
'hint.mc.body': 'O Monte Carlo executa o modelo muitas vezes e mostra uma faixa de resultados, não uma única previsão.',
@@ -522,6 +531,9 @@ const ui = {
'whatsNew.v0172.size': 'O botão de sessão temporária, no topo da janela, agora tem a mesma altura e os mesmos cantos arredondados que os botões de menu ao lado.',
'whatsNew.v0172.text': 'O texto do botão está maior e mais fácil de ler.',
'whatsNew.v0172.same': 'Ele mantém a borda laranja e abre o mesmo menu; o funcionamento de uma sessão temporária não mudou.',
+ 'whatsNew.v0180.licenses': 'Sobre o Loop Studio agora tem uma página de licenças de código aberto de terceiros, com o texto completo da licença de cada componente do aplicativo.',
+ 'whatsNew.v0180.offline': 'Os textos das licenças abrem sem conexão de rede no aplicativo instalado e na versão em arquivo único.',
+ 'whatsNew.v0180.own': 'O aviso de direitos autorais do próprio Loop Studio não mudou; essas licenças valem apenas para os componentes de terceiros incluídos.',
} as const
export type UiKey = keyof typeof ui
diff --git a/src/i18n/locales/pt-PT/ui.ts b/src/i18n/locales/pt-PT/ui.ts
index 6e1d355e..7c82b3b2 100644
--- a/src/i18n/locales/pt-PT/ui.ts
+++ b/src/i18n/locales/pt-PT/ui.ts
@@ -373,6 +373,15 @@ const ui = {
'about.repo': 'Repositório no GitHub',
'about.repoAria': 'Repositório do Loop Studio no GitHub',
'about.notAffiliated': 'O Loop Studio é um projeto independente e não tem vínculo com a Machinations.io nem é endossado por ela.',
+ 'about.licenses': 'Licenças de código aberto de terceiros',
+ 'licenses.back': 'Voltar',
+ 'licenses.lead': 'Estes são os avisos do software de terceiros incluído no Loop Studio. Aplicam-se apenas a esses componentes, e não ao próprio Loop Studio. Cada licença é reproduzida na íntegra, no original em inglês.',
+ 'licenses.openFile': 'Abrir como ficheiro de texto',
+ 'licenses.openFileAria': 'Abrir os textos das licenças como ficheiro de texto num novo separador',
+ 'licenses.loading': 'A carregar os textos das licenças…',
+ 'licenses.error': 'Não foi possível carregar os textos das licenças.',
+ 'licenses.retry': 'Tentar novamente',
+ 'licenses.textLabel': 'Textos das licenças',
'hint.close': 'Dispensar este aviso',
'hint.emptyCanvas.body': 'Comece por um template ou arraste tipos de nó do painel da esquerda.',
'hint.mc.body': 'O Monte Carlo executa o modelo muitas vezes e mostra uma faixa de resultados, não uma única previsão.',
@@ -536,6 +545,9 @@ const ui = {
'whatsNew.v0172.size': 'O botão de sessão temporária, no topo da janela, tem agora a mesma altura e os mesmos cantos arredondados que os botões de menu ao lado.',
'whatsNew.v0172.text': 'O texto do botão está maior e mais fácil de ler.',
'whatsNew.v0172.same': 'Mantém o contorno cor de laranja e abre o mesmo menu; o funcionamento de uma sessão temporária não mudou.',
+ 'whatsNew.v0180.licenses': 'Sobre o Loop Studio tem agora uma página de licenças de código aberto de terceiros, com o texto completo da licença de cada componente da aplicação.',
+ 'whatsNew.v0180.offline': 'Os textos das licenças abrem sem ligação à rede na aplicação instalada e na versão num único ficheiro.',
+ 'whatsNew.v0180.own': 'O aviso de direitos de autor do próprio Loop Studio não mudou; estas licenças aplicam-se apenas aos componentes de terceiros incluídos.',
} as const
export type UiKey = keyof typeof ui
diff --git a/src/i18n/locales/ru/ui.ts b/src/i18n/locales/ru/ui.ts
index a9bed2e9..e03aaffa 100644
--- a/src/i18n/locales/ru/ui.ts
+++ b/src/i18n/locales/ru/ui.ts
@@ -416,6 +416,15 @@ const ui = {
'about.repoAria': 'Репозиторий Loop Studio на GitHub',
'about.notAffiliated':
'Loop Studio — независимый проект, не связанный с Machinations.io и не одобренный ею.',
+ 'about.licenses': 'Лицензии открытого ПО сторонних разработчиков',
+ 'licenses.back': 'Назад',
+ 'licenses.lead': 'Здесь собраны уведомления о стороннем программном обеспечении, входящем в Loop Studio. Они относятся только к этим компонентам, а не к самому Loop Studio. Текст каждой лицензии приведён полностью, в английском оригинале.',
+ 'licenses.openFile': 'Открыть как текстовый файл',
+ 'licenses.openFileAria': 'Открыть тексты лицензий как текстовый файл в новой вкладке',
+ 'licenses.loading': 'Загрузка текстов лицензий…',
+ 'licenses.error': 'Не удалось загрузить тексты лицензий.',
+ 'licenses.retry': 'Повторить',
+ 'licenses.textLabel': 'Тексты лицензий',
'hint.close': 'Скрыть это примечание',
'hint.emptyCanvas.body': 'Начните с Шаблона или перетащите типы узлов с левой панели.',
'hint.mc.body':
@@ -590,6 +599,9 @@ const ui = {
'whatsNew.v0172.size': 'Кнопка временного сеанса в верхней части окна теперь такой же высоты и с такими же скруглёнными углами, как соседние кнопки меню.',
'whatsNew.v0172.text': 'Надпись на ней стала крупнее и легче читается.',
'whatsNew.v0172.same': 'Оранжевая рамка и открывающееся меню остались прежними; работа временного сеанса не изменилась.',
+ 'whatsNew.v0180.licenses': 'В окне «О Loop Studio» появилась страница лицензий открытого ПО сторонних разработчиков с полным текстом лицензии каждого компонента приложения.',
+ 'whatsNew.v0180.offline': 'Тексты лицензий открываются без подключения к сети в установленном приложении и в версии из одного файла.',
+ 'whatsNew.v0180.own': 'Уведомление об авторских правах самого Loop Studio не изменилось; эти лицензии относятся только к входящим в него сторонним компонентам.',
} as const
export type UiKey = keyof typeof ui
diff --git a/src/i18n/locales/th/ui.ts b/src/i18n/locales/th/ui.ts
index 443d330f..58cc5a84 100644
--- a/src/i18n/locales/th/ui.ts
+++ b/src/i18n/locales/th/ui.ts
@@ -412,6 +412,15 @@ const ui = {
'about.repoAria': 'ที่เก็บโค้ด Loop Studio บน GitHub',
'about.notAffiliated':
'Loop Studio เป็นโครงการอิสระ ไม่ได้มีความเกี่ยวข้องกับ Machinations.io และไม่ได้รับการรับรองจาก Machinations.io',
+ 'about.licenses': 'สัญญาอนุญาตโอเพนซอร์สของบุคคลที่สาม',
+ 'licenses.back': 'ย้อนกลับ',
+ 'licenses.lead': 'นี่คือประกาศของซอฟต์แวร์บุคคลที่สามที่รวมอยู่ใน Loop Studio ประกาศเหล่านี้ใช้กับส่วนประกอบเหล่านั้นเท่านั้น ไม่ใช่กับตัว Loop Studio เอง สัญญาอนุญาตแต่ละฉบับแสดงไว้ครบถ้วนตามต้นฉบับภาษาอังกฤษ',
+ 'licenses.openFile': 'เปิดเป็นไฟล์ข้อความ',
+ 'licenses.openFileAria': 'เปิดข้อความสัญญาอนุญาตเป็นไฟล์ข้อความในแท็บใหม่',
+ 'licenses.loading': 'กำลังโหลดข้อความสัญญาอนุญาต…',
+ 'licenses.error': 'โหลดข้อความสัญญาอนุญาตไม่ได้',
+ 'licenses.retry': 'ลองอีกครั้ง',
+ 'licenses.textLabel': 'ข้อความสัญญาอนุญาต',
'hint.close': 'ปิดหมายเหตุนี้',
'hint.emptyCanvas.body': 'เริ่มจากเทมเพลต หรือลากชนิดโหนดเข้ามาจากแผงด้านซ้าย',
'hint.mc.body':
@@ -582,6 +591,9 @@ const ui = {
'whatsNew.v0172.size': 'ปุ่มเซสชันชั่วคราวที่ด้านบนของหน้าต่างตอนนี้มีความสูงและมุมโค้งเท่ากับปุ่มเมนูที่อยู่ข้าง ๆ',
'whatsNew.v0172.text': 'ข้อความบนปุ่มใหญ่ขึ้นและอ่านง่ายขึ้น',
'whatsNew.v0172.same': 'ปุ่มยังคงมีขอบสีส้มและเปิดเมนูเดิม ส่วนการทำงานของเซสชันชั่วคราวไม่ได้เปลี่ยน',
+ 'whatsNew.v0180.licenses': 'ในหน้าเกี่ยวกับ Loop Studio มีหน้าสัญญาอนุญาตโอเพนซอร์สของบุคคลที่สามแล้ว พร้อมข้อความสัญญาอนุญาตฉบับเต็มของทุกส่วนประกอบในแอป',
+ 'whatsNew.v0180.offline': 'ข้อความสัญญาอนุญาตเปิดได้โดยไม่ต้องเชื่อมต่อเครือข่ายในแอปที่ติดตั้งและในเวอร์ชันไฟล์เดียว',
+ 'whatsNew.v0180.own': 'ประกาศลิขสิทธิ์ของ Loop Studio เองไม่เปลี่ยนแปลง สัญญาอนุญาตเหล่านี้ใช้กับส่วนประกอบของบุคคลที่สามที่รวมอยู่เท่านั้น',
} as const
export type UiKey = keyof typeof ui
diff --git a/src/i18n/locales/tr/ui.ts b/src/i18n/locales/tr/ui.ts
index ed9ee079..9f72c310 100644
--- a/src/i18n/locales/tr/ui.ts
+++ b/src/i18n/locales/tr/ui.ts
@@ -401,6 +401,15 @@ const ui = {
'about.repoAria': 'Loop Studio GitHub deposu',
'about.notAffiliated':
'Loop Studio bağımsız bir projedir; Machinations.io ile bağlantılı değildir ve onun tarafından onaylanmamıştır.',
+ 'about.licenses': 'Üçüncü taraf açık kaynak lisansları',
+ 'licenses.back': 'Geri',
+ 'licenses.lead': 'Bunlar Loop Studio’ya dahil üçüncü taraf yazılımların bildirimleridir. Yalnızca bu bileşenler için geçerlidir, Loop Studio’nun kendisi için değil. Her lisans, İngilizce orijinal metniyle eksiksiz yer alır.',
+ 'licenses.openFile': 'Metin dosyası olarak aç',
+ 'licenses.openFileAria': 'Lisans metinlerini yeni sekmede metin dosyası olarak aç',
+ 'licenses.loading': 'Lisans metinleri yükleniyor…',
+ 'licenses.error': 'Lisans metinleri yüklenemedi.',
+ 'licenses.retry': 'Tekrar dene',
+ 'licenses.textLabel': 'Lisans metinleri',
'hint.close': 'Bu notu gizle',
'hint.emptyCanvas.body':
'Bir Şablonla başlayın ya da sol panelden düğüm türlerini sürükleyin.',
@@ -576,6 +585,9 @@ const ui = {
'whatsNew.v0172.size': 'Pencerenin üst kısmındaki geçici oturum düğmesi artık yanındaki menü düğmeleriyle aynı yükseklikte ve aynı yuvarlak köşelere sahip.',
'whatsNew.v0172.text': 'Düğmenin yazısı daha büyük ve daha kolay okunuyor.',
'whatsNew.v0172.same': 'Turuncu kenarlığını koruyor ve aynı menüyü açıyor; geçici oturumun çalışma biçimi değişmedi.',
+ 'whatsNew.v0180.licenses': 'Loop Studio hakkında bölümünde artık üçüncü taraf açık kaynak lisansları sayfası var; uygulamadaki her bileşenin lisans metni eksiksiz olarak yer alıyor.',
+ 'whatsNew.v0180.offline': 'Lisans metinleri, yüklenmiş uygulamada ve tek dosyalık sürümde ağ bağlantısı olmadan açılır.',
+ 'whatsNew.v0180.own': 'Loop Studio’nun kendi telif hakkı bildirimi değişmedi; bu lisanslar yalnızca dahil edilen üçüncü taraf bileşenler için geçerlidir.',
} as const
export type UiKey = keyof typeof ui
diff --git a/src/i18n/locales/vi/ui.ts b/src/i18n/locales/vi/ui.ts
index e367034d..f0b6f653 100644
--- a/src/i18n/locales/vi/ui.ts
+++ b/src/i18n/locales/vi/ui.ts
@@ -359,6 +359,15 @@ const ui = {
'about.repo': 'Kho GitHub',
'about.repoAria': 'Kho GitHub của Loop Studio',
'about.notAffiliated': 'Loop Studio là một dự án độc lập, không liên kết với và không được Machinations.io bảo trợ.',
+ 'about.licenses': 'Giấy phép nguồn mở của bên thứ ba',
+ 'licenses.back': 'Quay lại',
+ 'licenses.lead': 'Đây là thông báo về phần mềm của bên thứ ba có trong Loop Studio. Chúng chỉ áp dụng cho các thành phần đó, không áp dụng cho chính Loop Studio. Mỗi giấy phép được giữ nguyên toàn văn bằng tiếng Anh.',
+ 'licenses.openFile': 'Mở dưới dạng tệp văn bản',
+ 'licenses.openFileAria': 'Mở văn bản giấy phép dưới dạng tệp văn bản trong thẻ mới',
+ 'licenses.loading': 'Đang tải văn bản giấy phép…',
+ 'licenses.error': 'Không tải được văn bản giấy phép.',
+ 'licenses.retry': 'Thử lại',
+ 'licenses.textLabel': 'Văn bản giấy phép',
'hint.close': 'Bỏ qua ghi chú này',
'hint.emptyCanvas.body': 'Bắt đầu từ một Mẫu, hoặc kéo các loại nút từ bảng bên trái vào.',
'hint.mc.body': 'Monte Carlo chạy mô hình nhiều lần và cho thấy khoảng trải của kết quả, không phải một dự đoán duy nhất.',
@@ -522,6 +531,9 @@ const ui = {
'whatsNew.v0172.size': 'Nút phiên tạm thời ở đầu cửa sổ giờ có cùng chiều cao và góc bo tròn như các nút menu bên cạnh.',
'whatsNew.v0172.text': 'Chữ trên nút lớn hơn và dễ đọc hơn.',
'whatsNew.v0172.same': 'Nút vẫn giữ viền màu cam và mở cùng một menu; cách phiên tạm thời hoạt động không thay đổi.',
+ 'whatsNew.v0180.licenses': 'Giới thiệu Loop Studio giờ có trang giấy phép nguồn mở của bên thứ ba, với toàn văn giấy phép của mọi thành phần trong ứng dụng.',
+ 'whatsNew.v0180.offline': 'Văn bản giấy phép mở được mà không cần kết nối mạng trong ứng dụng đã cài đặt và trong phiên bản một tệp.',
+ 'whatsNew.v0180.own': 'Thông báo bản quyền của chính Loop Studio không thay đổi; các giấy phép này chỉ áp dụng cho các thành phần của bên thứ ba có trong ứng dụng.',
} satisfies Record
export default ui
diff --git a/src/i18n/locales/zh-Hans/ui.ts b/src/i18n/locales/zh-Hans/ui.ts
index 79d77fec..9c33dbf6 100644
--- a/src/i18n/locales/zh-Hans/ui.ts
+++ b/src/i18n/locales/zh-Hans/ui.ts
@@ -350,6 +350,15 @@ const ui = {
'about.repo': 'GitHub 仓库',
'about.repoAria': 'Loop Studio 的 GitHub 仓库',
'about.notAffiliated': 'Loop Studio 是独立项目,与 Machinations.io 没有任何隶属或背书关系。',
+ 'about.licenses': '第三方开源许可证',
+ 'licenses.back': '返回',
+ 'licenses.lead': '以下是 Loop Studio 所含第三方软件的声明。这些声明仅适用于相应组件,不适用于 Loop Studio 本身。各许可证均以英文原文完整收录。',
+ 'licenses.openFile': '以文本文件打开',
+ 'licenses.openFileAria': '在新标签页中以文本文件打开许可证原文',
+ 'licenses.loading': '正在加载许可证原文…',
+ 'licenses.error': '无法加载许可证原文。',
+ 'licenses.retry': '重试',
+ 'licenses.textLabel': '许可证原文',
'hint.close': '关闭此说明',
'hint.emptyCanvas.body': '从模板开始,或者从左侧面板把节点类型拖进来。',
'hint.mc.body': '蒙特卡洛会把模型运行很多次,展示结果的分布范围,而不是单一的预测值。',
@@ -514,6 +523,9 @@ const ui = {
'whatsNew.v0172.size': '窗口顶部的临时会话按钮现在与旁边的菜单按钮高度相同、圆角一致。',
'whatsNew.v0172.text': '按钮文字更大,更易阅读。',
'whatsNew.v0172.same': '它仍保留橙色边框,打开的菜单也相同;临时会话的工作方式没有改变。',
+ 'whatsNew.v0180.licenses': '关于对话框中新增了第三方开源许可证页面,可查看应用所含每个组件的许可证全文。',
+ 'whatsNew.v0180.offline': '在已安装的应用和单文件版本中,无需联网即可打开许可证原文。',
+ 'whatsNew.v0180.own': 'Loop Studio 本身的版权声明没有改变;这些许可证仅适用于所含的第三方组件。',
} satisfies Record
export default ui
diff --git a/src/i18n/locales/zh-Hant/ui.ts b/src/i18n/locales/zh-Hant/ui.ts
index 1525957c..50682ed9 100644
--- a/src/i18n/locales/zh-Hant/ui.ts
+++ b/src/i18n/locales/zh-Hant/ui.ts
@@ -373,6 +373,15 @@ const ui = {
'about.repo': 'GitHub 儲存庫',
'about.repoAria': 'Loop Studio 的 GitHub 儲存庫',
'about.notAffiliated': 'Loop Studio 是獨立專案,與 Machinations.io 沒有任何隸屬或背書關係。',
+ 'about.licenses': '第三方開放原始碼授權',
+ 'licenses.back': '返回',
+ 'licenses.lead': '以下是 Loop Studio 所含第三方軟體的聲明。這些聲明僅適用於相應元件,不適用於 Loop Studio 本身。各授權均以英文原文完整收錄。',
+ 'licenses.openFile': '以文字檔開啟',
+ 'licenses.openFileAria': '在新分頁中以文字檔開啟授權原文',
+ 'licenses.loading': '正在載入授權原文…',
+ 'licenses.error': '無法載入授權原文。',
+ 'licenses.retry': '重試',
+ 'licenses.textLabel': '授權原文',
'hint.close': '關閉這則說明',
'hint.emptyCanvas.body': '從範本開始,或是從左側面板把節點類型拖進來。',
'hint.mc.body': '蒙地卡羅會把模型執行很多次,呈現結果的分布範圍,而不是單一的預測值。',
@@ -540,6 +549,9 @@ const ui = {
'whatsNew.v0172.size': '視窗頂端的臨時工作階段按鈕現在與旁邊的選單按鈕高度相同、圓角一致。',
'whatsNew.v0172.text': '按鈕文字更大,更容易閱讀。',
'whatsNew.v0172.same': '它仍保留橘色邊框,開啟的選單也相同;臨時工作階段的運作方式沒有改變。',
+ 'whatsNew.v0180.licenses': '關於對話方塊新增了第三方開放原始碼授權頁面,可查看應用程式所含每個元件的授權全文。',
+ 'whatsNew.v0180.offline': '在已安裝的應用程式和單一檔案版本中,無需連線即可開啟授權原文。',
+ 'whatsNew.v0180.own': 'Loop Studio 本身的版權聲明沒有改變;這些授權僅適用於所含的第三方元件。',
} satisfies Record
export default ui
diff --git a/src/i18n/nlCopy.test.ts b/src/i18n/nlCopy.test.ts
index 88453d43..7cd04ec2 100644
--- a/src/i18n/nlCopy.test.ts
+++ b/src/i18n/nlCopy.test.ts
@@ -183,6 +183,8 @@ const SURVIVING: ReadonlyArray = [
['the storage gate (issue #297)', ['computer', 'privacy', 'chrome', 'html']],
// issue #300 - password protection for share links: HTTPS is a protocol name
['password protection for share links (issue #300)', ['https']],
+ // issue #301 - the licence view: `software` is the word Dutch uses
+ ['the third-party licence view (issue #301)', ['software']],
]
const SURVIVING_WORDS = new Set(SURVIVING.flatMap(([, ws]) => ws))
@@ -255,15 +257,15 @@ const IDENTICAL_TO_EN: ReadonlyArray = [
// ------------------------------------------------------------------ shape
describe('nl copy — the surfaces exist and are complete', () => {
it('has exactly the base key set', () => {
- expect(KEYS).toHaveLength(984) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines)
+ expect(KEYS).toHaveLength(996) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines)
expect(Object.keys(NL).sort()).toEqual([...KEYS].sort())
})
it('covers all four runtime surfaces, at the measured sizes', () => {
const per: Record = {}
for (const r of RUNTIME) per[r.surface.split('/')[0]!] = (per[r.surface.split('/')[0]!] ?? 0) + 1
- expect(per).toEqual({ catalog: 984, template: 196, frame: 7, module: 19 })
- expect(RUNTIME).toHaveLength(1206)
+ expect(per).toEqual({ catalog: 996, template: 196, frame: 7, module: 19 })
+ expect(RUNTIME).toHaveLength(1218)
})
it('EVERY row has a non-empty ENGLISH side — the vacuity guard', () => {
diff --git a/src/i18n/ptPtCopy.test.ts b/src/i18n/ptPtCopy.test.ts
index 4ec921e6..fc3c6169 100644
--- a/src/i18n/ptPtCopy.test.ts
+++ b/src/i18n/ptPtCopy.test.ts
@@ -58,7 +58,7 @@ describe('pt-PT copy — the region audit over pt-BR', () => {
// ---------------------------------------------------------------- shape
it('has exactly the same key set as pt-BR', () => {
expect(Object.keys(ptValues).sort()).toEqual(KEYS.slice().sort())
- expect(KEYS).toHaveLength(984) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines)
+ expect(KEYS).toHaveLength(996) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines)
})
it('differs from pt-BR on exactly the audited keys', () => {
@@ -118,7 +118,14 @@ describe('pt-PT copy — the region audit over pt-BR', () => {
// release-note lines - `tem agora` not `agora tem`, `contorno cor de
// laranja` not `borda laranja`, no subject pronoun `Ele`; the third line
// reads the same in both.
- expect(DELTA).toHaveLength(261)
+ // The licence view (0.18.0) moved it from 261 to 268: seven of its twelve
+ // lines - `ficheiro` not `arquivo`, `num novo separador` not `em uma nova
+ // aba`, `A carregar` not `Carregando`, `aplicação` not `aplicativo`,
+ // `ligação à rede` not `conexão de rede`, `direitos de autor` not
+ // `direitos autorais`, `tem agora` not `agora tem`, `aplicam-se` not
+ // `valem`; the title, Back, Try again, the failure line and the text's
+ // name read the same in both.
+ expect(DELTA).toHaveLength(268)
// and it is a real audit, not a rewrite — most of the catalog agrees.
//
// The password strings of issue #300 are counted apart. The word for a
@@ -162,7 +169,7 @@ describe('pt-PT copy — the region audit over pt-BR', () => {
// outside them, the quarter bound stands as it always did
const rest = KEYS.filter((k) => !isPasswordKey(k))
const restDelta = DELTA.filter((k) => !isPasswordKey(k))
- expect(restDelta).toHaveLength(233)
+ expect(restDelta).toHaveLength(240)
expect(restDelta.length).toBeLessThan(rest.length / 4)
})
diff --git a/src/i18n/ruCopy.test.ts b/src/i18n/ruCopy.test.ts
index baafeec4..b37df423 100644
--- a/src/i18n/ruCopy.test.ts
+++ b/src/i18n/ruCopy.test.ts
@@ -43,7 +43,7 @@ describe('ru copy — the first Cyrillic catalog', () => {
// ------------------------------------------------------------------ shape
it('has exactly the base key set', () => {
expect(Object.keys(ruValues).sort()).toEqual(KEYS.slice().sort())
- expect(KEYS).toHaveLength(984) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines)
+ expect(KEYS).toHaveLength(996) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines)
})
// ----------------------------------------------------------------- script
@@ -242,6 +242,7 @@ describe('ru copy — the first Cyrillic catalog', () => {
'перемещён',
'подключён',
'прервёт',
+ 'приведён',
'прошёл',
'разделённые',
'своё',
diff --git a/src/i18n/thCopy.test.ts b/src/i18n/thCopy.test.ts
index dfb69f5e..ca6aef2d 100644
--- a/src/i18n/thCopy.test.ts
+++ b/src/i18n/thCopy.test.ts
@@ -88,11 +88,11 @@ const englishFor = (surface: string, id: string): string => {
describe('th copy — the first no-space script', () => {
it('has exactly the base key set', () => {
expect(Object.keys(TH).sort()).toEqual(KEYS.slice().sort())
- expect(KEYS).toHaveLength(984) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines)
+ expect(KEYS).toHaveLength(996) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines)
})
it('covers all three runtime surfaces', () => {
- expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(984)
+ expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(996)
expect(RUNTIME.filter(([s]) => s.startsWith('template/'))).toHaveLength(196)
expect(RUNTIME.filter(([s]) => s.startsWith('frame/'))).toHaveLength(7)
expect(RUNTIME.filter(([s]) => s.startsWith('module/'))).toHaveLength(19)
@@ -401,6 +401,10 @@ const LATIN_KEYS = [
'template/mmo-progression:z3_xp_meter',
'catalog:whatsNew.v0153.icons',
'catalog:whatsNew.v0170.limits',
+ // issue #301 - the licence view: `Loop Studio` in its lead and two 0.18.0 release-note lines
+ 'catalog:licenses.lead',
+ 'catalog:whatsNew.v0180.licenses',
+ 'catalog:whatsNew.v0180.own',
// the storage gate and the Storage and privacy area (issue #297)
'catalog:gate.lead',
'catalog:gate.portable.note',
@@ -418,7 +422,7 @@ describe('a Latin run is declared, twice over — by key and by word', () => {
it('the strings carrying a Latin run are exactly the declared ones', () => {
expect(withLatin.slice().sort()).toEqual(LATIN_KEYS.slice().sort())
- expect(LATIN_KEYS).toHaveLength(163)
+ expect(LATIN_KEYS).toHaveLength(166)
})
it('the Latin vocabulary is exactly the declared one', () => {
diff --git a/src/i18n/trCopy.test.ts b/src/i18n/trCopy.test.ts
index 512a4340..8a2f720f 100644
--- a/src/i18n/trCopy.test.ts
+++ b/src/i18n/trCopy.test.ts
@@ -30,7 +30,7 @@ const KEYS = Object.keys(EN)
describe('tr copy — the first Turkish catalog', () => {
it('has exactly the base key set', () => {
expect(Object.keys(TR).sort()).toEqual(KEYS.slice().sort())
- expect(KEYS).toHaveLength(984) // issue #300: +33; issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines)
+ expect(KEYS).toHaveLength(996) // issue #300: +33; issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines)
})
})
diff --git a/src/i18n/viCopy.test.ts b/src/i18n/viCopy.test.ts
index aac42a17..d403d0f0 100644
--- a/src/i18n/viCopy.test.ts
+++ b/src/i18n/viCopy.test.ts
@@ -132,11 +132,11 @@ const englishFor = (surface: string, id: string): string => {
describe('vi copy — the first locale English hides inside', () => {
it('has exactly the base key set', () => {
expect(Object.keys(VI).sort()).toEqual(KEYS.slice().sort())
- expect(KEYS).toHaveLength(984) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines)
+ expect(KEYS).toHaveLength(996) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines)
})
it('covers all four runtime surfaces', () => {
- expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(984)
+ expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(996)
expect(RUNTIME.filter(([s]) => s.startsWith('template/'))).toHaveLength(196)
expect(RUNTIME.filter(([s]) => s.startsWith('frame/'))).toHaveLength(7)
expect(RUNTIME.filter(([s]) => s.startsWith('module/'))).toHaveLength(19)
@@ -541,6 +541,10 @@ const ASCII_KEYS = [
// the session chip (0.17.2): `menu` in two release-note lines
'catalog:whatsNew.v0172.size',
'catalog:whatsNew.v0172.same',
+ // issue #301 - the licence view: `Loop Studio` in its lead and two 0.18.0 release-note lines
+ 'catalog:licenses.lead',
+ 'catalog:whatsNew.v0180.licenses',
+ 'catalog:whatsNew.v0180.own',
// the storage gate and the Storage and privacy area (issue #297)
'catalog:gate.lead',
'catalog:gate.portable.note',
@@ -562,7 +566,7 @@ describe('a kept English word is declared, twice over — by key and by word', (
([s, id]) => s + ':' + id,
)
expect(actual.slice().sort()).toEqual(ASCII_KEYS.slice().sort())
- expect(ASCII_KEYS).toHaveLength(186)
+ expect(ASCII_KEYS).toHaveLength(189)
})
it('the kept vocabulary is exactly the declared one', () => {
diff --git a/src/index.css b/src/index.css
index 609a25c3..430b8f0c 100644
--- a/src/index.css
+++ b/src/index.css
@@ -6317,6 +6317,69 @@ input[type='number']::-webkit-inner-spin-button {
font-size: 12px;
color: var(--text-tertiary);
}
+.about__licenses {
+ margin-top: 4px;
+}
+/* the dialog's links (the GitHub repository; the notices file in the licence
+ view) take the dialog's ink, underlined — the browser's default link blue
+ was unreadable on the dark panel */
+.about a,
+.licenses__file {
+ color: var(--text-primary);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+/* issue #301 — the About dialog's licence view: the same dialog, wider, with
+ the notices text in a scrollable, focusable (a text node: searchable,
+ selectable, copyable). The text is the file's own, left to right. */
+.mcdlg--licenses {
+ width: min(760px, calc(100vw - 24px));
+}
+.licenses {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 10px;
+ min-width: 0;
+}
+.licenses p {
+ margin: 0;
+}
+.licenses__lead {
+ font-size: 13px;
+ line-height: 1.6;
+ color: var(--text-secondary);
+}
+.licenses__file {
+ font-size: 12px;
+}
+.licenses__status {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+.licenses__text {
+ align-self: stretch;
+ margin: 0;
+ max-height: min(55vh, 520px);
+ overflow: auto;
+ padding: 10px 12px;
+ border: 1px solid var(--line-hairline);
+ border-radius: var(--control-radius);
+ background: var(--surface-sunken);
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+ font-size: 11.5px;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ text-align: left;
+ user-select: text;
+}
/* Contextual help dialog — reuses the .mcdlg shell (docs/contextual-inline-help.md §CIH4) */
.mcdlg--contextual-help {
diff --git a/src/licenses/notices.test.ts b/src/licenses/notices.test.ts
new file mode 100644
index 00000000..454431bb
--- /dev/null
+++ b/src/licenses/notices.test.ts
@@ -0,0 +1,88 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { loadNotices, noticesFileUrl, noticesHaveFile, resetNoticesForTest } from './notices'
+
+// Issue #301 — where each build keeps its notices, and how the licence view
+// reads them, against stand-ins for the page (the unit tests run in Node; the
+// real pages are covered in e2e: dev, production bundle, PWA offline, portable).
+
+const TEXT = 'Loop Studio\nCopyright © 2026 Hanrim. All rights reserved.\n\nThird-party open-source licenses\n'
+
+class FakeTemplate {
+ content: { textContent: string | null }
+ constructor(text: string | null) {
+ this.content = { textContent: text }
+ }
+}
+
+function page({ portable, template }: { portable: boolean; template?: unknown }) {
+ vi.stubGlobal('HTMLTemplateElement', FakeTemplate)
+ vi.stubGlobal('document', {
+ baseURI: 'https://example.test/app/index.html',
+ documentElement: { getAttribute: (n: string) => (n === 'data-build' && portable ? 'portable' : null) },
+ getElementById: (id: string) => (id === 'third-party-notices' ? (template ?? null) : null),
+ })
+}
+const response = (body: string, { status = 200, type = 'text/plain; charset=utf-8' } = {}) =>
+ new Response(body, { status, headers: { 'content-type': type } })
+
+beforeEach(() => resetNoticesForTest())
+afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+})
+
+describe('web, PWA and dev: the same-origin file', () => {
+ it('is fetched next to the page and read as text', async () => {
+ page({ portable: false })
+ const fetchMock = vi.fn(async () => response(TEXT))
+ vi.stubGlobal('fetch', fetchMock)
+ expect(noticesHaveFile()).toBe(true)
+ expect(noticesFileUrl()).toBe('https://example.test/app/THIRD_PARTY_NOTICES.txt')
+ await expect(loadNotices()).resolves.toBe(TEXT)
+ expect(fetchMock).toHaveBeenCalledWith('https://example.test/app/THIRD_PARTY_NOTICES.txt')
+ })
+ it('is read once per page: a second call shares the first read', async () => {
+ page({ portable: false })
+ const fetchMock = vi.fn(async () => response(TEXT))
+ vi.stubGlobal('fetch', fetchMock)
+ await loadNotices()
+ await loadNotices()
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
+ it.each([
+ ['a 404', response('not found', { status: 404 }), /answered 404/],
+ ['the host’s index.html fallback', response('', { type: 'text/html' }), /not plain text/],
+ ['plain text that is not the notices', response('hello\n'), /does not hold the notices/],
+ ])('%s is not the notices', async (_name, res, re) => {
+ page({ portable: false })
+ vi.stubGlobal('fetch', vi.fn(async () => res))
+ await expect(loadNotices()).rejects.toThrow(re)
+ })
+ it('a failed read is not remembered: trying again reads again', async () => {
+ page({ portable: false })
+ const fetchMock = vi.fn().mockRejectedValueOnce(new TypeError('offline')).mockResolvedValueOnce(response(TEXT))
+ vi.stubGlobal('fetch', fetchMock)
+ await expect(loadNotices()).rejects.toThrow('offline')
+ await expect(loadNotices()).resolves.toBe(TEXT)
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ })
+})
+
+describe('portable: the template in the file itself', () => {
+ it('reads template.content.textContent and fetches nothing', async () => {
+ page({ portable: true, template: new FakeTemplate(TEXT) })
+ const fetchMock = vi.fn()
+ vi.stubGlobal('fetch', fetchMock)
+ expect(noticesHaveFile()).toBe(false)
+ await expect(loadNotices()).resolves.toBe(TEXT)
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+ it.each([
+ ['no template', undefined, /no {
+ page({ portable: true, template })
+ await expect(loadNotices()).rejects.toThrow(re)
+ })
+})
diff --git a/src/licenses/notices.ts b/src/licenses/notices.ts
new file mode 100644
index 00000000..f59d5800
--- /dev/null
+++ b/src/licenses/notices.ts
@@ -0,0 +1,70 @@
+import { buildKind } from '../storage/buildKind'
+
+// Issue #301 - where the third-party notices are, and how the licence view in
+// the About dialog reads them. Every build carries them (licenses/README.md):
+//
+// web, PWA THIRD_PARTY_NOTICES.txt beside index.html, same origin; the PWA
+// precaches it, so it opens offline once the app is installed
+// portable the same text inside the single HTML file, escaped, in
+// - read with
+// `template.content.textContent`, never as HTML
+// dev the dev server answers THIRD_PARTY_NOTICES.txt with the web
+// build's text, rebuilt from the committed manifest
+//
+// The text is third-party input. It is shown as TEXT (a React text child), and
+// never passed through `innerHTML` or `dangerouslySetInnerHTML` -
+// `scripts/check-licence-screen.mjs` fails the build if this file or the view
+// ever does.
+
+export const NOTICES_FILE = 'THIRD_PARTY_NOTICES.txt'
+export const PORTABLE_TEMPLATE_ID = 'third-party-notices'
+
+/** the first line of every notices text (scripts/third-party-notices/core.mjs `NOTICES_HEADER`) */
+const FIRST_LINE = 'Loop Studio\n'
+
+/** the portable file carries the text in its own document; the others serve a file */
+export const noticesHaveFile = (): boolean => buildKind() !== 'portable'
+
+/** the same-origin address of THIRD_PARTY_NOTICES.txt (web, PWA, dev) */
+export const noticesFileUrl = (): string => new URL(NOTICES_FILE, document.baseURI).href
+
+export class NoticesUnavailable extends Error {}
+
+function fromTemplate(): string {
+ const el = document.getElementById(PORTABLE_TEMPLATE_ID)
+ if (!(el instanceof HTMLTemplateElement)) throw new NoticesUnavailable(`no in this file`)
+ const text = el.content.textContent ?? ''
+ if (!text.startsWith(FIRST_LINE)) throw new NoticesUnavailable('the notices template does not hold the notices text')
+ return text
+}
+
+async function fromFile(): Promise {
+ const res = await fetch(noticesFileUrl())
+ if (!res.ok) throw new NoticesUnavailable(`${NOTICES_FILE} answered ${res.status}`)
+ // a host that falls back to index.html for an unknown path answers 200 too
+ if (!/^text\/plain\b/i.test(res.headers.get('content-type') ?? '')) throw new NoticesUnavailable(`${NOTICES_FILE} is not plain text`)
+ const text = await res.text()
+ if (!text.startsWith(FIRST_LINE)) throw new NoticesUnavailable(`${NOTICES_FILE} does not hold the notices text`)
+ return text
+}
+
+let pending: Promise | null = null
+
+/**
+ * The notices text of this build, read once per page: later calls share the
+ * first result. A failure is not remembered, so "Try again" reads again.
+ */
+export function loadNotices(): Promise {
+ if (!pending) {
+ pending = (noticesHaveFile() ? fromFile() : Promise.resolve().then(fromTemplate)).catch((err: unknown) => {
+ pending = null
+ throw err
+ })
+ }
+ return pending
+}
+
+/** tests only: forget the shared read */
+export function resetNoticesForTest(): void {
+ pending = null
+}
diff --git a/src/releaseNotes/releaseNotes.ts b/src/releaseNotes/releaseNotes.ts
index 902eb9ab..b53e3dce 100644
--- a/src/releaseNotes/releaseNotes.ts
+++ b/src/releaseNotes/releaseNotes.ts
@@ -38,6 +38,14 @@ export const releaseNoteIdFor = (version: string): ReleaseNoteId => `release:${v
* mechanics, no test counts, no pull-request numbers.
*/
export const RELEASE_NOTES: readonly ReleaseNote[] = [
+ {
+ // issue #301 - the third-party open-source licences, in the About dialog;
+ // the date is the day it is deployed, set right before the merge
+ id: 'release:0.18.0',
+ version: '0.18.0',
+ date: '2026-10-05',
+ items: ['whatsNew.v0180.licenses', 'whatsNew.v0180.offline', 'whatsNew.v0180.own'],
+ },
{
// the temporary-session chip takes the box of the toolbar's menu buttons;
// the date is the day it is deployed, set right before the merge
diff --git a/vite.config.ts b/vite.config.ts
index 053f5cab..f6560c57 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -8,7 +8,7 @@ import { configDefaults, defineConfig } from 'vitest/config'
import { viteSingleFile } from 'vite-plugin-singlefile'
import { manifest } from './src/pwa/manifest.ts'
import { LOCALE_CHUNK_RE } from './scripts/locale-chunk.mjs'
-import { VITE_LICENSE_JSON, thirdPartyNotices } from './scripts/third-party-notices/vite-plugin.mjs'
+import { VITE_LICENSE_JSON, thirdPartyNotices, thirdPartyNoticesDev } from './scripts/third-party-notices/vite-plugin.mjs'
// docs/localization.md §L4.5 — each non-`en` UI catalog and template-label dict
// is its own chunk (`assets/locale--.js` /
@@ -113,6 +113,9 @@ export default defineConfig(({ mode }) => {
// the single HTML file (portable), checked against
// licenses/third-party-manifest.json; a mismatch fails the build.
thirdPartyNotices({ flavour: portable ? 'portable' : pwa ? 'pwa' : 'web', root: import.meta.dirname }),
+ // ... and the dev server serves the web build's text, rebuilt from that
+ // manifest in memory and checked against it byte for byte
+ thirdPartyNoticesDev({ root: import.meta.dirname }),
// Emits `sw.js` (Workbox generateSW) + `manifest.webmanifest` and injects
// ``. `injectRegister: false` — we call
// `navigator.serviceWorker.register` ourselves, dual-gated (§P7). The SW