@aglyn/aglyn 1.0.0-beta.214 → 1.0.0-beta.216

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/deferred-image.d.ts +3 -3
  3. package/src/lib/app-utils/deferred-image.js +3 -3
  4. package/src/lib/app-utils/deferred-image.js.map +1 -1
  5. package/src/lib/app-utils/docs-help.generated.d.ts +1 -1
  6. package/src/lib/app-utils/docs-help.generated.js +1 -0
  7. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  8. package/src/lib/app-utils/docs-index.generated.js +24 -10
  9. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  10. package/src/lib/app-utils/marketplace-theme.d.ts +9 -4
  11. package/src/lib/app-utils/marketplace-theme.js +11 -1
  12. package/src/lib/app-utils/marketplace-theme.js.map +1 -1
  13. package/src/lib/app-utils/notifications.d.ts +7 -0
  14. package/src/lib/app-utils/notifications.js.map +1 -1
  15. package/src/lib/app-utils/plan-entitlements.js +14 -8
  16. package/src/lib/app-utils/plan-entitlements.js.map +1 -1
  17. package/src/lib/app-utils/platform-brand.d.ts +4 -0
  18. package/src/lib/app-utils/platform-brand.js +2 -0
  19. package/src/lib/app-utils/platform-brand.js.map +1 -1
  20. package/src/lib/app-utils/publisher-agreement.d.ts +36 -1
  21. package/src/lib/app-utils/publisher-agreement.js +48 -1
  22. package/src/lib/app-utils/publisher-agreement.js.map +1 -1
  23. package/src/lib/app-utils/realm-host-surface.generated.js +2 -0
  24. package/src/lib/app-utils/realm-host-surface.generated.js.map +1 -1
  25. package/src/lib/app-utils/theme-library.d.ts +206 -0
  26. package/src/lib/app-utils/theme-library.js +566 -0
  27. package/src/lib/app-utils/theme-library.js.map +1 -0
  28. package/src/lib/foundation/definitions/org-billing.types.d.ts +12 -0
  29. package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
  30. package/src/lib/foundation/definitions/platform.types.js +2 -1
  31. package/src/lib/foundation/definitions/platform.types.js.map +1 -1
  32. package/src/lib/plugin-manager/feature-plugins.d.ts +44 -0
  33. package/src/lib/plugin-manager/feature-plugins.js +25 -0
  34. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  35. package/src/lib/plugin-manager/first-party-plugins.generated.js +11 -0
  36. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  37. package/src/lib/plugin-manager/realm-host-aglyn.generated.js +2 -0
  38. package/src/lib/plugin-manager/realm-host-aglyn.generated.js.map +1 -1
@@ -133,6 +133,8 @@ export interface ThemeHostDocument {
133
133
  sha256?: string | null;
134
134
  listingId?: string;
135
135
  } | null;
136
+ /** The library theme the site picked (AGL-3404) — see `theme-library`. */
137
+ themeSelection?: unknown;
136
138
  }
137
139
  /** Reads the stored theme override off a host document, tolerating junk. */
138
140
  export declare function readThemeOverride(host: ThemeHostDocument | null | undefined): ArtifactOverride | undefined;
