diff --git a/README.md b/README.md index 9bd3a37..869d18a 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ notifications switch. those, and Notify shows each as a published name/avatar (falling back to the full address) resolved through `RESOLVE_IDENTITIES`, with the address always available as accessible text and a copy action. +- A public English **Developers** workspace (`?view=developers`) with the manager contract, privacy boundaries, revision-safe examples, section links and accessible copy controls. It is available before permission and in standalone browsers. - Deep-linkable selection (`?app=`), kept in sync with the browser History API so Home's address bar and Back/Forward track the open app detail view. @@ -48,11 +49,11 @@ list/detail layout that stacks on narrow hosts. ## Runtime and QAVS Qortium Home supplies the `qdnRequest` bridge and the manager/Home-settings -actions above. Notify has no useful standalone-browser mode: opening it -outside Home shows an explanatory card, since every real feature requires -Home's device-local manager state. +actions above. The Developers reference works in standalone browsers; the +App notifications workspace explains that managing device-local settings +requires Home. Notify remains Qortium QDN only, with no Qortal app integration. -Notify is at QAVS `1.5.3`: `1.5` is the minimum Qortium platform level this +Notify is at QAVS `1.5.6`: `1.5` is the minimum Qortium platform level this first release is built against, and the patch number is the app's own free running release counter from here on. `vite.config.ts` reads `package.json`, injects the visible version, and emits `dist/qortium-app.json` with the name @@ -103,7 +104,13 @@ to report `READY`. ## Contract reference -See [`docs/NOTIFICATION_MANAGER.md`](docs/NOTIFICATION_MANAGER.md) for the -exact bridge actions Notify uses and how each maps to a UI affordance. The +Open **Developers** in Notify (`qdn://APP/Notify/Notify?view=developers`) for +the in-app reference. `?view=developer` and `?view=reference` normalize to the +same workspace. Switching tabs preserves `?app=...`; section links also +preserve Home parameters and fragments, and support Back/Forward. The body +remains English/LTR while the tab label follows Home language. + +See [`docs/NOTIFICATION_MANAGER.md`](docs/NOTIFICATION_MANAGER.md) for source +pointers and how each action maps to a UI affordance. The authoritative source is Qortium Home's own `docs/HOME_DATA_MANAGERS.md` and `docs/APP_NOTIFICATIONS.md`. diff --git a/docs/NOTIFICATION_MANAGER.md b/docs/NOTIFICATION_MANAGER.md index 0b1134b..602b61e 100644 --- a/docs/NOTIFICATION_MANAGER.md +++ b/docs/NOTIFICATION_MANAGER.md @@ -8,7 +8,8 @@ capability. This document maps each `qdnRequest` action to where it's used in `SHOW_ACTIONS` must include every action in `NOTIFICATION_MANAGER_ACTIONS` (`src/notificationManager.ts`) before Notify -shows anything but the "needs a newer Home" card: +enables the manager workspace. Developers remains accessible on older hosts +and outside Home: - `NOTIFICATION_MANAGER_HAS_PERMISSION` - `NOTIFICATION_MANAGER_GET` @@ -56,7 +57,7 @@ user must retry the action once the fresh data is visible. the desktop `qortiumNotificationManagerChanged` window event. Android sends the equivalent `{ type: 'qortium:notification-manager-changed', detail }` message; Notify source-checks it and forwards only its revision into the same -handler. Both forms carry a version number, not data. Notify rejects +handler. Both forms carry a revision number, not data. Notify rejects out-of-order responses and treats a newer revision as "go refetch", not as something to diff or merge itself. @@ -77,9 +78,10 @@ implying the array is complete. ## Address filter identity resolution -The four address filter keys above are the only filter values Notify ever -receives unmasked, and only once Home has validated them as Qortal -addresses. `src/identity.ts` gathers every such address across the current +The four address filter keys above may remain visible once Home validates +them as Q-addresses (the shared address format). Non-sensitive filters such +as resource service/name and coin can also remain visible; this does not +imply any Qortal app integration. `src/identity.ts` gathers every such address across the current summary (`extractAddressesFromSummary`), deduplicates, and resolves them through `RESOLVE_IDENTITIES` in batches of at most 500 (`resolveIdentities`/`chunkAddresses`) — Home's existing action, shared with @@ -107,3 +109,24 @@ through `GET_HOME_SETTINGS` / `UPDATE_HOME_SETTINGS`. The read supplies the initial theme, accent, language, text size, and UI style; desktop `qortiumHomeSettingsChanged`, Android `qortium:home-settings-changed`, and legacy display messages keep those host-owned values current. + + +## Developers workspace (1.5.6) + +The primary user-facing reference is `src/Reference.tsx`, linked from the +always-available Developers tab. It imports the adapter action/event/version +constants and the identity allowlist/batch size to reduce documentation drift. +Examples describe sanitized manager summaries, not producer registrations. +Manager support does not establish availability of producer/delivery backends. + +`src/routes.ts` handles the independent workspace and app-detail queries; +`src/ReferenceNavigation.tsx` uses a `section` query under Core's injected base, +preserving app selection, repeated/unknown parameters, fragments and host +history state. Switching workspaces does not remount App's manager state. +The English/LTR reference follows Home appearance, and copy buttons announce +success or manual-copy fallback without executing examples. + +Home 2 validates the revision before mutations and fails closed for corrupt +or unavailable notification stores. Home settings approval is independent of +`notifications.manage`. Sanitization hides account bindings and sensitive +filters; optional free-text title/text/link fields are still visible. diff --git a/package-lock.json b/package-lock.json index ca088d5..4eeb6cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "qortium-notify", - "version": "1.5.5", + "version": "1.5.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "qortium-notify", - "version": "1.5.5", + "version": "1.5.6", "license": "0BSD", "dependencies": { "@fontsource/comic-neue": "^5.2.7", diff --git a/package.json b/package.json index 5fb05c4..22ee229 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "qortium-notify", - "version": "1.5.5", + "version": "1.5.6", "private": true, "license": "0BSD", "description": "A QDN notification settings and subscription manager for Qortium Home.", diff --git a/src/App.tsx b/src/App.tsx index 1e43b00..273e75c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { Check, ChevronLeft, Copy, + Code2, Loader2, RefreshCw, ShieldCheck, @@ -34,6 +35,7 @@ import { getChangedRevisionFromMessage, isCurrentNotificationManagerResponse, NOTIFICATION_MANAGER_ACTIONS, + HOME_SETTINGS_ACTIONS_FOR_NOTIFY, removeAppRules, revokeApp, setAppMuted, @@ -43,7 +45,8 @@ import { type NotificationEvent, } from './notificationManager'; import { getBridgeState, hasAction, isStaleRevisionError, type BridgeState } from './qdnRequest'; -import { readSelectedAppFromLocation, subscribeToPopState, writeSelectedAppToLocation } from './routes'; +import { readSelectedAppFromLocation, readWorkspaceFromLocation, subscribeToPopState, writeSelectedAppToLocation, writeWorkspaceToLocation, type NotifyWorkspace } from './routes'; +import { Reference } from './Reference'; import { canMuteApp, countGrantedApps, @@ -448,6 +451,7 @@ function RuleCard({ export default function App() { const [initialAppKey] = useState(readSelectedAppFromLocation); + const [workspace, setWorkspace] = useState(readWorkspaceFromLocation); const [displaySettings, setDisplaySettings] = useState(getInitialDisplaySettings); const t = useMemo(() => createTranslator(displaySettings.language), [displaySettings.language]); const [bridgeState, setBridgeState] = useState(emptyBridgeState); @@ -480,7 +484,7 @@ export default function App() { [bridgeState.actions], ); const homeSettingsSupported = useMemo( - () => hasAction(bridgeState.actions, 'GET_HOME_SETTINGS') && hasAction(bridgeState.actions, 'UPDATE_HOME_SETTINGS'), + () => hasEveryAction(bridgeState.actions, HOME_SETTINGS_ACTIONS_FOR_NOTIFY), [bridgeState.actions], ); const identityResolutionSupported = useMemo( @@ -541,7 +545,11 @@ export default function App() { }, []); useEffect(() => { - return subscribeToPopState(setSelectedAppKey); + if (readWorkspaceFromLocation() === 'developers') writeWorkspaceToLocation('developers', false); + return subscribeToPopState((appKey) => { + setSelectedAppKey(appKey); + setWorkspace(readWorkspaceFromLocation()); + }); }, []); useEffect(() => { @@ -840,7 +848,7 @@ export default function App() {
- {homeSettingsSupported && globalEnabled !== null ? ( + {workspace === 'manager' && homeSettingsSupported && globalEnabled !== null ? (
{t('global.title')}
) : null} - {permissionGranted ? ( + {workspace === 'manager' && permissionGranted ? ( } @@ -862,6 +870,19 @@ export default function App() {
+ + {workspace === 'developers' ? : <> + {!bridgeLoaded ? (
@@ -1054,6 +1075,8 @@ export default function App() { ) : null} + } + {confirmRevokeTarget ? ( {}).constructor; +describe('developer reference contract', () => { + it('has complete sanitized event examples without private account bindings', () => { + expect(Object.keys(RULE_EXAMPLES)).toEqual([...NOTIFICATION_EVENTS]); + expect(JSON.parse(REFERENCE_SNIPPETS.summary)).toEqual(SUMMARY_EXAMPLE); + expect(REFERENCE_SNIPPETS.summary).not.toContain('accountAddress'); + expect(RULE_EXAMPLES.FOREIGN_PAYMENT_RECEIVED.filters).not.toHaveProperty('xpub'); + }); + it('renders an English public reference with selectable code and accessible copying', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('lang="en" dir="ltr"'); + expect(html).toContain('aria-live="polite"'); + for (const key of Object.keys(REFERENCE_SNIPPETS)) expect(html).toContain(`aria-label="Copy ${key} example"`); + for (const action of NOTIFICATION_MANAGER_ACTIONS) expect(html).toContain(action); + }); + it('keeps section links under the current Core path and preserves repeated parameters and fragments', () => { + expect(referenceSectionUrl('/render/APP/Notify/Notify?view=reference&app=example&host=a&host=b#fragment', 'summary')) + .toBe('/render/APP/Notify/Notify?view=developers&app=example&host=a&host=b§ion=summary#fragment'); + }); + it('runs capability discovery without requesting a permission-prompting read when ungranted', async () => { + const request = vi.fn(async ({ action }) => action === 'SHOW_ACTIONS' ? NOTIFICATION_MANAGER_ACTIONS : { granted: false }); + await new AsyncFunction('qdnRequest', REFERENCE_SNIPPETS.capabilities)(request); + expect(request.mock.calls.map(([r]) => r.action)).toEqual(['SHOW_ACTIONS', 'NOTIFICATION_MANAGER_HAS_PERMISSION']); + }); + it('checks the returned envelope when granted and stops on unsupported hosts', async () => { + const request = vi.fn(async ({ action }) => action === 'SHOW_ACTIONS' ? NOTIFICATION_MANAGER_ACTIONS : action.endsWith('HAS_PERMISSION') ? { granted: true } : { version: 2, revision: 0, apps: [] }); + await expect(new AsyncFunction('qdnRequest', REFERENCE_SNIPPETS.capabilities)(request)).rejects.toThrow('Unsupported'); + const unavailable = vi.fn(async () => []); + await expect(new AsyncFunction('qdnRequest', REFERENCE_SNIPPETS.capabilities)(unavailable)).rejects.toThrow('unavailable'); + expect(unavailable).toHaveBeenCalledOnce(); + }); + it('uses the reviewed revision for mute and never silently retries stale requests', async () => { + const request = vi.fn(async () => { throw Object.assign(new Error('stale'), { code: 'HOME_DATA_STALE' }); }); + const setMuted = await new AsyncFunction('qdnRequest', REFERENCE_SNIPPETS.mute + '\nreturn setMuted;')(request); + await expect(setMuted({ revision: 42 }, 'qdn://APP/Example/Example', true)).rejects.toMatchObject({ code: 'HOME_DATA_STALE' }); + expect(request).toHaveBeenCalledExactlyOnceWith({ action: 'NOTIFICATION_MANAGER_SET_MUTED', expectedRevision: 42, appKey: 'qdn://APP/Example/Example', muted: true }); + }); + it('writes only the Home notifications setting and resolves deduplicated bounded identity batches', async () => { + const request = vi.fn(async ({ action, addresses }) => action === 'SHOW_ACTIONS' ? ['GET_HOME_SETTINGS', 'UPDATE_HOME_SETTINGS', 'RESOLVE_IDENTITIES'] : action === 'GET_HOME_SETTINGS' ? { appNotifications: true, theme: 'dark' } : addresses ? addresses.map((address: string) => ({ address, name: null, avatarSrc: null })) : { appNotifications: false }); + await new AsyncFunction('qdnRequest', REFERENCE_SNIPPETS.settings)(request); + expect(request.mock.calls[2][0]).toEqual({ action: 'UPDATE_HOME_SETTINGS', patch: { appNotifications: false } }); + request.mockClear(); + const resolve = await new AsyncFunction('qdnRequest', REFERENCE_SNIPPETS.identities + '\nreturn resolveVisibleAddresses;')(request); + const values = Array.from({ length: 501 }, (_, i) => `fixture-${i}`); + expect(await resolve([...values, values[0]])).toHaveLength(501); + expect(request.mock.calls.slice(1).map(([r]) => r.addresses.length)).toEqual([500, 1]); + }); +}); diff --git a/src/Reference.tsx b/src/Reference.tsx new file mode 100644 index 0000000..5acfabd --- /dev/null +++ b/src/Reference.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react'; +import { + HOME_SETTINGS_ACTIONS_FOR_NOTIFY, NOTIFICATION_EVENTS, NOTIFICATION_MANAGER_ACTIONS, + NOTIFICATION_MANAGER_CHANGED_EVENT, NOTIFICATION_SUMMARY_VERSION, + type NotificationEvent, type NotificationManagerRule, type NotificationManagerSummary, +} from './notificationManager'; +import { ADDRESS_FILTER_KEYS, RESOLVE_IDENTITIES_CHUNK_SIZE } from './identity'; +import { copyTextToClipboard } from './clipboard'; +import { ReferenceNavigation } from './ReferenceNavigation'; + +const CREATED_AT = '2026-01-01T00:00:00.000Z'; +const EXAMPLE_ADDRESS = 'QUE2MRfg3vgxdLxNfLbjBS9jsyBSujeVED'; +export const RULE_EXAMPLES = { + RESOURCE_PUBLISHED: { notificationId: 'example.resource', event: 'RESOURCE_PUBLISHED', createdAt: CREATED_AT, filters: { service: 'APP', names: ['Example'] }, maskedFilterKeys: [], title: 'Example update', text: 'New resource', link: 'qdn://APP/Example/Example' }, + PAYMENT_RECEIVED: { notificationId: 'example.payment', event: 'PAYMENT_RECEIVED', createdAt: CREATED_AT, filters: {}, maskedFilterKeys: ['recipient'] }, + CHAT_MESSAGE: { notificationId: 'example.chat', event: 'CHAT_MESSAGE', createdAt: CREATED_AT, filters: { involving: [EXAMPLE_ADDRESS] }, maskedFilterKeys: [], partiallyMaskedFilterKeys: ['involving'] }, + TRANSACTION_CONFIRMED: { notificationId: 'example.confirmed', event: 'TRANSACTION_CONFIRMED', createdAt: CREATED_AT, filters: {}, maskedFilterKeys: ['signature'] }, + FOREIGN_PAYMENT_RECEIVED: { notificationId: 'example.foreign', event: 'FOREIGN_PAYMENT_RECEIVED', createdAt: CREATED_AT, filters: { coin: 'LTC' }, maskedFilterKeys: ['xpub'] }, +} satisfies Record; + +export const SUMMARY_EXAMPLE = { + version: NOTIFICATION_SUMMARY_VERSION, revision: 7, + apps: [{ appKey: 'qdn://APP/Example/Example', grant: { grantedAt: CREATED_AT, muted: false }, rules: Object.values(RULE_EXAMPLES) }], +} satisfies NotificationManagerSummary; + +export const MUTATION_EXAMPLES = { + mute: { action: NOTIFICATION_MANAGER_ACTIONS[2], appKey: SUMMARY_EXAMPLE.apps[0].appKey, muted: true, expectedRevision: SUMMARY_EXAMPLE.revision }, + remove: { action: NOTIFICATION_MANAGER_ACTIONS[3], appKey: SUMMARY_EXAMPLE.apps[0].appKey, notificationIds: [RULE_EXAMPLES.RESOURCE_PUBLISHED.notificationId], expectedRevision: SUMMARY_EXAMPLE.revision }, + revoke: { action: NOTIFICATION_MANAGER_ACTIONS[4], appKey: SUMMARY_EXAMPLE.apps[0].appKey, expectedRevision: SUMMARY_EXAMPLE.revision }, +} as const; + +export const REFERENCE_SNIPPETS = { + capabilities: `const actions = await qdnRequest({ action: 'SHOW_ACTIONS' }); +const required = ${JSON.stringify(NOTIFICATION_MANAGER_ACTIONS)}; +if (!Array.isArray(actions) || !required.every(action => actions.includes(action))) { + throw new Error('Qortium Home notification manager is unavailable.'); +} +const permission = await qdnRequest({ action: 'NOTIFICATION_MANAGER_HAS_PERMISSION' }); +// Non-prompting check. Only GET after the user chooses to proceed. +if (permission.granted) { + const summary = await qdnRequest({ action: 'NOTIFICATION_MANAGER_GET' }); + if (summary.version !== ${NOTIFICATION_SUMMARY_VERSION} || !Number.isSafeInteger(summary.revision) + || summary.revision < 0 || !Array.isArray(summary.apps)) { + throw new Error('Unsupported notification summary'); + } +}`, + summary: JSON.stringify(SUMMARY_EXAMPLE, null, 2), + mute: `// Use the summary the user reviewed, not a freshly fetched revision +// attached to an old, unreviewed decision. This function does not run itself. +async function setMuted(summary, appKey, muted) { + return qdnRequest({ action: '${NOTIFICATION_MANAGER_ACTIONS[2]}', + appKey, muted, expectedRevision: summary.revision }); +} +// On HOME_DATA_STALE: fetch again, show the new state, and let the user retry. +// On ambiguous failure: refresh; do not blindly replay the mutation.`, + removal: `// Each is a separate request, after confirmation against the displayed summary. +// Replace synthetic app/rule IDs and revision with the reviewed current values. +${JSON.stringify(MUTATION_EXAMPLES.remove, null, 2)} + +// Revocation removes the app's notification grant AND all of its rules. +${JSON.stringify(MUTATION_EXAMPLES.revoke, null, 2)}`, + settings: `const actions = await qdnRequest({ action: 'SHOW_ACTIONS' }); +if (!Array.isArray(actions) || !${JSON.stringify(HOME_SETTINGS_ACTIONS_FOR_NOTIFY)}.every(action => actions.includes(action))) { + throw new Error('Home settings bridge is unavailable'); +} +const settings = await qdnRequest({ action: 'GET_HOME_SETTINGS' }); +// Only after the user chooses this change; Home requests its own approval. +const updated = await qdnRequest({ + action: 'UPDATE_HOME_SETTINGS', patch: { appNotifications: !settings.appNotifications }, +}); +// Read updated.appNotifications; this uses Home's settings approval, +// not the notification manager's expectedRevision.`, + identities: `async function resolveVisibleAddresses(addresses) { + const actions = await qdnRequest({ action: 'SHOW_ACTIONS' }); + if (!Array.isArray(actions) || !actions.includes('RESOLVE_IDENTITIES')) return []; + const unique = [...new Set(addresses)]; + const results = []; + for (let offset = 0; offset < unique.length; offset += ${RESOLVE_IDENTITIES_CHUNK_SIZE}) { + results.push(...await qdnRequest({ action: 'RESOLVE_IDENTITIES', + addresses: unique.slice(offset, offset + ${RESOLVE_IDENTITIES_CHUNK_SIZE}) })); + } + return results; // [{ address, name, avatarSrc }]; absent names/avatars are null. +} +// Pass only validated visible ${ADDRESS_FILTER_KEYS.join('/')} values. +// Keep masked values hidden. Ignore responses from superseded summaries.`, +} as const; + +export function Reference() { + const [copied, setCopied] = useState(''); + async function copy(key: string, text: string, button: HTMLButtonElement) { + setCopied(await copyTextToClipboard(text) ? key : 'unavailable'); + button.focus({ preventScroll: true }); + } + return
+
+

Developers

Qortium Home notification manager · summary version {NOTIFICATION_SUMMARY_VERSION}

+ +

{copied === 'unavailable' ? 'Clipboard unavailable. Select the code and copy it manually.' : copied ? `Copied ${copied} example.` : 'Code examples can be selected for manual copying.'}

+
+
+
+

Contract and authority

+

Notify is published on Qortium QDN as APP/Notify/Notify. Its public app bundle contains no notification profile. Grants and rules belong to Home’s device-local store; Notify does not publish them to QDN or keep a competing local store. This app has no Qortal app integration or Qortal QDN publication.

+

Discover all {NOTIFICATION_MANAGER_ACTIONS.length} manager actions using SHOW_ACTIONS: {NOTIFICATION_MANAGER_ACTIONS.map((action, i) => {i ? ', ' : ''}{action})}. The manager was introduced at Home platform level 1.5; capability discovery, not a version string or node URL, determines availability.

+

NOTIFICATION_MANAGER_HAS_PERMISSION returns {'{ granted: boolean }'} without prompting. The first permitted read through NOTIFICATION_MANAGER_GET can request durable notifications.manage access. Notify asks after Grant access is chosen; it also rechecks on focus. Denial is a rejected promise. Other manager operations require this permission too.

+

This is broad administrative access to the sanitized notification settings of all apps on the device. It can mute, remove rules and revoke producer notification grants. It grants no wallet keys or spending authority. Home Settings can revoke Notify’s manager access independently; revoking an app through this manager concerns that app’s notification grant, not every Home capability.

+

Rule creation/replacement and sending notifications are producer operations. Notify’s manager actions cannot perform them. Discover producer actions separately; manager availability does not imply that a Home version supports registering new subscriptions.

+
+
+

Summary and privacy

+

Reads and mutations return {'{ version, revision, apps }'}. Version is {NOTIFICATION_SUMMARY_VERSION}; revision is a nonnegative safe integer. Notify checks this envelope and relies on Home for nested-record validation and sanitization. Unknown versions or an invalid envelope are rejected. Independent clients should validate nested records before rendering untrusted data.

+
+
App
appKey: string, grant: null | {'{ grantedAt: string, muted?: boolean }'}, and rules: Rule[]. Keys identify originating QDN apps, commonly qdn://APP/Name/Identifier. An app can have rules without a grant; mute requires a grant. Missing muted means false.
+
Rule
String notificationId, event, ISO timestamp createdAt, filters, maskedFilterKeys: string[], optional partiallyMaskedFilterKeys: string[], and optional strings title, text, link. Grant timestamps use ISO strings too. IDs belong to the originating app; they are not global IDs.
+
Events
{NOTIFICATION_EVENTS.map((event, i) => {i ? ', ' : ''}{event})}. These describe stored rules; their presence is not proof that the current host is running every producer/delivery backend.
+
Filters
A record of booleans, numbers, strings or string arrays. Non-sensitive filters such as resource service/name or coin may be visible. Values under {ADDRESS_FILTER_KEYS.map((key, i) => {i ? ', ' : ''}{key})} survive only when Home validates them as Q-addresses (the shared address format). Invalid or contact-like values are hidden; mixed arrays retain safe addresses and mark partial masking.
+
+

Home strips account bindings, masks signature filters, and removes the xpub filter from foreign-payment rules. maskedFilterKeys reports fully hidden keys; partiallyMaskedFilterKeys reports incomplete arrays whose surviving values remain visible. Notify displays “hidden” or “+ hidden” markers and never reconstructs omitted values. Optional notification template title/text/link fields remain visible; sanitization does not make user-supplied free text secret.

+

Notify never receives delivered-notification history or wallet private keys through this manager. Foreign-payment rules use a shared watch-only Core wallet view: address history, never spending authority. Masking the xpub from Notify does not hide that history from the Core used by the producer.

+
+
+

Mutations and revisions

+

Each mutation sends appKey and expectedRevision from the displayed summary. NOTIFICATION_MANAGER_SET_MUTED additionally takes boolean muted; it preserves the grant and rules. NOTIFICATION_MANAGER_REMOVE_RULES takes a nonempty notificationIds array and preserves the grant. NOTIFICATION_MANAGER_REVOKE removes both the app’s notification grant and its rules.

+

Home validates exact fields and performs the revision check before applying the change. Use the returned complete summary, not a guessed revision. HOME_DATA_STALE means refresh and ask the user to review/retry; no silent overwrite or automatic replay. An ambiguous transport failure does not prove a mutation was rejected. Rule removal and revocation show confirmation dialogs; restoration of removed rules belongs to the producer, not to a hidden undo store in Notify.

+

Home 2 rejects corrupt/unavailable stores with HOME_NOTIFICATION_STORE_CORRUPT or HOME_NOTIFICATION_STORE_UNAVAILABLE. Do not interpret these as an empty profile. Manager changes are device-local operations, without blockchain confirmation or QDN publication.

+

{NOTIFICATION_MANAGER_CHANGED_EVENT} carries only detail.revision. Android also sends qortium:notification-manager-changed with the same detail. Notify accepts parent-sourced messages, refetches on a newer revision, and discards out-of-order responses. The event is a refresh hint, never a replacement summary.

+

Host limits checked for summary version 1: stored rules are bounded at 20 per app; notification IDs use 1–64 ASCII letters, digits, dot, underscore or hyphen. Home trims/deduplicates removal IDs. Manager mutation app keys accept qdn APP/WEBSITE forms up to 2048 characters; display parsing alone does not guarantee a key is writable. These are Home-owned rules, not newly imposed client permissions.

+
+
+

Home settings and compatibility

+

Detect {HOME_SETTINGS_ACTIONS_FOR_NOTIFY.map((action, i) => {i ? ' and ' : ''}{action})} separately. They gate the global App notifications switch, not manager access. Notify writes only {'{ patch: { appNotifications: boolean } }'}. Home 2 requests approval for each settings change; the manager grant and manager revision are not that approval. A rejected change restores the displayed switch state.

+

Theme, language, accent, text size and UI style are read from Home and followed through its change events. The Developers body remains English and left-to-right. The global switch controls delivery; turning it off does not delete grants or rules and does not grant Notify access to their content.

+

RESOLVE_IDENTITIES is optional. Notify deduplicates visible address-filter values and sends at most {RESOLVE_IDENTITIES_CHUNK_SIZE} per call. Responses contain {'{ address, name, avatarSrc }'}; unresolved or unavailable identities keep the raw address. Superseded responses are ignored. This can retrieve public identity/avatar data through Home, even though Notify does not itself read Core resources.

+

In a plain browser, the shell and reference render but Home’s manager is unavailable. There is no local notification-store fallback. Canonical route: qdn://APP/Notify/Notify?view=developers. Developer/reference aliases normalize to developers, taking precedence over ?app=... without discarding that selection. Section navigation owns section and preserves app, Home/unknown/repeated parameters and the fragment. Returning to App notifications removes view/section; Back/Forward restores the workspace and selected app.

+
+
+

Bridge examples

All records below are synthetic sanitized summaries, not producer registration payloads. Copying does not run requests. Use current Home-provided IDs and the revision of the state the user reviewed before any mutation.

+ {Object.entries(REFERENCE_SNIPPETS).map(([key, snippet]) =>
+

{key}

+
{snippet}
+
)} +
+
+
; +} diff --git a/src/ReferenceNavigation.tsx b/src/ReferenceNavigation.tsx new file mode 100644 index 0000000..bd69142 --- /dev/null +++ b/src/ReferenceNavigation.tsx @@ -0,0 +1,57 @@ +import { useEffect, type MouseEvent } from 'react'; + +export const REFERENCE_SECTIONS = [ + ['contract', 'Contract and authority'], + ['summary', 'Summary and privacy'], + ['mutations', 'Mutations and revisions'], + ['settings', 'Home settings and compatibility'], + ['examples', 'Bridge examples'], +] as const; +type SectionId = typeof REFERENCE_SECTIONS[number][0]; + +/** Keep app selection and existing fragments under Core's injected base. */ +export function referenceSectionUrl(input: string, id: SectionId) { + const url = new URL(input, 'http://localhost'); + url.searchParams.set('view', 'developers'); + url.searchParams.set('section', id); + return `${url.pathname}${url.search}${url.hash}`; +} + +function scrollSection() { + const id = new URL(window.location.href).searchParams.get('section'); + if (!REFERENCE_SECTIONS.some(([section]) => section === id)) return; + const section = document.getElementById(`reference-${id}`); + const container = section?.closest('.reference-scroll'); + if (section && container) { + container.scrollTop += section.getBoundingClientRect().top - container.getBoundingClientRect().top + - (Number.parseFloat(getComputedStyle(section).scrollMarginTop) || 0); + // The reference header can exceed a phone viewport at Huge text size. + // Reveal the reading pane inside Notify without scrolling Home's document. + const shell = container.closest('.app-shell'); + if (shell) { + const topbarHeight = shell.querySelector('.topbar')?.getBoundingClientRect().height ?? 0; + shell.scrollTop += container.getBoundingClientRect().top - shell.getBoundingClientRect().top - topbarHeight; + } + section.focus({ preventScroll: true }); + } +} + +export function ReferenceNavigation() { + useEffect(() => { + scrollSection(); + window.addEventListener('popstate', scrollSection); + return () => window.removeEventListener('popstate', scrollSection); + }, []); + function visit(event: MouseEvent, id: SectionId) { + if (event.button || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + const next = referenceSectionUrl(window.location.href, id); + if (new URL(window.location.href).searchParams.get('section') !== id) window.history.pushState(window.history.state, '', next); + scrollSection(); + } + return ; +} diff --git a/src/clipboard.test.ts b/src/clipboard.test.ts new file mode 100644 index 0000000..9ded520 --- /dev/null +++ b/src/clipboard.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest'; +import { copyTextToClipboard, type ClipboardDependencies } from './clipboard'; + +function mockDocument(execCommandResult: boolean) { + const textarea = { + value: '', + style: {} as Record, + setAttribute: vi.fn(), + focus: vi.fn(), + select: vi.fn(), + setSelectionRange: vi.fn(), + }; + const documentRef = { + body: { + appendChild: vi.fn(), + removeChild: vi.fn(), + }, + createElement: vi.fn(() => textarea), + execCommand: vi.fn(() => execCommandResult), + }; + + return { documentRef, textarea }; +} + +describe('copyTextToClipboard', () => { + it('uses navigator.clipboard when the QDN view permits it', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const dependencies: ClipboardDependencies = { navigator: { clipboard: { writeText } } }; + + await expect(copyTextToClipboard('qdn://APP/Notify/Notify?view=developers', dependencies)).resolves.toBe(true); + expect(writeText).toHaveBeenCalledWith('qdn://APP/Notify/Notify?view=developers'); + }); + + it('falls back to a selected textarea when navigator.clipboard is rejected', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('NotAllowedError')); + const { documentRef, textarea } = mockDocument(true); + const dependencies: ClipboardDependencies = { + document: documentRef as unknown as ClipboardDependencies['document'], + navigator: { clipboard: { writeText } }, + }; + + await expect(copyTextToClipboard('fallback link', dependencies)).resolves.toBe(true); + expect(textarea.value).toBe('fallback link'); + expect(textarea.select).toHaveBeenCalledTimes(1); + expect(documentRef.execCommand).toHaveBeenCalledWith('copy'); + expect(documentRef.body.removeChild).toHaveBeenCalledTimes(1); + }); + + it('returns false when neither clipboard path is available', async () => { + await expect(copyTextToClipboard('unavailable', {})).resolves.toBe(false); + }); +}); diff --git a/src/clipboard.ts b/src/clipboard.ts new file mode 100644 index 0000000..c4712bf --- /dev/null +++ b/src/clipboard.ts @@ -0,0 +1,53 @@ +export interface ClipboardDependencies { + document?: Pick; + navigator?: { + clipboard?: { + writeText?: (text: string) => Promise | void; + }; + }; +} + +export async function copyTextToClipboard( + text: string, + dependencies: ClipboardDependencies = globalThis as ClipboardDependencies, +): Promise { + const writeText = dependencies.navigator?.clipboard?.writeText; + + if (writeText) { + try { + await writeText.call(dependencies.navigator?.clipboard, text); + return true; + } catch { + // Sandboxed QDN views can reject the modern Clipboard API. Fall through + // to the selection-based copy path while the button click is active. + } + } + + return copyTextWithTextarea(text, dependencies.document); +} + +function copyTextWithTextarea(text: string, documentRef: ClipboardDependencies['document']): boolean { + if (!documentRef?.body || !documentRef.createElement || !documentRef.execCommand) { + return false; + } + + const textarea = documentRef.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.left = '-9999px'; + textarea.style.opacity = '0'; + textarea.style.position = 'fixed'; + textarea.style.top = '0'; + documentRef.body.appendChild(textarea); + + try { + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + return documentRef.execCommand('copy'); + } catch { + return false; + } finally { + documentRef.body.removeChild(textarea); + } +} diff --git a/src/locales/catalogs.ts b/src/locales/catalogs.ts index a3aaa48..d16fcdc 100644 --- a/src/locales/catalogs.ts +++ b/src/locales/catalogs.ts @@ -74,6 +74,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "قام عرض آخر أو الصفحة الرئيسية نفسها بتغيير هذه الإعدادات. قم بالتحديث لرؤية الحالة الحالية قبل المحاولة مرة أخرى.", "إغلاق مربع الحوار", // ar translations + "المطورون", ], de: [ "Qortium-Benachrichtigung", @@ -143,6 +144,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "Eine andere Ansicht oder die Startseite selbst hat diese Einstellungen geändert. Aktualisieren Sie, um den aktuellen Status anzuzeigen, bevor Sie es erneut versuchen.", "Dialog schließen", // de translations + "Entwickler", ], el: [ "Qortium Notify", @@ -212,6 +214,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "Μια άλλη προβολή ή η ίδια η Αρχική σελίδα άλλαξε αυτές τις ρυθμίσεις. Κάντε ανανέωση για να δείτε την τρέχουσα κατάσταση πριν προσπαθήσετε ξανά.", "Κλείσιμο διαλόγου", // el translations + "Προγραμματιστές", ], es: [ "Notificar Qortium", @@ -281,6 +284,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "Otra vista o el propio Inicio cambiaron esta configuración. Actualice para ver el estado actual antes de volver a intentarlo.", "Cerrar diálogo", // es translations + "Desarrolladores", ], et: [ "Qortium Teavita", @@ -350,6 +354,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "Teine vaade või Kodu ise muutis neid seadeid. Enne uuesti proovimist värskendage hetkeoleku nägemiseks.", "Sule dialoog", // et translations + "Arendajad", ], fi: [ "Qortium Notify", @@ -419,6 +424,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "Toinen näkymä tai koti itse muutti nämä asetukset. Päivitä nähdäksesi nykyinen tila ennen kuin yrität uudelleen.", "Sulje valintaikkuna", // fi translations + "Kehittäjät", ], fr: [ "Notifier Qortium", @@ -488,6 +494,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "Une autre vue ou Accueil elle-même a modifié ces paramètres. Actualisez pour voir l’état actuel avant de réessayer.", "Fermer la boîte de dialogue", // fr translations + "Développeurs", ], he: [ "הודעה על קורטיום", @@ -557,6 +564,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "תצוגה אחרת או הבית עצמו שינו את ההגדרות הללו. רענן כדי לראות את המצב הנוכחי לפני שתנסה שוב.", "סגור דו-שיח", // he translations + "מפתחים", ], hi: [ "क्वॉर्टियम सूचित करें", @@ -626,6 +634,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "किसी अन्य दृश्य या होम ने ही इन सेटिंग्स को बदल दिया। पुनः प्रयास करने से पहले वर्तमान स्थिति देखने के लिए ताज़ा करें।", "संवाद बंद करें", // hi translations + "डेवलपर", ], hu: [ "Qortium Notify", @@ -695,6 +704,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "Egy másik nézet vagy maga a Kezdőlap megváltoztatta ezeket a beállításokat. Frissítsen az aktuális állapot megtekintéséhez, mielőtt újra próbálkozna.", "Párbeszéd bezárása", // hu translations + "Fejlesztők", ], it: [ "Notifica Qortium", @@ -764,6 +774,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "Un'altra vista o la stessa Home hanno modificato queste impostazioni. Aggiorna per vedere lo stato corrente prima di riprovare.", "Chiudi la finestra di dialogo", // it translations + "Sviluppatori", ], ja: [ "クォーティアム通知", @@ -833,6 +844,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "別のビューまたはホーム自体がこれらの設定を変更しました。再試行する前に、更新して現在の状態を確認してください。", "ダイアログを閉じる", // ja translations + "開発者", ], ko: [ "Qortium 알림", @@ -902,6 +914,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "다른 보기 또는 홈 자체가 이러한 설정을 변경했습니다. 다시 시도하기 전에 새로고침하여 현재 상태를 확인하세요.", "대화상자 닫기", // ko translations + "개발자", ], nb: [ "Qortium varsle", @@ -971,6 +984,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "En annen visning eller Home selv endret disse innstillingene. Oppdater for å se gjeldende tilstand før du prøver igjen.", "Lukk dialog", // nb translations + "Utviklere", ], nl: [ "Qortium op de hoogte", @@ -1040,6 +1054,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "Een andere weergave of Home zelf heeft deze instellingen gewijzigd. Vernieuw om de huidige status te zien voordat u het opnieuw probeert.", "Dialoogvenster sluiten", // nl translations + "Ontwikkelaars", ], pl: [ "Qortium Powiadom", @@ -1109,6 +1124,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "Inny widok lub sam Dom zmienił te ustawienia. Odśwież, aby zobaczyć bieżący stan, zanim spróbujesz ponownie.", "Zamknij okno dialogowe", // pl translations + "Deweloperzy", ], pt: [ "Notificação Qórtium", @@ -1178,6 +1194,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "Outra visualização ou a própria página inicial alteraram essas configurações. Atualize para ver o estado atual antes de tentar novamente.", "Fechar caixa de diálogo", // pt translations + "Desenvolvedores", ], ro: [ "Notificare Qortium", @@ -1247,6 +1264,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "O altă vizualizare sau Acasă în sine a schimbat aceste setări. Actualizează pentru a vedea starea curentă înainte de a încerca din nou.", "Închide caseta de dialog", // ro translations + "Dezvoltatori", ], ru: [ "Кортиум Уведомить", @@ -1316,6 +1334,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "Другой вид или главная страница изменили эти настройки. Обновите, чтобы увидеть текущее состояние, прежде чем повторить попытку.", "Закрыть диалог", // ru translations + "Разработчики", ], sv: [ "Qortium Notify", @@ -1385,6 +1404,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "En annan vy eller Home själv ändrade dessa inställningar. Uppdatera för att se aktuell status innan du försöker igen.", "Stäng dialogrutan", // sv translations + "Utvecklare", ], 'zh-CN': [ "Qortium 通知", @@ -1454,6 +1474,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "另一个视图或主页本身更改了这些设置。重试之前刷新以查看当前状态。", "关闭对话框", // zh-CN translations + "开发者", ], 'zh-TW': [ "Qortium 通知", @@ -1523,6 +1544,7 @@ const LOCALIZED_VALUES: Record, string[]> = { "另一個視圖或主頁本身更改了這些設定。重試之前刷新以查看目前狀態。", "關閉對話框", // zh-TW translations + "開發者", ], }; diff --git a/src/locales/en.ts b/src/locales/en.ts index 5b92775..00252d3 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -87,4 +87,5 @@ export const EN_STRINGS = { 'Another view or Home itself changed these settings. Refresh to see the current state before trying again.', 'a11y.closeDialog': 'Close dialog', + 'nav.developers': 'Developers', } as const; diff --git a/src/notificationManager.ts b/src/notificationManager.ts index 6355e11..4ec45ba 100644 --- a/src/notificationManager.ts +++ b/src/notificationManager.ts @@ -5,12 +5,12 @@ import { qdnRequest } from './qdnRequest'; // invents its own copy of this data — every read is a fresh sanitized // snapshot from Home, and every mutation round-trips the current revision. -export type NotificationEvent = - | 'RESOURCE_PUBLISHED' - | 'PAYMENT_RECEIVED' - | 'CHAT_MESSAGE' - | 'TRANSACTION_CONFIRMED' - | 'FOREIGN_PAYMENT_RECEIVED'; +export const NOTIFICATION_SUMMARY_VERSION = 1 as const; +export const NOTIFICATION_EVENTS = [ + 'RESOURCE_PUBLISHED', 'PAYMENT_RECEIVED', 'CHAT_MESSAGE', + 'TRANSACTION_CONFIRMED', 'FOREIGN_PAYMENT_RECEIVED', +] as const; +export type NotificationEvent = typeof NOTIFICATION_EVENTS[number]; export type NotificationFilters = Record; @@ -43,7 +43,7 @@ export type NotificationManagerApp = { export type NotificationManagerSummary = { apps: NotificationManagerApp[]; revision: number; - version: 1; + version: typeof NOTIFICATION_SUMMARY_VERSION; }; export const NOTIFICATION_MANAGER_ACTIONS = [ @@ -63,7 +63,7 @@ function isRecord(value: unknown): value is Record { export function isNotificationManagerSummary(value: unknown): value is NotificationManagerSummary { return ( isRecord(value) && - value.version === 1 && + value.version === NOTIFICATION_SUMMARY_VERSION && Number.isSafeInteger(value.revision) && (value.revision as number) >= 0 && Array.isArray(value.apps) diff --git a/src/routes.test.ts b/src/routes.test.ts index 61bdaa6..58bfbe1 100644 --- a/src/routes.test.ts +++ b/src/routes.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildLocationForSelectedApp, + buildLocationForWorkspace, + readWorkspaceFromLocation, + writeWorkspaceToLocation, readSelectedAppFromLocation, subscribeToPopState, writeSelectedAppToLocation, @@ -81,3 +84,31 @@ describe('subscribeToPopState', () => { window.history.replaceState(null, '', '/'); }); }); + + +describe('workspace routing', () => { + afterEach(() => { window.history.replaceState(null, '', '/'); vi.restoreAllMocks(); }); + it.each(['developers', 'developer', 'reference'])('recognizes %s independently of an app detail', view => { + expect(readWorkspaceFromLocation({ search: `?app=qdn://APP/Example/Example&view=${view}` })).toBe('developers'); + }); + it('canonicalizes aliases without dropping app, repeated host parameters or fragment', () => { + window.history.replaceState({ host: 3 }, '', '/render/APP/Notify/Notify?view=reference&app=example&future=a&future=b§ion=summary#retained'); + writeWorkspaceToLocation('developers', false); + expect(window.history.state).toEqual({ host: 3 }); + expect(new URLSearchParams(location.search).getAll('future')).toEqual(['a', 'b']); + expect(new URLSearchParams(location.search).get('app')).toBe('example'); + expect(location.hash).toBe('#retained'); + expect(new URLSearchParams(location.search).get('view')).toBe('developers'); + const push = vi.spyOn(window.history, 'pushState'); + writeWorkspaceToLocation('developers', true); + expect(push).not.toHaveBeenCalled(); + writeWorkspaceToLocation('manager', true); + expect(push).toHaveBeenCalledOnce(); + expect(location.search).toBe('?app=example&future=a&future=b'); + }); + it('retains developer routing when a background mutation clears the selected app', () => { + const next = buildLocationForSelectedApp({ pathname: '/', search: '?view=developers§ion=mutations&app=example', hash: '#host' }, null); + expect(next).toBe('/?view=developers§ion=mutations#host'); + expect(buildLocationForWorkspace({ pathname: '/', search: '?app=example', hash: '#host' }, 'developers')).toBe('/?app=example&view=developers#host'); + }); +}); diff --git a/src/routes.ts b/src/routes.ts index 5239228..e14bad5 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -45,7 +45,7 @@ export function writeSelectedAppToLocation(appKey: string | null, push: boolean) const nextUrl = buildLocationForSelectedApp(window.location, appKey); const method = push ? 'pushState' : 'replaceState'; - window.history[method](null, '', nextUrl); + window.history[method](window.history.state, '', nextUrl); } export function subscribeToPopState(listener: (appKey: string | null) => void): () => void { @@ -59,3 +59,32 @@ export function subscribeToPopState(listener: (appKey: string | null) => void): return () => window.removeEventListener('popstate', handler); } + +export type NotifyWorkspace = 'manager' | 'developers'; +const DEVELOPER_ALIASES = ['developers', 'developer', 'reference']; + +/** The workspace is independent of the retained app-detail selection. */ +export function readWorkspaceFromLocation(location: Pick = window.location): NotifyWorkspace { + return DEVELOPER_ALIASES.includes(new URLSearchParams(location.search).get('view') ?? '') + ? 'developers' : 'manager'; +} + +export function buildLocationForWorkspace( + location: Pick, + workspace: NotifyWorkspace, +): string { + const params = new URLSearchParams(location.search); + if (workspace === 'developers') params.set('view', 'developers'); + else { + params.delete('view'); + params.delete('section'); + } + const query = params.toString(); + return `${location.pathname}${query ? `?${query}` : ''}${location.hash ?? ''}`; +} + +export function writeWorkspaceToLocation(workspace: NotifyWorkspace, push: boolean) { + const next = buildLocationForWorkspace(window.location, workspace); + if (next === `${location.pathname}${location.search}${location.hash}`) return; + window.history[push ? 'pushState' : 'replaceState'](window.history.state, '', next); +} diff --git a/src/styles.css b/src/styles.css index c1cd318..37064bf 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1101,3 +1101,26 @@ svg { transition-duration: 0.01ms !important; } } + +/* Developers shares the app appearance and has its own scroll container. */ +.workspace-tabs { display: flex; flex-wrap: wrap; gap: 8px; flex: none; padding: 12px clamp(14px, 3vw, 28px); background: var(--qn-surface); border-bottom: 1px solid var(--qn-border); } +.workspace-tab { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 14px; min-height: 44px; border: 1px solid var(--qn-border); border-radius: var(--qn-radius); background: var(--qn-control); color: var(--qn-text-primary); font: inherit; overflow-wrap: anywhere; } +.workspace-tab--active { background: var(--qn-accent-soft); border-color: var(--qn-accent); } +.workspace-tab svg { width: 1.1em; height: 1.1em; flex: none; } +.workspace-tab:focus-visible, .reference a:focus-visible, .reference section:focus-visible { outline: 3px solid var(--qn-accent); outline-offset: 2px; } +.reference { display: flex; flex-direction: column; flex: 1 0 auto; width: 100%; max-width: 1240px; margin: 0 auto; min-width: 0; background: var(--qn-surface); } +.reference-header { padding: 16px clamp(14px, 3vw, 28px); } +.reference-header h2 { margin: 0 0 8px; font-size: var(--qn-text-heading); } +.reference-toc { display: flex; flex-wrap: wrap; gap: 8px 16px; padding: 8px 0; } +.reference a { color: var(--qn-accent); overflow-wrap: anywhere; } +.reference-scroll { max-height: 65dvh; min-height: 220px; overflow: auto; overscroll-behavior: contain; padding: 16px clamp(14px, 3vw, 28px); border-top: 1px solid var(--qn-border); } +.reference section { scroll-margin-top: 12px; margin-bottom: 32px; min-width: 0; } +.reference h3 { font-size: 1.2em; margin-top: 0; } +.reference p, .reference li, .reference dd { line-height: 1.6; overflow-wrap: anywhere; } +.reference dt { font-weight: 700; margin-top: 12px; overflow-wrap: anywhere; } +.reference dd { margin: 4px 0 12px; } +.reference code { font-family: ui-monospace, monospace; overflow-wrap: anywhere; } +.reference pre { background: var(--qn-control); border: 1px solid var(--qn-border); border-radius: var(--qn-radius); padding: 12px; max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; } +.reference pre code { font-size: .85em; } +.reference .copy-status { font-size: var(--qn-text-small); } +.reference-example { min-width: 0; margin: 18px 0; } diff --git a/src/workspace.test.tsx b/src/workspace.test.tsx new file mode 100644 index 0000000..e0f4efa --- /dev/null +++ b/src/workspace.test.tsx @@ -0,0 +1,71 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import App from './App'; +import { SUMMARY_EXAMPLE } from './Reference'; +import { NOTIFICATION_MANAGER_ACTIONS } from './notificationManager'; + +let root: Root; +let container: HTMLDivElement; +const tab = (index: number) => container.querySelectorAll('.workspace-tab')[index]; +async function click(element: HTMLElement) { await act(async () => element.click()); } +async function mount() { await act(async () => root.render()); } +async function traverse(direction: 'back' | 'forward') { + await act(async () => { + await new Promise(resolve => { + window.addEventListener('popstate', () => resolve(), { once: true }); + window.history[direction](); + }); + }); +} +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + window.history.replaceState({ host: 'retained' }, '', '/?app=qdn%3A%2F%2FAPP%2FExample%2FExample&future=a&future=b#retained'); + container = document.createElement('div'); document.body.append(container); root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); container.remove(); + delete window.qdnRequest; vi.unstubAllGlobals(); vi.restoreAllMocks(); window.history.replaceState(null, '', '/'); +}); +describe('workspace integration', () => { + it('exposes Developers without a bridge and explains manager unavailability', async () => { + await mount(); expect(container.querySelector('.bridge-card')).not.toBeNull(); + await click(tab(1)); expect(container.querySelector('.reference')).not.toBeNull(); + await click(tab(0)); expect(container.querySelector('.bridge-card')).not.toBeNull(); + }); + it('never prompts or mutates for ungranted reference navigation/copy', async () => { + const request = vi.fn(async ({ action }) => action === 'SHOW_ACTIONS' ? NOTIFICATION_MANAGER_ACTIONS : { granted: false }); + window.qdnRequest = request as NonNullable; + await mount(); await click(tab(1)); + await click(container.querySelector('a[href*="section=examples"]')!); + const copy = vi.fn(async () => {}); Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: copy } }); + await click(container.querySelector('[aria-label="Copy summary example"]')!); + expect(copy).toHaveBeenCalledOnce(); + expect(container.querySelector('[role="status"]')?.textContent).toContain('Copied summary'); + expect(request.mock.calls.map(([r]) => r.action)).toEqual(['SHOW_ACTIONS', 'NOTIFICATION_MANAGER_HAS_PERMISSION']); + await click(tab(0)); expect(container.querySelector('.permission-card')).not.toBeNull(); + }); + it('retains app and selected rules through Developers and actual browser Back/Forward', async () => { + const request = vi.fn(async ({ action }) => action === 'SHOW_ACTIONS' ? NOTIFICATION_MANAGER_ACTIONS : action.endsWith('HAS_PERMISSION') ? { granted: true } : SUMMARY_EXAMPLE); + window.qdnRequest = request as NonNullable; + await mount(); + const box = container.querySelector('input[type="checkbox"]')!; + expect(box).not.toBeNull(); await click(box); expect(box.checked).toBe(true); + await click(tab(1)); expect(container.querySelector('.reference')).not.toBeNull(); + await traverse('back'); expect(container.querySelector('input[type="checkbox"]')?.checked).toBe(true); + await traverse('forward'); expect(container.querySelector('.reference')).not.toBeNull(); + await click(tab(0)); expect(container.querySelector('input[type="checkbox"]')?.checked).toBe(true); + expect(new URLSearchParams(location.search).getAll('future')).toEqual(['a', 'b']); + expect(location.hash).toBe('#retained'); expect(window.history.state).toEqual({ host: 'retained' }); + expect(request.mock.calls.some(([r]) => /SET_MUTED|REMOVE_RULES|REVOKE/.test(r.action))).toBe(false); + }); + it('does not leave Developers when an already permitted summary resolves late', async () => { + let finish!: (value: typeof SUMMARY_EXAMPLE) => void; + const pending = new Promise(resolve => { finish = resolve; }); + window.qdnRequest = vi.fn(async ({ action }) => action === 'SHOW_ACTIONS' ? NOTIFICATION_MANAGER_ACTIONS : action.endsWith('HAS_PERMISSION') ? { granted: true } : pending) as NonNullable; + await mount(); await click(tab(1)); + await act(async () => finish(SUMMARY_EXAMPLE)); + expect(container.querySelector('.reference')).not.toBeNull(); + await click(tab(0)); expect(container.querySelector('.detail-header h2')?.textContent).toBe('Example'); + }); +});