ConusAI vendor v0.1.0-beta.114
ci / gate (push) Waiting to run

This commit is contained in:
ConusAI vendor v0.1.0-beta.114
2026-11-14 09:56:45 +00:00
commit 1ce8d23c35
867 changed files with 122104 additions and 0 deletions
+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 { useDataClient } from "@conusai-kit/ui/data"
import { FormLayout, type FormValues, PageSection, TextField, usePageNavigation } from "@conusai-kit/ui/patterns"
import { Button, Field, FieldDescription, FieldGroup, FieldLabel, Input, useHydrated } from "@conusai-kit/ui/primitives"
import { 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"
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
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 ready = useHydrated()
const navigation = usePageNavigation()
const client = useDataClient()
const id = useId()
const [primary, setPrimary] = useState(brand.primary ?? "")
const [accent, setAccent] = useState(brand.accent ?? "")
const [saved, setSaved] = useState(false)
const [uploadRevision, setUploadRevision] = useState(0)
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 ({ displayName = "" }: FormValues, form: FormData): Promise<void> => {
setSaved(false)
await client.call("branding.brand.set", {
org,
displayName: emptyToNull(displayName),
primary: emptyToNull(primary),
accent: emptyToNull(accent),
})
for (const variant of cleared) await client.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 client.call("branding.logo.set", { org, variant, contentType: file.type, body: await base64Of(file) })
}
}
await navigation.refresh()
setCleared([])
setUploadRevision((revision) => revision + 1)
setSaved(true)
}
const colourField = (name: string, label: string, value: string, set: (value: string) => void) => (
<TextField
name={name}
label={label}
value={value}
onChange={(event) => set(event.currentTarget.value)}
disabled={!canEdit}
maxLength={7}
spellCheck={false}
autoComplete="off"
aria-invalid={!valid(value)}
aria-describedby={`${id}-colour-hint`}
/>
)
const fields = (
<>
<TextField
name="displayName"
label={labels.displayName}
defaultValue={brand.displayName ?? ""}
disabled={!canEdit}
maxLength={60}
/>
{colourField("primary", labels.primary, primary, setPrimary)}
{colourField("accent", labels.accent, accent, setAccent)}
<FieldDescription id={`${id}-colour-hint`}>{labels.colourHint}</FieldDescription>
{(["light", "dark"] as const).map((variant) => {
const current = variant === "light" ? brand.logoLight : brand.logoDark
const theme = variant === "light" ? labels.themeLight : labels.themeDark
return (
<Field key={variant}>
<FieldLabel>{variant === "light" ? labels.logoLight : labels.logoDark}</FieldLabel>
<Input
key={uploadRevision}
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"
variant="outline"
disabled={!ready}
onClick={() => setCleared([...cleared, variant])}
>
{labels.clearLogo.replace("{theme}", theme)}
</Button>
) : null}
</>
) : null}
</Field>
)
})}
<FieldDescription id={`${id}-logo-hint`}>{labels.logoHint}</FieldDescription>
<PageSection title={labels.logoShows}>
<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>
</PageSection>
<BrandPreview
primary={previewPrimary}
accent={previewAccent}
defaultPrimary={defaultPrimary}
labels={labels.preview}
/>
</>
)
if (!canEdit) {
return (
<>
<p role="note">{labels.readOnly}</p>
<FieldGroup>{fields}</FieldGroup>
</>
)
}
return (
<>
<FormLayout submitLabel={labels.save} submitDisabled={!valid(primary) || !valid(accent)} onSubmit={save}>
{fields}
</FormLayout>
<p role="status">{saved ? labels.saved : ""}</p>
</>
)
}
+17
View File
@@ -0,0 +1,17 @@
// The branding module owns its colour calculation; its visual comparison is a shared kit surface.
import { type BrandPreviewLabels, BrandPreview as KitBrandPreview } from "@conusai-kit/ui/patterns"
import { tenantStyle } from "../contract/style.js"
export type PreviewLabels = BrandPreviewLabels
export function BrandPreview({
primary,
accent,
defaultPrimary,
labels,
}: {
primary: string | null
accent: string | null
defaultPrimary: string
labels: PreviewLabels
}) {
return <KitBrandPreview {...tenantStyle({ primary: primary ?? defaultPrimary, accent })} labels={labels} />
}
+5
View File
@@ -0,0 +1,5 @@
// 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"
+46
View File
@@ -0,0 +1,46 @@
// 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.
import type { BrandFormLabels } from "./brand-form.js"
export type Translate = (key: string, values?: Record<string, string | number>) => string
export function brandFormLabels(t: Translate): 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"),
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"),
},
}
}