@@ -161,16 +163,19 @@ export declare function resolveSiteTheme(host: ThemeHostDocument | null | undefi
161
163
  * - `installed` — a marketplace theme. The site's edits are its override
162
164
  * patch over the publisher's version, which stays untouched and can still
163
165
  * take an update.
164
- * - `custom` — the site's own theme, edited in place.
165
- * - `default` — the platform default. The site stores nothing until it
166
- * changes something, and then only what it changed.
166
+ * - `preset` — a built-in theme a plugin contributes (AGL-3404).
167
+ * - `custom` — one of the site's own themes.
168
+ * - `default` — the platform default.
169
+ *
170
+ * Whichever it is, an edit is an override patch over the picked theme and the
171
+ * theme itself is never rewritten (AGL-3404), so every source can be restored.
167
172
  *
168
173
  * One reading for every surface that needs it — the page that lays out the
169
174
  * Theme section, a widget proposing a change there, and a generator deciding
170
175
  * what its change will be stored as — so none of them can file a site under
171
176
  * a different source than the save path does.
172
177
  */
173
- export type HostThemeSource = 'installed' | 'custom' | 'default';
178
+ export type HostThemeSource = 'installed' | 'preset' | 'custom' | 'default';
174
179
  export declare function hostThemeSource(host: ThemeHostDocument | null | undefined): HostThemeSource;
175
180
  /**
176
181
  * Was this override authored against the theme currently installed?
@@ -397,8 +397,18 @@ function validateFonts(theme, warnings) {
397
397
  if (!override) return base;
398
398
  return resolveOverride(base != null ? base : {}, override.patch);
399
399
  }
400
+ const THEME_SOURCES = [
401
+ 'installed',
402
+ 'preset',
403
+ 'custom',
404
+ 'default'
405
+ ];
400
406
  export function hostThemeSource(host) {
401
- var _host_themeInstalledFrom;
407
+ var _host_themeSelection, _host_themeInstalledFrom;
408
+ const picked = host == null ? void 0 : (_host_themeSelection = host.themeSelection) == null ? void 0 : _host_themeSelection.kind;
409
+ if (THEME_SOURCES.includes(picked)) {
410
+ return picked;
411
+ }
402
412
  if (host == null ? void 0 : (_host_themeInstalledFrom = host.themeInstalledFrom) == null ? void 0 : _host_themeInstalledFrom.listingId) return 'installed';
403
413
  return (host == null ? void 0 : host.theme) && Object.keys(host.theme).length ? 'custom' : 'default';
404
414
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/marketplace-theme.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n HostTheme,\n HostThemeFont,\n HostThemeScheme,\n HostThemeSchemeColors,\n} from '@aglyn/shared-data-types'\nimport {\n diffOverride,\n isEmptyOverride,\n overrideConflicts,\n overridePaths,\n readArtifactOverride,\n resolveOverride,\n type ArtifactOverride,\n} from './marketplace-overrides'\n\n/**\n * Publish-time validation for themes as a marketplace artifact (AGL-1020).\n *\n * A plugin bundle is validated because it is code and the risk is a\n * compromised site. A theme is data, so the risk is different in kind but not\n * in consequence: the failure mode is an unreadable or half-painted site, which\n * a publisher cannot see from their own machine because their fonts are\n * installed and their eyes already know what the text says.\n *\n * Both of the things that go wrong are mechanically checkable, so they are\n * checked here rather than left to review:\n *\n * * **Completeness.** Light and dark are not one palette with a filter. A theme\n * that defines only light is half a theme, and installing it leaves every\n * dark-mode visitor on MUI's stock blue — which reads as the site being\n * broken, not as the theme being partial.\n * * **Contrast.** Text on its background, and label on its button, are the two\n * pairs that make a site unusable when they are wrong. WCAG gives an exact\n * ratio, so \"is this readable\" has an answer rather than an opinion.\n *\n * Errors refuse the publish; warnings are shown and can be published past. The\n * split is deliberate — a 3.9:1 heading is a judgement call a designer may\n * legitimately win, but a theme with no dark scheme is not a complete artifact\n * whoever is asking.\n *\n * Pure and MUI-free so the publish route, the console's pre-flight and the\n * tests all reach the same verdict from the same code.\n */\n\nexport interface ThemeValidationIssue {\n /** Dotted path into the theme, so the editor can focus the offending field. */\n path: string\n message: string\n}\n\nexport interface ThemeValidation {\n /** No errors. Warnings do not block. */\n ok: boolean\n errors: ThemeValidationIssue[]\n warnings: ThemeValidationIssue[]\n}\n\n/**\n * WCAG AA for body text. The same 4.5 the accessibility audit uses, rather\n * than a house number, so a theme that passes here passes there.\n */\nexport const CONTRAST_AA = 4.5\n\n/**\n * WCAG AA for large text (18pt+/14pt bold) and UI component boundaries. Button\n * labels are judged against this rather than 4.5 because they are large, short\n * and centred, and holding them to body-text contrast rejects palettes that are\n * genuinely fine.\n */\nexport const CONTRAST_AA_LARGE = 3\n\nconst SCHEMES: HostThemeScheme[] = ['light', 'dark']\n\n/**\n * Font families that need no loading. Anything else is either declared in the\n * theme's own `fonts` array or will silently fall back to the browser's default\n * — which is the failure the issue calls out, so it is at least a warning.\n */\nconst SYSTEM_FAMILIES = new Set(\n [\n 'system-ui',\n 'sans-serif',\n 'serif',\n 'monospace',\n 'cursive',\n 'fantasy',\n 'ui-sans-serif',\n 'ui-serif',\n 'ui-monospace',\n 'ui-rounded',\n '-apple-system',\n 'blinkmacsystemfont',\n 'segoe ui',\n 'roboto',\n 'helvetica',\n 'helvetica neue',\n 'arial',\n 'georgia',\n 'times new roman',\n 'courier new',\n 'inherit',\n 'initial',\n ].map((family) => family.toLowerCase()),\n)\n\n/**\n * Parses a CSS colour into sRGB channels, or null when it is not something we\n * can reason about.\n *\n * `null` means \"not checked\", never \"failed\": a theme is free to use `color-mix`\n * or a CSS variable, and refusing to publish because this parser is narrower\n * than CSS would be a validator inventing a rule the platform does not have.\n */\nexport function parseColor(\n value: unknown,\n): { r: number; g: number; b: number } | null {\n if (typeof value !== 'string') return null\n const text = value.trim().toLowerCase()\n\n const hex = /^#([0-9a-f]{3,8})$/.exec(text)\n if (hex) {\n const digits = hex[1]\n if (digits.length === 3 || digits.length === 4) {\n return {\n r: parseInt(digits[0] + digits[0], 16),\n g: parseInt(digits[1] + digits[1], 16),\n b: parseInt(digits[2] + digits[2], 16),\n }\n }\n if (digits.length === 6 || digits.length === 8) {\n return {\n r: parseInt(digits.slice(0, 2), 16),\n g: parseInt(digits.slice(2, 4), 16),\n b: parseInt(digits.slice(4, 6), 16),\n }\n }\n return null\n }\n\n const rgb = /^rgba?\\(([^)]+)\\)$/.exec(text)\n if (rgb) {\n const parts = rgb[1].split(/[,/\\s]+/).filter(Boolean)\n if (parts.length < 3) return null\n const channels = parts.slice(0, 3).map((part) =>\n part.endsWith('%')\n ? (Number(part.slice(0, -1)) / 100) * 255\n : Number(part),\n )\n if (channels.some((channel) => !Number.isFinite(channel))) return null\n const [r, g, b] = channels\n return { r, g, b }\n }\n\n if (text === 'white') return { r: 255, g: 255, b: 255 }\n if (text === 'black') return { r: 0, g: 0, b: 0 }\n return null\n}\n\n/** WCAG relative luminance. */\nfunction luminance(channels: { r: number; g: number; b: number }): number {\n const channel = (raw: number) => {\n const value = Math.min(255, Math.max(0, raw)) / 255\n return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4\n }\n return (\n 0.2126 * channel(channels.r) +\n 0.7152 * channel(channels.g) +\n 0.0722 * channel(channels.b)\n )\n}\n\n/**\n * WCAG contrast ratio between two colours, or null when either is unparseable.\n * Ranges 1 (identical) to 21 (black on white).\n */\nexport function contrastRatio(a: unknown, b: unknown): number | null {\n const first = parseColor(a)\n const second = parseColor(b)\n if (!first || !second) return null\n const light = Math.max(luminance(first), luminance(second))\n const dark = Math.min(luminance(first), luminance(second))\n return (light + 0.05) / (dark + 0.05)\n}\n\nconst round = (ratio: number) => Math.round(ratio * 10) / 10\n\n/**\n * The colours a scheme must actually define to be usable on its own.\n *\n * Deliberately short. MUI derives an enormous amount from very little, and\n * demanding a full palette would reject good themes; but a scheme that names no\n * primary, no background and no text has not expressed an identity at all — it\n * is an empty object that happens to have a key.\n */\nfunction validateScheme(\n scheme: HostThemeScheme,\n colors: HostThemeSchemeColors | undefined,\n errors: ThemeValidationIssue[],\n warnings: ThemeValidationIssue[],\n): void {\n const at = `colorSchemes.${scheme}`\n if (!colors || !Object.keys(colors).length) {\n errors.push({\n path: at,\n message:\n `This theme defines no ${scheme} scheme. Light and dark are not one ` +\n 'palette with a filter — install it and every visitor in ' +\n `${scheme} mode sees the stock palette instead of your design.`,\n })\n return\n }\n if (!colors.primary?.main) {\n errors.push({\n path: `${at}.primary.main`,\n message: `The ${scheme} scheme has no primary color.`,\n })\n }\n\n const background = colors.background?.default\n const paper = colors.background?.paper\n const text = colors.text?.primary\n const secondaryText = colors.text?.secondary\n\n // Text on background is the pair that decides whether a site can be read at\n // all, so it is the one contrast failure that refuses the publish.\n const bodyContrast = contrastRatio(text, background)\n if (bodyContrast != null && bodyContrast < CONTRAST_AA) {\n errors.push({\n path: `${at}.text.primary`,\n message:\n `Body text on the ${scheme} background is ${round(bodyContrast)}:1, ` +\n `below the ${CONTRAST_AA}:1 minimum for readable text.`,\n })\n }\n\n const paperContrast = contrastRatio(text, paper)\n if (paperContrast != null && paperContrast < CONTRAST_AA) {\n errors.push({\n path: `${at}.background.paper`,\n message:\n `Body text on ${scheme} cards and menus is ${round(paperContrast)}:1, ` +\n `below the ${CONTRAST_AA}:1 minimum. Surfaces inherit this everywhere ` +\n 'a Paper is used, which is most of the site.',\n })\n }\n\n // Secondary text is legitimately dimmer by design, so it warns rather than\n // refuses — but silently shipping unreadable captions is worse than saying so.\n const captionContrast = contrastRatio(secondaryText, background)\n if (captionContrast != null && captionContrast < CONTRAST_AA) {\n warnings.push({\n path: `${at}.text.secondary`,\n message:\n `Secondary text on the ${scheme} background is ` +\n `${round(captionContrast)}:1. Captions and helper text will be hard ` +\n 'to read.',\n })\n }\n\n // A button whose label disappears into its own fill. Only checkable when the\n // theme states contrastText — otherwise MUI derives one, and derives it well.\n for (const key of ['primary', 'secondary', 'error'] as const) {\n const color = colors[key]\n const ratio = contrastRatio(color?.contrastText, color?.main)\n if (ratio != null && ratio < CONTRAST_AA_LARGE) {\n warnings.push({\n path: `${at}.${key}.contrastText`,\n message:\n `The ${scheme} ${key} button label is ${round(ratio)}:1 against its ` +\n `own fill, below ${CONTRAST_AA_LARGE}:1. Leave contrast text unset ` +\n 'to let the theme derive a readable one.',\n })\n }\n }\n}\n\n/**\n * Every family named in the typography, split out of its CSS stack.\n *\n * A stack is a fallback chain, so only the FIRST entry is the one the design\n * depends on; the rest exist precisely to be substituted and warning about them\n * would be noise.\n */\nfunction declaredFamilies(theme: HostTheme): string[] {\n const stacks: string[] = []\n if (theme.typography?.fontFamily) stacks.push(theme.typography.fontFamily)\n for (const variant of Object.values(theme.typography?.variants ?? {})) {\n if (variant?.fontFamily) stacks.push(variant.fontFamily)\n }\n const primary: string[] = []\n for (const stack of stacks) {\n const first = stack.split(',')[0]?.trim().replace(/^[\"']|[\"']$/g, '')\n if (first) primary.push(first)\n }\n return primary\n}\n\nfunction validateFonts(\n theme: HostTheme,\n warnings: ThemeValidationIssue[],\n): void {\n const loadable = new Set(\n (theme.fonts ?? [])\n .map((font: HostThemeFont) => font.family?.trim().toLowerCase())\n .filter(Boolean) as string[],\n )\n const seen = new Set<string>()\n for (const family of declaredFamilies(theme)) {\n const key = family.toLowerCase()\n if (seen.has(key) || loadable.has(key) || SYSTEM_FAMILIES.has(key)) continue\n seen.add(key)\n warnings.push({\n path: 'typography.fontFamily',\n message:\n `\"${family}\" is used but not listed in the theme's fonts, so it will ` +\n 'only appear on machines that happen to have it installed. Add it as ' +\n 'a Google font, or fall back to a system family.',\n })\n }\n}\n\n/**\n * Validates a theme for publishing.\n *\n * The same function backs the console's pre-flight and the publish route: a\n * publisher who is told \"this will be refused\" and then has it accepted (or the\n * reverse) stops trusting either, so there is one verdict from one place.\n */\nexport function validateThemeForPublish(\n theme: HostTheme | null | undefined,\n): ThemeValidation {\n const errors: ThemeValidationIssue[] = []\n const warnings: ThemeValidationIssue[] = []\n\n if (!theme || !Object.keys(theme).length) {\n return {\n ok: false,\n errors: [\n {\n path: '',\n message:\n 'This site has no theme customisations yet, so there is nothing ' +\n 'to publish.',\n },\n ],\n warnings,\n }\n }\n\n for (const scheme of SCHEMES) {\n validateScheme(scheme, theme.colorSchemes?.[scheme], errors, warnings)\n }\n validateFonts(theme, warnings)\n\n return { ok: !errors.length, errors, warnings }\n}\n\n/**\n * Every field of a `HostTheme` that is part of the published artifact.\n *\n * Typed as a total record over `keyof HostTheme` on purpose: adding a field to\n * `HostTheme` fails to compile here until someone decides whether it ships. The\n * alternative — a hand-written list of `if` statements — silently drops the new\n * field, and a theme that loses one is not obviously broken, it is subtly\n * wrong. `mixins` arrived exactly that way (AGL-1242, toolbar heights) while\n * this was being written, which is why the check is a type and not a comment.\n *\n * A `false` means \"host-local, never published\". `darkScheme` is the one such\n * field: whether visitors get a dark scheme is a decision about a SITE's\n * content (does it read in dark?), not about the design being published, so a\n * publisher's opt-out must not travel with the theme and an installer's must\n * not read as a divergence from it.\n */\nconst THEME_ARTIFACT_FIELDS: Record<keyof Required<HostTheme>, boolean> = {\n colorSchemes: true,\n typography: true,\n fonts: true,\n shape: true,\n spacing: true,\n mixins: true,\n components: true,\n darkScheme: false,\n}\n\n/**\n * The theme content as published — the artifact and nothing else.\n *\n * What goes in here is what \"reset to the publisher's version\" restores and\n * what the update diff compares against, so it must hold no per-install data:\n * a host document also carries `updatedAt`, `updatedBy` and whatever else the\n * editor writes alongside, and any of those in the content would read as a user\n * edit on the next diff.\n *\n * Empty branches are dropped so two themes that render identically also hash\n * identically — `{fonts: []}` and `{}` must not produce two base snapshots.\n */\nexport function themeArtifactContent(\n theme: HostTheme | null | undefined,\n): HostTheme {\n const content: Record<string, unknown> = {}\n if (!theme) return content\n for (const [key, ships] of Object.entries(THEME_ARTIFACT_FIELDS)) {\n if (!ships) continue\n const value = (theme as Record<string, unknown>)[key]\n if (value == null) continue\n if (Array.isArray(value) ? !value.length : typeof value === 'object'\n ? !Object.keys(value as object).length\n : false) {\n continue\n }\n content[key] = value\n }\n return content\n}\n\n/**\n * A short, human summary of what a theme actually contains, for the install\n * confirmation and the listing card.\n *\n * Installing a theme replaces a site's visual identity, so the confirmation has\n * to say what is about to change in terms someone recognises — \"both schemes,\n * 2 fonts, 4 component styles\" — rather than showing them a JSON blob and\n * asking them to agree to it.\n */\nexport function describeTheme(theme: HostTheme | null | undefined): string[] {\n if (!theme) return []\n const parts: string[] = []\n const schemes = SCHEMES.filter(\n (scheme) => Object.keys(theme.colorSchemes?.[scheme] ?? {}).length,\n )\n if (schemes.length === 2) parts.push('Light and dark schemes')\n else if (schemes.length === 1) parts.push(`${schemes[0]} scheme only`)\n\n const fonts = theme.fonts?.length ?? 0\n if (fonts) parts.push(`${fonts} font${fonts === 1 ? '' : 's'}`)\n if (theme.typography?.variants) {\n const count = Object.keys(theme.typography.variants).length\n if (count) parts.push(`${count} text style${count === 1 ? '' : 's'}`)\n }\n if (theme.shape?.borderRadius != null) {\n parts.push(`${theme.shape.borderRadius}px corners`)\n }\n if (typeof theme.spacing === 'number') parts.push(`${theme.spacing}px spacing`)\n const components = Object.keys(theme.components ?? {}).length\n if (components) {\n parts.push(`${components} component style${components === 1 ? '' : 's'}`)\n }\n return parts\n}\n\n/* ------------------------------------------------------------------------ *\n * The override layer, applied to themes (AGL-1021)\n * ------------------------------------------------------------------------ */\n\n/**\n * The field on a host document holding the site's theme override.\n *\n * A theme is a FIELD on the host, not a document of its own, so it cannot use\n * `ARTIFACT_OVERRIDE_FIELD` — a host may one day override more than one thing,\n * and a bare `overrides` would be the first to claim the name. The override\n * layer is general precisely so a namespaced field works with it unchanged.\n */\nexport const THEME_OVERRIDE_FIELD = 'themeOverride'\n\n/** A host document, as far as the theme layer is concerned. */\nexport interface ThemeHostDocument {\n theme?: HostTheme | null\n themeOverride?: unknown\n themeInstalledFrom?: { sha256?: string | null; listingId?: string } | null\n}\n\n/** Reads the stored theme override off a host document, tolerating junk. */\nexport function readThemeOverride(\n host: ThemeHostDocument | null | undefined,\n): ArtifactOverride | undefined {\n // The override layer's reader is keyed to its own field name, so hand it a\n // shim rather than duplicating its validation — which is where the \"a\n // top-level null blanks the artifact\" guard lives.\n return readArtifactOverride({ overrides: host?.themeOverride })\n}\n\n/**\n * The site's effective theme: the marketplace theme (or the site's own) with\n * this site's overrides resolved over it.\n *\n * This is the MIDDLE and TOP of the three layers the issue describes. The\n * bottom — the platform default — is applied further down by\n * `HostThemeProvider`, which layers whatever this returns onto the brand base\n * (AGL-1180) so a theme that omits a value still yields a complete theme\n * rather than an undefined-shaped hole. Doing it there rather than here is not\n * a detail: the default is a runtime MUI theme with FUNCTION style overrides\n * that JSON cannot represent, so a \"default\" merged at this layer would be a\n * lossy copy of the real one.\n *\n * Every surface that renders a site's appearance calls this — the tenant, all\n * four besigner editors, and the document preview — because a theme that\n * resolves differently in the editor than on the site is worse than one that\n * does not resolve at all.\n */\nexport function resolveSiteTheme(\n host: ThemeHostDocument | null | undefined,\n): HostTheme | undefined {\n const base = host?.theme ?? undefined\n const override = readThemeOverride(host)\n if (!override) return base\n return resolveOverride<HostTheme>(base ?? {}, override.patch)\n}\n\n/**\n * Where a site's theme came from (AGL-2938).\n *\n * - `installed` — a marketplace theme. The site's edits are its override\n * patch over the publisher's version, which stays untouched and can still\n * take an update.\n * - `custom` — the site's own theme, edited in place.\n * - `default` — the platform default. The site stores nothing until it\n * changes something, and then only what it changed.\n *\n * One reading for every surface that needs it — the page that lays out the\n * Theme section, a widget proposing a change there, and a generator deciding\n * what its change will be stored as — so none of them can file a site under\n * a different source than the save path does.\n */\nexport type HostThemeSource = 'installed' | 'custom' | 'default'\n\nexport function hostThemeSource(\n host: ThemeHostDocument | null | undefined,\n): HostThemeSource {\n if (host?.themeInstalledFrom?.listingId) return 'installed'\n return host?.theme && Object.keys(host.theme).length ? 'custom' : 'default'\n}\n\n/**\n * Was this override authored against the theme currently installed?\n *\n * False after a theme swap, which is the whole reason `baseSha256` is recorded.\n * Overrides deliberately SURVIVE a swap (the issue's call, and the right one —\n * a brand colour still expresses intent when the theme underneath it changes)\n * but surviving silently is what would surprise people, so every surface that\n * shows an override can ask this and say so.\n *\n * True when there is no override, and when either side has no hash to compare:\n * \"we cannot tell\" must not render as \"these are stale\".\n */\nexport function isOverrideForCurrentTheme(\n host: ThemeHostDocument | null | undefined,\n): boolean {\n const override = readThemeOverride(host)\n if (!override?.baseSha256) return true\n const installed = host?.themeInstalledFrom?.sha256\n if (!installed) return true\n return override.baseSha256 === installed\n}\n\n/** One row of the \"what have I changed?\" view. */\nexport interface ThemeOverrideEntry {\n /** Dotted path into the theme, e.g. `colorSchemes.dark.primary.main`. */\n path: string\n /** A human label for the path, for a UI that is not a JSON viewer. */\n label: string\n /** Which scheme this touches, when it touches one. */\n scheme?: HostThemeScheme\n /** The publisher's value, or undefined when the override adds the field. */\n themeValue: unknown\n /** What this site set it to, or undefined when the override removes it. */\n overrideValue: unknown\n}\n\nconst SEGMENT_LABELS: Record<string, string> = {\n darkScheme: 'Dark scheme',\n colorSchemes: 'Color',\n typography: 'Typography',\n components: 'Component',\n shape: 'Shape',\n spacing: 'Spacing',\n fonts: 'Fonts',\n mixins: 'Layout',\n variants: '',\n background: 'background',\n text: 'text',\n main: '',\n contrastText: 'label color',\n borderRadius: 'corner radius',\n fontFamily: 'font',\n}\n\n/**\n * A readable label for a theme path.\n *\n * \"Colour · dark · primary\" beats `colorSchemes.dark.primary.main`, and the\n * difference matters because this list is the answer to \"what have I changed?\"\n * — a question nobody asks wanting to read JSON paths.\n */\nexport function describeThemePath(path: string): string {\n const segments = path.split('.').filter(Boolean)\n const words: string[] = []\n for (const segment of segments) {\n const mapped = SEGMENT_LABELS[segment]\n if (mapped === '') continue\n words.push(mapped ?? segment)\n }\n return words.join(' · ') || 'the whole theme'\n}\n\n/**\n * \"What have I changed?\" — the patch, rendered.\n *\n * Literally a read of the stored override rather than a diff computed at call\n * time, which is the property the whole layer exists to provide: the answer is\n * the thing that was saved, so it cannot disagree with what will be applied.\n */\nexport function describeThemeOverride(\n host: ThemeHostDocument | null | undefined,\n): ThemeOverrideEntry[] {\n const override = readThemeOverride(host)\n if (!override) return []\n const base = host?.theme ?? {}\n const resolved = resolveOverride<HostTheme>(base, override.patch)\n return overridePaths(override.patch).map((path) => {\n const segments = path.split('.')\n const scheme =\n segments[0] === 'colorSchemes' &&\n (segments[1] === 'light' || segments[1] === 'dark')\n ? (segments[1] as HostThemeScheme)\n : undefined\n return {\n path,\n label: describeThemePath(path),\n scheme,\n themeValue: readThemePath(base, path),\n overrideValue: readThemePath(resolved, path),\n }\n })\n}\n\n/** Reads a dotted path out of a theme. */\nfunction readThemePath(theme: unknown, path: string): unknown {\n let cursor: unknown = theme\n for (const segment of path.split('.')) {\n if (cursor == null || typeof cursor !== 'object') return undefined\n cursor = (cursor as Record<string, unknown>)[segment]\n }\n return cursor\n}\n\n/**\n * The override this site should store after the theme editor produced `edited`.\n *\n * The editor edits the RESOLVED theme — that is what it renders and what the\n * user sees — so the patch is the difference between the publisher's base and\n * what came back. Diffing here rather than tracking per-field dirty state means\n * a field the user set and then set back to the theme's value stops being an\n * override, which is what \"reset\" means without needing a reset button to have\n * been pressed.\n */\nexport function themeOverridePatch(\n host: ThemeHostDocument | null | undefined,\n edited: HostTheme,\n): unknown {\n return diffOverride(host?.theme ?? {}, edited)\n}\n\n/**\n * Where this site's overrides sit on top of values a theme update also changes\n * — the only place a conflict can exist.\n *\n * Narrow by construction: an override touching four paths can conflict in at\n * most four places however much of the theme the publisher rewrote. That is the\n * difference between \"the publisher also changed your heading colour, whose\n * wins?\" and \"this update rewrites your theme, continue?\".\n */\nexport function themeUpdateConflicts(\n host: ThemeHostDocument | null | undefined,\n incoming: HostTheme,\n): ThemeOverrideEntry[] {\n const override = readThemeOverride(host)\n if (!override || isEmptyOverride(override.patch)) return []\n const base = host?.theme ?? {}\n const resolved = resolveOverride<HostTheme>(base, override.patch)\n return overrideConflicts(base, override.patch, incoming).map((path) => ({\n path,\n label: describeThemePath(path),\n scheme:\n path.startsWith('colorSchemes.light.')\n ? ('light' as const)\n : path.startsWith('colorSchemes.dark.')\n ? ('dark' as const)\n : undefined,\n themeValue: readThemePath(incoming, path),\n overrideValue: readThemePath(resolved, path),\n }))\n}\n"],"names":["diffOverride","isEmptyOverride","overrideConflicts","overridePaths","readArtifactOverride","resolveOverride","CONTRAST_AA","CONTRAST_AA_LARGE","SCHEMES","SYSTEM_FAMILIES","Set","map","family","toLowerCase","parseColor","value","text","trim","hex","exec","digits","length","r","parseInt","g","b","slice","rgb","parts","split","filter","Boolean","channels","part","endsWith","Number","some","channel","isFinite","luminance","raw","Math","min","max","contrastRatio","a","first","second","light","dark","round","ratio","validateScheme","scheme","colors","errors","warnings","at","Object","keys","push","path","message","primary","main","background","default","paper","secondaryText","secondary","bodyContrast","paperContrast","captionContrast","key","color","contrastText","declaredFamilies","theme","stacks","typography","fontFamily","variant","values","variants","stack","replace","validateFonts","loadable","fonts","font","seen","has","add","validateThemeForPublish","ok","colorSchemes","THEME_ARTIFACT_FIELDS","shape","spacing","mixins","components","darkScheme","themeArtifactContent","content","ships","entries","Array","isArray","describeTheme","schemes","count","borderRadius","THEME_OVERRIDE_FIELD","readThemeOverride","host","overrides","themeOverride","resolveSiteTheme","base","undefined","override","patch","hostThemeSource","themeInstalledFrom","listingId","isOverrideForCurrentTheme","baseSha256","installed","sha256","SEGMENT_LABELS","describeThemePath","segments","words","segment","mapped","join","describeThemeOverride","resolved","label","themeValue","readThemePath","overrideValue","cursor","themeOverridePatch","edited","themeUpdateConflicts","incoming","startsWith"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/marketplace-theme.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n HostTheme,\n HostThemeFont,\n HostThemeScheme,\n HostThemeSchemeColors,\n} from '@aglyn/shared-data-types'\nimport {\n diffOverride,\n isEmptyOverride,\n overrideConflicts,\n overridePaths,\n readArtifactOverride,\n resolveOverride,\n type ArtifactOverride,\n} from './marketplace-overrides'\n\n/**\n * Publish-time validation for themes as a marketplace artifact (AGL-1020).\n *\n * A plugin bundle is validated because it is code and the risk is a\n * compromised site. A theme is data, so the risk is different in kind but not\n * in consequence: the failure mode is an unreadable or half-painted site, which\n * a publisher cannot see from their own machine because their fonts are\n * installed and their eyes already know what the text says.\n *\n * Both of the things that go wrong are mechanically checkable, so they are\n * checked here rather than left to review:\n *\n * * **Completeness.** Light and dark are not one palette with a filter. A theme\n * that defines only light is half a theme, and installing it leaves every\n * dark-mode visitor on MUI's stock blue — which reads as the site being\n * broken, not as the theme being partial.\n * * **Contrast.** Text on its background, and label on its button, are the two\n * pairs that make a site unusable when they are wrong. WCAG gives an exact\n * ratio, so \"is this readable\" has an answer rather than an opinion.\n *\n * Errors refuse the publish; warnings are shown and can be published past. The\n * split is deliberate — a 3.9:1 heading is a judgement call a designer may\n * legitimately win, but a theme with no dark scheme is not a complete artifact\n * whoever is asking.\n *\n * Pure and MUI-free so the publish route, the console's pre-flight and the\n * tests all reach the same verdict from the same code.\n */\n\nexport interface ThemeValidationIssue {\n /** Dotted path into the theme, so the editor can focus the offending field. */\n path: string\n message: string\n}\n\nexport interface ThemeValidation {\n /** No errors. Warnings do not block. */\n ok: boolean\n errors: ThemeValidationIssue[]\n warnings: ThemeValidationIssue[]\n}\n\n/**\n * WCAG AA for body text. The same 4.5 the accessibility audit uses, rather\n * than a house number, so a theme that passes here passes there.\n */\nexport const CONTRAST_AA = 4.5\n\n/**\n * WCAG AA for large text (18pt+/14pt bold) and UI component boundaries. Button\n * labels are judged against this rather than 4.5 because they are large, short\n * and centred, and holding them to body-text contrast rejects palettes that are\n * genuinely fine.\n */\nexport const CONTRAST_AA_LARGE = 3\n\nconst SCHEMES: HostThemeScheme[] = ['light', 'dark']\n\n/**\n * Font families that need no loading. Anything else is either declared in the\n * theme's own `fonts` array or will silently fall back to the browser's default\n * — which is the failure the issue calls out, so it is at least a warning.\n */\nconst SYSTEM_FAMILIES = new Set(\n [\n 'system-ui',\n 'sans-serif',\n 'serif',\n 'monospace',\n 'cursive',\n 'fantasy',\n 'ui-sans-serif',\n 'ui-serif',\n 'ui-monospace',\n 'ui-rounded',\n '-apple-system',\n 'blinkmacsystemfont',\n 'segoe ui',\n 'roboto',\n 'helvetica',\n 'helvetica neue',\n 'arial',\n 'georgia',\n 'times new roman',\n 'courier new',\n 'inherit',\n 'initial',\n ].map((family) => family.toLowerCase()),\n)\n\n/**\n * Parses a CSS colour into sRGB channels, or null when it is not something we\n * can reason about.\n *\n * `null` means \"not checked\", never \"failed\": a theme is free to use `color-mix`\n * or a CSS variable, and refusing to publish because this parser is narrower\n * than CSS would be a validator inventing a rule the platform does not have.\n */\nexport function parseColor(\n value: unknown,\n): { r: number; g: number; b: number } | null {\n if (typeof value !== 'string') return null\n const text = value.trim().toLowerCase()\n\n const hex = /^#([0-9a-f]{3,8})$/.exec(text)\n if (hex) {\n const digits = hex[1]\n if (digits.length === 3 || digits.length === 4) {\n return {\n r: parseInt(digits[0] + digits[0], 16),\n g: parseInt(digits[1] + digits[1], 16),\n b: parseInt(digits[2] + digits[2], 16),\n }\n }\n if (digits.length === 6 || digits.length === 8) {\n return {\n r: parseInt(digits.slice(0, 2), 16),\n g: parseInt(digits.slice(2, 4), 16),\n b: parseInt(digits.slice(4, 6), 16),\n }\n }\n return null\n }\n\n const rgb = /^rgba?\\(([^)]+)\\)$/.exec(text)\n if (rgb) {\n const parts = rgb[1].split(/[,/\\s]+/).filter(Boolean)\n if (parts.length < 3) return null\n const channels = parts.slice(0, 3).map((part) =>\n part.endsWith('%')\n ? (Number(part.slice(0, -1)) / 100) * 255\n : Number(part),\n )\n if (channels.some((channel) => !Number.isFinite(channel))) return null\n const [r, g, b] = channels\n return { r, g, b }\n }\n\n if (text === 'white') return { r: 255, g: 255, b: 255 }\n if (text === 'black') return { r: 0, g: 0, b: 0 }\n return null\n}\n\n/** WCAG relative luminance. */\nfunction luminance(channels: { r: number; g: number; b: number }): number {\n const channel = (raw: number) => {\n const value = Math.min(255, Math.max(0, raw)) / 255\n return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4\n }\n return (\n 0.2126 * channel(channels.r) +\n 0.7152 * channel(channels.g) +\n 0.0722 * channel(channels.b)\n )\n}\n\n/**\n * WCAG contrast ratio between two colours, or null when either is unparseable.\n * Ranges 1 (identical) to 21 (black on white).\n */\nexport function contrastRatio(a: unknown, b: unknown): number | null {\n const first = parseColor(a)\n const second = parseColor(b)\n if (!first || !second) return null\n const light = Math.max(luminance(first), luminance(second))\n const dark = Math.min(luminance(first), luminance(second))\n return (light + 0.05) / (dark + 0.05)\n}\n\nconst round = (ratio: number) => Math.round(ratio * 10) / 10\n\n/**\n * The colours a scheme must actually define to be usable on its own.\n *\n * Deliberately short. MUI derives an enormous amount from very little, and\n * demanding a full palette would reject good themes; but a scheme that names no\n * primary, no background and no text has not expressed an identity at all — it\n * is an empty object that happens to have a key.\n */\nfunction validateScheme(\n scheme: HostThemeScheme,\n colors: HostThemeSchemeColors | undefined,\n errors: ThemeValidationIssue[],\n warnings: ThemeValidationIssue[],\n): void {\n const at = `colorSchemes.${scheme}`\n if (!colors || !Object.keys(colors).length) {\n errors.push({\n path: at,\n message:\n `This theme defines no ${scheme} scheme. Light and dark are not one ` +\n 'palette with a filter — install it and every visitor in ' +\n `${scheme} mode sees the stock palette instead of your design.`,\n })\n return\n }\n if (!colors.primary?.main) {\n errors.push({\n path: `${at}.primary.main`,\n message: `The ${scheme} scheme has no primary color.`,\n })\n }\n\n const background = colors.background?.default\n const paper = colors.background?.paper\n const text = colors.text?.primary\n const secondaryText = colors.text?.secondary\n\n // Text on background is the pair that decides whether a site can be read at\n // all, so it is the one contrast failure that refuses the publish.\n const bodyContrast = contrastRatio(text, background)\n if (bodyContrast != null && bodyContrast < CONTRAST_AA) {\n errors.push({\n path: `${at}.text.primary`,\n message:\n `Body text on the ${scheme} background is ${round(bodyContrast)}:1, ` +\n `below the ${CONTRAST_AA}:1 minimum for readable text.`,\n })\n }\n\n const paperContrast = contrastRatio(text, paper)\n if (paperContrast != null && paperContrast < CONTRAST_AA) {\n errors.push({\n path: `${at}.background.paper`,\n message:\n `Body text on ${scheme} cards and menus is ${round(paperContrast)}:1, ` +\n `below the ${CONTRAST_AA}:1 minimum. Surfaces inherit this everywhere ` +\n 'a Paper is used, which is most of the site.',\n })\n }\n\n // Secondary text is legitimately dimmer by design, so it warns rather than\n // refuses — but silently shipping unreadable captions is worse than saying so.\n const captionContrast = contrastRatio(secondaryText, background)\n if (captionContrast != null && captionContrast < CONTRAST_AA) {\n warnings.push({\n path: `${at}.text.secondary`,\n message:\n `Secondary text on the ${scheme} background is ` +\n `${round(captionContrast)}:1. Captions and helper text will be hard ` +\n 'to read.',\n })\n }\n\n // A button whose label disappears into its own fill. Only checkable when the\n // theme states contrastText — otherwise MUI derives one, and derives it well.\n for (const key of ['primary', 'secondary', 'error'] as const) {\n const color = colors[key]\n const ratio = contrastRatio(color?.contrastText, color?.main)\n if (ratio != null && ratio < CONTRAST_AA_LARGE) {\n warnings.push({\n path: `${at}.${key}.contrastText`,\n message:\n `The ${scheme} ${key} button label is ${round(ratio)}:1 against its ` +\n `own fill, below ${CONTRAST_AA_LARGE}:1. Leave contrast text unset ` +\n 'to let the theme derive a readable one.',\n })\n }\n }\n}\n\n/**\n * Every family named in the typography, split out of its CSS stack.\n *\n * A stack is a fallback chain, so only the FIRST entry is the one the design\n * depends on; the rest exist precisely to be substituted and warning about them\n * would be noise.\n */\nfunction declaredFamilies(theme: HostTheme): string[] {\n const stacks: string[] = []\n if (theme.typography?.fontFamily) stacks.push(theme.typography.fontFamily)\n for (const variant of Object.values(theme.typography?.variants ?? {})) {\n if (variant?.fontFamily) stacks.push(variant.fontFamily)\n }\n const primary: string[] = []\n for (const stack of stacks) {\n const first = stack.split(',')[0]?.trim().replace(/^[\"']|[\"']$/g, '')\n if (first) primary.push(first)\n }\n return primary\n}\n\nfunction validateFonts(\n theme: HostTheme,\n warnings: ThemeValidationIssue[],\n): void {\n const loadable = new Set(\n (theme.fonts ?? [])\n .map((font: HostThemeFont) => font.family?.trim().toLowerCase())\n .filter(Boolean) as string[],\n )\n const seen = new Set<string>()\n for (const family of declaredFamilies(theme)) {\n const key = family.toLowerCase()\n if (seen.has(key) || loadable.has(key) || SYSTEM_FAMILIES.has(key)) continue\n seen.add(key)\n warnings.push({\n path: 'typography.fontFamily',\n message:\n `\"${family}\" is used but not listed in the theme's fonts, so it will ` +\n 'only appear on machines that happen to have it installed. Add it as ' +\n 'a Google font, or fall back to a system family.',\n })\n }\n}\n\n/**\n * Validates a theme for publishing.\n *\n * The same function backs the console's pre-flight and the publish route: a\n * publisher who is told \"this will be refused\" and then has it accepted (or the\n * reverse) stops trusting either, so there is one verdict from one place.\n */\nexport function validateThemeForPublish(\n theme: HostTheme | null | undefined,\n): ThemeValidation {\n const errors: ThemeValidationIssue[] = []\n const warnings: ThemeValidationIssue[] = []\n\n if (!theme || !Object.keys(theme).length) {\n return {\n ok: false,\n errors: [\n {\n path: '',\n message:\n 'This site has no theme customisations yet, so there is nothing ' +\n 'to publish.',\n },\n ],\n warnings,\n }\n }\n\n for (const scheme of SCHEMES) {\n validateScheme(scheme, theme.colorSchemes?.[scheme], errors, warnings)\n }\n validateFonts(theme, warnings)\n\n return { ok: !errors.length, errors, warnings }\n}\n\n/**\n * Every field of a `HostTheme` that is part of the published artifact.\n *\n * Typed as a total record over `keyof HostTheme` on purpose: adding a field to\n * `HostTheme` fails to compile here until someone decides whether it ships. The\n * alternative — a hand-written list of `if` statements — silently drops the new\n * field, and a theme that loses one is not obviously broken, it is subtly\n * wrong. `mixins` arrived exactly that way (AGL-1242, toolbar heights) while\n * this was being written, which is why the check is a type and not a comment.\n *\n * A `false` means \"host-local, never published\". `darkScheme` is the one such\n * field: whether visitors get a dark scheme is a decision about a SITE's\n * content (does it read in dark?), not about the design being published, so a\n * publisher's opt-out must not travel with the theme and an installer's must\n * not read as a divergence from it.\n */\nconst THEME_ARTIFACT_FIELDS: Record<keyof Required<HostTheme>, boolean> = {\n colorSchemes: true,\n typography: true,\n fonts: true,\n shape: true,\n spacing: true,\n mixins: true,\n components: true,\n darkScheme: false,\n}\n\n/**\n * The theme content as published — the artifact and nothing else.\n *\n * What goes in here is what \"reset to the publisher's version\" restores and\n * what the update diff compares against, so it must hold no per-install data:\n * a host document also carries `updatedAt`, `updatedBy` and whatever else the\n * editor writes alongside, and any of those in the content would read as a user\n * edit on the next diff.\n *\n * Empty branches are dropped so two themes that render identically also hash\n * identically — `{fonts: []}` and `{}` must not produce two base snapshots.\n */\nexport function themeArtifactContent(\n theme: HostTheme | null | undefined,\n): HostTheme {\n const content: Record<string, unknown> = {}\n if (!theme) return content\n for (const [key, ships] of Object.entries(THEME_ARTIFACT_FIELDS)) {\n if (!ships) continue\n const value = (theme as Record<string, unknown>)[key]\n if (value == null) continue\n if (Array.isArray(value) ? !value.length : typeof value === 'object'\n ? !Object.keys(value as object).length\n : false) {\n continue\n }\n content[key] = value\n }\n return content\n}\n\n/**\n * A short, human summary of what a theme actually contains, for the install\n * confirmation and the listing card.\n *\n * Installing a theme replaces a site's visual identity, so the confirmation has\n * to say what is about to change in terms someone recognises — \"both schemes,\n * 2 fonts, 4 component styles\" — rather than showing them a JSON blob and\n * asking them to agree to it.\n */\nexport function describeTheme(theme: HostTheme | null | undefined): string[] {\n if (!theme) return []\n const parts: string[] = []\n const schemes = SCHEMES.filter(\n (scheme) => Object.keys(theme.colorSchemes?.[scheme] ?? {}).length,\n )\n if (schemes.length === 2) parts.push('Light and dark schemes')\n else if (schemes.length === 1) parts.push(`${schemes[0]} scheme only`)\n\n const fonts = theme.fonts?.length ?? 0\n if (fonts) parts.push(`${fonts} font${fonts === 1 ? '' : 's'}`)\n if (theme.typography?.variants) {\n const count = Object.keys(theme.typography.variants).length\n if (count) parts.push(`${count} text style${count === 1 ? '' : 's'}`)\n }\n if (theme.shape?.borderRadius != null) {\n parts.push(`${theme.shape.borderRadius}px corners`)\n }\n if (typeof theme.spacing === 'number') parts.push(`${theme.spacing}px spacing`)\n const components = Object.keys(theme.components ?? {}).length\n if (components) {\n parts.push(`${components} component style${components === 1 ? '' : 's'}`)\n }\n return parts\n}\n\n/* ------------------------------------------------------------------------ *\n * The override layer, applied to themes (AGL-1021)\n * ------------------------------------------------------------------------ */\n\n/**\n * The field on a host document holding the site's theme override.\n *\n * A theme is a FIELD on the host, not a document of its own, so it cannot use\n * `ARTIFACT_OVERRIDE_FIELD` — a host may one day override more than one thing,\n * and a bare `overrides` would be the first to claim the name. The override\n * layer is general precisely so a namespaced field works with it unchanged.\n */\nexport const THEME_OVERRIDE_FIELD = 'themeOverride'\n\n/** A host document, as far as the theme layer is concerned. */\nexport interface ThemeHostDocument {\n theme?: HostTheme | null\n themeOverride?: unknown\n themeInstalledFrom?: { sha256?: string | null; listingId?: string } | null\n /** The library theme the site picked (AGL-3404) — see `theme-library`. */\n themeSelection?: unknown\n}\n\n/** Reads the stored theme override off a host document, tolerating junk. */\nexport function readThemeOverride(\n host: ThemeHostDocument | null | undefined,\n): ArtifactOverride | undefined {\n // The override layer's reader is keyed to its own field name, so hand it a\n // shim rather than duplicating its validation — which is where the \"a\n // top-level null blanks the artifact\" guard lives.\n return readArtifactOverride({ overrides: host?.themeOverride })\n}\n\n/**\n * The site's effective theme: the marketplace theme (or the site's own) with\n * this site's overrides resolved over it.\n *\n * This is the MIDDLE and TOP of the three layers the issue describes. The\n * bottom — the platform default — is applied further down by\n * `HostThemeProvider`, which layers whatever this returns onto the brand base\n * (AGL-1180) so a theme that omits a value still yields a complete theme\n * rather than an undefined-shaped hole. Doing it there rather than here is not\n * a detail: the default is a runtime MUI theme with FUNCTION style overrides\n * that JSON cannot represent, so a \"default\" merged at this layer would be a\n * lossy copy of the real one.\n *\n * Every surface that renders a site's appearance calls this — the tenant, all\n * four besigner editors, and the document preview — because a theme that\n * resolves differently in the editor than on the site is worse than one that\n * does not resolve at all.\n */\nexport function resolveSiteTheme(\n host: ThemeHostDocument | null | undefined,\n): HostTheme | undefined {\n const base = host?.theme ?? undefined\n const override = readThemeOverride(host)\n if (!override) return base\n return resolveOverride<HostTheme>(base ?? {}, override.patch)\n}\n\n/**\n * Where a site's theme came from (AGL-2938).\n *\n * - `installed` — a marketplace theme. The site's edits are its override\n * patch over the publisher's version, which stays untouched and can still\n * take an update.\n * - `preset` — a built-in theme a plugin contributes (AGL-3404).\n * - `custom` — one of the site's own themes.\n * - `default` — the platform default.\n *\n * Whichever it is, an edit is an override patch over the picked theme and the\n * theme itself is never rewritten (AGL-3404), so every source can be restored.\n *\n * One reading for every surface that needs it — the page that lays out the\n * Theme section, a widget proposing a change there, and a generator deciding\n * what its change will be stored as — so none of them can file a site under\n * a different source than the save path does.\n */\nexport type HostThemeSource = 'installed' | 'preset' | 'custom' | 'default'\n\nconst THEME_SOURCES: readonly HostThemeSource[] = [\n 'installed',\n 'preset',\n 'custom',\n 'default',\n]\n\nexport function hostThemeSource(\n host: ThemeHostDocument | null | undefined,\n): HostThemeSource {\n const picked = (host?.themeSelection as { kind?: unknown } | null | undefined)\n ?.kind\n if (THEME_SOURCES.includes(picked as HostThemeSource)) {\n return picked as HostThemeSource\n }\n if (host?.themeInstalledFrom?.listingId) return 'installed'\n return host?.theme && Object.keys(host.theme).length ? 'custom' : 'default'\n}\n\n/**\n * Was this override authored against the theme currently installed?\n *\n * False after a theme swap, which is the whole reason `baseSha256` is recorded.\n * Overrides deliberately SURVIVE a swap (the issue's call, and the right one —\n * a brand colour still expresses intent when the theme underneath it changes)\n * but surviving silently is what would surprise people, so every surface that\n * shows an override can ask this and say so.\n *\n * True when there is no override, and when either side has no hash to compare:\n * \"we cannot tell\" must not render as \"these are stale\".\n */\nexport function isOverrideForCurrentTheme(\n host: ThemeHostDocument | null | undefined,\n): boolean {\n const override = readThemeOverride(host)\n if (!override?.baseSha256) return true\n const installed = host?.themeInstalledFrom?.sha256\n if (!installed) return true\n return override.baseSha256 === installed\n}\n\n/** One row of the \"what have I changed?\" view. */\nexport interface ThemeOverrideEntry {\n /** Dotted path into the theme, e.g. `colorSchemes.dark.primary.main`. */\n path: string\n /** A human label for the path, for a UI that is not a JSON viewer. */\n label: string\n /** Which scheme this touches, when it touches one. */\n scheme?: HostThemeScheme\n /** The publisher's value, or undefined when the override adds the field. */\n themeValue: unknown\n /** What this site set it to, or undefined when the override removes it. */\n overrideValue: unknown\n}\n\nconst SEGMENT_LABELS: Record<string, string> = {\n darkScheme: 'Dark scheme',\n colorSchemes: 'Color',\n typography: 'Typography',\n components: 'Component',\n shape: 'Shape',\n spacing: 'Spacing',\n fonts: 'Fonts',\n mixins: 'Layout',\n variants: '',\n background: 'background',\n text: 'text',\n main: '',\n contrastText: 'label color',\n borderRadius: 'corner radius',\n fontFamily: 'font',\n}\n\n/**\n * A readable label for a theme path.\n *\n * \"Colour · dark · primary\" beats `colorSchemes.dark.primary.main`, and the\n * difference matters because this list is the answer to \"what have I changed?\"\n * — a question nobody asks wanting to read JSON paths.\n */\nexport function describeThemePath(path: string): string {\n const segments = path.split('.').filter(Boolean)\n const words: string[] = []\n for (const segment of segments) {\n const mapped = SEGMENT_LABELS[segment]\n if (mapped === '') continue\n words.push(mapped ?? segment)\n }\n return words.join(' · ') || 'the whole theme'\n}\n\n/**\n * \"What have I changed?\" — the patch, rendered.\n *\n * Literally a read of the stored override rather than a diff computed at call\n * time, which is the property the whole layer exists to provide: the answer is\n * the thing that was saved, so it cannot disagree with what will be applied.\n */\nexport function describeThemeOverride(\n host: ThemeHostDocument | null | undefined,\n): ThemeOverrideEntry[] {\n const override = readThemeOverride(host)\n if (!override) return []\n const base = host?.theme ?? {}\n const resolved = resolveOverride<HostTheme>(base, override.patch)\n return overridePaths(override.patch).map((path) => {\n const segments = path.split('.')\n const scheme =\n segments[0] === 'colorSchemes' &&\n (segments[1] === 'light' || segments[1] === 'dark')\n ? (segments[1] as HostThemeScheme)\n : undefined\n return {\n path,\n label: describeThemePath(path),\n scheme,\n themeValue: readThemePath(base, path),\n overrideValue: readThemePath(resolved, path),\n }\n })\n}\n\n/** Reads a dotted path out of a theme. */\nfunction readThemePath(theme: unknown, path: string): unknown {\n let cursor: unknown = theme\n for (const segment of path.split('.')) {\n if (cursor == null || typeof cursor !== 'object') return undefined\n cursor = (cursor as Record<string, unknown>)[segment]\n }\n return cursor\n}\n\n/**\n * The override this site should store after the theme editor produced `edited`.\n *\n * The editor edits the RESOLVED theme — that is what it renders and what the\n * user sees — so the patch is the difference between the publisher's base and\n * what came back. Diffing here rather than tracking per-field dirty state means\n * a field the user set and then set back to the theme's value stops being an\n * override, which is what \"reset\" means without needing a reset button to have\n * been pressed.\n */\nexport function themeOverridePatch(\n host: ThemeHostDocument | null | undefined,\n edited: HostTheme,\n): unknown {\n return diffOverride(host?.theme ?? {}, edited)\n}\n\n/**\n * Where this site's overrides sit on top of values a theme update also changes\n * — the only place a conflict can exist.\n *\n * Narrow by construction: an override touching four paths can conflict in at\n * most four places however much of the theme the publisher rewrote. That is the\n * difference between \"the publisher also changed your heading colour, whose\n * wins?\" and \"this update rewrites your theme, continue?\".\n */\nexport function themeUpdateConflicts(\n host: ThemeHostDocument | null | undefined,\n incoming: HostTheme,\n): ThemeOverrideEntry[] {\n const override = readThemeOverride(host)\n if (!override || isEmptyOverride(override.patch)) return []\n const base = host?.theme ?? {}\n const resolved = resolveOverride<HostTheme>(base, override.patch)\n return overrideConflicts(base, override.patch, incoming).map((path) => ({\n path,\n label: describeThemePath(path),\n scheme:\n path.startsWith('colorSchemes.light.')\n ? ('light' as const)\n : path.startsWith('colorSchemes.dark.')\n ? ('dark' as const)\n : undefined,\n themeValue: readThemePath(incoming, path),\n overrideValue: readThemePath(resolved, path),\n }))\n}\n"],"names":["diffOverride","isEmptyOverride","overrideConflicts","overridePaths","readArtifactOverride","resolveOverride","CONTRAST_AA","CONTRAST_AA_LARGE","SCHEMES","SYSTEM_FAMILIES","Set","map","family","toLowerCase","parseColor","value","text","trim","hex","exec","digits","length","r","parseInt","g","b","slice","rgb","parts","split","filter","Boolean","channels","part","endsWith","Number","some","channel","isFinite","luminance","raw","Math","min","max","contrastRatio","a","first","second","light","dark","round","ratio","validateScheme","scheme","colors","errors","warnings","at","Object","keys","push","path","message","primary","main","background","default","paper","secondaryText","secondary","bodyContrast","paperContrast","captionContrast","key","color","contrastText","declaredFamilies","theme","stacks","typography","fontFamily","variant","values","variants","stack","replace","validateFonts","loadable","fonts","font","seen","has","add","validateThemeForPublish","ok","colorSchemes","THEME_ARTIFACT_FIELDS","shape","spacing","mixins","components","darkScheme","themeArtifactContent","content","ships","entries","Array","isArray","describeTheme","schemes","count","borderRadius","THEME_OVERRIDE_FIELD","readThemeOverride","host","overrides","themeOverride","resolveSiteTheme","base","undefined","override","patch","THEME_SOURCES","hostThemeSource","picked","themeSelection","kind","includes","themeInstalledFrom","listingId","isOverrideForCurrentTheme","baseSha256","installed","sha256","SEGMENT_LABELS","describeThemePath","segments","words","segment","mapped","join","describeThemeOverride","resolved","label","themeValue","readThemePath","overrideValue","cursor","themeOverridePatch","edited","themeUpdateConflicts","incoming","startsWith"],"mappings":"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"}
@@ -32,6 +32,13 @@ export interface AglynNotification {
32
32
  link?: string;
33
33
  orgId?: string;
34
34
  hostId?: string;
35
+ /**
36
+ * The pending invitation this notification offers the recipient
37
+ * (AGL-3402), under `orgs/{orgId}/invites`. Present only on the invitee's
38
+ * own `team.invite`; the console opens the accept/decline dialog for it
39
+ * instead of following `link`, and answering marks it read.
40
+ */
41
+ inviteId?: string;
35
42
  createdAt?: ITimestamp;
36
43
  /** When the owner read it; absent or null while unread. */
37
44
  readAt?: ITimestamp | null;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/notifications.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { ITimestamp } from '@aglyn/shared-util-timestamp'\nimport { buildRoute, Route } from './console-routes'\n\n/**\n * In-app notifications (AGL-259): per-user docs at\n * `users/{uid}/notifications/{id}`, written by the Admin SDK (emitters in\n * the API routes + the `notifyAdmins` automation step) and read/marked by\n * their owner. Types follow the common SaaS taxonomy so the console can\n * icon/group them without a registry.\n */\nexport type AglynNotificationType =\n | 'billing.invoice'\n | 'billing.paymentFailed'\n // Stripe gave up on a failed renewal and CANCELLED the subscription\n // (AGL-1877). Distinct from `paymentFailed`, which announces one failed\n // attempt inside a retry window the customer can still recover from; this\n // one is the window having closed. Measured on the test account with a\n // test clock: five attempts over 21.08 days, then\n // `cancellation_details.reason: 'payment_failed'` — and until this existed\n // the org went silently to Free at that moment, with the `past_due` banner\n // disappearing at exactly the instant the consequence arrived.\n | 'billing.subscriptionCanceled'\n | 'billing.usage'\n | 'team.invite'\n | 'team.roleChanged'\n | 'team.hostAccessGranted'\n | 'content.formSubmission'\n | 'content.booking'\n | 'content.order'\n | 'content.lowStock'\n // A CRM task was assigned to the recipient by somebody else (AGL-2599).\n // `content.` because it is the same kind of message as a form submission\n // or a booking: a thing on the site that now needs this person's hands,\n // not a change to their standing (`team.`) and not a platform notice\n // (`system.`). Someone who has muted the operational stream has said they\n // do not want to be told about work as it arrives, and a task is work.\n | 'content.taskAssigned'\n // A CRM task's reminder came due (AGL-2659): the hourly runner telling\n // the assignee, at the task's own time, that the task is due. `content.`\n // beside `taskAssigned` for the same reason it gives — work on the site,\n // not standing — and so the one mute that stops \"work arriving\" stops\n // \"work falling due\" with it. The mute governs the whole reminder, mail\n // included, unlike the digest's: a reminder is one message about one\n // task, and there is no schedule to keep separately from it.\n | 'content.taskReminder'\n // A contact, or a lead, became the recipient's to work (AGL-2618): a\n // capture the assignment rules or the site's default owner routed to\n // them, a lead somebody converted and handed to them, or an automation's\n // \"assign an owner\" step — every server path that writes `ownerUid`\n // except the recipient assigning themselves. Two types rather than one\n // because the record the person opens differs: a lead is the site's own\n // working record and a contact is the org's, and the link goes to the\n // one they will actually work. `content.`, beside `taskAssigned`, for the\n // same reason: work arriving, not standing changing.\n | 'content.contactAssigned'\n | 'content.leadAssigned'\n // The morning's CRM digest (AGL-2619): what the recipient owes today —\n // overdue and due-today tasks, and the leads nobody has worked. `content.`\n // for the reason `taskAssigned` is: it is about work on the site, and the\n // person who muted the operational stream has asked not to be told about\n // work. The digest EMAIL is governed separately, by\n // `crmDailyDigestEnabled`: the mute is a fact about the console feed and\n // the digest switch is a fact about the digest.\n | 'content.crmDailyDigest'\n // The weekly insights (AGL-2915): what a site's figures showed that week,\n // for a person who asked for them. `content.` beside the CRM digest, for the\n // reason that one gives: it is about the site's work, and the operational\n // mute governs the console notification while the person's own switch for\n // the digest governs the digest.\n | 'content.insightsDigest'\n // Marketplace review verdicts (AGL-432/653).\n | 'marketplace.review'\n // Support desk, staff audience (AGL-850): a subscriber opened or replied to\n // a ticket. Fanned out to staff-claim holders, not org members.\n | 'support.ticketOpened'\n | 'support.ticketReply'\n | 'system.announcement'\n // A live plugin version stopped passing the static verifier (AGL-1086).\n // Staff audience: bytes we told workspaces were checked now fail checks\n // that did not exist when they were approved.\n //\n // Deliberately NOT under `marketplace.` (AGL-1088). Category is the prefix,\n // categories are mutable per user, and Marketplace is the category a staff\n // member mutes to stop routine listing-review chatter — which would drop\n // this alert as collateral. `system` is the bucket nobody mutes to reduce\n // noise. The adminAudit record survives a mute either way; the timeliness\n // does not, and timeliness is the whole point of the alert.\n | 'system.pluginVerifierRegression'\n // A sign-in method was removed from the user's own account because their\n // organization turned on SSO enforcement (AGL-1129). `system.`, not\n // `team.`, for the AGL-1088 reason above: `team` is the category someone\n // mutes to stop routine roster chatter, and \"the way you sign in just\n // changed\" is not chatter — the next sign-in fails without it.\n | 'system.signInMethodRemoved'\n // Documents in a scoped collection carrying no `visibleTo` (AGL-1478).\n // Staff audience: the weekly dry run found resources that are invisible\n // to every site-scoped read, which always means a creation path forgot\n // the field. `system.` for the AGL-1088 reason above, and because the\n // collection it names may be `marketplace`-adjacent or not.\n | 'system.scopeDrift'\n // An SSO domain that WAS DNS-proven has stopped answering with our\n // challenge record, for three consecutive weekly sweeps (AGL-1210).\n // Nothing has been turned off — sign-in for that domain still routes\n // exactly as it did — and that is precisely why this has to be said out\n // loud rather than left in a log.\n //\n // `system.`, not `team.`, for the AGL-1088 reason above and one of its\n // own: the audience is the org's own admins, the subject is whether their\n // people can still sign in, and it must reach them BEFORE anybody decides\n // to revoke the routing. A category somebody mutes to quieten roster\n // chatter is the wrong place for the one warning that precedes a lockout.\n | 'system.ssoDomainUnverified'\n // A site hit the per-month form-submission abuse ceiling and further\n // submissions are being refused (AGL-1655). `system.`, not `content.`, for\n // the AGL-1088 reason above and more sharply than any of them: `content` is\n // literally the category a site owner mutes to stop routine form-submission\n // chatter, and this is the one form notification that says the form has\n // STOPPED accepting. Filing it under the muted bucket would guarantee it\n // reaches nobody on exactly the sites busy enough to trip it.\n | 'system.formSubmissionsPaused'\n // A site reached the flat platform ceiling on member accounts or on lead\n // records, so further ones are being refused (AGL-1529). `system.` for the\n // same reason as `formSubmissionsPaused` directly above, and with the same\n // sharpness: `content.` is the bucket an owner mutes to stop routine\n // sign-up and lead chatter, and this is the one notification saying the\n // sign-ups have STOPPED — muted exactly on the sites busy enough to trip it.\n | 'system.visitorRecordsPaused'\n // An outsider reported one of our sites for phishing, malware or CSAM\n // (AGL-1964). `system` for the AGL-1088 reason and, again, more sharply\n // than most: this notification goes to STAFF, not to a customer, and its\n // subject is somebody else's site. There is no bucket a recipient could\n // mute it into that would be honest — nobody has opted into being told a\n // stranger is being phished through our platform, and nobody should be able\n // to opt out of it either.\n //\n // Only the urgent categories raise one, and only on a first report. See the\n // fan-out in apps/tenant/app/api/report-abuse/route.ts for why: a flood of\n // alerts IS the flood, and the alert it would cost us is the phishing one.\n | 'system.abuseReportUrgent'\n // Something on the recipient's OWN workspace was held, flagged, locked or\n // paused (AGL-3368): an email or page held for review, a flagged domain,\n // a fraud signal on a payment, a lock and its lift. Written by\n // `notifyRiskEvent` from the risk notice catalog, which says what happened,\n // what it means and how to request a review. `system.` for the AGL-1088\n // reason: it is the one message saying the owner's work has STOPPED, and a\n // category muted to quieten routine chatter must not swallow it. It sends\n // its own email, transactional and preference-free, so it is also in\n // NOTIFICATION_SELF_SENT_EMAIL_TYPES.\n | 'system.riskNotice'\n // The §512(g) counter-notice (AGL-1983), and the one place the \"only urgent\n // categories raise a notification\" restraint above is deliberately not\n // applied. Every counter-notice carries a statutory deadline that is\n // ALREADY RUNNING when it arrives — the clock counts from the subscriber's\n // submission, not from our attention — so there is no low-value tail of\n // these to drown out the important ones, and the one that goes unread is a\n // customer locked out of their own work plus an unmet obligation under\n // §512(g)(2)(A). Raised on first submission only, like the one above, so a\n // resubmitting customer cannot re-alert.\n | 'system.dmcaCounterNotice'\n // A site crossed the per-month bandwidth abuse ceiling (AGL-2155). Two\n // audiences share one type: staff, because an uncompensated free site\n // serving six figures of page views is an incident; and the site's own\n // managers, because on free the site is now serving a capped notice and\n // nobody should learn that from a visitor.\n //\n // `system.`, not `billing.`, for the AGL-1088 reason and one specific to\n // this meter: on the free plan there is no bill, so `billing` would be the\n // one category where the message is literally never about money — and it is\n // exactly the free-tier trip that changes what visitors see.\n | 'system.bandwidthCeilingTripped'\n // A free site crossed its PLAN's included bandwidth and is now serving the\n // capped notice (AGL-2413). Distinct from the ceiling above on purpose: that\n // one is an incident at 10x the band and goes to staff first; this one is an\n // ordinary quota at 1x, concerns only the site's own managers, and its\n // remedy is an upgrade rather than an investigation. Sending both under one\n // type would tell an owner their traffic was being treated as suspected\n // abuse when it is simply a successful free site.\n | 'system.bandwidthCapEngaged'\n // A Stripe billing webhook threw AFTER its handlers had begun (AGL-2157),\n // so its side effects may be half applied and its idempotency claim is\n // being HELD — Stripe will not retry it. Staff audience: this is the one\n // failure on that route no automatic retry can make safe, because the\n // handlers behind it are not all idempotent, and a human has to reconcile.\n | 'system.billingWebhookHalfApplied'\n // A card dispute arrived that NOTHING in the platform claimed (AGL-2429):\n // no `platformRevenue` row, no storefront order, no marketplace purchase.\n // Staff audience, and the reason it needs a type of its own is that its\n // absence was the bug — the routine case (a storefront or marketplace\n // chargeback, which the plugins own) and the fault case were the same\n // silence from the route's side, so a dispute nobody handled looked\n // exactly like one somebody did.\n //\n // `system.`, not `billing.`, for the AGL-1088 reason and one specific to\n // this alert: `billing` is the category a recipient is most likely to have\n // muted as routine invoice traffic, and this is the one message on that\n // route where muting it means money moves with nobody looking.\n | 'system.disputeUnattributed'\n // An operator alert from the registry in `./operator-alerts` (AGL-3377)\n // that has no notification type of its own: a lost dispute, a failed\n // erasure, a degraded health check. One type for all of them because the\n // registry, not the taxonomy, is what names the alert; the title and body\n // say which one it is. `system.` for the AGL-1088 reason above: these are\n // the faults nobody may mute away as routine chatter.\n | 'system.operatorAlert'\n // Somebody created an account, and somebody created a workspace\n // (AGL-3225). Staff audience, and the only two types in the taxonomy whose\n // subject is the platform's own growth rather than anybody's work.\n //\n // `staff.`, and NOT `system.`, and the AGL-1088 note above is the reason\n // rather than an exception to it: `system` is the bucket nobody mutes to\n // reduce noise, which is exactly what makes it the wrong home for the two\n // routine, high-volume events in the product. A staff member must be able\n // to stop hearing about every sign-up without also dropping the verifier\n // regression and the unattributed dispute that share that bucket — and\n // before this category existed, there was nowhere to put them where that\n // was true.\n //\n // The category also tells the settings page who a row is for: `staff` is\n // rendered only to claim holders, so no customer is shown a switch for\n // notifications they could never receive.\n | 'staff.userSignedUp'\n | 'staff.orgCreated'\n // And when money moves (AGL-3267). The category was two signup types, so\n // the one thing a platform feed exists to report — revenue starting,\n // changing and stopping — was the one thing it did not.\n //\n // `staff.paymentFailed` is deliberately NOT the customer's\n // `billing.paymentFailed`: different audience, different action. The\n // customer is told to fix their card; staff are told a paying workspace is\n // about to stop paying.\n | 'staff.subscriptionStarted'\n | 'staff.subscriptionCanceled'\n | 'staff.planChanged'\n | 'staff.paymentFailed'\n\nexport interface AglynNotification {\n $id?: string\n type: AglynNotificationType\n title: string\n body?: string\n /** Console path the notification opens (e.g. a host inbox). */\n link?: string\n orgId?: string\n hostId?: string\n createdAt?: ITimestamp\n /** When the owner read it; absent or null while unread. */\n readAt?: ITimestamp | null\n /**\n * Whether the owner has read it — `false` on create, `true` beside\n * `readAt` when read (AGL-3321).\n *\n * A boolean rather than `readAt: null`, because the feed filters by it\n * under its newest-first cursor. Equality on a boolean answers BOTH\n * states (`read == false`, `read == true`) beneath `orderBy('createdAt')`\n * with one composite index. A nullable timestamp answers \"unread\" by\n * equality but \"read\" only as `readAt != null`, and Firestore requires an\n * inequality's field to be the FIRST sort, which would order the feed by\n * when things were read instead of when they arrived. Absent only on\n * notifications written before the field existed, which\n * `tools/scripts/backfill-notification-read.mjs` stamps.\n */\n read?: boolean\n}\n\nexport const NOTIFICATION_TYPE_LABELS: Record<AglynNotificationType, string> = {\n 'billing.invoice': 'Invoice available',\n 'billing.paymentFailed': 'Payment failed',\n 'billing.subscriptionCanceled': 'Subscription canceled',\n 'billing.usage': 'Usage threshold',\n 'team.invite': 'Team invite',\n 'team.roleChanged': 'Role changed',\n 'team.hostAccessGranted': 'Site access granted',\n 'content.formSubmission': 'Form submission',\n 'content.booking': 'New booking',\n 'content.order': 'New order',\n 'content.lowStock': 'Low stock',\n 'content.taskAssigned': 'Task assigned to you',\n 'content.taskReminder': 'Task reminder',\n 'content.contactAssigned': 'Contact assigned to you',\n 'content.leadAssigned': 'Lead assigned to you',\n 'content.crmDailyDigest': 'Daily CRM digest',\n 'content.insightsDigest': 'Weekly insights',\n 'marketplace.review': 'Listing review',\n\n 'support.ticketOpened': 'New support ticket',\n 'support.ticketReply': 'Support ticket reply',\n 'system.announcement': 'Announcement',\n 'system.pluginVerifierRegression': 'Plugin verifier regression',\n 'system.signInMethodRemoved': 'Sign-in method removed',\n 'system.scopeDrift': 'Resources missing a sharing scope',\n 'system.ssoDomainUnverified': 'SSO domain no longer proves ownership',\n 'system.formSubmissionsPaused': 'Form submissions paused',\n 'system.visitorRecordsPaused': 'Sign-ups or leads paused',\n 'system.abuseReportUrgent': 'Urgent abuse report',\n 'system.riskNotice': 'Held, flagged or locked on your workspace',\n 'system.dmcaCounterNotice': 'DMCA counter-notice',\n 'system.bandwidthCeilingTripped': 'Bandwidth ceiling reached',\n 'system.bandwidthCapEngaged': 'Monthly traffic limit reached',\n 'system.billingWebhookHalfApplied': 'Billing webhook half applied',\n 'system.disputeUnattributed': 'Card dispute with no owner',\n 'system.operatorAlert': 'Operator alert',\n 'staff.userSignedUp': 'New account',\n 'staff.orgCreated': 'New workspace',\n 'staff.subscriptionStarted': 'New subscription',\n 'staff.subscriptionCanceled': 'Subscription canceled',\n 'staff.planChanged': 'Plan changed',\n 'staff.paymentFailed': 'Payment failed (workspace)',\n}\n\n/** Preference buckets (AGL-267): the prefix before the dot. */\nexport type NotificationCategory =\n | 'billing'\n | 'team'\n | 'content'\n | 'marketplace'\n | 'support'\n | 'system'\n // Staff-only, and shown only to staff (AGL-3225) — see\n // {@link STAFF_NOTIFICATION_CATEGORIES}.\n | 'staff'\n\nexport const NOTIFICATION_CATEGORY_LABELS: Record<\n NotificationCategory,\n string\n> = {\n billing: 'Billing',\n team: 'Team & access',\n content: 'Forms & bookings',\n marketplace: 'Marketplace',\n support: 'Support',\n system: 'Product & system',\n staff: 'Platform growth',\n}\n\n/**\n * The categories only staff can receive (AGL-3225).\n *\n * The settings page hides these rows from everybody else, because a switch\n * for mail that can never arrive is a promise the product does not keep. It\n * is presentation only: `notifyStaff` is what decides the audience, and it\n * enumerates the `staff` claim rather than reading this.\n */\nexport const STAFF_NOTIFICATION_CATEGORIES: ReadonlySet<NotificationCategory> =\n new Set<NotificationCategory>(['staff'])\n\n/**\n * What each category actually covers, in the reader's words (AGL-3251).\n *\n * The settings page listed seven bare labels and two switches, so deciding\n * whether to silence `Product & system` meant guessing what was in it. The\n * type labels beneath each row now name the contents exactly; this is the\n * one-line answer for somebody who does not want to expand anything.\n *\n * Written as what ARRIVES rather than as what the bucket is called: \"someone\n * joins, a role changes\" is checkable against your own feed in a way that\n * \"team and access events\" is not. Exhaustive `Record`, like the channel\n * defaults below — a new category cannot ship without somebody saying in\n * plain words what lands in it.\n */\nexport const NOTIFICATION_CATEGORY_DESCRIPTIONS: Record<\n NotificationCategory,\n string\n> = {\n billing:\n 'Invoices, failed payments, cancellations, and usage that crosses a plan limit.',\n team: 'Somebody joins or leaves, a role changes, or a site is shared with you.',\n content:\n 'Work arriving on your sites: form submissions, bookings, orders, low stock, and the tasks and leads assigned to you.',\n marketplace: 'Decisions on plugin listings you submitted for review.',\n support: 'New support tickets and replies on tickets you are following.',\n system:\n 'Announcements, and the faults the platform finds in your account: sign-in methods removed, traffic limits reached, billing or sharing left in a broken state.',\n staff:\n 'New accounts, new workspaces, and money moving — subscriptions starting, changing, failing and ending — across the whole platform. Only staff receive these.',\n}\n\nexport function notificationCategory(\n type: AglynNotificationType | string,\n): NotificationCategory {\n const prefix = String(type).split('.')[0]\n return (\n [\n 'billing',\n 'team',\n 'content',\n 'marketplace',\n 'support',\n 'system',\n 'staff',\n ].includes(prefix)\n ? prefix\n : 'system'\n ) as NotificationCategory\n}\n\n/**\n * Per-user mute map stored at `users/{uid}.notificationPrefs`\n * (`{ [category]: false }` mutes); absent categories stay on.\n */\nexport function notificationMuted(\n prefs: Record<string, boolean> | null | undefined,\n type: AglynNotificationType | string,\n): boolean {\n return prefs?.[notificationCategory(type)] === false\n}\n\n/**\n * The field on `users/{uid}` that holds a person's digest switches\n * (AGL-2619): `{ crmDaily: false }` turns the daily CRM digest off, and an\n * absent key leaves it on. Its own map rather than a key in\n * `notificationPrefs`, because that map is keyed by CATEGORY and read by\n * {@link notificationMuted} — a digest is a schedule a person keeps or\n * drops, not a bucket of types, and one switch governs both the console\n * notification and the email it travels with.\n */\nexport const DIGEST_PREFS_FIELD = 'digestPrefs'\n\n/**\n * The key the daily CRM digest keeps inside {@link DIGEST_PREFS_FIELD}.\n *\n * Exported so the console writes the switch through a name rather than a\n * second copy of the string (AGL-3230). The reader below and the page that\n * flips it were spelling the same literal in two files, which is how a\n * preference comes to be written under one key and read under another — and\n * it kept the plugin's vocabulary in a console page, where\n * `check:plugin-domain-in-core` rightly refuses it.\n */\nexport const CRM_DAILY_DIGEST_KEY = 'crmDaily'\n\nexport function crmDailyDigestEnabled(\n prefs: Record<string, boolean> | null | undefined,\n): boolean {\n return prefs?.[CRM_DAILY_DIGEST_KEY] !== false\n}\n\n/**\n * The field on `users/{uid}` naming the workspaces whose weekly insights a\n * person asked for (AGL-2915): `{ [orgId]: true }`. OPT-IN, unlike the CRM\n * digest — an absent key is off — because a weekly insight is generated, and a\n * generation spends the workspace's credits. The person turns it on beside the\n * answers themselves, where the plan, the release and their permission have\n * already been checked, and off again there or in Notifications; the weekly\n * sweep checks all three again before it spends anything.\n */\nexport const INSIGHT_DIGESTS_FIELD = 'insightDigests'\n\n/** Whether a person asked for a workspace's weekly insights. */\nexport function insightDigestSubscribed(\n value: Record<string, boolean> | null | undefined,\n orgId: string,\n): boolean {\n return Boolean(orgId) && value?.[orgId] === true\n}\n\n/**\n * The channels a notification can travel on (AGL-3223).\n *\n * `console` is the feed at `/manage/notifications` and the app-bar dropdown\n * that reads it — the only channel that existed. `email` is the message the\n * fan-out sends beside that doc when the recipient asked for one.\n */\nexport type NotificationChannel = 'console' | 'email'\n\n/**\n * One scope's answer for one category. **Tri-state on purpose**: `true` and\n * `false` decide, and an ABSENT key inherits from the scope above.\n *\n * Inheritance is the whole reason this is a partial rather than a pair of\n * booleans. A person who wants form submissions from one busy site and not\n * from the other five has to be able to say that about the one site without\n * restating their answer for every other category at every other scope — and\n * a two-valued leaf cannot express \"I have not said\", so every override would\n * have to be written out in full and would then stop tracking the account\n * default it was never meant to detach from.\n */\nexport interface NotificationChannelPrefs {\n console?: boolean\n email?: boolean\n}\n\nexport type NotificationCategoryPrefs = Partial<\n Record<NotificationCategory, NotificationChannelPrefs>\n>\n\n/**\n * One scope's answer for a single notification TYPE (AGL-3251), tri-state on\n * the same terms as {@link NotificationChannelPrefs}: an absent key falls\n * through to the category.\n *\n * Its own map rather than extra keys in {@link NotificationCategoryPrefs},\n * because that one is an exhaustive record keyed by category and a type key\n * inside it would type-check only by widening the thing that makes a missing\n * category a compile error.\n */\nexport type NotificationTypePrefs = Partial<\n Record<AglynNotificationType, NotificationChannelPrefs>\n>\n\n/**\n * Per-scope, per-channel notification preferences (AGL-3223), stored at\n * `users/{uid}.notificationSettings`.\n *\n * Three layers, narrowest first when resolving: the SITE a notification\n * concerns, then the WORKSPACE, then the account. Anything none of them\n * answers falls to {@link NOTIFICATION_CHANNEL_DEFAULTS}.\n *\n * ON THE USER DOCUMENT, not on `orgs/{orgId}/members/{uid}`, and that is a\n * cost decision rather than a modelling preference. `notifyUsers` already\n * does exactly one `getAll` over the recipients' user docs to read their\n * category mutes, so every layer living here means per-site preferences are\n * read for free on the fan-out's hot path. The member row would add a read\n * per recipient per notification to answer a question the document already in\n * hand could have answered.\n */\nexport interface NotificationSettings {\n account?: NotificationCategoryPrefs\n /**\n * Per-TYPE answers at the account scope (AGL-3251), consulted before\n * {@link NotificationSettings.account}'s category answer and never above a\n * narrower scope — see {@link notificationChannelEnabled}.\n *\n * ACCOUNT ONLY, deliberately. The per-workspace and per-site card is\n * already seven categories by three states by two channels, and a type row\n * for each of the ~thirty types, per workspace AND per site, is a page\n * nobody can read — which is the complaint this whole issue started as.\n * The account scope is where \"stop telling me about THIS\" belongs anyway:\n * it is a statement about the kind of thing, and kinds do not vary by site.\n */\n accountTypes?: NotificationTypePrefs\n /** Keyed by org id. */\n orgs?: Record<string, NotificationCategoryPrefs>\n /** Keyed by host id — a site's own answer, narrower than its workspace's. */\n hosts?: Record<string, NotificationCategoryPrefs>\n /**\n * Per-TYPE answers at the workspace and site scopes (AGL-3267), beside the\n * category maps above rather than nested inside them.\n *\n * Parallel maps because the category maps are already stored under `orgs`\n * and `hosts` on live user documents: folding both into one\n * `{ categories, types }` object per scope would be a migration of every\n * preference anybody has set, to express something two more keys express\n * without touching a byte of what exists.\n *\n * This supersedes the account-only limit AGL-3251 shipped under. That was\n * the right call for a card nobody had used yet and the wrong one once it\n * existed: \"quiet this one type down on this one busy site\" is the question\n * the scope card is FOR, and answering it only for whole categories made\n * the fine grain stop exactly where the noise is worst.\n */\n orgTypes?: Record<string, NotificationTypePrefs>\n hostTypes?: Record<string, NotificationTypePrefs>\n}\n\nexport const NOTIFICATION_SETTINGS_FIELD = 'notificationSettings'\n\n/**\n * What a category does when nobody has said otherwise.\n *\n * Console on, email OFF, everywhere. Email defaults off because the inbox is\n * not ours to fill: the product already sends transactional mail nobody opted\n * into — welcome, verification, invites, dunning, usage alerts — and every one\n * of those leaves on the same domain a customer's password reset depends on.\n * Turning a busy site's form submissions into mail by default would put that\n * domain's reputation behind traffic the recipient never asked for.\n *\n * Exhaustive `Record` deliberately: a new {@link NotificationCategory} is a\n * compile error here until somebody decides what it does by default, which is\n * the one question a new category must not be able to ship without answering.\n */\nexport const NOTIFICATION_CHANNEL_DEFAULTS: Record<\n NotificationCategory,\n Record<NotificationChannel, boolean>\n> = {\n billing: { console: true, email: false },\n team: { console: true, email: false },\n content: { console: true, email: false },\n marketplace: { console: true, email: false },\n support: { console: true, email: false },\n system: { console: true, email: false },\n // Console ON, because the complaint this answers is that staff never heard\n // about a sign-up at all; email off, like everything else, because that is\n // what the channel defaults to and a sign-up is not urgent enough to be the\n // exception that starts filling inboxes by default.\n staff: { console: true, email: false },\n}\n\n/**\n * The types that send their OWN email and must never be mailed again by the\n * generic channel (AGL-3224).\n *\n * Both digests compose a message the fan-out could not reproduce — a day's\n * owed tasks, a week's figures — and send it from their own route under their\n * own switch (`digestPrefs.crmDaily`, `insightDigests.{orgId}`). A recipient\n * who switches the `content` email channel on would otherwise receive the\n * digest twice: once as the digest, once as a one-line \"Daily CRM digest\"\n * notification saying that the digest happened.\n */\nexport const NOTIFICATION_SELF_SENT_EMAIL_TYPES: ReadonlySet<string> =\n new Set<AglynNotificationType>([\n 'content.crmDailyDigest',\n 'content.insightsDigest',\n // Emailed by `notifyRiskEvent` itself, as account mail (AGL-3368).\n 'system.riskNotice',\n ])\n\n/**\n * Whether a channel is on for one notification, at its own scope.\n *\n * Resolution order, first answer wins: the notification's SITE, its\n * WORKSPACE, the account, then {@link NOTIFICATION_CHANNEL_DEFAULTS}. A scope\n * that holds no entry for the category, or an entry with the channel key\n * absent, does not answer — see {@link NotificationChannelPrefs}.\n *\n * `legacyPrefs` is the flat `notificationPrefs` mute map this replaces\n * (AGL-267), and it sits BELOW the account layer rather than beside it.\n * Nothing migrates it: a person who never opens the new settings page keeps\n * being governed by the mutes they set years ago, and the first thing they do\n * set on the account layer overrides the old map for that category without\n * disturbing the rest of it. It answers for `console` only, because the map\n * predates there being a second channel and reading a console mute as an\n * email preference would be inventing an answer its author never gave.\n */\nexport function notificationChannelEnabled(\n settings: NotificationSettings | null | undefined,\n channel: NotificationChannel,\n type: AglynNotificationType | string,\n scope?: { orgId?: string | null; hostId?: string | null },\n legacyPrefs?: Record<string, boolean> | null,\n): boolean {\n const category = notificationCategory(type)\n /*\n * ⛔ A STAFF NOTIFICATION HAS NO SCOPE TO BE NARROWED BY (AGL-3267).\n *\n * It is about the platform, and the workspace one MENTIONS is its subject,\n * not its audience: the staff member reading \"Acme Co subscribed\" is\n * almost never a member of Acme Co, and if they happen to be, their\n * preferences for their own membership have nothing to do with it.\n *\n * This was already true by accident — no staff emitter passes `orgId` or\n * `hostId`, so the scope layers never matched — which made every\n * \"Platform growth\" row in the per-workspace card a control that could be\n * set and could never do anything. Stating it here rather than only hiding\n * those rows is the difference between the model being right and the UI\n * covering for a model that is wrong: a `staff.*` type that ever does\n * start carrying an `orgId`, for its link or its subject line, must not\n * silently become mutable per workspace.\n */\n const staffWide = STAFF_NOTIFICATION_CATEGORIES.has(category)\n /*\n * Narrowest scope first, and within each scope the TYPE before its\n * CATEGORY (AGL-3251, widened to every scope by AGL-3267).\n *\n * Both halves matter and they answer different questions. Scope order is\n * \"where is this from\" — a site's answer beats its workspace's, which beats\n * the account's, because quietening one busy site is the thing the scope\n * card exists for. Type-before-category is \"what kind of thing is this\" —\n * somebody who switched `Payment failed` off said something about that\n * type, and it must beat their own broader `Billing` answer at the SAME\n * scope without leaking upward past a narrower one.\n */\n const layers: Array<NotificationChannelPrefs | undefined> = staffWide\n ? [\n settings?.accountTypes?.[type as AglynNotificationType],\n settings?.account?.[category],\n ]\n : [\n scope?.hostId\n ? settings?.hostTypes?.[scope.hostId]?.[type as AglynNotificationType]\n : undefined,\n scope?.hostId ? settings?.hosts?.[scope.hostId]?.[category] : undefined,\n scope?.orgId\n ? settings?.orgTypes?.[scope.orgId]?.[type as AglynNotificationType]\n : undefined,\n scope?.orgId ? settings?.orgs?.[scope.orgId]?.[category] : undefined,\n settings?.accountTypes?.[type as AglynNotificationType],\n settings?.account?.[category],\n ]\n for (const layer of layers) {\n const answer = layer?.[channel]\n if (typeof answer === 'boolean') return answer\n }\n if (channel === 'console' && notificationMuted(legacyPrefs, type))\n return false\n return NOTIFICATION_CHANNEL_DEFAULTS[category][channel]\n}\n\n/**\n * What one scope says, with no inheritance applied — what the settings page\n * renders as Inherit / On / Off, and `undefined` is Inherit.\n *\n * `scope` names the layer directly rather than being derived from a\n * notification, because the page edits a layer whether or not anything has\n * ever arrived from it.\n */\n/**\n * What the account scope says about one TYPE, with no inheritance applied\n * (AGL-3251) — `undefined` means the row follows its category.\n *\n * The page needs the unresolved answer to draw the difference between \"on\n * because Billing is on\" and \"on because you said so\": only the second can be\n * reset, and a switch that cannot show which one it is leaves a person unable\n * to get back to following the category.\n */\nexport function notificationAccountTypePref(\n settings: NotificationSettings | null | undefined,\n type: AglynNotificationType | string,\n channel: NotificationChannel,\n): boolean | undefined {\n return settings?.accountTypes?.[type as AglynNotificationType]?.[channel]\n}\n\n/**\n * Every type in a category, in the order the labels declare them\n * (AGL-3251) — what the settings page expands a category row into.\n *\n * Derived from {@link NOTIFICATION_TYPE_LABELS} rather than held as a second\n * map, so a type added there appears under its category without anybody\n * remembering to list it twice.\n */\nexport function notificationTypesInCategory(\n category: NotificationCategory,\n): AglynNotificationType[] {\n return (\n Object.keys(NOTIFICATION_TYPE_LABELS) as AglynNotificationType[]\n ).filter((type) => notificationCategory(type) === category)\n}\n\n/**\n * What ONE scope says about ONE type, with no inheritance applied\n * (AGL-3267) — `undefined` means the row follows its category at that scope.\n *\n * The scope-aware sibling of {@link notificationAccountTypePref}, which stays\n * as the account-only shorthand its callers already use.\n */\nexport function notificationScopeTypePref(\n settings: NotificationSettings | null | undefined,\n scope: { kind: 'account' } | { kind: 'org' | 'host'; id: string },\n type: AglynNotificationType | string,\n channel: NotificationChannel,\n): boolean | undefined {\n const layer =\n scope.kind === 'account'\n ? settings?.accountTypes\n : scope.kind === 'org'\n ? settings?.orgTypes?.[scope.id]\n : settings?.hostTypes?.[scope.id]\n return layer?.[type as AglynNotificationType]?.[channel]\n}\n\nexport function notificationScopePref(\n settings: NotificationSettings | null | undefined,\n scope: { kind: 'account' } | { kind: 'org' | 'host'; id: string },\n category: NotificationCategory,\n channel: NotificationChannel,\n): boolean | undefined {\n const layer =\n scope.kind === 'account'\n ? settings?.account\n : scope.kind === 'org'\n ? settings?.orgs?.[scope.id]\n : settings?.hosts?.[scope.id]\n return layer?.[category]?.[channel]\n}\n\n/**\n * The scopes this person has said something different about — what the\n * settings page lists so an override is never somewhere you have to go\n * looking for.\n *\n * Returns ids, not names: the page holds the org and host rosters and this\n * module holds no lookups. An id with no name is still worth listing, because\n * a preference about a workspace somebody left is exactly the kind of\n * leftover that is invisible until it is listed.\n */\nexport function notificationOverriddenScopes(\n settings: NotificationSettings | null | undefined,\n): { orgIds: string[]; hostIds: string[] } {\n /*\n * BOTH maps for a scope, categories and types (AGL-3267).\n *\n * Reading only the category map would make a scope whose ONLY answer is a\n * per-type one invisible here — and this list is the single place a person\n * can find an override they set months ago. Silencing one type on one busy\n * site and then being unable to find where you did it is precisely the\n * failure this function exists to prevent.\n */\n const answered = (prefs: Record<string, NotificationChannelPrefs> | undefined) =>\n Object.values(prefs ?? {}).some((channels) =>\n Object.values(channels ?? {}).some((value) => typeof value === 'boolean'),\n )\n const named = (\n categories: Record<string, NotificationCategoryPrefs> | undefined,\n types: Record<string, NotificationTypePrefs> | undefined,\n ) =>\n [\n ...new Set([\n ...Object.keys(categories ?? {}),\n ...Object.keys(types ?? {}),\n ]),\n ]\n .filter(\n (id) =>\n answered(categories?.[id] as Record<string, NotificationChannelPrefs>) ||\n answered(types?.[id] as Record<string, NotificationChannelPrefs>),\n )\n .sort()\n return {\n orgIds: named(settings?.orgs, settings?.orgTypes),\n hostIds: named(settings?.hosts, settings?.hostTypes),\n }\n}\n\n/**\n * Rewrites a stored notification link onto the current URL scheme (AGL-644).\n *\n * A notification's `link` is frozen at write time — `notifyUsers` persists\n * whatever string the emitter passed and never normalizes it. So every\n * notification written before the org-slug migration (AGL-621) and the\n * subdomain migration (AGL-622) still points at a route that no longer\n * exists, and fixing the emitters alone would leave that whole backlog dead.\n * Normalizing when the link is FOLLOWED repairs old and new alike, and keeps\n * working for emitters that haven't been migrated yet.\n *\n * Rewrites, in order:\n * - `/{hostDocId}` or `/{hostDocId}/rest` → `/{orgSlug}/hosts/{subdomain}/rest`\n * - `/org` or `/org/rest` → `/{orgSlug}/rest`\n * - `/hosts` (exactly) → `/{orgSlug}/hosts`\n *\n * Anything already canonical, user-scoped (`/manage/...`), staff (`/admin/...`)\n * or absolute is returned untouched. Every rewrite is gated on having the\n * context it needs, so an unresolvable link degrades to its stored value\n * rather than to a wrong destination.\n *\n * The host rewrite is keyed on the notification's own `hostId` rather than\n * guessing from the path shape, so it can never mistake a real first segment\n * for a doc id.\n *\n * The email copy of a notification goes through it too (AGL-3367): a button\n * in an inbox is a followed link with no console around it to repair it.\n */\nexport function normalizeNotificationLink(\n link: string | undefined | null,\n context: {\n orgSlug?: string | null\n /** The notification's `hostId` (a Firestore doc id). */\n hostId?: string | null\n /** That host's subdomain, which the current routes are keyed by. */\n hostSubdomain?: string | null\n },\n): string | undefined {\n if (!link) return undefined\n // Absolute URLs (staff broadcasts can carry them) are not ours to rewrite.\n if (!link.startsWith('/')) return link\n\n const { orgSlug, hostId, hostSubdomain } = context\n\n if (orgSlug && hostId && hostSubdomain) {\n const prefix = `/${hostId}`\n if (link === prefix || link.startsWith(`${prefix}/`)) {\n return `${buildRoute(Route.HOST_DASHBOARD, {\n orgSlug,\n host: hostSubdomain,\n })}${link.slice(prefix.length)}`\n }\n }\n\n if (orgSlug) {\n if (link === '/org' || link.startsWith('/org/')) {\n return `${buildRoute(Route.ORG_HOME, { orgSlug })}${link.slice(\n '/org'.length,\n )}`\n }\n // Only the bare list — `/hosts/{docId}` would still need a subdomain, and\n // guessing one is worse than leaving the link alone.\n if (link === '/hosts') return buildRoute(Route.HOST_LIST, { orgSlug })\n }\n\n return link\n}\n"],"names":["buildRoute","Route","NOTIFICATION_TYPE_LABELS","NOTIFICATION_CATEGORY_LABELS","billing","team","content","marketplace","support","system","staff","STAFF_NOTIFICATION_CATEGORIES","Set","NOTIFICATION_CATEGORY_DESCRIPTIONS","notificationCategory","type","prefix","String","split","includes","notificationMuted","prefs","DIGEST_PREFS_FIELD","CRM_DAILY_DIGEST_KEY","crmDailyDigestEnabled","INSIGHT_DIGESTS_FIELD","insightDigestSubscribed","value","orgId","Boolean","NOTIFICATION_SETTINGS_FIELD","NOTIFICATION_CHANNEL_DEFAULTS","console","email","NOTIFICATION_SELF_SENT_EMAIL_TYPES","notificationChannelEnabled","settings","channel","scope","legacyPrefs","category","staffWide","has","layers","accountTypes","account","hostId","hostTypes","undefined","hosts","orgTypes","orgs","layer","answer","notificationAccountTypePref","notificationTypesInCategory","Object","keys","filter","notificationScopeTypePref","kind","id","notificationScopePref","notificationOverriddenScopes","answered","values","some","channels","named","categories","types","sort","orgIds","hostIds","normalizeNotificationLink","link","context","startsWith","orgSlug","hostSubdomain","HOST_DASHBOARD","host","slice","length","ORG_HOME","HOST_LIST"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/notifications.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { ITimestamp } from '@aglyn/shared-util-timestamp'\nimport { buildRoute, Route } from './console-routes'\n\n/**\n * In-app notifications (AGL-259): per-user docs at\n * `users/{uid}/notifications/{id}`, written by the Admin SDK (emitters in\n * the API routes + the `notifyAdmins` automation step) and read/marked by\n * their owner. Types follow the common SaaS taxonomy so the console can\n * icon/group them without a registry.\n */\nexport type AglynNotificationType =\n | 'billing.invoice'\n | 'billing.paymentFailed'\n // Stripe gave up on a failed renewal and CANCELLED the subscription\n // (AGL-1877). Distinct from `paymentFailed`, which announces one failed\n // attempt inside a retry window the customer can still recover from; this\n // one is the window having closed. Measured on the test account with a\n // test clock: five attempts over 21.08 days, then\n // `cancellation_details.reason: 'payment_failed'` — and until this existed\n // the org went silently to Free at that moment, with the `past_due` banner\n // disappearing at exactly the instant the consequence arrived.\n | 'billing.subscriptionCanceled'\n | 'billing.usage'\n | 'team.invite'\n | 'team.roleChanged'\n | 'team.hostAccessGranted'\n | 'content.formSubmission'\n | 'content.booking'\n | 'content.order'\n | 'content.lowStock'\n // A CRM task was assigned to the recipient by somebody else (AGL-2599).\n // `content.` because it is the same kind of message as a form submission\n // or a booking: a thing on the site that now needs this person's hands,\n // not a change to their standing (`team.`) and not a platform notice\n // (`system.`). Someone who has muted the operational stream has said they\n // do not want to be told about work as it arrives, and a task is work.\n | 'content.taskAssigned'\n // A CRM task's reminder came due (AGL-2659): the hourly runner telling\n // the assignee, at the task's own time, that the task is due. `content.`\n // beside `taskAssigned` for the same reason it gives — work on the site,\n // not standing — and so the one mute that stops \"work arriving\" stops\n // \"work falling due\" with it. The mute governs the whole reminder, mail\n // included, unlike the digest's: a reminder is one message about one\n // task, and there is no schedule to keep separately from it.\n | 'content.taskReminder'\n // A contact, or a lead, became the recipient's to work (AGL-2618): a\n // capture the assignment rules or the site's default owner routed to\n // them, a lead somebody converted and handed to them, or an automation's\n // \"assign an owner\" step — every server path that writes `ownerUid`\n // except the recipient assigning themselves. Two types rather than one\n // because the record the person opens differs: a lead is the site's own\n // working record and a contact is the org's, and the link goes to the\n // one they will actually work. `content.`, beside `taskAssigned`, for the\n // same reason: work arriving, not standing changing.\n | 'content.contactAssigned'\n | 'content.leadAssigned'\n // The morning's CRM digest (AGL-2619): what the recipient owes today —\n // overdue and due-today tasks, and the leads nobody has worked. `content.`\n // for the reason `taskAssigned` is: it is about work on the site, and the\n // person who muted the operational stream has asked not to be told about\n // work. The digest EMAIL is governed separately, by\n // `crmDailyDigestEnabled`: the mute is a fact about the console feed and\n // the digest switch is a fact about the digest.\n | 'content.crmDailyDigest'\n // The weekly insights (AGL-2915): what a site's figures showed that week,\n // for a person who asked for them. `content.` beside the CRM digest, for the\n // reason that one gives: it is about the site's work, and the operational\n // mute governs the console notification while the person's own switch for\n // the digest governs the digest.\n | 'content.insightsDigest'\n // Marketplace review verdicts (AGL-432/653).\n | 'marketplace.review'\n // Support desk, staff audience (AGL-850): a subscriber opened or replied to\n // a ticket. Fanned out to staff-claim holders, not org members.\n | 'support.ticketOpened'\n | 'support.ticketReply'\n | 'system.announcement'\n // A live plugin version stopped passing the static verifier (AGL-1086).\n // Staff audience: bytes we told workspaces were checked now fail checks\n // that did not exist when they were approved.\n //\n // Deliberately NOT under `marketplace.` (AGL-1088). Category is the prefix,\n // categories are mutable per user, and Marketplace is the category a staff\n // member mutes to stop routine listing-review chatter — which would drop\n // this alert as collateral. `system` is the bucket nobody mutes to reduce\n // noise. The adminAudit record survives a mute either way; the timeliness\n // does not, and timeliness is the whole point of the alert.\n | 'system.pluginVerifierRegression'\n // A sign-in method was removed from the user's own account because their\n // organization turned on SSO enforcement (AGL-1129). `system.`, not\n // `team.`, for the AGL-1088 reason above: `team` is the category someone\n // mutes to stop routine roster chatter, and \"the way you sign in just\n // changed\" is not chatter — the next sign-in fails without it.\n | 'system.signInMethodRemoved'\n // Documents in a scoped collection carrying no `visibleTo` (AGL-1478).\n // Staff audience: the weekly dry run found resources that are invisible\n // to every site-scoped read, which always means a creation path forgot\n // the field. `system.` for the AGL-1088 reason above, and because the\n // collection it names may be `marketplace`-adjacent or not.\n | 'system.scopeDrift'\n // An SSO domain that WAS DNS-proven has stopped answering with our\n // challenge record, for three consecutive weekly sweeps (AGL-1210).\n // Nothing has been turned off — sign-in for that domain still routes\n // exactly as it did — and that is precisely why this has to be said out\n // loud rather than left in a log.\n //\n // `system.`, not `team.`, for the AGL-1088 reason above and one of its\n // own: the audience is the org's own admins, the subject is whether their\n // people can still sign in, and it must reach them BEFORE anybody decides\n // to revoke the routing. A category somebody mutes to quieten roster\n // chatter is the wrong place for the one warning that precedes a lockout.\n | 'system.ssoDomainUnverified'\n // A site hit the per-month form-submission abuse ceiling and further\n // submissions are being refused (AGL-1655). `system.`, not `content.`, for\n // the AGL-1088 reason above and more sharply than any of them: `content` is\n // literally the category a site owner mutes to stop routine form-submission\n // chatter, and this is the one form notification that says the form has\n // STOPPED accepting. Filing it under the muted bucket would guarantee it\n // reaches nobody on exactly the sites busy enough to trip it.\n | 'system.formSubmissionsPaused'\n // A site reached the flat platform ceiling on member accounts or on lead\n // records, so further ones are being refused (AGL-1529). `system.` for the\n // same reason as `formSubmissionsPaused` directly above, and with the same\n // sharpness: `content.` is the bucket an owner mutes to stop routine\n // sign-up and lead chatter, and this is the one notification saying the\n // sign-ups have STOPPED — muted exactly on the sites busy enough to trip it.\n | 'system.visitorRecordsPaused'\n // An outsider reported one of our sites for phishing, malware or CSAM\n // (AGL-1964). `system` for the AGL-1088 reason and, again, more sharply\n // than most: this notification goes to STAFF, not to a customer, and its\n // subject is somebody else's site. There is no bucket a recipient could\n // mute it into that would be honest — nobody has opted into being told a\n // stranger is being phished through our platform, and nobody should be able\n // to opt out of it either.\n //\n // Only the urgent categories raise one, and only on a first report. See the\n // fan-out in apps/tenant/app/api/report-abuse/route.ts for why: a flood of\n // alerts IS the flood, and the alert it would cost us is the phishing one.\n | 'system.abuseReportUrgent'\n // Something on the recipient's OWN workspace was held, flagged, locked or\n // paused (AGL-3368): an email or page held for review, a flagged domain,\n // a fraud signal on a payment, a lock and its lift. Written by\n // `notifyRiskEvent` from the risk notice catalog, which says what happened,\n // what it means and how to request a review. `system.` for the AGL-1088\n // reason: it is the one message saying the owner's work has STOPPED, and a\n // category muted to quieten routine chatter must not swallow it. It sends\n // its own email, transactional and preference-free, so it is also in\n // NOTIFICATION_SELF_SENT_EMAIL_TYPES.\n | 'system.riskNotice'\n // The §512(g) counter-notice (AGL-1983), and the one place the \"only urgent\n // categories raise a notification\" restraint above is deliberately not\n // applied. Every counter-notice carries a statutory deadline that is\n // ALREADY RUNNING when it arrives — the clock counts from the subscriber's\n // submission, not from our attention — so there is no low-value tail of\n // these to drown out the important ones, and the one that goes unread is a\n // customer locked out of their own work plus an unmet obligation under\n // §512(g)(2)(A). Raised on first submission only, like the one above, so a\n // resubmitting customer cannot re-alert.\n | 'system.dmcaCounterNotice'\n // A site crossed the per-month bandwidth abuse ceiling (AGL-2155). Two\n // audiences share one type: staff, because an uncompensated free site\n // serving six figures of page views is an incident; and the site's own\n // managers, because on free the site is now serving a capped notice and\n // nobody should learn that from a visitor.\n //\n // `system.`, not `billing.`, for the AGL-1088 reason and one specific to\n // this meter: on the free plan there is no bill, so `billing` would be the\n // one category where the message is literally never about money — and it is\n // exactly the free-tier trip that changes what visitors see.\n | 'system.bandwidthCeilingTripped'\n // A free site crossed its PLAN's included bandwidth and is now serving the\n // capped notice (AGL-2413). Distinct from the ceiling above on purpose: that\n // one is an incident at 10x the band and goes to staff first; this one is an\n // ordinary quota at 1x, concerns only the site's own managers, and its\n // remedy is an upgrade rather than an investigation. Sending both under one\n // type would tell an owner their traffic was being treated as suspected\n // abuse when it is simply a successful free site.\n | 'system.bandwidthCapEngaged'\n // A Stripe billing webhook threw AFTER its handlers had begun (AGL-2157),\n // so its side effects may be half applied and its idempotency claim is\n // being HELD — Stripe will not retry it. Staff audience: this is the one\n // failure on that route no automatic retry can make safe, because the\n // handlers behind it are not all idempotent, and a human has to reconcile.\n | 'system.billingWebhookHalfApplied'\n // A card dispute arrived that NOTHING in the platform claimed (AGL-2429):\n // no `platformRevenue` row, no storefront order, no marketplace purchase.\n // Staff audience, and the reason it needs a type of its own is that its\n // absence was the bug — the routine case (a storefront or marketplace\n // chargeback, which the plugins own) and the fault case were the same\n // silence from the route's side, so a dispute nobody handled looked\n // exactly like one somebody did.\n //\n // `system.`, not `billing.`, for the AGL-1088 reason and one specific to\n // this alert: `billing` is the category a recipient is most likely to have\n // muted as routine invoice traffic, and this is the one message on that\n // route where muting it means money moves with nobody looking.\n | 'system.disputeUnattributed'\n // An operator alert from the registry in `./operator-alerts` (AGL-3377)\n // that has no notification type of its own: a lost dispute, a failed\n // erasure, a degraded health check. One type for all of them because the\n // registry, not the taxonomy, is what names the alert; the title and body\n // say which one it is. `system.` for the AGL-1088 reason above: these are\n // the faults nobody may mute away as routine chatter.\n | 'system.operatorAlert'\n // Somebody created an account, and somebody created a workspace\n // (AGL-3225). Staff audience, and the only two types in the taxonomy whose\n // subject is the platform's own growth rather than anybody's work.\n //\n // `staff.`, and NOT `system.`, and the AGL-1088 note above is the reason\n // rather than an exception to it: `system` is the bucket nobody mutes to\n // reduce noise, which is exactly what makes it the wrong home for the two\n // routine, high-volume events in the product. A staff member must be able\n // to stop hearing about every sign-up without also dropping the verifier\n // regression and the unattributed dispute that share that bucket — and\n // before this category existed, there was nowhere to put them where that\n // was true.\n //\n // The category also tells the settings page who a row is for: `staff` is\n // rendered only to claim holders, so no customer is shown a switch for\n // notifications they could never receive.\n | 'staff.userSignedUp'\n | 'staff.orgCreated'\n // And when money moves (AGL-3267). The category was two signup types, so\n // the one thing a platform feed exists to report — revenue starting,\n // changing and stopping — was the one thing it did not.\n //\n // `staff.paymentFailed` is deliberately NOT the customer's\n // `billing.paymentFailed`: different audience, different action. The\n // customer is told to fix their card; staff are told a paying workspace is\n // about to stop paying.\n | 'staff.subscriptionStarted'\n | 'staff.subscriptionCanceled'\n | 'staff.planChanged'\n | 'staff.paymentFailed'\n\nexport interface AglynNotification {\n $id?: string\n type: AglynNotificationType\n title: string\n body?: string\n /** Console path the notification opens (e.g. a host inbox). */\n link?: string\n orgId?: string\n hostId?: string\n /**\n * The pending invitation this notification offers the recipient\n * (AGL-3402), under `orgs/{orgId}/invites`. Present only on the invitee's\n * own `team.invite`; the console opens the accept/decline dialog for it\n * instead of following `link`, and answering marks it read.\n */\n inviteId?: string\n createdAt?: ITimestamp\n /** When the owner read it; absent or null while unread. */\n readAt?: ITimestamp | null\n /**\n * Whether the owner has read it — `false` on create, `true` beside\n * `readAt` when read (AGL-3321).\n *\n * A boolean rather than `readAt: null`, because the feed filters by it\n * under its newest-first cursor. Equality on a boolean answers BOTH\n * states (`read == false`, `read == true`) beneath `orderBy('createdAt')`\n * with one composite index. A nullable timestamp answers \"unread\" by\n * equality but \"read\" only as `readAt != null`, and Firestore requires an\n * inequality's field to be the FIRST sort, which would order the feed by\n * when things were read instead of when they arrived. Absent only on\n * notifications written before the field existed, which\n * `tools/scripts/backfill-notification-read.mjs` stamps.\n */\n read?: boolean\n}\n\nexport const NOTIFICATION_TYPE_LABELS: Record<AglynNotificationType, string> = {\n 'billing.invoice': 'Invoice available',\n 'billing.paymentFailed': 'Payment failed',\n 'billing.subscriptionCanceled': 'Subscription canceled',\n 'billing.usage': 'Usage threshold',\n 'team.invite': 'Team invite',\n 'team.roleChanged': 'Role changed',\n 'team.hostAccessGranted': 'Site access granted',\n 'content.formSubmission': 'Form submission',\n 'content.booking': 'New booking',\n 'content.order': 'New order',\n 'content.lowStock': 'Low stock',\n 'content.taskAssigned': 'Task assigned to you',\n 'content.taskReminder': 'Task reminder',\n 'content.contactAssigned': 'Contact assigned to you',\n 'content.leadAssigned': 'Lead assigned to you',\n 'content.crmDailyDigest': 'Daily CRM digest',\n 'content.insightsDigest': 'Weekly insights',\n 'marketplace.review': 'Listing review',\n\n 'support.ticketOpened': 'New support ticket',\n 'support.ticketReply': 'Support ticket reply',\n 'system.announcement': 'Announcement',\n 'system.pluginVerifierRegression': 'Plugin verifier regression',\n 'system.signInMethodRemoved': 'Sign-in method removed',\n 'system.scopeDrift': 'Resources missing a sharing scope',\n 'system.ssoDomainUnverified': 'SSO domain no longer proves ownership',\n 'system.formSubmissionsPaused': 'Form submissions paused',\n 'system.visitorRecordsPaused': 'Sign-ups or leads paused',\n 'system.abuseReportUrgent': 'Urgent abuse report',\n 'system.riskNotice': 'Held, flagged or locked on your workspace',\n 'system.dmcaCounterNotice': 'DMCA counter-notice',\n 'system.bandwidthCeilingTripped': 'Bandwidth ceiling reached',\n 'system.bandwidthCapEngaged': 'Monthly traffic limit reached',\n 'system.billingWebhookHalfApplied': 'Billing webhook half applied',\n 'system.disputeUnattributed': 'Card dispute with no owner',\n 'system.operatorAlert': 'Operator alert',\n 'staff.userSignedUp': 'New account',\n 'staff.orgCreated': 'New workspace',\n 'staff.subscriptionStarted': 'New subscription',\n 'staff.subscriptionCanceled': 'Subscription canceled',\n 'staff.planChanged': 'Plan changed',\n 'staff.paymentFailed': 'Payment failed (workspace)',\n}\n\n/** Preference buckets (AGL-267): the prefix before the dot. */\nexport type NotificationCategory =\n | 'billing'\n | 'team'\n | 'content'\n | 'marketplace'\n | 'support'\n | 'system'\n // Staff-only, and shown only to staff (AGL-3225) — see\n // {@link STAFF_NOTIFICATION_CATEGORIES}.\n | 'staff'\n\nexport const NOTIFICATION_CATEGORY_LABELS: Record<\n NotificationCategory,\n string\n> = {\n billing: 'Billing',\n team: 'Team & access',\n content: 'Forms & bookings',\n marketplace: 'Marketplace',\n support: 'Support',\n system: 'Product & system',\n staff: 'Platform growth',\n}\n\n/**\n * The categories only staff can receive (AGL-3225).\n *\n * The settings page hides these rows from everybody else, because a switch\n * for mail that can never arrive is a promise the product does not keep. It\n * is presentation only: `notifyStaff` is what decides the audience, and it\n * enumerates the `staff` claim rather than reading this.\n */\nexport const STAFF_NOTIFICATION_CATEGORIES: ReadonlySet<NotificationCategory> =\n new Set<NotificationCategory>(['staff'])\n\n/**\n * What each category actually covers, in the reader's words (AGL-3251).\n *\n * The settings page listed seven bare labels and two switches, so deciding\n * whether to silence `Product & system` meant guessing what was in it. The\n * type labels beneath each row now name the contents exactly; this is the\n * one-line answer for somebody who does not want to expand anything.\n *\n * Written as what ARRIVES rather than as what the bucket is called: \"someone\n * joins, a role changes\" is checkable against your own feed in a way that\n * \"team and access events\" is not. Exhaustive `Record`, like the channel\n * defaults below — a new category cannot ship without somebody saying in\n * plain words what lands in it.\n */\nexport const NOTIFICATION_CATEGORY_DESCRIPTIONS: Record<\n NotificationCategory,\n string\n> = {\n billing:\n 'Invoices, failed payments, cancellations, and usage that crosses a plan limit.',\n team: 'Somebody joins or leaves, a role changes, or a site is shared with you.',\n content:\n 'Work arriving on your sites: form submissions, bookings, orders, low stock, and the tasks and leads assigned to you.',\n marketplace: 'Decisions on plugin listings you submitted for review.',\n support: 'New support tickets and replies on tickets you are following.',\n system:\n 'Announcements, and the faults the platform finds in your account: sign-in methods removed, traffic limits reached, billing or sharing left in a broken state.',\n staff:\n 'New accounts, new workspaces, and money moving — subscriptions starting, changing, failing and ending — across the whole platform. Only staff receive these.',\n}\n\nexport function notificationCategory(\n type: AglynNotificationType | string,\n): NotificationCategory {\n const prefix = String(type).split('.')[0]\n return (\n [\n 'billing',\n 'team',\n 'content',\n 'marketplace',\n 'support',\n 'system',\n 'staff',\n ].includes(prefix)\n ? prefix\n : 'system'\n ) as NotificationCategory\n}\n\n/**\n * Per-user mute map stored at `users/{uid}.notificationPrefs`\n * (`{ [category]: false }` mutes); absent categories stay on.\n */\nexport function notificationMuted(\n prefs: Record<string, boolean> | null | undefined,\n type: AglynNotificationType | string,\n): boolean {\n return prefs?.[notificationCategory(type)] === false\n}\n\n/**\n * The field on `users/{uid}` that holds a person's digest switches\n * (AGL-2619): `{ crmDaily: false }` turns the daily CRM digest off, and an\n * absent key leaves it on. Its own map rather than a key in\n * `notificationPrefs`, because that map is keyed by CATEGORY and read by\n * {@link notificationMuted} — a digest is a schedule a person keeps or\n * drops, not a bucket of types, and one switch governs both the console\n * notification and the email it travels with.\n */\nexport const DIGEST_PREFS_FIELD = 'digestPrefs'\n\n/**\n * The key the daily CRM digest keeps inside {@link DIGEST_PREFS_FIELD}.\n *\n * Exported so the console writes the switch through a name rather than a\n * second copy of the string (AGL-3230). The reader below and the page that\n * flips it were spelling the same literal in two files, which is how a\n * preference comes to be written under one key and read under another — and\n * it kept the plugin's vocabulary in a console page, where\n * `check:plugin-domain-in-core` rightly refuses it.\n */\nexport const CRM_DAILY_DIGEST_KEY = 'crmDaily'\n\nexport function crmDailyDigestEnabled(\n prefs: Record<string, boolean> | null | undefined,\n): boolean {\n return prefs?.[CRM_DAILY_DIGEST_KEY] !== false\n}\n\n/**\n * The field on `users/{uid}` naming the workspaces whose weekly insights a\n * person asked for (AGL-2915): `{ [orgId]: true }`. OPT-IN, unlike the CRM\n * digest — an absent key is off — because a weekly insight is generated, and a\n * generation spends the workspace's credits. The person turns it on beside the\n * answers themselves, where the plan, the release and their permission have\n * already been checked, and off again there or in Notifications; the weekly\n * sweep checks all three again before it spends anything.\n */\nexport const INSIGHT_DIGESTS_FIELD = 'insightDigests'\n\n/** Whether a person asked for a workspace's weekly insights. */\nexport function insightDigestSubscribed(\n value: Record<string, boolean> | null | undefined,\n orgId: string,\n): boolean {\n return Boolean(orgId) && value?.[orgId] === true\n}\n\n/**\n * The channels a notification can travel on (AGL-3223).\n *\n * `console` is the feed at `/manage/notifications` and the app-bar dropdown\n * that reads it — the only channel that existed. `email` is the message the\n * fan-out sends beside that doc when the recipient asked for one.\n */\nexport type NotificationChannel = 'console' | 'email'\n\n/**\n * One scope's answer for one category. **Tri-state on purpose**: `true` and\n * `false` decide, and an ABSENT key inherits from the scope above.\n *\n * Inheritance is the whole reason this is a partial rather than a pair of\n * booleans. A person who wants form submissions from one busy site and not\n * from the other five has to be able to say that about the one site without\n * restating their answer for every other category at every other scope — and\n * a two-valued leaf cannot express \"I have not said\", so every override would\n * have to be written out in full and would then stop tracking the account\n * default it was never meant to detach from.\n */\nexport interface NotificationChannelPrefs {\n console?: boolean\n email?: boolean\n}\n\nexport type NotificationCategoryPrefs = Partial<\n Record<NotificationCategory, NotificationChannelPrefs>\n>\n\n/**\n * One scope's answer for a single notification TYPE (AGL-3251), tri-state on\n * the same terms as {@link NotificationChannelPrefs}: an absent key falls\n * through to the category.\n *\n * Its own map rather than extra keys in {@link NotificationCategoryPrefs},\n * because that one is an exhaustive record keyed by category and a type key\n * inside it would type-check only by widening the thing that makes a missing\n * category a compile error.\n */\nexport type NotificationTypePrefs = Partial<\n Record<AglynNotificationType, NotificationChannelPrefs>\n>\n\n/**\n * Per-scope, per-channel notification preferences (AGL-3223), stored at\n * `users/{uid}.notificationSettings`.\n *\n * Three layers, narrowest first when resolving: the SITE a notification\n * concerns, then the WORKSPACE, then the account. Anything none of them\n * answers falls to {@link NOTIFICATION_CHANNEL_DEFAULTS}.\n *\n * ON THE USER DOCUMENT, not on `orgs/{orgId}/members/{uid}`, and that is a\n * cost decision rather than a modelling preference. `notifyUsers` already\n * does exactly one `getAll` over the recipients' user docs to read their\n * category mutes, so every layer living here means per-site preferences are\n * read for free on the fan-out's hot path. The member row would add a read\n * per recipient per notification to answer a question the document already in\n * hand could have answered.\n */\nexport interface NotificationSettings {\n account?: NotificationCategoryPrefs\n /**\n * Per-TYPE answers at the account scope (AGL-3251), consulted before\n * {@link NotificationSettings.account}'s category answer and never above a\n * narrower scope — see {@link notificationChannelEnabled}.\n *\n * ACCOUNT ONLY, deliberately. The per-workspace and per-site card is\n * already seven categories by three states by two channels, and a type row\n * for each of the ~thirty types, per workspace AND per site, is a page\n * nobody can read — which is the complaint this whole issue started as.\n * The account scope is where \"stop telling me about THIS\" belongs anyway:\n * it is a statement about the kind of thing, and kinds do not vary by site.\n */\n accountTypes?: NotificationTypePrefs\n /** Keyed by org id. */\n orgs?: Record<string, NotificationCategoryPrefs>\n /** Keyed by host id — a site's own answer, narrower than its workspace's. */\n hosts?: Record<string, NotificationCategoryPrefs>\n /**\n * Per-TYPE answers at the workspace and site scopes (AGL-3267), beside the\n * category maps above rather than nested inside them.\n *\n * Parallel maps because the category maps are already stored under `orgs`\n * and `hosts` on live user documents: folding both into one\n * `{ categories, types }` object per scope would be a migration of every\n * preference anybody has set, to express something two more keys express\n * without touching a byte of what exists.\n *\n * This supersedes the account-only limit AGL-3251 shipped under. That was\n * the right call for a card nobody had used yet and the wrong one once it\n * existed: \"quiet this one type down on this one busy site\" is the question\n * the scope card is FOR, and answering it only for whole categories made\n * the fine grain stop exactly where the noise is worst.\n */\n orgTypes?: Record<string, NotificationTypePrefs>\n hostTypes?: Record<string, NotificationTypePrefs>\n}\n\nexport const NOTIFICATION_SETTINGS_FIELD = 'notificationSettings'\n\n/**\n * What a category does when nobody has said otherwise.\n *\n * Console on, email OFF, everywhere. Email defaults off because the inbox is\n * not ours to fill: the product already sends transactional mail nobody opted\n * into — welcome, verification, invites, dunning, usage alerts — and every one\n * of those leaves on the same domain a customer's password reset depends on.\n * Turning a busy site's form submissions into mail by default would put that\n * domain's reputation behind traffic the recipient never asked for.\n *\n * Exhaustive `Record` deliberately: a new {@link NotificationCategory} is a\n * compile error here until somebody decides what it does by default, which is\n * the one question a new category must not be able to ship without answering.\n */\nexport const NOTIFICATION_CHANNEL_DEFAULTS: Record<\n NotificationCategory,\n Record<NotificationChannel, boolean>\n> = {\n billing: { console: true, email: false },\n team: { console: true, email: false },\n content: { console: true, email: false },\n marketplace: { console: true, email: false },\n support: { console: true, email: false },\n system: { console: true, email: false },\n // Console ON, because the complaint this answers is that staff never heard\n // about a sign-up at all; email off, like everything else, because that is\n // what the channel defaults to and a sign-up is not urgent enough to be the\n // exception that starts filling inboxes by default.\n staff: { console: true, email: false },\n}\n\n/**\n * The types that send their OWN email and must never be mailed again by the\n * generic channel (AGL-3224).\n *\n * Both digests compose a message the fan-out could not reproduce — a day's\n * owed tasks, a week's figures — and send it from their own route under their\n * own switch (`digestPrefs.crmDaily`, `insightDigests.{orgId}`). A recipient\n * who switches the `content` email channel on would otherwise receive the\n * digest twice: once as the digest, once as a one-line \"Daily CRM digest\"\n * notification saying that the digest happened.\n */\nexport const NOTIFICATION_SELF_SENT_EMAIL_TYPES: ReadonlySet<string> =\n new Set<AglynNotificationType>([\n 'content.crmDailyDigest',\n 'content.insightsDigest',\n // Emailed by `notifyRiskEvent` itself, as account mail (AGL-3368).\n 'system.riskNotice',\n ])\n\n/**\n * Whether a channel is on for one notification, at its own scope.\n *\n * Resolution order, first answer wins: the notification's SITE, its\n * WORKSPACE, the account, then {@link NOTIFICATION_CHANNEL_DEFAULTS}. A scope\n * that holds no entry for the category, or an entry with the channel key\n * absent, does not answer — see {@link NotificationChannelPrefs}.\n *\n * `legacyPrefs` is the flat `notificationPrefs` mute map this replaces\n * (AGL-267), and it sits BELOW the account layer rather than beside it.\n * Nothing migrates it: a person who never opens the new settings page keeps\n * being governed by the mutes they set years ago, and the first thing they do\n * set on the account layer overrides the old map for that category without\n * disturbing the rest of it. It answers for `console` only, because the map\n * predates there being a second channel and reading a console mute as an\n * email preference would be inventing an answer its author never gave.\n */\nexport function notificationChannelEnabled(\n settings: NotificationSettings | null | undefined,\n channel: NotificationChannel,\n type: AglynNotificationType | string,\n scope?: { orgId?: string | null; hostId?: string | null },\n legacyPrefs?: Record<string, boolean> | null,\n): boolean {\n const category = notificationCategory(type)\n /*\n * ⛔ A STAFF NOTIFICATION HAS NO SCOPE TO BE NARROWED BY (AGL-3267).\n *\n * It is about the platform, and the workspace one MENTIONS is its subject,\n * not its audience: the staff member reading \"Acme Co subscribed\" is\n * almost never a member of Acme Co, and if they happen to be, their\n * preferences for their own membership have nothing to do with it.\n *\n * This was already true by accident — no staff emitter passes `orgId` or\n * `hostId`, so the scope layers never matched — which made every\n * \"Platform growth\" row in the per-workspace card a control that could be\n * set and could never do anything. Stating it here rather than only hiding\n * those rows is the difference between the model being right and the UI\n * covering for a model that is wrong: a `staff.*` type that ever does\n * start carrying an `orgId`, for its link or its subject line, must not\n * silently become mutable per workspace.\n */\n const staffWide = STAFF_NOTIFICATION_CATEGORIES.has(category)\n /*\n * Narrowest scope first, and within each scope the TYPE before its\n * CATEGORY (AGL-3251, widened to every scope by AGL-3267).\n *\n * Both halves matter and they answer different questions. Scope order is\n * \"where is this from\" — a site's answer beats its workspace's, which beats\n * the account's, because quietening one busy site is the thing the scope\n * card exists for. Type-before-category is \"what kind of thing is this\" —\n * somebody who switched `Payment failed` off said something about that\n * type, and it must beat their own broader `Billing` answer at the SAME\n * scope without leaking upward past a narrower one.\n */\n const layers: Array<NotificationChannelPrefs | undefined> = staffWide\n ? [\n settings?.accountTypes?.[type as AglynNotificationType],\n settings?.account?.[category],\n ]\n : [\n scope?.hostId\n ? settings?.hostTypes?.[scope.hostId]?.[type as AglynNotificationType]\n : undefined,\n scope?.hostId ? settings?.hosts?.[scope.hostId]?.[category] : undefined,\n scope?.orgId\n ? settings?.orgTypes?.[scope.orgId]?.[type as AglynNotificationType]\n : undefined,\n scope?.orgId ? settings?.orgs?.[scope.orgId]?.[category] : undefined,\n settings?.accountTypes?.[type as AglynNotificationType],\n settings?.account?.[category],\n ]\n for (const layer of layers) {\n const answer = layer?.[channel]\n if (typeof answer === 'boolean') return answer\n }\n if (channel === 'console' && notificationMuted(legacyPrefs, type))\n return false\n return NOTIFICATION_CHANNEL_DEFAULTS[category][channel]\n}\n\n/**\n * What one scope says, with no inheritance applied — what the settings page\n * renders as Inherit / On / Off, and `undefined` is Inherit.\n *\n * `scope` names the layer directly rather than being derived from a\n * notification, because the page edits a layer whether or not anything has\n * ever arrived from it.\n */\n/**\n * What the account scope says about one TYPE, with no inheritance applied\n * (AGL-3251) — `undefined` means the row follows its category.\n *\n * The page needs the unresolved answer to draw the difference between \"on\n * because Billing is on\" and \"on because you said so\": only the second can be\n * reset, and a switch that cannot show which one it is leaves a person unable\n * to get back to following the category.\n */\nexport function notificationAccountTypePref(\n settings: NotificationSettings | null | undefined,\n type: AglynNotificationType | string,\n channel: NotificationChannel,\n): boolean | undefined {\n return settings?.accountTypes?.[type as AglynNotificationType]?.[channel]\n}\n\n/**\n * Every type in a category, in the order the labels declare them\n * (AGL-3251) — what the settings page expands a category row into.\n *\n * Derived from {@link NOTIFICATION_TYPE_LABELS} rather than held as a second\n * map, so a type added there appears under its category without anybody\n * remembering to list it twice.\n */\nexport function notificationTypesInCategory(\n category: NotificationCategory,\n): AglynNotificationType[] {\n return (\n Object.keys(NOTIFICATION_TYPE_LABELS) as AglynNotificationType[]\n ).filter((type) => notificationCategory(type) === category)\n}\n\n/**\n * What ONE scope says about ONE type, with no inheritance applied\n * (AGL-3267) — `undefined` means the row follows its category at that scope.\n *\n * The scope-aware sibling of {@link notificationAccountTypePref}, which stays\n * as the account-only shorthand its callers already use.\n */\nexport function notificationScopeTypePref(\n settings: NotificationSettings | null | undefined,\n scope: { kind: 'account' } | { kind: 'org' | 'host'; id: string },\n type: AglynNotificationType | string,\n channel: NotificationChannel,\n): boolean | undefined {\n const layer =\n scope.kind === 'account'\n ? settings?.accountTypes\n : scope.kind === 'org'\n ? settings?.orgTypes?.[scope.id]\n : settings?.hostTypes?.[scope.id]\n return layer?.[type as AglynNotificationType]?.[channel]\n}\n\nexport function notificationScopePref(\n settings: NotificationSettings | null | undefined,\n scope: { kind: 'account' } | { kind: 'org' | 'host'; id: string },\n category: NotificationCategory,\n channel: NotificationChannel,\n): boolean | undefined {\n const layer =\n scope.kind === 'account'\n ? settings?.account\n : scope.kind === 'org'\n ? settings?.orgs?.[scope.id]\n : settings?.hosts?.[scope.id]\n return layer?.[category]?.[channel]\n}\n\n/**\n * The scopes this person has said something different about — what the\n * settings page lists so an override is never somewhere you have to go\n * looking for.\n *\n * Returns ids, not names: the page holds the org and host rosters and this\n * module holds no lookups. An id with no name is still worth listing, because\n * a preference about a workspace somebody left is exactly the kind of\n * leftover that is invisible until it is listed.\n */\nexport function notificationOverriddenScopes(\n settings: NotificationSettings | null | undefined,\n): { orgIds: string[]; hostIds: string[] } {\n /*\n * BOTH maps for a scope, categories and types (AGL-3267).\n *\n * Reading only the category map would make a scope whose ONLY answer is a\n * per-type one invisible here — and this list is the single place a person\n * can find an override they set months ago. Silencing one type on one busy\n * site and then being unable to find where you did it is precisely the\n * failure this function exists to prevent.\n */\n const answered = (prefs: Record<string, NotificationChannelPrefs> | undefined) =>\n Object.values(prefs ?? {}).some((channels) =>\n Object.values(channels ?? {}).some((value) => typeof value === 'boolean'),\n )\n const named = (\n categories: Record<string, NotificationCategoryPrefs> | undefined,\n types: Record<string, NotificationTypePrefs> | undefined,\n ) =>\n [\n ...new Set([\n ...Object.keys(categories ?? {}),\n ...Object.keys(types ?? {}),\n ]),\n ]\n .filter(\n (id) =>\n answered(categories?.[id] as Record<string, NotificationChannelPrefs>) ||\n answered(types?.[id] as Record<string, NotificationChannelPrefs>),\n )\n .sort()\n return {\n orgIds: named(settings?.orgs, settings?.orgTypes),\n hostIds: named(settings?.hosts, settings?.hostTypes),\n }\n}\n\n/**\n * Rewrites a stored notification link onto the current URL scheme (AGL-644).\n *\n * A notification's `link` is frozen at write time — `notifyUsers` persists\n * whatever string the emitter passed and never normalizes it. So every\n * notification written before the org-slug migration (AGL-621) and the\n * subdomain migration (AGL-622) still points at a route that no longer\n * exists, and fixing the emitters alone would leave that whole backlog dead.\n * Normalizing when the link is FOLLOWED repairs old and new alike, and keeps\n * working for emitters that haven't been migrated yet.\n *\n * Rewrites, in order:\n * - `/{hostDocId}` or `/{hostDocId}/rest` → `/{orgSlug}/hosts/{subdomain}/rest`\n * - `/org` or `/org/rest` → `/{orgSlug}/rest`\n * - `/hosts` (exactly) → `/{orgSlug}/hosts`\n *\n * Anything already canonical, user-scoped (`/manage/...`), staff (`/admin/...`)\n * or absolute is returned untouched. Every rewrite is gated on having the\n * context it needs, so an unresolvable link degrades to its stored value\n * rather than to a wrong destination.\n *\n * The host rewrite is keyed on the notification's own `hostId` rather than\n * guessing from the path shape, so it can never mistake a real first segment\n * for a doc id.\n *\n * The email copy of a notification goes through it too (AGL-3367): a button\n * in an inbox is a followed link with no console around it to repair it.\n */\nexport function normalizeNotificationLink(\n link: string | undefined | null,\n context: {\n orgSlug?: string | null\n /** The notification's `hostId` (a Firestore doc id). */\n hostId?: string | null\n /** That host's subdomain, which the current routes are keyed by. */\n hostSubdomain?: string | null\n },\n): string | undefined {\n if (!link) return undefined\n // Absolute URLs (staff broadcasts can carry them) are not ours to rewrite.\n if (!link.startsWith('/')) return link\n\n const { orgSlug, hostId, hostSubdomain } = context\n\n if (orgSlug && hostId && hostSubdomain) {\n const prefix = `/${hostId}`\n if (link === prefix || link.startsWith(`${prefix}/`)) {\n return `${buildRoute(Route.HOST_DASHBOARD, {\n orgSlug,\n host: hostSubdomain,\n })}${link.slice(prefix.length)}`\n }\n }\n\n if (orgSlug) {\n if (link === '/org' || link.startsWith('/org/')) {\n return `${buildRoute(Route.ORG_HOME, { orgSlug })}${link.slice(\n '/org'.length,\n )}`\n }\n // Only the bare list — `/hosts/{docId}` would still need a subdomain, and\n // guessing one is worse than leaving the link alone.\n if (link === '/hosts') return buildRoute(Route.HOST_LIST, { orgSlug })\n }\n\n return link\n}\n"],"names":["buildRoute","Route","NOTIFICATION_TYPE_LABELS","NOTIFICATION_CATEGORY_LABELS","billing","team","content","marketplace","support","system","staff","STAFF_NOTIFICATION_CATEGORIES","Set","NOTIFICATION_CATEGORY_DESCRIPTIONS","notificationCategory","type","prefix","String","split","includes","notificationMuted","prefs","DIGEST_PREFS_FIELD","CRM_DAILY_DIGEST_KEY","crmDailyDigestEnabled","INSIGHT_DIGESTS_FIELD","insightDigestSubscribed","value","orgId","Boolean","NOTIFICATION_SETTINGS_FIELD","NOTIFICATION_CHANNEL_DEFAULTS","console","email","NOTIFICATION_SELF_SENT_EMAIL_TYPES","notificationChannelEnabled","settings","channel","scope","legacyPrefs","category","staffWide","has","layers","accountTypes","account","hostId","hostTypes","undefined","hosts","orgTypes","orgs","layer","answer","notificationAccountTypePref","notificationTypesInCategory","Object","keys","filter","notificationScopeTypePref","kind","id","notificationScopePref","notificationOverriddenScopes","answered","values","some","channels","named","categories","types","sort","orgIds","hostIds","normalizeNotificationLink","link","context","startsWith","orgSlug","hostSubdomain","HOST_DASHBOARD","host","slice","length","ORG_HOME","HOST_LIST"],"mappings":"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"}
@@ -3363,7 +3363,7 @@ export { PLATFORM_BRANDING_PROFILE } from "./platform-brand.js";
3363
3363
  * brand is returned — so an org that white-labeled and then downgraded
3364
3364
  * reverts cleanly.
3365
3365
  */ export function resolveBrandingProfile(org) {
3366
- var _ref, _cleanBrandString, _cleanBrandString1, _cleanBrandString2, _cleanBrandString3, _cleanBrandString4, _cleanBrandString5, _cleanBrandString6, _cleanBrandString7, _cleanBrandString8;
3366
+ var _ref, _cleanBrandString, _cleanBrandString1, _cleanBrandString2, _cleanBrandString3, _cleanBrandString4, _cleanBrandString5, _cleanBrandString6, _cleanBrandString7, _cleanBrandString8, _cleanBrandString9, _cleanBrandString10;
3367
3367
  if (!checkEntitlement(org, 'whiteLabel')) return PLATFORM_BRANDING_PROFILE;
3368
3368
  const profile = (_ref = org == null ? void 0 : org.brandingProfile) != null ? _ref : {};
3369
3369
  return {
@@ -3374,8 +3374,14 @@ export { PLATFORM_BRANDING_PROFILE } from "./platform-brand.js";
3374
3374
  // which is precisely the disclosure white-label is sold to prevent. A
3375
3375
  // white-label org that set no logo shows none.
3376
3376
  logoUrl: (_cleanBrandString1 = cleanBrandString(profile.logoUrl)) != null ? _cleanBrandString1 : null,
3377
- faviconUrl: (_cleanBrandString2 = cleanBrandString(profile.faviconUrl)) != null ? _cleanBrandString2 : PLATFORM_BRANDING_PROFILE.faviconUrl,
3378
- primaryColor: (_cleanBrandString3 = cleanBrandString(profile.primaryColor)) != null ? _cleanBrandString3 : PLATFORM_BRANDING_PROFILE.primaryColor,
3377
+ // The same no-fallback rule, and no fallback to the light logo either:
3378
+ // a reader wanting "the logo for a dark ground" asks for
3379
+ // `logoDarkUrl ?? logoUrl` itself, so null here keeps "the org set a
3380
+ // dark variant" answerable.
3381
+ logoDarkUrl: (_cleanBrandString2 = cleanBrandString(profile.logoDarkUrl)) != null ? _cleanBrandString2 : null,
3382
+ faviconUrl: (_cleanBrandString3 = cleanBrandString(profile.faviconUrl)) != null ? _cleanBrandString3 : PLATFORM_BRANDING_PROFILE.faviconUrl,
3383
+ faviconDarkUrl: (_cleanBrandString4 = cleanBrandString(profile.faviconDarkUrl)) != null ? _cleanBrandString4 : null,
3384
+ primaryColor: (_cleanBrandString5 = cleanBrandString(profile.primaryColor)) != null ? _cleanBrandString5 : PLATFORM_BRANDING_PROFILE.primaryColor,
3379
3385
  // NO FALLBACK, and this is the one field that gets none (AGL-2428).
3380
3386
  //
3381
3387
  // Only a white-label org reaches this line — the guard above hands every
@@ -3391,7 +3397,7 @@ export { PLATFORM_BRANDING_PROFILE } from "./platform-brand.js";
3391
3397
  // down already means no logo. An email with a gap where a link should be
3392
3398
  // reads as broken; an email with no support line reads as plain, which
3393
3399
  // is the right appearance for an organization that has not set one.
3394
- supportUrl: (_cleanBrandString4 = cleanBrandString(profile.supportUrl)) != null ? _cleanBrandString4 : null,
3400
+ supportUrl: (_cleanBrandString6 = cleanBrandString(profile.supportUrl)) != null ? _cleanBrandString6 : null,
3395
3401
  // A white-label org has no separate "home" field to store, and the same
3396
3402
  // no-fallback rule applies for the same reason: `PLATFORM_HOME_URL` here
3397
3403
  // would put a link to the platform's marketing site on the badge of an
@@ -3400,10 +3406,10 @@ export { PLATFORM_BRANDING_PROFILE } from "./platform-brand.js";
3400
3406
  // badge already linked to before the split — so white-label badges are
3401
3407
  // unchanged, and only the platform's own badge stops opening a mail
3402
3408
  // client.
3403
- homeUrl: (_cleanBrandString5 = cleanBrandString(profile.supportUrl)) != null ? _cleanBrandString5 : null,
3404
- fromName: (_cleanBrandString6 = cleanBrandString(profile.fromName)) != null ? _cleanBrandString6 : PLATFORM_BRANDING_PROFILE.fromName,
3405
- emailLogoUrl: (_cleanBrandString7 = cleanBrandString(profile.emailLogoUrl)) != null ? _cleanBrandString7 : PLATFORM_BRANDING_PROFILE.emailLogoUrl,
3406
- customConsoleDomain: (_cleanBrandString8 = cleanBrandString(profile.customConsoleDomain)) != null ? _cleanBrandString8 : PLATFORM_BRANDING_PROFILE.customConsoleDomain
3409
+ homeUrl: (_cleanBrandString7 = cleanBrandString(profile.supportUrl)) != null ? _cleanBrandString7 : null,
3410
+ fromName: (_cleanBrandString8 = cleanBrandString(profile.fromName)) != null ? _cleanBrandString8 : PLATFORM_BRANDING_PROFILE.fromName,
3411
+ emailLogoUrl: (_cleanBrandString9 = cleanBrandString(profile.emailLogoUrl)) != null ? _cleanBrandString9 : PLATFORM_BRANDING_PROFILE.emailLogoUrl,
3412
+ customConsoleDomain: (_cleanBrandString10 = cleanBrandString(profile.customConsoleDomain)) != null ? _cleanBrandString10 : PLATFORM_BRANDING_PROFILE.customConsoleDomain
3407
3413
  };
3408
3414
  }
3409
3415
  /**