This commit is contained in:
@@ -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