diff --git a/services/frontend/src/domains/boards/accent.ts b/services/frontend/src/domains/boards/accent.ts new file mode 100644 index 000000000..19882c70a --- /dev/null +++ b/services/frontend/src/domains/boards/accent.ts @@ -0,0 +1,67 @@ +/** + * A board's colour, and what reads on top of it. + * + * The accent is a fill rather than an ink: it washes a band, lights a stretch of the timeline + * and backs a swatch. So the question a page has to answer is not "is this colour readable" but + * "which ink reads on it" — a pale fill takes dark text and a deep fill takes light text. The + * colour itself is painted as it was chosen, because it is the board's own. + * + * Board knowledge rather than layout knowledge, so it lives in the domain beside the other + * reading rules and imports nothing (frontend ADR-001). A colour string is the whole input, + * which is what lets the rule be read against the accents the boards actually carry. + */ + +/** + * The association's blue, which is what a board with no colour of its own is drawn in. + * + * Spelled out because a luminance cannot be read out of `var(--color-brand)`: the token in + * `styles/island.css` is the one this follows, and the two are the same colour by hand. It is + * only ever used to answer which ink pairs with the blue, never painted from here. + */ +export const BOARD_BLUE = "#3387fa" + +/** Which of the two inks reads on a fill: the near-black one, or the near-white one. */ +export type AccentInk = "light" | "dark" + +/** The two inks the island paints on a fill: `--color-void`, and the numeral's own white. */ +const DARK_INK = 0.011 +const LIGHT_INK = 0.905 + +/** The channels of a `#rgb`, `#rrggbb` or `#rrggbbaa` colour, or nothing where none reads. */ +function channelsOf(colour: string): [number, number, number] | null { + const written = colour.trim().replace(/^#/, "") + if (!/^[0-9a-f]+$/i.test(written)) return null + if (written.length === 3) { + const [r, g, b] = [...written].map(one => Number.parseInt(one + one, 16) / 255) + return [r!, g!, b!] + } + // An alpha may ride along and nothing here reads it: a fill is painted over the page. + if (written.length !== 6 && written.length !== 8) return null + const pairs = [written.slice(0, 2), written.slice(2, 4), written.slice(4, 6)] + const [r, g, b] = pairs.map(pair => Number.parseInt(pair, 16) / 255) + return [r!, g!, b!] +} + +/** How bright a colour is to an eye, on the scale WCAG's contrast ratio is built on. */ +function luminance([r, g, b]: [number, number, number]): number { + const linear = (one: number) => (one <= 0.04045 ? one / 12.92 : ((one + 0.055) / 1.055) ** 2.4) + return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) +} + +/** + * Which ink reads on a board's colour: whichever of the two the fill contrasts with more. + * + * That is the whole rule, and it is why it cannot be a threshold on lightness alone — the two + * inks are not equally far from the middle of the scale, so the colour where the answer turns + * over is where their two ratios cross rather than at any round number. + * + * A board with no colour of its own is drawn in the association's blue, so that is what the + * blank case is answered against — as is a colour written in some notation this cannot read. + * The fill is the blue in both cases, so the pairing is right rather than merely safe. + */ +export function inkOnAccent(accent?: string | null): AccentInk { + const fill = luminance(channelsOf(accent?.trim() || BOARD_BLUE) ?? channelsOf(BOARD_BLUE)!) + const onDark = (fill + 0.05) / (DARK_INK + 0.05) + const onLight = (LIGHT_INK + 0.05) / (fill + 0.05) + return onDark >= onLight ? "dark" : "light" +} diff --git a/services/frontend/src/domains/boards/adapters/boards.ts b/services/frontend/src/domains/boards/adapters/boards.ts index a4116b921..af48d2605 100644 --- a/services/frontend/src/domains/boards/adapters/boards.ts +++ b/services/frontend/src/domains/boards/adapters/boards.ts @@ -88,22 +88,39 @@ export async function loadBoards(): Promise { .sort((left, right) => right.startDate.localeCompare(left.startDate)) } -export async function saveBoard( - board: { - id?: number - number: number - name?: string | null - candidate?: string | null - cheer?: string | null - accent?: string | null - description?: string | null - startDate: string - endDate?: string | null - image?: string | null - photo?: string | null - version?: number - }, -): Promise { +/** + * A board as it is written down: everything the api records, and the key where one exists. + * + * `candidate` is passed through rather than composed here. The column is `NOT NULL`, nothing + * reads it, and the api fills it with the board's name — or with its number where there is no + * name — for a write that carries none. A second copy of that rule on this side would be a + * second thing to keep in step. + */ +export interface BoardWrite { + id?: number + number: number + name?: string | null + candidate?: string | null + cheer?: string | null + accent?: string | null + description?: string | null + startDate: string + endDate?: string | null + image?: string | null + photo?: string | null + version?: number +} + +/** + * A board written down, or the api's own words for why it was not. + * + * A clashing number is the refusal this exists for: the api answers "Board 9 already exists", + * and a dialog that could only report that something went wrong would leave whoever typed it + * guessing at which field to change. + */ +export async function saveBoardOrReason( + board: BoardWrite, +): Promise<{ok: true; board: Board} | Refused> { const body = { number: board.number, name: board.name ?? undefined, @@ -119,7 +136,15 @@ export async function saveBoard( const res = board.id == null ? await createBoard({body}) : await updateBoard({path: {id: board.id}, body: {...body, version: board.version ?? 0}}) - return res.data ? withPictures(res.data) : null + if (res.error || !res.data) { + return {ok: false, reason: reasonFor(res.error, "That board could not be saved.")} + } + return {ok: true, board: withPictures(res.data)} +} + +export async function saveBoard(board: BoardWrite): Promise { + const saved = await saveBoardOrReason(board) + return saved.ok ? saved.board : null } /** diff --git a/services/frontend/src/domains/boards/index.ts b/services/frontend/src/domains/boards/index.ts index a4e391221..a71428b56 100644 --- a/services/frontend/src/domains/boards/index.ts +++ b/services/frontend/src/domains/boards/index.ts @@ -33,3 +33,5 @@ export { seatsInOrder, UNRANKED_SEAT, } from "./seatOrder" +export {type AccentInk, BOARD_BLUE, inkOnAccent} from "./accent" +export {nextBoardNumber, type Numbered} from "./numbering" diff --git a/services/frontend/src/domains/boards/island/BoardBand.vue b/services/frontend/src/domains/boards/island/BoardBand.vue index 664f9e447..711b5e8a2 100644 --- a/services/frontend/src/domains/boards/island/BoardBand.vue +++ b/services/frontend/src/domains/boards/island/BoardBand.vue @@ -24,8 +24,17 @@ const props = withDefaults(defineProps<{ photo?: Picture | null /** What the photograph is of, for a reader who is not being shown it. */ label?: string + /** + * Whether to offer a photograph on a board that has none. + * + * Offered in the band itself, which is where its absence is what a reader is looking at. + * Decided by the page, which knows who is reading; nothing here is a guard. + */ + mayAddPhoto?: boolean testid?: string -}>(), {photo: null, label: "", testid: "board-band"}) +}>(), {photo: null, label: "", mayAddPhoto: false, testid: "board-band"}) + +const emit = defineEmits<{(event: "add-photo"): void}>() const numeral = computed(() => romanNumeral(props.number)) const srcset = computed(() => srcsetOf(props.photo)) @@ -114,6 +123,21 @@ onBeforeUnmount(() => observer?.disconnect()) :class="{'board-band__numeral--centred': !photo}" data-testid="board-numeral" >{{ numeral }} + + + @@ -199,6 +223,35 @@ onBeforeUnmount(() => observer?.disconnect()) text-shadow: 0 2px 24px oklch(0 0 0 / 45%); } +/* + * The way to a photograph on a board that has none, under the numeral it stands beside. + * + * Written rather than drawn: the band is the largest empty space on the page and a bare plus + * in the middle of it would read as decoration. Set on the chalk of the theme, which the + * band's own fill is washed into, so it is legible whatever colour the board chose. + */ +.board-band__add { + position: absolute; + bottom: 14%; + left: 50%; + translate: -50%; + padding: 0.5rem 1.1rem; + font-family: var(--font-display); + font-size: 0.75rem; + font-style: italic; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-chalk); + cursor: pointer; + background: color-mix(in oklab, var(--color-chalk) 10%, transparent); + border: 1px solid color-mix(in oklab, var(--color-chalk) 34%, transparent); + clip-path: polygon(10px 0, 100% 0, calc(100% - 10px) 100%, 0 100%); +} + +.board-band__add:hover { + background: color-mix(in oklab, var(--color-chalk) 20%, transparent); +} + /* No photograph, no lower left to hold: the numeral is the whole band, so it takes the middle. */ .board-band__numeral--centred { bottom: auto; diff --git a/services/frontend/src/domains/boards/island/BoardDialog.vue b/services/frontend/src/domains/boards/island/BoardDialog.vue new file mode 100644 index 000000000..51955732a --- /dev/null +++ b/services/frontend/src/domains/boards/island/BoardDialog.vue @@ -0,0 +1,527 @@ + + +