ConusAI vendor v0.1.0-dev.20261027+9b1131fb
ci / gate (push) Waiting to run

This commit is contained in:
ConusAI vendor v0.1.0-dev.20261027+9b1131fb
2026-10-27 18:05:17 +00:00
commit 3102273ec7
538 changed files with 80203 additions and 0 deletions
+54
View File
@@ -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")
})
})
+56
View File
@@ -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]
+151
View File
@@ -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 })
}
+84
View File
@@ -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()
+52
View File
@@ -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}`
+18
View File
@@ -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 }),
})
}
+18
View File
@@ -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>
+64
View File
@@ -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)
})
}
+185
View File
@@ -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")
}
+41
View File
@@ -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
+5
View File
@@ -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"
+44
View File
@@ -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.",
},
}
+6
View File
@@ -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"
+32
View File
@@ -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 },
})
+24
View File
@@ -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 },
})
+226
View File
@@ -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>
)
}
+86
View File
@@ -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>
)
}
+6
View File
@@ -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"
+47
View File
@@ -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"),
},
}
}
+28
View File
@@ -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
}