Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions services/frontend/src/domains/boards/accent.ts
Original file line number Diff line number Diff line change
@@ -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"
}
59 changes: 42 additions & 17 deletions services/frontend/src/domains/boards/adapters/boards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,22 +88,39 @@ export async function loadBoards(): Promise<Board[]> {
.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<Board | null> {
/**
* 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,
Expand All @@ -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<Board | null> {
const saved = await saveBoardOrReason(board)
return saved.ok ? saved.board : null
}

/**
Expand Down
2 changes: 2 additions & 0 deletions services/frontend/src/domains/boards/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,5 @@ export {
seatsInOrder,
UNRANKED_SEAT,
} from "./seatOrder"
export {type AccentInk, BOARD_BLUE, inkOnAccent} from "./accent"
export {nextBoardNumber, type Numbered} from "./numbering"
55 changes: 54 additions & 1 deletion services/frontend/src/domains/boards/island/BoardBand.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -114,6 +123,21 @@ onBeforeUnmount(() => observer?.disconnect())
:class="{'board-band__numeral--centred': !photo}"
data-testid="board-numeral"
>{{ numeral }}</span>

<!--
Half the history has no photograph, so the way to add one belongs in the band that is
standing in for it rather than behind a pencil somewhere else. Only where there is
none: a photograph that is wrong is replaced in the dialog, beside the crop.
-->
<button
v-if="!photo && mayAddPhoto"
class="board-band__add"
data-testid="board-band-add-photo"
type="button"
@click="emit('add-photo')"
>
Add a photograph
</button>
</div>
</section>
</template>
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading