- Garden and plant dialogs keep centimeters as the source of truth (LengthField in lib/units.ts): a no-change Save no longer rewrites 900 cm as 899.922 or a 45 cm spacing as 44.958, bumping versions and writing bogus history entries on the way. - The UI stamps every date with the browser's local day (lib/dates.ts). Journal notes already did; plop placement, fill and removal now do too, so a 9 pm placement isn't "planted tomorrow". The fill endpoint gained an optional plantedAt; API and agent callers still default to UTC today. - Removing an object that holds plants asks first and says how many go with it. An empty one still goes straight away (one Undo restores it). - The expanded plant card's action row wraps instead of clipping "Delete". - Monogram lettering switches to a dark ink on pale marker colors (garlic, cabbage, marigold) instead of near-white on near-white. - Copy-as-plan proposes the next free year and warns when the typed name already exists, so two gardens can't both read as "the 2027 plan". - Plan cards show the base name with a "2027 plan" tag, so the year — the point of the name — survives truncation. - A rejected model spec now says which model and why: a wrapped ErrInvalidInput's reason reaches the client as the 400's message, and the Settings field shows it inline instead of toasting "invalid input". Also defuses a clock bomb in TestRemainingReturnsWhenAPlantingIsRemoved, which only passed while the real date was before 2026-08-01. Co-Authored-By: Claude Fable 5 <[email protected]>
95 lines
4.0 KiB
TypeScript
95 lines
4.0 KiB
TypeScript
// Plant markers are a solid circle in the plant's color with a 1–2 letter
|
||
// monogram — the design's replacement for emoji icons. The letters are derived
|
||
// from the name, never stored, so a renamed plant re-letters itself.
|
||
//
|
||
// Rule: the initial of the species (the part before a " — Variety" suffix).
|
||
// Within one catalog, plants that share an initial are told apart by a second,
|
||
// lowercase letter — Marigold → Ma, Mint → Mi. The bare initial goes to the
|
||
// plant that entered the catalog first (lowest id): the built-ins are seeded
|
||
// roughly commonest-first, so Tomato keeps T over Thyme, and a variety you add
|
||
// later ("Tomato — Cherokee Purple") gets To rather than displacing it. The
|
||
// collision set is the whole catalog, so a plant's letters are the same on
|
||
// every screen.
|
||
|
||
/** "Tomato — Cherokee Purple" → "Tomato"; "Melon (Hale's Best)" → "Melon". */
|
||
export function speciesName(name: string): string {
|
||
const cut = name.split(/\s[—–-]\s|\s\(/)[0] ?? name
|
||
return cut.trim() || name.trim()
|
||
}
|
||
|
||
function letters(name: string): string[] {
|
||
// Letters only; a leading digit or punctuation would make an unreadable mark.
|
||
return speciesName(name)
|
||
.normalize('NFD')
|
||
.replace(/[̀-ͯ]/g, '')
|
||
.replace(/[^A-Za-z]/g, '')
|
||
.split('')
|
||
}
|
||
|
||
/** The monogram for every plant in the catalog, keyed by id. */
|
||
export function monogramMap<P extends { id: number; name: string }>(plants: readonly P[]): Map<number, string> {
|
||
const out = new Map<number, string>()
|
||
const taken = new Set<string>()
|
||
const sorted = [...plants].sort((a, b) => a.id - b.id || a.name.localeCompare(b.name))
|
||
for (const p of sorted) {
|
||
const ls = letters(p.name)
|
||
if (ls.length === 0) {
|
||
out.set(p.id, '?')
|
||
continue
|
||
}
|
||
const initial = ls[0].toUpperCase()
|
||
// Prefer the bare initial, then initial + each following letter in turn —
|
||
// skipping a repeat of the initial ("Pp" is not a mark anyone can read) —
|
||
// then give up on uniqueness rather than grow past two letters.
|
||
const candidates = [
|
||
initial,
|
||
...ls
|
||
.slice(1)
|
||
.filter((l) => l.toUpperCase() !== initial)
|
||
.map((l) => initial + l.toLowerCase()),
|
||
]
|
||
const pick = candidates.find((c) => !taken.has(c)) ?? candidates[candidates.length - 1]
|
||
taken.add(pick)
|
||
out.set(p.id, pick)
|
||
}
|
||
return out
|
||
}
|
||
|
||
/** A single plant's monogram when the catalog isn't at hand (a fallback, not the
|
||
* collision-aware mark — prefer monogramMap where a list exists). */
|
||
export function monogramFor(name: string): string {
|
||
const ls = letters(name)
|
||
return ls.length ? ls[0].toUpperCase() : '?'
|
||
}
|
||
|
||
// --- Lettering color --------------------------------------------------------
|
||
// Letters are paper on the marker's color — which reads on tomato red and sage,
|
||
// and vanishes on garlic's #d9d2c5. Pale markers take the dark marker ink
|
||
// instead. Both inks are theme-stable (the marker's own color is), so the
|
||
// choice depends only on the color, never on light/dark mode.
|
||
|
||
const PAPER = 'var(--color-paper)'
|
||
const INK = 'var(--color-marker-ink)'
|
||
// Below this relative luminance, paper still clears ~2.5:1 against the marker;
|
||
// above it, the dark ink does better. Sage (#7a8a5e, 0.23) keeps paper; cabbage
|
||
// green (#8bc98b, 0.49), marigold orange and garlic flip to ink.
|
||
const PAPER_MAX_LUMINANCE = 0.37
|
||
|
||
/** WCAG relative luminance of a #rgb / #rrggbb color; null if unparseable. */
|
||
function luminance(color: string): number | null {
|
||
const m = color.trim().match(/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i)
|
||
if (!m) return null
|
||
const hex = m[1].length === 3 ? [...m[1]].map((c) => c + c).join('') : m[1]
|
||
const channel = (i: number) => {
|
||
const v = parseInt(hex.slice(i, i + 2), 16) / 255
|
||
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
|
||
}
|
||
return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4)
|
||
}
|
||
|
||
/** The CSS color the monogram letters take on a marker of `color`. */
|
||
export function monogramInk(color: string): string {
|
||
const l = luminance(color)
|
||
return l !== null && l > PAPER_MAX_LUMINANCE ? INK : PAPER
|
||
}
|