Files
pansy/web/src/lib/monogram.ts
T
steveandClaude Fable 5 27f658c1f7
Build image / build-and-push (push) Successful in 2m55s
Gadfly review (reusable) / review (pull_request) Successful in 8m59s
Adversarial Review (Gadfly) / review (pull_request) Successful in 8m59s
Smoke-sweep fixes: exact saves, local dates, safer remove, readable markers
- 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]>
2026-08-22 22:11:12 -04:00

95 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Plant markers are a solid circle in the plant's color with a 12 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
}