This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
// The branding module's messages: both languages, the same keys, every key asked for, and no string
|
||||
// or colour literal in its components.
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { lintUiStrings } from "@conusai-kit/i18n"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { appModules } from "../../index.js"
|
||||
import { brandFormLabels } from "../ui/labels.js"
|
||||
|
||||
// The messages as the app has them: from the manifest, flattened and merged by the kit.
|
||||
const catalogues = buildRegistry([...appModules]).catalogues
|
||||
const en = catalogues.en?.branding ?? {}
|
||||
const lt = catalogues.lt?.branding ?? {}
|
||||
|
||||
function keysUsed(): string[] {
|
||||
const asked: string[] = []
|
||||
brandFormLabels((key) => {
|
||||
asked.push(key)
|
||||
return key
|
||||
}, "failed")
|
||||
return asked
|
||||
}
|
||||
|
||||
describe("the branding catalogues", () => {
|
||||
it("hold the same keys in English and in Lithuanian", () => {
|
||||
expect(Object.keys(lt).sort()).toEqual(Object.keys(en).sort())
|
||||
})
|
||||
|
||||
it("hold every key the form asks for, and nothing it does not (the page asks for title and intro itself)", () => {
|
||||
const asked = new Set([...keysUsed(), "title", "intro"])
|
||||
for (const key of asked) {
|
||||
expect(en[key as keyof typeof en], `en ${key}`).toBeTruthy()
|
||||
expect(lt[key as keyof typeof lt], `lt ${key}`).toBeTruthy()
|
||||
}
|
||||
expect(Object.keys(en).filter((key) => !asked.has(key))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("the components", () => {
|
||||
const dir = new URL("../ui/", import.meta.url).pathname
|
||||
const sources = readdirSync(dir)
|
||||
.filter((name) => name.endsWith(".tsx"))
|
||||
.map((name) => ({ name, text: readFileSync(join(dir, name), "utf8") }))
|
||||
|
||||
it("carry no hard-coded user-facing string and no colour literal", () => {
|
||||
expect(sources.length).toBeGreaterThanOrEqual(3)
|
||||
for (const { name, text } of sources) {
|
||||
expect(lintUiStrings(text, name), name).toEqual([])
|
||||
expect(text, name).not.toMatch(/#[0-9a-fA-F]{3,8}\b|\brgb\(|\bhsl\(|\boklch\(/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
// Which logo a theme shows when an organization has uploaded one, both, or none: the one rule the
|
||||
// layout, the settings page, the share image and the email all read (contract/logo.ts).
|
||||
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { logoUrl, resolveLogo, tenantLogo } from "../contract/logo.js"
|
||||
|
||||
const light = "/acme/brand/logo/light?v=aaaaaaaaaaaaaaaa"
|
||||
const dark = "/acme/brand/logo/dark?v=bbbbbbbbbbbbbbbb"
|
||||
const app = { light: "/app-light.svg", dark: "/app-dark.svg" }
|
||||
|
||||
describe("the logo an organization shows on a theme", () => {
|
||||
it("is its own when it has one for that theme", () => {
|
||||
const both = { logoLight: light, logoDark: dark }
|
||||
expect(tenantLogo(both, "light")).toEqual({ source: "tenant", variant: "light", path: light })
|
||||
expect(tenantLogo(both, "dark")).toEqual({ source: "tenant", variant: "dark", path: dark })
|
||||
})
|
||||
|
||||
it("is the only one it has, on both themes", () => {
|
||||
expect(tenantLogo({ logoLight: light, logoDark: null }, "dark")).toEqual({
|
||||
source: "tenant",
|
||||
variant: "light",
|
||||
path: light,
|
||||
})
|
||||
expect(tenantLogo({ logoLight: null, logoDark: dark }, "light")).toEqual({
|
||||
source: "tenant",
|
||||
variant: "dark",
|
||||
path: dark,
|
||||
})
|
||||
expect(tenantLogo({ logoLight: light, logoDark: null }, "light")?.variant).toBe("light")
|
||||
})
|
||||
|
||||
it("is none when it has none", () => {
|
||||
expect(tenantLogo({ logoLight: null, logoDark: null }, "dark")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("the logo a share image or an email shows", () => {
|
||||
it("is the organization's, with the same fallback between its two", () => {
|
||||
expect(resolveLogo({ logoLight: light, logoDark: null }, app, "dark")).toEqual({
|
||||
source: "tenant",
|
||||
variant: "light",
|
||||
path: light,
|
||||
})
|
||||
expect(resolveLogo({ logoLight: light, logoDark: dark }, app, "dark")).toEqual({
|
||||
source: "tenant",
|
||||
variant: "dark",
|
||||
path: dark,
|
||||
})
|
||||
})
|
||||
|
||||
it("is the app brand's own for that theme when the organization has none, or there is no organization", () => {
|
||||
expect(resolveLogo({ logoLight: null, logoDark: null }, app, "dark")).toEqual({
|
||||
source: "app",
|
||||
variant: "dark",
|
||||
path: "/app-dark.svg",
|
||||
})
|
||||
expect(resolveLogo(null, app, "light")).toEqual({ source: "app", variant: "light", path: "/app-light.svg" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("the address of a stored size", () => {
|
||||
it("adds the size after the content tag", () => {
|
||||
expect(logoUrl(light, "1x")).toBe(`${light}&s=1x`)
|
||||
expect(logoUrl("/acme/brand/logo/light", "2x")).toBe("/acme/brand/logo/light?s=2x")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
// The handlers of the branding procedures: each resolves its ports and calls one use case.
|
||||
|
||||
import { type Container, implement, validationFailed } from "@conusai-kit/core"
|
||||
import { DATA_SOURCE, OBJECT_STORE } from "@conusai-kit/data"
|
||||
import { brandGet, brandSet, logoClear, logoGet, logoSet } from "../contract/index.js"
|
||||
import { type BrandingDeps, clearLogo, getBrand, readLogo, setBrand, setLogo } from "../core/brand.js"
|
||||
|
||||
export const brandingDeps = (container: Container): BrandingDeps => ({
|
||||
data: container.resolve(DATA_SOURCE),
|
||||
store: container.resolve(OBJECT_STORE),
|
||||
})
|
||||
|
||||
/** Standard base64 to bytes, refusing anything else as a 422 that names the field. */
|
||||
function bytesOf(body: string): Uint8Array {
|
||||
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(body) || body.length % 4 !== 0) {
|
||||
throw validationFailed([{ path: "body", message: "is not standard base64" }])
|
||||
}
|
||||
return Uint8Array.from(atob(body), (char) => char.charCodeAt(0))
|
||||
}
|
||||
|
||||
const toBase64 = (bytes: Uint8Array): string => {
|
||||
let binary = ""
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
export const brandGetImpl = implement(brandGet, async ({ ctx }) =>
|
||||
getBrand(brandingDeps(ctx.container).data, ctx.scope),
|
||||
)
|
||||
|
||||
export const brandSetImpl = implement(brandSet, async ({ input, ctx }) =>
|
||||
setBrand(brandingDeps(ctx.container).data, ctx.scope, {
|
||||
displayName: input.displayName,
|
||||
primary: input.primary,
|
||||
accent: input.accent,
|
||||
}),
|
||||
)
|
||||
|
||||
export const logoSetImpl = implement(logoSet, async ({ input, ctx }) =>
|
||||
setLogo(brandingDeps(ctx.container), ctx.scope, {
|
||||
variant: input.variant,
|
||||
contentType: input.contentType,
|
||||
bytes: bytesOf(input.body),
|
||||
}),
|
||||
)
|
||||
|
||||
export const logoClearImpl = implement(logoClear, async ({ input, ctx }) =>
|
||||
clearLogo(brandingDeps(ctx.container), ctx.scope, input.variant),
|
||||
)
|
||||
|
||||
export const logoGetImpl = implement(logoGet, async ({ input, ctx }) => {
|
||||
const logo = await readLogo(brandingDeps(ctx.container), ctx.scope, input.variant, input.size)
|
||||
return { contentType: logo.contentType, body: toBase64(logo.bytes) }
|
||||
})
|
||||
|
||||
export const BRANDING_PROCEDURES = [brandGetImpl, brandSetImpl, logoSetImpl, logoClearImpl, logoGetImpl]
|
||||
@@ -0,0 +1,151 @@
|
||||
// The brand as a share image and an email show it (spec §10.2, §10.3): the OG image of an app page or an
|
||||
// organization's page, and a transactional email's header. Both draw the same thing, a name and a logo, so
|
||||
// both start from `shareBrand`, which decides them once.
|
||||
//
|
||||
// The name is the organization's display name, else its own name, else the app brand's. The logo is the one
|
||||
// `resolveLogo` picks for the dark page both of them sit on (the organization's own, the organization's
|
||||
// other one when it has only one, or the app brand's), and it goes through the kit's logo checks wherever it
|
||||
// came from: an organization's logo as stored, the app brand's as a file of apps/web/public.
|
||||
//
|
||||
// A logo that cannot be drawn does not stop a card or a mail: the app brand's logo stands in, and the reason
|
||||
// is the kit's, naming a rule and not the file.
|
||||
|
||||
import { tokens } from "@conusai-kit/brand"
|
||||
import { emailHeader, emailHeaderLogo } from "@conusai-kit/brand/email"
|
||||
import type { LogoSource } from "@conusai-kit/brand/logo/check"
|
||||
import { renderOgImage } from "@conusai-kit/brand/og"
|
||||
import { type BrandView, type LogoVariant, resolveLogo } from "../contract/index.js"
|
||||
|
||||
/** What the app brand (config/src/brand.ts) contributes: its name, and the paths of its two logos. */
|
||||
export interface AppIdentity {
|
||||
readonly name: string
|
||||
readonly logo: Readonly<Record<LogoVariant, string>>
|
||||
}
|
||||
|
||||
/** Where the logos are read from: the organization's stored logo, and a file of the app's public folder. */
|
||||
export interface ShareReaders {
|
||||
tenantLogo(variant: LogoVariant): Promise<LogoSource>
|
||||
appLogo(path: string): Promise<LogoSource>
|
||||
}
|
||||
|
||||
export interface ShareBrand {
|
||||
/** The name the card or the mail carries. */
|
||||
readonly name: string
|
||||
/** The logo to draw; an organization's when it has one. */
|
||||
readonly logo: LogoSource
|
||||
/** The app brand's own logo, which stands in when `logo` cannot be drawn. */
|
||||
readonly fallback: LogoSource
|
||||
}
|
||||
|
||||
export async function shareBrand(input: {
|
||||
readonly app: AppIdentity
|
||||
/** The organization the card or mail is for, or null for the app's own. */
|
||||
readonly tenant: { readonly name: string; readonly brand: BrandView } | null
|
||||
readonly readers: ShareReaders
|
||||
/** The page the logo sits on; both a share card and a mail header are dark. */
|
||||
readonly theme?: LogoVariant
|
||||
}): Promise<ShareBrand> {
|
||||
const theme = input.theme ?? "dark"
|
||||
const choice = resolveLogo(input.tenant?.brand ?? null, input.app.logo, theme)
|
||||
const name = input.tenant?.brand.displayName ?? input.tenant?.name ?? input.app.name
|
||||
if (choice.source === "app") {
|
||||
const logo = await input.readers.appLogo(input.app.logo[theme])
|
||||
return { name, logo, fallback: logo }
|
||||
}
|
||||
const logo = await input.readers.tenantLogo(choice.variant)
|
||||
// The app brand's logo only stands in; an organization with a good logo is not stopped by a missing app file.
|
||||
const fallback = await input.readers.appLogo(input.app.logo[theme]).catch(() => logo)
|
||||
return { name, logo, fallback }
|
||||
}
|
||||
|
||||
/** True for the error the kit's renderers throw when the logo they were given is refused. */
|
||||
const isLogoRefusal = (error: unknown): boolean =>
|
||||
error instanceof Error && /the logo cannot be used/.test(error.message)
|
||||
|
||||
/** Tries the chosen logo, then the app brand's; any other failure is not about the logo and is thrown. */
|
||||
async function withLogo<T>(share: ShareBrand, draw: (logo: LogoSource) => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await draw(share.logo)
|
||||
} catch (error) {
|
||||
if (share.logo === share.fallback || !isLogoRefusal(error)) throw error
|
||||
return draw(share.fallback)
|
||||
}
|
||||
}
|
||||
|
||||
export interface ShareText {
|
||||
readonly title: string
|
||||
readonly subtitle?: string
|
||||
readonly footer?: string
|
||||
}
|
||||
|
||||
/** The share image of a page: 1200x630 PNG in the brand's treatment, the organization's logo if it has one. */
|
||||
export function renderShareImage(share: ShareBrand, text: ShareText): Promise<Uint8Array> {
|
||||
return withLogo(share, (logo) => renderOgImage({ ...text, logo }))
|
||||
}
|
||||
|
||||
export interface EmailContent {
|
||||
readonly subject: string
|
||||
/** The body, one string per paragraph, as plain text: it is escaped, never taken as markup. */
|
||||
readonly paragraphs: readonly string[]
|
||||
readonly locale?: string
|
||||
}
|
||||
|
||||
export interface EmailAttachment {
|
||||
readonly cid: string
|
||||
readonly contentType: "image/png"
|
||||
readonly content: Uint8Array
|
||||
}
|
||||
|
||||
export interface ComposedEmail {
|
||||
readonly subject: string
|
||||
readonly html: string
|
||||
readonly text: string
|
||||
/** The logo, inline: the HTML refers to it as `cid:`, so the mail names no host. */
|
||||
readonly attachments: readonly EmailAttachment[]
|
||||
}
|
||||
|
||||
/** The port an app implements to send mail (SMTP, a provider's API): this module composes and does not deliver. */
|
||||
export interface MailTransport {
|
||||
send(message: ComposedEmail & { readonly to: string }): Promise<void>
|
||||
}
|
||||
|
||||
const escapeHtml = (text: string): string => text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`)
|
||||
const LINE_BREAK = /[\r\n]/
|
||||
|
||||
/** A transactional email in the brand: the header with the logo, the paragraphs, and the name at the foot. */
|
||||
export async function composeEmail(share: ShareBrand, content: EmailContent): Promise<ComposedEmail> {
|
||||
if (LINE_BREAK.test(content.subject)) throw new Error("composeEmail: the subject is one line")
|
||||
if (content.paragraphs.length === 0) throw new Error("composeEmail: a mail has at least one paragraph")
|
||||
const logo = await withLogo(share, (source) => emailHeaderLogo(source))
|
||||
const dark = tokens.color.dark
|
||||
const fonts = `'${tokens.typography.families.sans}', ${tokens.typography.fallbacks.sans}`
|
||||
const cell = `padding:24px 32px;font-family:${fonts};color:${dark.foreground};line-height:${tokens.typography.roles.body.lineHeight};`
|
||||
const body = content.paragraphs
|
||||
.map((paragraph) => `<p style="margin:0 0 16px;">${escapeHtml(paragraph)}</p>`)
|
||||
.join("")
|
||||
const html =
|
||||
`<!doctype html><html lang="${escapeHtml(content.locale ?? "en")}"><head><meta charset="utf-8"><meta name="color-scheme" content="dark"></head>` +
|
||||
`<body style="margin:0;background-color:${dark.background};">` +
|
||||
emailHeader({ alt: share.name, aspect: logo.aspect }) +
|
||||
`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:${dark.background};">` +
|
||||
`<tr><td style="${cell}">${body}</td></tr>` +
|
||||
`<tr><td style="${cell}color:${dark.text70};font-size:14px;">${escapeHtml(share.name)}</td></tr></table></body></html>`
|
||||
return {
|
||||
subject: `${content.subject} - ${share.name}`,
|
||||
html,
|
||||
text: `${content.paragraphs.join("\n\n")}\n\n${share.name}\n`,
|
||||
attachments: [{ cid: logo.cid, contentType: logo.contentType, content: logo.content }],
|
||||
}
|
||||
}
|
||||
|
||||
/** Composes the mail and hands it to the app's transport. `to` is one address and cannot carry a header. */
|
||||
export async function sendEmail(
|
||||
transport: MailTransport,
|
||||
share: ShareBrand,
|
||||
to: string,
|
||||
content: EmailContent,
|
||||
): Promise<void> {
|
||||
if (LINE_BREAK.test(to) || !/^[^\s@,;<>]+@[^\s@,;<>]+$/.test(to))
|
||||
throw new Error("sendEmail: to is one email address")
|
||||
await transport.send({ ...(await composeEmail(share, content)), to })
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,64 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The database-only brand use cases. This file is safe to import from Convex mutations; logo
|
||||
// decoding and ObjectStore effects live in brand.ts and the Convex Node action workflow.
|
||||
|
||||
import { isHexColor } from "@conusai-kit/brand"
|
||||
import { notFound, type TenantScope, validationFailed } from "@conusai-kit/core"
|
||||
import type { DataSource } from "@conusai-kit/data"
|
||||
import { brandingSchema, organizations } from "@conusai-kit/tenancy"
|
||||
import type { z } from "zod"
|
||||
import type { BrandView } from "../contract/view.js"
|
||||
|
||||
type StoredBrand = z.output<typeof brandingSchema>
|
||||
|
||||
export interface BrandChange {
|
||||
displayName?: string | null | undefined
|
||||
primary?: string | null | undefined
|
||||
accent?: string | null | undefined
|
||||
}
|
||||
|
||||
export const viewOf = (branding: StoredBrand | null): BrandView => ({
|
||||
displayName: branding?.displayName ?? null,
|
||||
primary: branding?.primary ?? null,
|
||||
accent: branding?.accent ?? null,
|
||||
logoLight: branding?.logoLight ?? null,
|
||||
logoDark: branding?.logoDark ?? null,
|
||||
})
|
||||
|
||||
const stored = (branding: StoredBrand): StoredBrand | null => (Object.keys(branding).length === 0 ? null : branding)
|
||||
|
||||
export async function getBrand(data: DataSource, scope: TenantScope): Promise<BrandView> {
|
||||
const org = await data.global(organizations).get(scope.orgId)
|
||||
if (!org) throw notFound("no such organization")
|
||||
return viewOf(org.branding)
|
||||
}
|
||||
|
||||
export async function setBrand(data: DataSource, scope: TenantScope, change: BrandChange): Promise<BrandView> {
|
||||
const problems: Array<{ path: string; message: string }> = []
|
||||
for (const field of ["primary", "accent"] as const) {
|
||||
const value = change[field]
|
||||
if (typeof value === "string" && !isHexColor(value))
|
||||
problems.push({ path: field, message: "is a hex colour such as #1a1a8c" })
|
||||
}
|
||||
if (typeof change.displayName === "string" && change.displayName.trim().length === 0) {
|
||||
problems.push({ path: "displayName", message: "is not empty; send null to clear it" })
|
||||
}
|
||||
if (problems.length > 0) throw validationFailed(problems, "this brand cannot be saved")
|
||||
|
||||
return data.unitOfWork.run(scope, async (tx) => {
|
||||
const org = await tx.global(organizations).get(scope.orgId)
|
||||
if (!org) throw notFound("no such organization")
|
||||
const next: Record<string, string | undefined> = { ...(org.branding ?? {}) }
|
||||
const set = (key: keyof StoredBrand, value: string | null | undefined) => {
|
||||
if (value === undefined) return
|
||||
if (value === null) delete next[key]
|
||||
else next[key] = value.trim()
|
||||
}
|
||||
set("displayName", change.displayName)
|
||||
set("primary", change.primary)
|
||||
set("accent", change.accent)
|
||||
const branding = stored(brandingSchema.parse(next))
|
||||
const updated = await tx.global(organizations).update(scope.orgId, { branding }, org.version)
|
||||
return viewOf(updated.branding)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// The use cases of the branding module: read the organization's brand, change it, replace a logo,
|
||||
// and read a logo back. Everything a person may type is checked here, on the way in, because what is
|
||||
// stored is later written into a stylesheet and an image tag for every member of the organization.
|
||||
|
||||
import { type LogoRendition, logoRenditions } from "@conusai-kit/brand/logo"
|
||||
import { checkLogo, checkLogoUpload } from "@conusai-kit/brand/logo/check"
|
||||
import { conflict, notFound, type TenantScope, validationFailed } from "@conusai-kit/core"
|
||||
import type { DataSource, ObjectStore } from "@conusai-kit/data"
|
||||
import { brandingSchema, organizations } from "@conusai-kit/tenancy"
|
||||
import type { z } from "zod"
|
||||
import type { BrandView, LogoSize, LogoVariant } from "../contract/index.js"
|
||||
|
||||
export { type BrandChange, getBrand, setBrand } from "./brand-data.js"
|
||||
|
||||
export interface BrandingDeps {
|
||||
readonly data: DataSource
|
||||
readonly store: ObjectStore
|
||||
}
|
||||
|
||||
export type OrgScope = TenantScope
|
||||
|
||||
type StoredBrand = z.output<typeof brandingSchema>
|
||||
|
||||
const viewOf = (branding: StoredBrand | null): BrandView => ({
|
||||
displayName: branding?.displayName ?? null,
|
||||
primary: branding?.primary ?? null,
|
||||
accent: branding?.accent ?? null,
|
||||
logoLight: branding?.logoLight ?? null,
|
||||
logoDark: branding?.logoDark ?? null,
|
||||
})
|
||||
|
||||
const stored = (branding: StoredBrand): StoredBrand | null => (Object.keys(branding).length === 0 ? null : branding)
|
||||
|
||||
/** The 16-hex-character tag in a logo's path, which is also the middle of its storage keys. */
|
||||
const TAG = /[?&]v=([0-9a-f]{16})$/
|
||||
|
||||
/**
|
||||
* A raster logo is stored as its two renditions, `1x` and `2x` (PNGs, `@conusai-kit/brand/logo`), and never as it was
|
||||
* uploaded; an SVG is stored as uploaded, under `svg`, because the SVG rule refuses and never rewrites.
|
||||
*/
|
||||
type Kind = "1x" | "2x" | "svg"
|
||||
const KINDS: readonly Kind[] = ["1x", "2x", "svg"]
|
||||
const keyOf = (variant: LogoVariant, tag: string, kind: Kind): string => `branding/logo-${variant}-${tag}-${kind}`
|
||||
const fieldOf = (variant: LogoVariant): "logoLight" | "logoDark" => (variant === "light" ? "logoLight" : "logoDark")
|
||||
const kindOf = (rendition: LogoRendition): Kind => (rendition.scale === 1 ? "1x" : "2x")
|
||||
|
||||
async function tagOf(bytes: Uint8Array): Promise<string> {
|
||||
const copy = new Uint8Array(bytes.length)
|
||||
copy.set(bytes)
|
||||
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", copy))
|
||||
return Array.from(digest.slice(0, 8), (b) => b.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
/** Deletes the stored files behind a logo path, whichever of them exist. A failure leaves an unreferenced file at worst, so it does not fail the change. */
|
||||
async function removeLogoFiles(
|
||||
deps: BrandingDeps,
|
||||
scope: OrgScope,
|
||||
variant: LogoVariant,
|
||||
path: string | undefined,
|
||||
): Promise<void> {
|
||||
const tag = path === undefined ? undefined : TAG.exec(path)?.[1]
|
||||
if (tag === undefined) return
|
||||
for (const kind of KINDS) await deps.store.delete(scope, keyOf(variant, tag, kind)).catch(() => undefined)
|
||||
}
|
||||
|
||||
/** Removes a logo: the row stops naming it, and then its stored files go (a leftover file is unreferenced, never served). */
|
||||
export async function clearLogo(deps: BrandingDeps, scope: OrgScope, variant: LogoVariant): Promise<BrandView> {
|
||||
const field = fieldOf(variant)
|
||||
const result = await deps.data.unitOfWork.run(scope, async (tx) => {
|
||||
const org = await tx.global(organizations).get(scope.orgId)
|
||||
if (!org) throw notFound("no such organization")
|
||||
const { [field]: previous, ...rest } = org.branding ?? {}
|
||||
const branding = stored(brandingSchema.parse(rest))
|
||||
const updated = await tx.global(organizations).update(scope.orgId, { branding }, org.version)
|
||||
return { view: viewOf(updated.branding), previous }
|
||||
})
|
||||
await removeLogoFiles(deps, scope, variant, result.previous)
|
||||
return result.view
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a logo. The upload is checked by the kit's rules (`@conusai-kit/brand/logo`): a PNG or WebP that is one, up
|
||||
* to 2 MiB, or an SVG with nothing in it that can run or fetch, up to 256 KiB. A raster file is resized and re-encoded
|
||||
* into the 1x and 2x PNGs that are stored and served (each is checked again as a stored logo before it is put); an
|
||||
* SVG is stored as it came. A file the checks refuse, or the decoder cannot draw, is a 422 that names the rule.
|
||||
*/
|
||||
export async function setLogo(
|
||||
deps: BrandingDeps,
|
||||
scope: OrgScope,
|
||||
input: { variant: LogoVariant; contentType: string; bytes: Uint8Array },
|
||||
): Promise<BrandView> {
|
||||
// The reason names a rule, never a part of the file: a rejected upload is somebody's document.
|
||||
const refused = (reason: string): never => {
|
||||
throw validationFailed([{ path: "body", message: reason }], "this logo cannot be used")
|
||||
}
|
||||
const upload = checkLogoUpload(input.contentType, input.bytes)
|
||||
if (!upload.ok) return refused(upload.reason)
|
||||
|
||||
const files: Array<{ kind: Kind; contentType: string; bytes: Uint8Array }> = []
|
||||
if (upload.kind === "svg") files.push({ kind: "svg", contentType: "image/svg+xml", bytes: input.bytes })
|
||||
else {
|
||||
const made = await logoRenditions(input.contentType, input.bytes)
|
||||
if (!made.ok) return refused(made.reason)
|
||||
for (const rendition of made.renditions) {
|
||||
const verdict = checkLogo("image/png", rendition.png)
|
||||
if (!verdict.ok) return refused(verdict.reason)
|
||||
files.push({ kind: kindOf(rendition), contentType: "image/png", bytes: rendition.png })
|
||||
}
|
||||
}
|
||||
|
||||
// The tag is the content of what is stored (the largest file): the same image uploaded twice is the same logo.
|
||||
const tag = await tagOf((files[files.length - 1] as { bytes: Uint8Array }).bytes)
|
||||
const org = await deps.data.global(organizations).get(scope.orgId)
|
||||
if (!org) throw notFound("no such organization")
|
||||
for (const file of files) {
|
||||
await deps.store.put(scope, {
|
||||
key: keyOf(input.variant, tag, file.kind),
|
||||
body: file.bytes,
|
||||
contentType: file.contentType,
|
||||
})
|
||||
}
|
||||
|
||||
const field = fieldOf(input.variant)
|
||||
const path = `/${org.slug}/brand/logo/${input.variant}?v=${tag}`
|
||||
// The same image uploaded again produces the files the row already names: those are in use, and
|
||||
// a failed update below must not remove them.
|
||||
const inUse = TAG.exec(org.branding?.[field] ?? "")?.[1] === tag
|
||||
try {
|
||||
const result = await deps.data.unitOfWork.run(scope, async (tx) => {
|
||||
const current = await tx.global(organizations).get(scope.orgId)
|
||||
if (!current) throw notFound("no such organization")
|
||||
const branding = stored(brandingSchema.parse({ ...(current.branding ?? {}), [field]: path }))
|
||||
const updated = await tx.global(organizations).update(scope.orgId, { branding }, current.version)
|
||||
return { view: viewOf(updated.branding), previous: current.branding?.[field] }
|
||||
})
|
||||
// The replaced files go once the row no longer names them (and not when the same image was uploaded again).
|
||||
if (result.previous !== path) await removeLogoFiles(deps, scope, input.variant, result.previous)
|
||||
return result.view
|
||||
} catch (error) {
|
||||
// The row never named the new files (a version conflict, a lost connection): they are
|
||||
// unreferenced the moment they were put, so they go now rather than staying as orphans.
|
||||
if (!inUse) {
|
||||
for (const file of files) {
|
||||
await deps.store.delete(scope, keyOf(input.variant, tag, file.kind)).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored logo's bytes: the size asked for (2x by default), or the SVG when that is what the organization uploaded
|
||||
* (an SVG has one size, so either size answers with it).
|
||||
*/
|
||||
export async function readLogo(
|
||||
deps: BrandingDeps,
|
||||
scope: OrgScope,
|
||||
variant: LogoVariant,
|
||||
size: LogoSize = "2x",
|
||||
): Promise<{ contentType: string; bytes: Uint8Array }> {
|
||||
const org = await deps.data.global(organizations).get(scope.orgId)
|
||||
if (!org) throw notFound("no such organization")
|
||||
const path = org.branding?.[fieldOf(variant)]
|
||||
const tag = path === undefined ? undefined : TAG.exec(path)?.[1]
|
||||
if (tag === undefined) throw notFound("this organization has no such logo")
|
||||
for (const kind of [size, "svg"] as const) {
|
||||
const object = await deps.store.get(scope, keyOf(variant, tag, kind))
|
||||
if (!object) continue
|
||||
const chunks: Uint8Array[] = []
|
||||
const reader = object.body.getReader()
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (value) chunks.push(value)
|
||||
}
|
||||
const bytes = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.length, 0))
|
||||
let at = 0
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, at)
|
||||
at += chunk.length
|
||||
}
|
||||
return { contentType: object.contentType, bytes }
|
||||
}
|
||||
throw conflict("the logo is recorded but its file is missing")
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// English messages of the branding module (spec §10.4). `__tests__/i18n.test.ts` fails when
|
||||
// lt.ts does not hold exactly the same keys.
|
||||
|
||||
export const en = {
|
||||
title: "Brand",
|
||||
intro:
|
||||
"Your organization's name, colours and logos as its members see them. The layout and typography of the app stay as they are, so a brand cannot make a page hard to read.",
|
||||
readOnly: "Only administrators can change the brand.",
|
||||
displayName: "Display name",
|
||||
primary: "Primary colour",
|
||||
accent: "Accent colour",
|
||||
colourHint: "A hex colour such as #1a1a8c. Leave empty to keep the app's own.",
|
||||
logoLight: "Logo for the light theme",
|
||||
logoDark: "Logo for the dark theme",
|
||||
logoHint:
|
||||
"PNG, WebP or SVG. A PNG or WebP up to 2 MB is resized for you; an SVG, up to 256 KB, is refused if it contains a script or a link to another file. Set only one logo and it is used on both themes.",
|
||||
currentLogo: "Current logo",
|
||||
logoShows: "Logo shown on each theme",
|
||||
logoOwn: "its own logo",
|
||||
logoOther: "the {other} logo, because this theme has none of its own",
|
||||
logoApp: "the app's logo, because no logo is set",
|
||||
clearLogo: "Remove the {theme} logo",
|
||||
themeLight: "light",
|
||||
themeDark: "dark",
|
||||
save: "Save the brand",
|
||||
saved: "The brand was saved.",
|
||||
preview: {
|
||||
title: "Preview",
|
||||
dark: "Dark theme",
|
||||
light: "Light theme",
|
||||
action: "Primary action",
|
||||
link: "A link in this colour",
|
||||
sample: "Sample text in the page's own colour.",
|
||||
fillContrast: "Text on the colour: {ratio} to 1",
|
||||
inkContrast: "The colour as text on the page: {ratio} to 1",
|
||||
edgeContrast: "The colour against the page: {ratio} to 1",
|
||||
meets: "Meets WCAG AA",
|
||||
below: "Below WCAG AA",
|
||||
adjusted: "As text, the colour is lightened or darkened so that links stay readable.",
|
||||
},
|
||||
} as const
|
||||
@@ -0,0 +1,5 @@
|
||||
// The branding module's messages, as the TypeScript objects its manifest declares (`i18n: { en, lt }`).
|
||||
// kit-core's registry carries them and kit-i18n merges them, so nothing outside the module lists them.
|
||||
|
||||
export { en } from "./en.js"
|
||||
export { lt } from "./lt.js"
|
||||
@@ -0,0 +1,44 @@
|
||||
// Lietuviški branding modulio pranešimai. `__tests__/i18n.test.ts` žlunga, kai raktai skiriasi nuo en.ts.
|
||||
|
||||
import type { en } from "./en.js"
|
||||
|
||||
type Messages<T> = { [K in keyof T]: T[K] extends string ? string : Messages<T[K]> }
|
||||
|
||||
export const lt: Messages<typeof en> = {
|
||||
title: "Prekės ženklas",
|
||||
intro:
|
||||
"Jūsų organizacijos pavadinimas, spalvos ir logotipai, kaip juos mato nariai. Programos išdėstymas ir šriftai nesikeičia, todėl prekės ženklas negali padaryti puslapio sunkiai skaitomo.",
|
||||
readOnly: "Prekės ženklą gali keisti tik administratoriai.",
|
||||
displayName: "Rodomas pavadinimas",
|
||||
primary: "Pagrindinė spalva",
|
||||
accent: "Papildoma spalva",
|
||||
colourHint: "Šešioliktainė spalva, pavyzdžiui, #1a1a8c. Palikite tuščią, jei norite programos spalvos.",
|
||||
logoLight: "Šviesios temos logotipas",
|
||||
logoDark: "Tamsios temos logotipas",
|
||||
logoHint:
|
||||
"PNG, WebP arba SVG. PNG ar WebP iki 2 MB pakeičiamas pagal dydį automatiškai; SVG iki 256 KB atmetamas, jei jame yra scenarijus arba nuoroda į kitą failą. Įkėlus tik vieną logotipą, jis naudojamas abiejose temose.",
|
||||
currentLogo: "Dabartinis logotipas",
|
||||
logoShows: "Kiekvienoje temoje rodomas logotipas",
|
||||
logoOwn: "jos pačios logotipas",
|
||||
logoOther: "{other} logotipas, nes šioje temoje savo logotipo nėra",
|
||||
logoApp: "programos logotipas, nes logotipas neįkeltas",
|
||||
clearLogo: "Pašalinti {theme} logotipą",
|
||||
themeLight: "šviesios temos",
|
||||
themeDark: "tamsios temos",
|
||||
save: "Išsaugoti prekės ženklą",
|
||||
saved: "Prekės ženklas išsaugotas.",
|
||||
preview: {
|
||||
title: "Peržiūra",
|
||||
dark: "Tamsi tema",
|
||||
light: "Šviesi tema",
|
||||
action: "Pagrindinis veiksmas",
|
||||
link: "Nuoroda šios spalvos",
|
||||
sample: "Pavyzdinis tekstas puslapio spalva.",
|
||||
fillContrast: "Tekstas ant spalvos: {ratio} su 1",
|
||||
inkContrast: "Spalva kaip tekstas puslapyje: {ratio} su 1",
|
||||
edgeContrast: "Spalva puslapio fone: {ratio} su 1",
|
||||
meets: "Atitinka WCAG AA",
|
||||
below: "Žemiau WCAG AA",
|
||||
adjusted: "Kaip tekstas spalva paryškinama arba pritemdoma, kad nuorodos išliktų įskaitomos.",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// The branding module's public entry: its manifest, its contract, and the share image and the email the
|
||||
// brand is drawn into. A page imports `./ui`.
|
||||
|
||||
export * from "./adapters/share.js"
|
||||
export * from "./contract/index.js"
|
||||
export { brandingModule } from "./module.js"
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Convex-safe half of branding. Logo decoding and Blob access run in brandingActions.ts;
|
||||
// database-only reads and writes stay in one Convex transaction through these handlers.
|
||||
|
||||
import { defineModule, implement } from "@conusai-kit/core"
|
||||
import { DATA_SOURCE } from "@conusai-kit/data"
|
||||
import { BRAND_CHANGED, brandGet, brandSet } from "./contract/index.js"
|
||||
import { getBrand, setBrand } from "./core/brand-data.js"
|
||||
import { en, lt } from "./i18n/index.js"
|
||||
|
||||
const brandGetImpl = implement(brandGet, async ({ ctx }) => getBrand(ctx.container.resolve(DATA_SOURCE), ctx.scope))
|
||||
const brandSetImpl = implement(brandSet, async ({ input, ctx }) =>
|
||||
setBrand(ctx.container.resolve(DATA_SOURCE), ctx.scope, {
|
||||
displayName: input.displayName,
|
||||
primary: input.primary,
|
||||
accent: input.accent,
|
||||
}),
|
||||
)
|
||||
|
||||
export const convexBrandingModule = defineModule({
|
||||
id: "branding",
|
||||
version: "0.1.0",
|
||||
kit: "^0.1.0",
|
||||
requires: ["tenancy"],
|
||||
permissions: {
|
||||
"branding.brand.read": "viewer",
|
||||
"branding.brand.write": "admin",
|
||||
},
|
||||
procedures: [brandGetImpl, brandSetImpl],
|
||||
audit: [BRAND_CHANGED],
|
||||
i18n: { en, lt },
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
// The branding module's manifest (plan item AT2.6). It owns no table: the brand is a column of the
|
||||
// tenancy module's `organizations`, written through that module's entity.
|
||||
|
||||
import { defineModule } from "@conusai-kit/core"
|
||||
import { BRANDING_PROCEDURES } from "./adapters/procedures.js"
|
||||
import { BRAND_CHANGED } from "./contract/index.js"
|
||||
import { en, lt } from "./i18n/index.js"
|
||||
|
||||
export const brandingModule = defineModule({
|
||||
id: "branding",
|
||||
version: "0.1.0",
|
||||
kit: "^0.1.0",
|
||||
requires: ["tenancy"],
|
||||
permissions: {
|
||||
// Every member sees the brand: it is what the product looks like to them.
|
||||
"branding.brand.read": "viewer",
|
||||
// Changing it changes the product for every member, so it is an administrator's act.
|
||||
"branding.brand.write": "admin",
|
||||
},
|
||||
procedures: BRANDING_PROCEDURES,
|
||||
audit: [BRAND_CHANGED],
|
||||
// Both languages, as objects: kit-i18n merges them from the registry (AT-D23).
|
||||
i18n: { en, lt },
|
||||
})
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client"
|
||||
|
||||
// The organization's brand settings: a name, two colours and two logos, with a preview of both themes
|
||||
// that updates as a colour is typed. Saving is two kinds of call -- `branding.brand.set` for the text
|
||||
// fields and `branding.logo.set` for each file chosen -- both administrators' acts, both audited by
|
||||
// the kernel. A member who is not an administrator sees the same page, read-only.
|
||||
//
|
||||
// A save is therefore up to five calls, and a failure in the middle leaves the earlier ones applied:
|
||||
// the calls are one transaction each, never one transaction together. An atomic
|
||||
// `branding.brand.update` (fields, clears and uploads in one procedure) is the known improvement and
|
||||
// needs a contract addition; until then a failed save is retried as a whole, which is safe because
|
||||
// every call is idempotent for the same input.
|
||||
|
||||
import { isHexColor } from "@conusai-kit/brand"
|
||||
import { type FormEvent, type ReactElement, useId, useState } from "react"
|
||||
import type { BrandView, LogoVariant } from "../contract/index.js"
|
||||
import { logoUrl, tenantLogo } from "../contract/logo.js"
|
||||
import { BrandPreview, type PreviewLabels } from "./brand-preview.js"
|
||||
import { useTransport } from "./transport.js"
|
||||
|
||||
export interface BrandFormLabels {
|
||||
readonly displayName: string
|
||||
readonly primary: string
|
||||
readonly accent: string
|
||||
readonly colourHint: string
|
||||
readonly logoLight: string
|
||||
readonly logoDark: string
|
||||
readonly logoHint: string
|
||||
readonly currentLogo: string
|
||||
/** The heading of the list that says which logo each theme shows. */
|
||||
readonly logoShows: string
|
||||
readonly logoOwn: string
|
||||
/** Contains `{other}`. */
|
||||
readonly logoOther: string
|
||||
readonly logoApp: string
|
||||
/** Contains `{theme}`. */
|
||||
readonly clearLogo: string
|
||||
readonly themeLight: string
|
||||
readonly themeDark: string
|
||||
readonly save: string
|
||||
readonly saved: string
|
||||
readonly readOnly: string
|
||||
/** The app's `app.failed` message, with `{detail}` still in it. */
|
||||
readonly failed: string
|
||||
readonly preview: PreviewLabels
|
||||
}
|
||||
|
||||
const emptyToNull = (value: string): string | null => (value.trim().length === 0 ? null : value.trim())
|
||||
|
||||
function base64Of(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onerror = () => reject(reader.error ?? new Error("the file could not be read"))
|
||||
reader.onload = () => {
|
||||
const url = String(reader.result)
|
||||
resolve(url.slice(url.indexOf(",") + 1))
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
export function BrandForm({
|
||||
org,
|
||||
brand,
|
||||
canEdit,
|
||||
defaultPrimary,
|
||||
labels,
|
||||
}: {
|
||||
org: string
|
||||
brand: BrandView
|
||||
canEdit: boolean
|
||||
defaultPrimary: string
|
||||
labels: BrandFormLabels
|
||||
}): ReactElement {
|
||||
const { call, describe } = useTransport()
|
||||
const id = useId()
|
||||
const [primary, setPrimary] = useState(brand.primary ?? "")
|
||||
const [accent, setAccent] = useState(brand.accent ?? "")
|
||||
const [state, setState] = useState<{ kind: "idle" | "busy" | "saved" } | { kind: "failed"; detail: string }>({
|
||||
kind: "idle",
|
||||
})
|
||||
const [cleared, setCleared] = useState<readonly LogoVariant[]>([])
|
||||
|
||||
// The logos as they will be once saved: a cleared one is gone, so the fallback it leaves is what the list says.
|
||||
const shown = {
|
||||
logoLight: cleared.includes("light") ? null : brand.logoLight,
|
||||
logoDark: cleared.includes("dark") ? null : brand.logoDark,
|
||||
}
|
||||
const valid = (value: string): boolean => value.trim() === "" || isHexColor(value.trim())
|
||||
const previewPrimary = isHexColor(primary.trim()) ? primary.trim() : brand.primary
|
||||
const previewAccent = isHexColor(accent.trim()) ? accent.trim() : brand.accent
|
||||
|
||||
const save = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
const form = new FormData(event.currentTarget)
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
await call("branding.brand.set", {
|
||||
org,
|
||||
displayName: emptyToNull(String(form.get("displayName") ?? "")),
|
||||
primary: emptyToNull(primary),
|
||||
accent: emptyToNull(accent),
|
||||
})
|
||||
for (const variant of cleared) await call("branding.logo.clear", { org, variant })
|
||||
for (const variant of ["light", "dark"] as const) {
|
||||
const file = form.get(`logo-${variant}`)
|
||||
if (file instanceof File && file.size > 0) {
|
||||
await call("branding.logo.set", { org, variant, contentType: file.type, body: await base64Of(file) })
|
||||
}
|
||||
}
|
||||
setState({ kind: "saved" })
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: describe(error) })
|
||||
}
|
||||
}
|
||||
|
||||
const colourField = (name: string, label: string, value: string, set: (value: string) => void) => (
|
||||
<div className="kit-field">
|
||||
<label htmlFor={`${id}-${name}`}>{label}</label>
|
||||
<input
|
||||
id={`${id}-${name}`}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={(event) => set(event.target.value)}
|
||||
disabled={!canEdit}
|
||||
maxLength={7}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
aria-invalid={!valid(value)}
|
||||
aria-describedby={`${id}-colour-hint`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<form onSubmit={save} aria-busy={state.kind === "busy"}>
|
||||
{canEdit ? null : <p role="note">{labels.readOnly}</p>}
|
||||
<div className="kit-field">
|
||||
<label htmlFor={`${id}-name`}>{labels.displayName}</label>
|
||||
<input
|
||||
id={`${id}-name`}
|
||||
name="displayName"
|
||||
defaultValue={brand.displayName ?? ""}
|
||||
disabled={!canEdit}
|
||||
maxLength={60}
|
||||
/>
|
||||
</div>
|
||||
{colourField("primary", labels.primary, primary, setPrimary)}
|
||||
{colourField("accent", labels.accent, accent, setAccent)}
|
||||
<p id={`${id}-colour-hint`}>{labels.colourHint}</p>
|
||||
{(["light", "dark"] as const).map((variant) => {
|
||||
const current = variant === "light" ? brand.logoLight : brand.logoDark
|
||||
const theme = variant === "light" ? labels.themeLight : labels.themeDark
|
||||
return (
|
||||
<div className="kit-field" key={variant}>
|
||||
<label htmlFor={`${id}-logo-${variant}`}>{variant === "light" ? labels.logoLight : labels.logoDark}</label>
|
||||
<input
|
||||
id={`${id}-logo-${variant}`}
|
||||
name={`logo-${variant}`}
|
||||
type="file"
|
||||
accept="image/png,image/webp,image/svg+xml"
|
||||
disabled={!canEdit}
|
||||
aria-describedby={`${id}-logo-hint`}
|
||||
/>
|
||||
{current && !cleared.includes(variant) ? (
|
||||
<>
|
||||
<img
|
||||
src={logoUrl(current, "1x")}
|
||||
srcSet={`${logoUrl(current, "1x")} 1x, ${logoUrl(current, "2x")} 2x`}
|
||||
alt={labels.currentLogo}
|
||||
height={32}
|
||||
/>
|
||||
{canEdit ? (
|
||||
<button type="button" onClick={() => setCleared([...cleared, variant])}>
|
||||
{labels.clearLogo.replace("{theme}", theme)}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p id={`${id}-logo-hint`}>{labels.logoHint}</p>
|
||||
<section aria-labelledby={`${id}-logo-shows`}>
|
||||
<h2 id={`${id}-logo-shows`}>{labels.logoShows}</h2>
|
||||
<ul>
|
||||
{(["light", "dark"] as const).map((theme) => {
|
||||
const choice = tenantLogo(shown, theme)
|
||||
const state =
|
||||
choice === null
|
||||
? labels.logoApp
|
||||
: choice.variant === theme
|
||||
? labels.logoOwn
|
||||
: labels.logoOther.replace(
|
||||
"{other}",
|
||||
choice.variant === "light" ? labels.themeLight : labels.themeDark,
|
||||
)
|
||||
return (
|
||||
<li key={theme}>
|
||||
{theme === "light" ? labels.preview.light : labels.preview.dark}: {state}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
<BrandPreview
|
||||
primary={previewPrimary}
|
||||
accent={previewAccent}
|
||||
defaultPrimary={defaultPrimary}
|
||||
labels={labels.preview}
|
||||
/>
|
||||
{canEdit ? (
|
||||
<button type="submit" disabled={state.kind === "busy" || !valid(primary) || !valid(accent)}>
|
||||
{labels.save}
|
||||
</button>
|
||||
) : null}
|
||||
<p role="status">{state.kind === "saved" ? labels.saved : ""}</p>
|
||||
{state.kind === "failed" ? (
|
||||
<p className="app-error" role="alert">
|
||||
{labels.failed.replace("{detail}", state.detail)}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// What a brand looks like on each theme, and whether it is legible there: the sample of a primary
|
||||
// action and of a link, drawn with the very declarations the organization layout will write, and the
|
||||
// contrast numbers in words. A colour that is chosen is shown before it is saved, so "it saves but
|
||||
// nobody can read it" is not a state a person can reach without being told.
|
||||
|
||||
import type { CSSProperties, ReactElement } from "react"
|
||||
import { tenantStyle } from "../contract/style.js"
|
||||
|
||||
export interface PreviewLabels {
|
||||
readonly title: string
|
||||
readonly dark: string
|
||||
readonly light: string
|
||||
readonly action: string
|
||||
readonly link: string
|
||||
readonly sample: string
|
||||
/** Contain `{ratio}`, replaced with the measured ratio to one decimal. */
|
||||
readonly fillContrast: string
|
||||
readonly inkContrast: string
|
||||
readonly edgeContrast: string
|
||||
readonly meets: string
|
||||
readonly below: string
|
||||
readonly adjusted: string
|
||||
}
|
||||
|
||||
/** The default brand's own fill, so a preview with no colour chosen shows what the app already has. */
|
||||
export interface PreviewProps {
|
||||
readonly primary: string | null
|
||||
readonly accent: string | null
|
||||
/** The brand package's fill, which a preview with no colour of its own shows. */
|
||||
readonly defaultPrimary: string
|
||||
readonly labels: PreviewLabels
|
||||
}
|
||||
|
||||
const AA = 4.5
|
||||
/** The 3:1 that a non-text boundary needs (WCAG 1.4.11). */
|
||||
const NON_TEXT = 3
|
||||
|
||||
const ratio = (template: string, value: number): string => template.replace("{ratio}", value.toFixed(1))
|
||||
|
||||
export function BrandPreview({ primary, accent, defaultPrimary, labels }: PreviewProps): ReactElement {
|
||||
const { style, report } = tenantStyle({ primary: primary ?? defaultPrimary, accent })
|
||||
return (
|
||||
<section aria-labelledby="brand-preview-title" className="brand-preview">
|
||||
<h2 id="brand-preview-title">{labels.title}</h2>
|
||||
<div className="brand-preview-grid">
|
||||
{(["dark", "light"] as const).map((theme) => {
|
||||
const numbers = report[theme]
|
||||
if (!numbers) return null
|
||||
const heading = `brand-preview-${theme}`
|
||||
return (
|
||||
<article
|
||||
key={theme}
|
||||
aria-labelledby={heading}
|
||||
className={theme === "light" ? "brand-preview-panel light" : "brand-preview-panel"}
|
||||
style={style as CSSProperties}
|
||||
>
|
||||
<h3 id={heading}>{theme === "light" ? labels.light : labels.dark}</h3>
|
||||
<p>{labels.sample}</p>
|
||||
<button type="button" className="brand-preview-action">
|
||||
{labels.action}
|
||||
</button>{" "}
|
||||
<a href="#brand-preview-title" className="brand-preview-link">
|
||||
{labels.link}
|
||||
</a>
|
||||
<ul>
|
||||
<li>
|
||||
{ratio(labels.fillContrast, numbers.fillContrast)} (
|
||||
{numbers.fillContrast >= AA ? labels.meets : labels.below})
|
||||
</li>
|
||||
<li>
|
||||
{ratio(labels.inkContrast, numbers.inkContrast)} (
|
||||
{numbers.inkContrast >= AA ? labels.meets : labels.below})
|
||||
</li>
|
||||
<li>
|
||||
{ratio(labels.edgeContrast, numbers.edgeContrast)} (
|
||||
{numbers.edgeContrast >= NON_TEXT ? labels.meets : labels.below})
|
||||
</li>
|
||||
</ul>
|
||||
{numbers.ink.toLowerCase() !== numbers.fill.toLowerCase() ? <p>{labels.adjusted}</p> : null}
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// The branding module's components: the settings form and the preview of both themes.
|
||||
|
||||
export { BrandForm, type BrandFormLabels } from "./brand-form.js"
|
||||
export { BrandPreview, type PreviewLabels } from "./brand-preview.js"
|
||||
export { brandFormLabels, type Translate } from "./labels.js"
|
||||
export { type DescribeError, type Transport, TransportProvider, useTransport } from "./transport.js"
|
||||
@@ -0,0 +1,47 @@
|
||||
// The labels the brand form takes, built from a message lookup (the same arrangement as the documents
|
||||
// module's labels.ts: every key is named here so a test can run the builder against both catalogues).
|
||||
// `t` takes a key relative to the module; `failed` is the app's own `app.failed`, looked up by the page.
|
||||
|
||||
import type { BrandFormLabels } from "./brand-form.js"
|
||||
|
||||
export type Translate = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
export function brandFormLabels(t: Translate, failed: string): BrandFormLabels {
|
||||
// `{theme}` and `{ratio}` are filled by the component, so they are looked up with the placeholder in place.
|
||||
const keep = (key: string, name: string) => t(key, { [name]: `{${name}}` })
|
||||
return {
|
||||
displayName: t("displayName"),
|
||||
primary: t("primary"),
|
||||
accent: t("accent"),
|
||||
colourHint: t("colourHint"),
|
||||
logoLight: t("logoLight"),
|
||||
logoDark: t("logoDark"),
|
||||
logoHint: t("logoHint"),
|
||||
currentLogo: t("currentLogo"),
|
||||
logoShows: t("logoShows"),
|
||||
logoOwn: t("logoOwn"),
|
||||
logoOther: keep("logoOther", "other"),
|
||||
logoApp: t("logoApp"),
|
||||
clearLogo: keep("clearLogo", "theme"),
|
||||
themeLight: t("themeLight"),
|
||||
themeDark: t("themeDark"),
|
||||
save: t("save"),
|
||||
saved: t("saved"),
|
||||
readOnly: t("readOnly"),
|
||||
failed,
|
||||
preview: {
|
||||
title: t("preview.title"),
|
||||
dark: t("preview.dark"),
|
||||
light: t("preview.light"),
|
||||
action: t("preview.action"),
|
||||
link: t("preview.link"),
|
||||
sample: t("preview.sample"),
|
||||
fillContrast: keep("preview.fillContrast", "ratio"),
|
||||
inkContrast: keep("preview.inkContrast", "ratio"),
|
||||
edgeContrast: keep("preview.edgeContrast", "ratio"),
|
||||
meets: t("preview.meets"),
|
||||
below: t("preview.below"),
|
||||
adjusted: t("preview.adjusted"),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
// How this module's interactive components reach the API: through a transport the app provides
|
||||
// (apps/web/src/ui/transport.tsx). A module may not import the app, and a server component cannot
|
||||
// hand a function to a client one, so the seam is a context -- the same one every module with a
|
||||
// client component declares for itself, until `@conusai-kit/ui/data` is the one shared facade.
|
||||
|
||||
import { createContext, type ReactElement, type ReactNode, useContext } from "react"
|
||||
|
||||
export type Transport = (procedure: string, input: unknown) => Promise<unknown>
|
||||
export type DescribeError = (error: unknown) => string
|
||||
|
||||
interface Provided {
|
||||
readonly call: Transport
|
||||
readonly describe: DescribeError
|
||||
}
|
||||
|
||||
const TransportContext = createContext<Provided | undefined>(undefined)
|
||||
|
||||
export function TransportProvider({ call, describe, children }: Provided & { children: ReactNode }): ReactElement {
|
||||
return <TransportContext.Provider value={{ call, describe }}>{children}</TransportContext.Provider>
|
||||
}
|
||||
|
||||
export function useTransport(): Provided {
|
||||
const provided = useContext(TransportContext)
|
||||
if (!provided) throw new Error("branding: wrap the page in a TransportProvider (apps/web/src/ui/transport.tsx)")
|
||||
return provided
|
||||
}
|
||||
Reference in New Issue
Block a user