This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
// The public API of the branding module (plan item AT2.6, spec §10.3): an organization's own brand
|
||||
// -- its display name, two colours and two logos -- as the end customers of the app see it. It is a
|
||||
// layer over the app brand (config/src/brand.ts) and never replaces it: layout, type and the neutral
|
||||
// tokens stay the app's, so one tenant cannot break the app's accessibility.
|
||||
//
|
||||
// The data is `organizations.branding`, a column of the tenancy module's own table; this module
|
||||
// writes it through the tenancy module's entity and owns no table of its own.
|
||||
|
||||
import { LOGO_UPLOAD_LIMIT_BYTES } from "@conusai-kit/brand/logo/check"
|
||||
import { procedure } from "@conusai-kit/core"
|
||||
import { z } from "zod"
|
||||
import { LOGO_SIZES } from "./logo.js"
|
||||
import { brandViewSchema, LOGO_VARIANTS } from "./view.js"
|
||||
|
||||
export { LOGO_SIZES, type LogoChoice, type LogoSize, logoUrl, resolveLogo, tenantLogo } from "./logo.js"
|
||||
export { type BrandView, brandViewSchema, LOGO_VARIANTS, type LogoVariant } from "./view.js"
|
||||
|
||||
/** The audit event of every change: one per call, whether a colour, a name or a logo. */
|
||||
export const BRAND_CHANGED = "branding.brand_changed"
|
||||
|
||||
const inOrg = { org: z.string() }
|
||||
|
||||
export const brandGet = procedure("branding.brand.get")
|
||||
.kind("read")
|
||||
.scope("org")
|
||||
.permission("branding.brand.read")
|
||||
.input(z.object(inOrg))
|
||||
.output(brandViewSchema)
|
||||
.contract()
|
||||
|
||||
export const brandSet = procedure("branding.brand.set")
|
||||
.kind("write")
|
||||
.scope("org")
|
||||
.permission("branding.brand.write")
|
||||
.input(
|
||||
z.object({
|
||||
...inOrg,
|
||||
// Absent leaves a field as it is; null clears it; a value sets it.
|
||||
displayName: z.string().max(60).nullable().optional(),
|
||||
primary: z.string().max(32).nullable().optional(),
|
||||
accent: z.string().max(32).nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.output(brandViewSchema)
|
||||
.audit(BRAND_CHANGED)
|
||||
.contract()
|
||||
|
||||
export const logoSet = procedure("branding.logo.set")
|
||||
.kind("effect")
|
||||
.scope("org")
|
||||
.permission("branding.brand.write")
|
||||
.input(
|
||||
z.object({
|
||||
...inOrg,
|
||||
variant: z.enum(LOGO_VARIANTS),
|
||||
contentType: z.string().max(64),
|
||||
/** The image, standard base64: a PNG or WebP up to the upload limit (it is resized), an SVG up to the stored one. */
|
||||
body: z.string().max(Math.ceil((LOGO_UPLOAD_LIMIT_BYTES * 4) / 3) + 4),
|
||||
}),
|
||||
)
|
||||
.output(brandViewSchema)
|
||||
.audit(BRAND_CHANGED)
|
||||
.contract()
|
||||
|
||||
// Removing a logo deletes its stored files, which only an effect may do, so it is not a field of `brand.set`.
|
||||
export const logoClear = procedure("branding.logo.clear")
|
||||
.kind("effect")
|
||||
.scope("org")
|
||||
.permission("branding.brand.write")
|
||||
.input(z.object({ ...inOrg, variant: z.enum(LOGO_VARIANTS) }))
|
||||
.output(brandViewSchema)
|
||||
.audit(BRAND_CHANGED)
|
||||
.contract()
|
||||
|
||||
export { tenantStyle } from "./style.js"
|
||||
|
||||
export const logoGet = procedure("branding.logo.get")
|
||||
.kind("effect")
|
||||
.scope("org")
|
||||
.permission("branding.brand.read")
|
||||
// `size` picks the 1x or the 2x rendition of a raster logo (2x when absent); an SVG is one file and has one size.
|
||||
.input(z.object({ ...inOrg, variant: z.enum(LOGO_VARIANTS), size: z.enum(LOGO_SIZES).optional() }))
|
||||
.output(z.object({ contentType: z.string(), body: z.string() }))
|
||||
.contract()
|
||||
@@ -0,0 +1,52 @@
|
||||
// Which logo an organization's pages show, and where one is fetched from. A file of the contract
|
||||
// because the organization layout, the settings page's preview and the share image and email all
|
||||
// need the same answer, and a rule that three places each re-derive is a rule that drifts.
|
||||
//
|
||||
// An organization has up to two logos, one for each theme: `light` is the logo for a light page,
|
||||
// `dark` the one for a dark page. The fallback is one sentence and this file is where it is kept:
|
||||
//
|
||||
// A theme shows its own logo. When only one is uploaded it shows on both themes. When none is,
|
||||
// the app brand's logo for that theme shows (config/src/brand.ts).
|
||||
//
|
||||
// The first two sentences are `tenantLogo`; the third is what `resolveLogo` adds.
|
||||
|
||||
import type { BrandView, LogoVariant } from "./view.js"
|
||||
|
||||
/** The two stored sizes of a raster logo, 1x and 2x (`@conusai-kit/brand/logo`'s standard set). */
|
||||
export const LOGO_SIZES = ["1x", "2x"] as const
|
||||
export type LogoSize = (typeof LOGO_SIZES)[number]
|
||||
|
||||
export interface LogoChoice {
|
||||
/** Whose logo it is: the organization's own, or the app brand's. */
|
||||
readonly source: "tenant" | "app"
|
||||
/** Which of that source's two logos was chosen; it differs from the theme asked for when the other one stood in. */
|
||||
readonly variant: LogoVariant
|
||||
/** The path of that logo on this app's origin. */
|
||||
readonly path: string
|
||||
}
|
||||
|
||||
const other = (variant: LogoVariant): LogoVariant => (variant === "light" ? "dark" : "light")
|
||||
|
||||
const pathOf = (brand: Pick<BrandView, "logoLight" | "logoDark">, variant: LogoVariant): string | null =>
|
||||
variant === "light" ? brand.logoLight : brand.logoDark
|
||||
|
||||
/** The organization's logo for a theme: its own, else the other theme's, else null. */
|
||||
export function tenantLogo(brand: Pick<BrandView, "logoLight" | "logoDark">, theme: LogoVariant): LogoChoice | null {
|
||||
for (const variant of [theme, other(theme)]) {
|
||||
const path = pathOf(brand, variant)
|
||||
if (path !== null) return { source: "tenant", variant, path }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The logo for a theme: the organization's (`tenantLogo`), or, when it has none, the app brand's own for that theme. */
|
||||
export function resolveLogo(
|
||||
brand: Pick<BrandView, "logoLight" | "logoDark"> | null,
|
||||
app: Readonly<Record<LogoVariant, string>>,
|
||||
theme: LogoVariant,
|
||||
): LogoChoice {
|
||||
return (brand === null ? null : tenantLogo(brand, theme)) ?? { source: "app", variant: theme, path: app[theme] }
|
||||
}
|
||||
|
||||
/** The address of one stored size of an organization's logo: its path, which carries the content tag, and the size. */
|
||||
export const logoUrl = (path: string, size: LogoSize): string => `${path}${path.includes("?") ? "&" : "?"}s=${size}`
|
||||
@@ -0,0 +1,18 @@
|
||||
// An organization's brand as the stylesheet layer it becomes, and the numbers behind it. A file of
|
||||
// the contract because both halves of the module use it: the organization layout writes the style on
|
||||
// the server (so the first paint is already branded), and the settings page's preview computes the
|
||||
// same thing in the browser as a person chooses a colour. One function, so the preview cannot say
|
||||
// something the page then does not do.
|
||||
|
||||
import { type BrandReport, themeStyle } from "@conusai-kit/brand"
|
||||
import type { BrandView } from "./view.js"
|
||||
|
||||
export function tenantStyle(brand: Pick<BrandView, "primary" | "accent">): {
|
||||
style: Record<string, string>
|
||||
report: BrandReport
|
||||
} {
|
||||
return themeStyle({
|
||||
...(brand.primary === null ? {} : { primary: brand.primary }),
|
||||
...(brand.accent === null ? {} : { accent: brand.accent }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// The shapes both halves of the branding contract use: the procedures (index.ts) and the stylesheet
|
||||
// layer a brand becomes (style.ts). A file of its own so neither imports the other.
|
||||
|
||||
import { z } from "zod"
|
||||
|
||||
export const LOGO_VARIANTS = ["light", "dark"] as const
|
||||
export type LogoVariant = (typeof LOGO_VARIANTS)[number]
|
||||
|
||||
/** `null` is "not set": the app's own brand shows through. */
|
||||
export const brandViewSchema = z.object({
|
||||
displayName: z.string().nullable(),
|
||||
primary: z.string().nullable(),
|
||||
accent: z.string().nullable(),
|
||||
logoLight: z.string().nullable(),
|
||||
logoDark: z.string().nullable(),
|
||||
})
|
||||
|
||||
export type BrandView = z.output<typeof brandViewSchema>
|
||||
Reference in New Issue
Block a user