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]>
This commit is contained in:
@@ -6,24 +6,31 @@ import { Dialog } from '@/components/ui/Dialog'
|
||||
import { TextField } from '@/components/ui/Field'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { useCopyGarden, type Garden } from '@/lib/gardens'
|
||||
import { parsePlanName, planNameFor } from '@/lib/plan'
|
||||
import { useCopyGarden, useGardens, type Garden } from '@/lib/gardens'
|
||||
import { nextPlanYear, parsePlanName, planNameFor } from '@/lib/plan'
|
||||
|
||||
/**
|
||||
* Duplicate a garden — the way to scheme a season: the copy is a separate
|
||||
* garden you rearrange freely while this one stays put. Beds and everything
|
||||
* currently planted come along; the share link and shares don't. The name is
|
||||
* prefilled as "<name> — <next year>", which is what the editor's season
|
||||
* control and the `plan` tag read back (see lib/plan.ts). On success we land in
|
||||
* the copy, since the point of copying is to start editing it.
|
||||
* prefilled as "<name> — <year>" for the next year that doesn't already have a
|
||||
* plan, which is what the editor's season control and the `plan` tag read back
|
||||
* (see lib/plan.ts). On success we land in the copy, since the point of copying
|
||||
* is to start editing it.
|
||||
*/
|
||||
export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||
const copy = useCopyGarden()
|
||||
const navigate = useNavigate()
|
||||
const gardens = useGardens()
|
||||
const names = (gardens.data ?? []).map((g) => g.name)
|
||||
const base = parsePlanName(garden.name)?.base ?? garden.name
|
||||
const year = (parsePlanName(garden.name)?.year ?? new Date().getFullYear()) + 1
|
||||
const from = (parsePlanName(garden.name)?.year ?? new Date().getFullYear()) + 1
|
||||
const year = nextPlanYear(base, names, from)
|
||||
const [name, setName] = useState(() => planNameFor(base, year))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// The API allows duplicate names; say so rather than let two gardens read as
|
||||
// the same season's plan.
|
||||
const taken = names.some((n) => n.trim() === name.trim())
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
@@ -47,6 +54,7 @@ export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () =>
|
||||
</p>
|
||||
<TextField label="Name" name="name" required autoFocus value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<p className="text-xs text-ink-mute">Keep the “— {year}” and it shows up as that season's plan in the editor.</p>
|
||||
{taken && <Alert tone="info">You already have a garden called “{name.trim()}” — pick another name so the two don't read as the same plan.</Alert>}
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" onClick={onClose} disabled={copy.isPending}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IconButton } from '@/components/ui/Button'
|
||||
import { Tag } from '@/components/ui/Tag'
|
||||
import type { Garden } from '@/lib/gardens'
|
||||
import { useGardenFull } from '@/lib/objects'
|
||||
import { planYearOf } from '@/lib/plan'
|
||||
import { parsePlanName, planYearOf } from '@/lib/plan'
|
||||
import { sharesQueryOptions } from '@/lib/shares'
|
||||
import { formatSize } from '@/lib/units'
|
||||
import { kindPlural } from '@/editor/kinds'
|
||||
@@ -15,8 +15,8 @@ import { GardenThumb } from './GardenThumb'
|
||||
const COUNTED_KINDS = ['bed', 'grow_bag', 'container', 'in_ground', 'tree', 'path', 'structure']
|
||||
|
||||
/**
|
||||
* One garden as a card: the plot thumbnail (a link into the editor), name + an
|
||||
* optional `plan` tag, size, a counts line, who it's shared with, and a footer
|
||||
* One garden as a card: the plot thumbnail (a link into the editor), name (a
|
||||
* plan copy shows its base name and a `<year> plan` tag), size, a counts line, who it's shared with, and a footer
|
||||
* of Open + share / copy / edit / delete. A garden shared WITH you shows its
|
||||
* role and a leave action instead of the owner's tools.
|
||||
*/
|
||||
@@ -41,6 +41,9 @@ export function GardenCard({
|
||||
const full = useGardenFull(garden.id)
|
||||
const shares = useQuery({ ...sharesQueryOptions(garden.id), enabled: owner })
|
||||
const planYear = planYearOf(garden.name)
|
||||
// A plan's year is the point of its name, and the first thing truncation
|
||||
// would eat ("Back Yard — 20…"); show the base name and put the year on the tag.
|
||||
const title = planYear != null ? (parsePlanName(garden.name)?.base ?? garden.name) : garden.name
|
||||
|
||||
const meta = useMemo(() => {
|
||||
const data = full.data
|
||||
@@ -79,9 +82,9 @@ export function GardenCard({
|
||||
<div className="flex flex-1 flex-col gap-2 px-[18px] py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 truncate font-heading text-lg" title={garden.name}>
|
||||
{garden.name}
|
||||
{title}
|
||||
</span>
|
||||
{planYear != null && <Tag tone="accent">plan</Tag>}
|
||||
{planYear != null && <Tag tone="accent">{planYear} plan</Tag>}
|
||||
<span className="ml-auto flex-none text-[12.5px] font-semibold text-ink-mute">
|
||||
{formatSize(garden.widthCm, garden.heightCm, garden.unitPref)}
|
||||
</span>
|
||||
|
||||
@@ -9,13 +9,15 @@ import { errorMessage } from '@/lib/api'
|
||||
import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens'
|
||||
import {
|
||||
cmFromFtIn,
|
||||
convertDimensionField,
|
||||
dimensionField,
|
||||
dimensionInputMode,
|
||||
dimensionUnitLabel,
|
||||
editDimensionField,
|
||||
formatCm,
|
||||
formatDimensionInput,
|
||||
isValidDimensionCm,
|
||||
MIN_GARDEN_GRID_CM,
|
||||
parseDimension,
|
||||
type LengthField,
|
||||
type UnitPref,
|
||||
} from '@/lib/units'
|
||||
|
||||
@@ -36,8 +38,11 @@ function entryHint(unit: UnitPref): string {
|
||||
|
||||
/**
|
||||
* "A new garden" (no garden) or edit (garden given). Dimensions are typed in the
|
||||
* chosen unit and stored as centimeters; switching units converts what's typed so
|
||||
* the physical size holds. A 409 rebases the form onto the server's fresh row.
|
||||
* chosen unit and stored as centimeters: each field is a LengthField, so
|
||||
* switching units re-shows the same centimeters and a Save sends exactly what
|
||||
* was loaded unless the person typed over it (re-parsing the display string is
|
||||
* how 900 cm once became 899.922). A 409 rebases the form onto the server's
|
||||
* fresh row.
|
||||
*/
|
||||
export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: () => void }) {
|
||||
const isEdit = !!garden
|
||||
@@ -48,14 +53,10 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
|
||||
const initialUnit: UnitPref = garden?.unitPref ?? 'imperial'
|
||||
const [name, setName] = useState(garden?.name ?? '')
|
||||
const [unit, setUnit] = useState<UnitPref>(initialUnit)
|
||||
const [width, setWidth] = useState(() =>
|
||||
garden ? formatDimensionInput(garden.widthCm, initialUnit) : formatDimensionInput(cmFromFtIn(DEFAULT_W_FT), 'imperial'),
|
||||
)
|
||||
const [height, setHeight] = useState(() =>
|
||||
garden ? formatDimensionInput(garden.heightCm, initialUnit) : formatDimensionInput(cmFromFtIn(DEFAULT_H_FT), 'imperial'),
|
||||
)
|
||||
const [gridSize, setGridSize] = useState(() =>
|
||||
formatDimensionInput(garden?.gridSizeCm ?? (initialUnit === 'imperial' ? cmFromFtIn(1) : DEFAULT_GRID_CM), initialUnit),
|
||||
const [width, setWidth] = useState<LengthField>(() => dimensionField(garden?.widthCm ?? cmFromFtIn(DEFAULT_W_FT), initialUnit))
|
||||
const [height, setHeight] = useState<LengthField>(() => dimensionField(garden?.heightCm ?? cmFromFtIn(DEFAULT_H_FT), initialUnit))
|
||||
const [gridSize, setGridSize] = useState<LengthField>(() =>
|
||||
dimensionField(garden?.gridSizeCm ?? (initialUnit === 'imperial' ? cmFromFtIn(1) : DEFAULT_GRID_CM), initialUnit),
|
||||
)
|
||||
const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false)
|
||||
const [notes, setNotes] = useState(garden?.notes ?? '')
|
||||
@@ -65,17 +66,13 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
|
||||
function changeUnit(next: UnitPref) {
|
||||
const convert = (s: string) => {
|
||||
const cm = parseDimension(s, unit)
|
||||
return cm === null ? s : formatDimensionInput(cm, next)
|
||||
}
|
||||
setWidth(convert(width))
|
||||
setHeight(convert(height))
|
||||
setGridSize(convert(gridSize))
|
||||
setWidth((f) => convertDimensionField(f, next))
|
||||
setHeight((f) => convertDimensionField(f, next))
|
||||
setGridSize((f) => convertDimensionField(f, next))
|
||||
setUnit(next)
|
||||
}
|
||||
|
||||
const gridSizeCm = parseDimension(gridSize, unit)
|
||||
const gridSizeCm = gridSize.cm
|
||||
const gridTooFine = gridSizeCm !== null && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
@@ -86,8 +83,8 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
|
||||
setFormError('Give the garden a name.')
|
||||
return
|
||||
}
|
||||
const widthCm = parseDimension(width, unit)
|
||||
const heightCm = parseDimension(height, unit)
|
||||
const widthCm = width.cm
|
||||
const heightCm = height.cm
|
||||
if (widthCm === null || heightCm === null) {
|
||||
setFormError(entryHint(unit))
|
||||
return
|
||||
@@ -111,9 +108,9 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
|
||||
setVersion(current.version)
|
||||
setName(current.name)
|
||||
setUnit(current.unitPref)
|
||||
setWidth(formatDimensionInput(current.widthCm, current.unitPref))
|
||||
setHeight(formatDimensionInput(current.heightCm, current.unitPref))
|
||||
setGridSize(formatDimensionInput(current.gridSizeCm, current.unitPref))
|
||||
setWidth(dimensionField(current.widthCm, current.unitPref))
|
||||
setHeight(dimensionField(current.heightCm, current.unitPref))
|
||||
setGridSize(dimensionField(current.gridSizeCm, current.unitPref))
|
||||
setSnapToGrid(current.snapToGrid)
|
||||
setNotes(current.notes)
|
||||
setConflict('This garden changed elsewhere. The latest values are shown — look them over and save again.')
|
||||
@@ -138,8 +135,8 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
required
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
value={width.text}
|
||||
onChange={(e) => setWidth(editDimensionField(e.target.value, unit))}
|
||||
wrapperClassName="flex-1"
|
||||
/>
|
||||
<TextField
|
||||
@@ -148,8 +145,8 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
required
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
value={height.text}
|
||||
onChange={(e) => setHeight(editDimensionField(e.target.value, unit))}
|
||||
wrapperClassName="flex-1"
|
||||
/>
|
||||
<div className="field">
|
||||
@@ -171,8 +168,8 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
|
||||
name="gridSize"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
value={gridSize}
|
||||
onChange={(e) => setGridSize(e.target.value)}
|
||||
value={gridSize.text}
|
||||
onChange={(e) => setGridSize(editDimensionField(e.target.value, unit))}
|
||||
wrapperClassName="flex-1"
|
||||
hint={
|
||||
gridTooFine && gridSizeCm !== null
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
import { monogramInk } from '@/lib/monogram'
|
||||
|
||||
/** A plant marker off the canvas: a solid circle in the plant's color with its
|
||||
* 1–2 letter monogram in the display face. Size in px. */
|
||||
* 1–2 letter monogram in the display face, paper or ink by the color's
|
||||
* lightness (see monogramInk). Size in px. */
|
||||
export function Monogram({
|
||||
color,
|
||||
letters,
|
||||
@@ -16,8 +18,8 @@ export function Monogram({
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('grid flex-none place-items-center rounded-full font-heading leading-none text-paper', className)}
|
||||
style={{ width: size, height: size, background: color, fontSize: Math.round(size * 0.425) }}
|
||||
className={cn('grid flex-none place-items-center rounded-full font-heading leading-none', className)}
|
||||
style={{ width: size, height: size, background: color, color: monogramInk(color), fontSize: Math.round(size * 0.425) }}
|
||||
>
|
||||
{letters}
|
||||
</span>
|
||||
|
||||
@@ -95,7 +95,7 @@ export function PlantCard({
|
||||
<Button variant="ghost" icon="plus" iconSize={12} className="px-2.5 text-[13px]" onClick={onAddLot}>
|
||||
Record a lot
|
||||
</Button>
|
||||
<span className="ml-auto flex gap-1">
|
||||
<span className="ml-auto flex flex-wrap justify-end gap-1">
|
||||
<Button variant="ghost" icon="copy" iconSize={12} className="px-2.5 text-[13px]" onClick={onDuplicate}>
|
||||
Duplicate
|
||||
</Button>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type PlantInput,
|
||||
} from '@/lib/plants'
|
||||
import { safeExternalUrl } from '@/lib/seedLots'
|
||||
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
|
||||
import { editSpacingField, spacingField, spacingUnitLabel, type LengthField, type UnitPref } from '@/lib/units'
|
||||
import { ColorSwatches, CURATED_SWATCHES, expandHex } from './ColorSwatches'
|
||||
|
||||
// Markers are monograms now, but the API still carries an icon per plant; a
|
||||
@@ -26,8 +26,10 @@ const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY
|
||||
/**
|
||||
* "A new plant" — or edit (`plant`), or a fresh create prefilled from another
|
||||
* (`template`, the Duplicate action; the only way to customize a built-in).
|
||||
* Spacing is typed in the page's unit and stored in centimeters. A 409 rebases
|
||||
* onto the server's current row.
|
||||
* Spacing is typed in the page's unit and stored in centimeters — as a
|
||||
* LengthField, so a Save that didn't touch it sends the centimeters that were
|
||||
* loaded rather than re-parsing "17.7 in" into 44.958. A 409 rebases onto the
|
||||
* server's current row.
|
||||
*/
|
||||
export function PlantDialog({
|
||||
plant,
|
||||
@@ -48,7 +50,7 @@ export function PlantDialog({
|
||||
|
||||
const [name, setName] = useState(source ? (isEdit ? source.name : `${source.name} (copy)`) : '')
|
||||
const [category, setCategory] = useState<PlantCategory>(source?.category ?? 'vegetable')
|
||||
const [spacing, setSpacing] = useState(String(spacingFromCm(source?.spacingCm ?? 30, unit)))
|
||||
const [spacing, setSpacing] = useState<LengthField>(() => spacingField(source?.spacingCm ?? 30, unit))
|
||||
const [color, setColor] = useState(expandHex(source?.color ?? CURATED_SWATCHES[0]))
|
||||
const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '')
|
||||
const [vendor, setVendor] = useState(source?.vendor ?? '')
|
||||
@@ -68,8 +70,8 @@ export function PlantDialog({
|
||||
setFormError('Give the plant a name.')
|
||||
return
|
||||
}
|
||||
const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
|
||||
if (!Number.isFinite(spacingCm) || spacingCm < 1) {
|
||||
const spacingCm = spacing.cm
|
||||
if (spacingCm === null || spacingCm < 1) {
|
||||
setFormError(`Spacing must be at least 1 ${unitLabel}.`)
|
||||
return
|
||||
}
|
||||
@@ -107,7 +109,7 @@ export function PlantDialog({
|
||||
setVersion(current.version)
|
||||
setName(current.name)
|
||||
setCategory(current.category)
|
||||
setSpacing(String(spacingFromCm(current.spacingCm, unit)))
|
||||
setSpacing(spacingField(current.spacingCm, unit))
|
||||
setColor(expandHex(current.color))
|
||||
setDays(current.daysToMaturity != null ? String(current.daysToMaturity) : '')
|
||||
setVendor(current.vendor)
|
||||
@@ -142,8 +144,8 @@ export function PlantDialog({
|
||||
step="any"
|
||||
min="1"
|
||||
required
|
||||
value={spacing}
|
||||
onChange={(e) => setSpacing(e.target.value)}
|
||||
value={spacing.text}
|
||||
onChange={(e) => setSpacing(editSpacingField(e.target.value, unit))}
|
||||
wrapperClassName="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from 'react'
|
||||
import { clampScale, type Point } from '@/lib/geometry'
|
||||
import { monogramInk } from '@/lib/monogram'
|
||||
import { useCreateObject, useCreatePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import type { EditorPlanting } from '@/lib/plantings'
|
||||
@@ -34,6 +35,10 @@ import {
|
||||
import { useEditorStore, type Viewport } from './store'
|
||||
import type { EditorGarden, EditorObject } from './types'
|
||||
|
||||
// A plop whose plant is missing from the catalog (a shared garden's private
|
||||
// plant) still needs a color to be drawn in.
|
||||
const FALLBACK_PLANT_COLOR = '#97a97c'
|
||||
|
||||
const WHEEL_SENSITIVITY = 0.0016
|
||||
const ANIM_MS = 520
|
||||
const REFIT_THRESHOLD_PX = 60
|
||||
@@ -589,7 +594,7 @@ export const Canvas = forwardRef<
|
||||
>
|
||||
{/* A fingertip-sized hit area so a tiny plop at low zoom is still grabbable. */}
|
||||
<circle r={Math.max(p.radiusCm, 10 / s)} fill="transparent" />
|
||||
<circle r={p.radiusCm} fill={plant?.color ?? '#97a97c'} stroke={isSel ? 'var(--color-paper)' : 'none'} strokeWidth={2.5 / s} />
|
||||
<circle r={p.radiusCm} fill={plant?.color ?? FALLBACK_PLANT_COLOR} stroke={isSel ? 'var(--color-paper)' : 'none'} strokeWidth={2.5 / s} />
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
@@ -642,7 +647,7 @@ export const Canvas = forwardRef<
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={r * 1.05}
|
||||
fill="var(--color-paper)"
|
||||
fill={monogramInk(plant?.color ?? FALLBACK_PLANT_COLOR)}
|
||||
style={{ fontFamily: 'var(--font-heading)' }}
|
||||
>
|
||||
{letters.get(p.plantId) ?? '?'}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { ColorDot } from '@/components/plants/Monogram'
|
||||
import { Button, IconButton } from '@/components/ui/Button'
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
|
||||
import { TextAreaField, TextField } from '@/components/ui/Field'
|
||||
import { Icon } from '@/components/ui/Icon'
|
||||
import { Tag } from '@/components/ui/Tag'
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
spacingUnitLabel,
|
||||
type UnitPref,
|
||||
} from '@/lib/units'
|
||||
import { kindDef, kindPlural } from './kinds'
|
||||
import { kindDef, kindPlural, objectDisplayName } from './kinds'
|
||||
import { MIN_OBJECT_CM, plopCount } from './shared'
|
||||
import { useEditorStore } from './store'
|
||||
import type { EditorObject } from './types'
|
||||
@@ -40,6 +41,13 @@ export function rosterText(o: EditorObject, plantings: EditorPlanting[], plantsB
|
||||
return 'Growing: ' + [...roster].map(([n, c]) => `${c} ${n}`).join(' · ')
|
||||
}
|
||||
|
||||
/** How many plants an object holds right now (its plops × their counts). */
|
||||
export function plantCountIn(o: EditorObject, plantings: EditorPlanting[], plantsById: Map<number, Plant>): number {
|
||||
let n = 0
|
||||
for (const p of plantings) if (p.objectId === o.id) n += plopCount(p, plantsById.get(p.plantId))
|
||||
return n
|
||||
}
|
||||
|
||||
/** A collapsible block of the less-often-needed fields. */
|
||||
function Details({ open, onToggle, children }: { open: boolean; onToggle: () => void; children: ReactNode }) {
|
||||
return (
|
||||
@@ -66,6 +74,7 @@ export function ObjectInspector({
|
||||
canEdit,
|
||||
focused,
|
||||
roster,
|
||||
plantCount,
|
||||
noteCount,
|
||||
large,
|
||||
onPlantThis,
|
||||
@@ -79,6 +88,8 @@ export function ObjectInspector({
|
||||
/** Already inside this bed (so "Plant this" is redundant). */
|
||||
focused: boolean
|
||||
roster: string
|
||||
/** Live plants in it (see plantCountIn) — Remove asks first when this is > 0. */
|
||||
plantCount: number
|
||||
noteCount: number
|
||||
/** Phone: 16px inputs, 44px targets. */
|
||||
large?: boolean
|
||||
@@ -89,6 +100,7 @@ export function ObjectInspector({
|
||||
const update = useUpdateObject(gardenId)
|
||||
const del = useDeleteObject(gardenId)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const [confirmRemove, setConfirmRemove] = useState(false)
|
||||
const [name, setName] = useState(object.name)
|
||||
const [details, setDetails] = useState(false)
|
||||
const [width, setWidth] = useState(formatDimensionInput(object.widthCm, unit))
|
||||
@@ -178,6 +190,13 @@ export function ObjectInspector({
|
||||
iconClassName="text-accent-700"
|
||||
disabled={del.isPending}
|
||||
onClick={() => {
|
||||
// An empty object goes straight away (one Undo brings it back); a
|
||||
// planted one takes its plants with it, which is worth a question —
|
||||
// on the phone this button sits right beside "Plant this".
|
||||
if (plantCount > 0) {
|
||||
setConfirmRemove(true)
|
||||
return
|
||||
}
|
||||
onDeleted()
|
||||
del.mutate(object.id)
|
||||
}}
|
||||
@@ -259,6 +278,22 @@ export function ObjectInspector({
|
||||
<TextAreaField label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} onBlur={() => notes !== object.notes && patch({ notes })} />
|
||||
</fieldset>
|
||||
</Details>
|
||||
{confirmRemove && (
|
||||
<ConfirmDialog
|
||||
title={`Remove ${objectDisplayName(object)}?`}
|
||||
confirmLabel="Remove it"
|
||||
busyLabel="Removing…"
|
||||
errorFallback="Could not remove it."
|
||||
onConfirm={async () => {
|
||||
await del.mutateAsync(object.id)
|
||||
onDeleted()
|
||||
}}
|
||||
onClose={() => setConfirmRemove(false)}
|
||||
>
|
||||
It has {plantCount === 1 ? 'one plant' : `${plantCount} plants`} in it, and they go with it. One Undo brings
|
||||
everything back.
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { today } from './dates'
|
||||
|
||||
describe('today', () => {
|
||||
it('is the local calendar day, zero-padded', () => {
|
||||
// 21:30 local on Aug 22 is Aug 23 in UTC for anyone west of Greenwich; the
|
||||
// gardener still planted on the 22nd.
|
||||
expect(today(new Date(2026, 7, 22, 21, 30))).toBe('2026-08-22')
|
||||
expect(today(new Date(2026, 0, 5, 0, 1))).toBe('2026-01-05')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
// The day it is where the person is. Everything the UI stamps with "today" — a
|
||||
// journal note, a placed or filled plant — uses the browser's local date,
|
||||
// because a gardener planting at 9 pm in Ohio planted today, not (in UTC)
|
||||
// tomorrow. The server's own defaults are UTC and only apply when a date is
|
||||
// omitted, which is for API callers and the agent; the UI never omits one.
|
||||
|
||||
/** Today as YYYY-MM-DD in the browser's local time zone. */
|
||||
export function today(now = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||
}
|
||||
@@ -127,13 +127,8 @@ export function useDeleteJournalEntry(gardenId: number) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Today as YYYY-MM-DD in the viewer's own timezone — "today" means the day you
|
||||
* are standing in the garden, not the day it is in UTC. */
|
||||
export function today(): string {
|
||||
const now = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||
}
|
||||
// "Today" for a note is the same local day everything else stamps (lib/dates.ts).
|
||||
export { today } from './dates'
|
||||
|
||||
/** A date-only string as a short human label, without dragging the value
|
||||
* through a Date (which would shift it by the timezone offset). */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { monogramFor, monogramMap, speciesName } from './monogram'
|
||||
import { monogramFor, monogramInk, monogramMap, speciesName } from './monogram'
|
||||
|
||||
describe('speciesName', () => {
|
||||
it('drops a variety suffix and a parenthetical', () => {
|
||||
@@ -65,3 +65,22 @@ describe('monogramMap', () => {
|
||||
expect(monogramFor('🌱')).toBe('?')
|
||||
})
|
||||
})
|
||||
|
||||
describe('monogramInk', () => {
|
||||
it('keeps paper lettering on colors dark enough to carry it', () => {
|
||||
expect(monogramInk('#c8553d')).toBe('var(--color-paper)') // tomato
|
||||
expect(monogramInk('#7a8a5e')).toBe('var(--color-paper)') // sage
|
||||
expect(monogramInk('#5f8f45')).toBe('var(--color-paper)')
|
||||
})
|
||||
|
||||
it('switches to the dark marker ink on pale colors', () => {
|
||||
expect(monogramInk('#d9d2c5')).toBe('var(--color-marker-ink)') // garlic
|
||||
expect(monogramInk('#8bc98b')).toBe('var(--color-marker-ink)') // cabbage
|
||||
expect(monogramInk('#fff')).toBe('var(--color-marker-ink)')
|
||||
})
|
||||
|
||||
it('falls back to paper for anything it cannot read', () => {
|
||||
expect(monogramInk('tomato')).toBe('var(--color-paper)')
|
||||
expect(monogramInk('')).toBe('var(--color-paper)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,3 +61,34 @@ 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
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCallback } from 'react'
|
||||
import { queryOptions, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
import { ApiError, api } from './api'
|
||||
import { today } from './dates'
|
||||
import { gardenSchema } from './gardens'
|
||||
import { plantSchema, type Plant } from './plants'
|
||||
import { serverPlantingSchema, type ServerPlanting } from './plantings'
|
||||
@@ -260,13 +261,17 @@ export interface PlantingCreate {
|
||||
label?: string | null
|
||||
/** Attributes the plop to a purchase, so that lot can report what's left. */
|
||||
seedLotId?: number
|
||||
/** YYYY-MM-DD; defaults to the browser's local today (never the server's UTC one). */
|
||||
plantedAt?: string
|
||||
}
|
||||
|
||||
export function useCreatePlanting(gardenId: number) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ objectId, ...body }: PlantingCreate): Promise<ServerPlanting> =>
|
||||
serverPlantingSchema.parse(await api.post(`/objects/${objectId}/plantings`, body)),
|
||||
serverPlantingSchema.parse(
|
||||
await api.post(`/objects/${objectId}/plantings`, { plantedAt: today(), ...body }),
|
||||
),
|
||||
onSuccess: (created) => {
|
||||
patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: [...full.plantings, created] }))
|
||||
},
|
||||
@@ -353,7 +358,7 @@ export function useFillObject(gardenId: number) {
|
||||
layout: FillLayout
|
||||
}): Promise<number> => {
|
||||
const res = fillResultSchema.parse(
|
||||
await api.post(`/objects/${objectId}/fill`, { plantId, region: 'all', layout }),
|
||||
await api.post(`/objects/${objectId}/fill`, { plantId, region: 'all', layout, plantedAt: today() }),
|
||||
)
|
||||
return res.created
|
||||
},
|
||||
@@ -393,8 +398,7 @@ export function useRemovePlanting(gardenId: number) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, version }: { id: number; version: number }): Promise<ServerPlanting> => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
return serverPlantingSchema.parse(await api.patch(`/plantings/${id}`, { removedAt: today, version }))
|
||||
return serverPlantingSchema.parse(await api.patch(`/plantings/${id}`, { removedAt: today(), version }))
|
||||
},
|
||||
onMutate: async ({ id }) => {
|
||||
await qc.cancelQueries({ queryKey: fullKey(gardenId) })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parsePlanName, planGardensOf, planNameFor, planYearOf } from './plan'
|
||||
import { nextPlanYear, parsePlanName, planGardensOf, planNameFor, planYearOf } from './plan'
|
||||
|
||||
describe('plan names', () => {
|
||||
it('round-trips the default copy name', () => {
|
||||
@@ -35,3 +35,15 @@ describe('plan names', () => {
|
||||
expect(planGardensOf('Home Garden', gardens).map((g) => g.id)).toEqual([3, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextPlanYear', () => {
|
||||
it('skips years that already have a plan copy, however the dash was typed', () => {
|
||||
const names = ['Back Yard', 'Back Yard — 2027', 'Back Yard - 2028', 'Front Strip — 2029']
|
||||
expect(nextPlanYear('Back Yard', names, 2027)).toBe(2029)
|
||||
expect(nextPlanYear('Front Strip', names, 2027)).toBe(2027)
|
||||
})
|
||||
|
||||
it('is the starting year when nothing is taken', () => {
|
||||
expect(nextPlanYear('Plot', [], 2030)).toBe(2030)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,3 +40,17 @@ export function planGardensOf<G extends { id: number; name: string }>(base: stri
|
||||
}
|
||||
return out.sort((a, b) => a.year - b.year)
|
||||
}
|
||||
|
||||
/** The first year from `from` on that `base` has no plan copy for among
|
||||
* `names`, so a new copy never proposes a name that is already taken — two
|
||||
* "Back Yard — 2027"s would both be offered as the 2027 plan. */
|
||||
export function nextPlanYear(base: string, names: readonly string[], from: number): number {
|
||||
const taken = new Set<number>()
|
||||
for (const n of names) {
|
||||
const p = parsePlanName(n)
|
||||
if (p && p.base === base.trim()) taken.add(p.year)
|
||||
}
|
||||
let year = from
|
||||
while (taken.has(year)) year += 1
|
||||
return year
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
cmFromFtIn,
|
||||
convertDimensionField,
|
||||
dimensionField,
|
||||
editDimensionField,
|
||||
editSpacingField,
|
||||
spacingField,
|
||||
cmFromMeters,
|
||||
cmFromSpacing,
|
||||
dimensionInputMode,
|
||||
@@ -242,3 +247,30 @@ describe('formatSize / formatLength', () => {
|
||||
expect(formatLength(1219, 'imperial')).toBe('40′')
|
||||
})
|
||||
})
|
||||
|
||||
describe('LengthField', () => {
|
||||
it('keeps the stored centimeters through a unit switch and back', () => {
|
||||
let f = dimensionField(900, 'imperial')
|
||||
expect(f.text).toBe('29′ 6.3″')
|
||||
f = convertDimensionField(f, 'metric')
|
||||
expect(f.text).toBe('9')
|
||||
f = convertDimensionField(f, 'imperial')
|
||||
expect(f).toEqual({ text: '29′ 6.3″', cm: 900 })
|
||||
})
|
||||
|
||||
it('moves the centimeters only when the text is edited', () => {
|
||||
expect(editDimensionField("15' 6\"", 'imperial').cm).toBe(472.44)
|
||||
const typo = editDimensionField('nope', 'imperial')
|
||||
expect(typo.cm).toBeNull()
|
||||
// A typo survives a unit switch as typed rather than turning into a number.
|
||||
expect(convertDimensionField(typo, 'metric')).toEqual(typo)
|
||||
})
|
||||
|
||||
it('spacing: 45 cm reads 17.7 in and stays 45 cm until typed over', () => {
|
||||
const f = spacingField(45, 'imperial')
|
||||
expect(f).toEqual({ text: '17.7', cm: 45 })
|
||||
expect(editSpacingField('18', 'imperial').cm).toBe(45.72)
|
||||
expect(editSpacingField('', 'imperial').cm).toBeNull()
|
||||
expect(editSpacingField('25', 'metric').cm).toBe(25)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -227,3 +227,45 @@ export function formatLength(cm: number, unit: UnitPref): string {
|
||||
export function formatSize(widthCm: number, heightCm: number, unit: UnitPref): string {
|
||||
return `${formatLength(widthCm, unit)} × ${formatLength(heightCm, unit)}`
|
||||
}
|
||||
|
||||
// --- Typed-length fields ----------------------------------------------------
|
||||
// A dialog field that takes a length holds TWO things: the text the person sees
|
||||
// and the centimeters it means. The centimeters change only when the person
|
||||
// types; re-showing the field in another unit, or saving without touching it,
|
||||
// reuses them as they are. Parsing the displayed text back on save is how a
|
||||
// no-change Save once turned 900 cm into 899.922 — "29′ 6.3″" is the nearest
|
||||
// tenth of an inch, not the number that was loaded.
|
||||
|
||||
/** A length as typed and as stored; `cm` is null while the text doesn't parse. */
|
||||
export interface LengthField {
|
||||
text: string
|
||||
cm: number | null
|
||||
}
|
||||
|
||||
/** A dimension field (meters, or feet and inches) showing `cm`. */
|
||||
export function dimensionField(cm: number, unit: UnitPref): LengthField {
|
||||
return { text: formatDimensionInput(cm, unit), cm }
|
||||
}
|
||||
|
||||
/** The person typed `text` into a dimension field. */
|
||||
export function editDimensionField(text: string, unit: UnitPref): LengthField {
|
||||
return { text, cm: parseDimension(text, unit) }
|
||||
}
|
||||
|
||||
/** Re-show a dimension field in another unit; the centimeters don't move. A
|
||||
* field that doesn't parse keeps its text, so the typo stays visible. */
|
||||
export function convertDimensionField(field: LengthField, unit: UnitPref): LengthField {
|
||||
return field.cm === null ? field : dimensionField(field.cm, unit)
|
||||
}
|
||||
|
||||
/** A spacing field (cm, or inches) showing `cm`. */
|
||||
export function spacingField(cm: number, unit: UnitPref): LengthField {
|
||||
return { text: String(spacingFromCm(cm, unit)), cm }
|
||||
}
|
||||
|
||||
/** The person typed `text` into a spacing field. */
|
||||
export function editSpacingField(text: string, unit: UnitPref): LengthField {
|
||||
const trimmed = text.trim()
|
||||
const n = Number(trimmed)
|
||||
return { text, cm: trimmed !== '' && Number.isFinite(n) ? cmFromSpacing(n, unit) : null }
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { AssistantTab } from '@/editor/AssistantTab'
|
||||
import { Canvas, type CanvasHandle } from '@/editor/Canvas'
|
||||
import { ClearBedDialog } from '@/editor/ClearBedDialog'
|
||||
import { HistoryTab } from '@/editor/HistoryTab'
|
||||
import { GardenSummary, ObjectInspector, PlopInspector, rosterText } from '@/editor/Inspector'
|
||||
import { GardenSummary, ObjectInspector, PlopInspector, plantCountIn, rosterText } from '@/editor/Inspector'
|
||||
import { JournalTab, type JournalAttach } from '@/editor/JournalTab'
|
||||
import { KindSwatch } from '@/editor/KindSwatch'
|
||||
import { OBJECT_KINDS, objectDisplayName } from '@/editor/kinds'
|
||||
@@ -390,6 +390,7 @@ function Editor({
|
||||
canEdit={canEdit}
|
||||
focused={focusId === selectedObject.id}
|
||||
roster={rosterText(selectedObject, plantings, plantsById)}
|
||||
plantCount={plantCountIn(selectedObject, plantings, plantsById)}
|
||||
noteCount={journalCounts.data?.get(selectedObject.id) ?? 0}
|
||||
large={isMobile}
|
||||
onPlantThis={() => plantThis(selectedObject)}
|
||||
|
||||
@@ -113,11 +113,16 @@ function WhoGetsInCard({ data }: { data: SettingsResponse }) {
|
||||
)
|
||||
}
|
||||
|
||||
type ModelField = 'agentModel' | 'visionModel'
|
||||
|
||||
function AssistantCard({ data }: { data: SettingsResponse }) {
|
||||
const update = useUpdateSettings()
|
||||
const { settings, effective } = data
|
||||
const [model, setModel] = useState(settings.agentModel)
|
||||
const [vModel, setVModel] = useState(settings.visionModel)
|
||||
// A rejected model spec stays in its field with the server's reason under it
|
||||
// (the field keeps the typo so it can be fixed); everything else toasts.
|
||||
const [fieldError, setFieldError] = useState<{ field: ModelField; message: string } | null>(null)
|
||||
|
||||
// Re-sync the fields when the stored row changes underneath (a save, a
|
||||
// refetch), so what's shown is what's saved.
|
||||
@@ -136,14 +141,20 @@ function AssistantCard({ data }: { data: SettingsResponse }) {
|
||||
version: settings.version,
|
||||
},
|
||||
{
|
||||
onSuccess: () => toast.info('Saved — the assistant picked it up.'),
|
||||
onSuccess: () => {
|
||||
setFieldError(null)
|
||||
toast.info('Saved — the assistant picked it up.')
|
||||
},
|
||||
onError: (err) => {
|
||||
const current = conflictSettings(err)
|
||||
toast.error(
|
||||
current
|
||||
? 'Someone else changed these settings just now — showing their version. Re-apply yours if you still want it.'
|
||||
: errorMessage(err, "Couldn't save settings."),
|
||||
)
|
||||
if (current) {
|
||||
toast.error('Someone else changed these settings just now — showing their version. Re-apply yours if you still want it.')
|
||||
return
|
||||
}
|
||||
const message = errorMessage(err, "Couldn't save settings.")
|
||||
const field = (['agentModel', 'visionModel'] as const).find((f) => patch[f] !== undefined)
|
||||
if (field) setFieldError({ field, message })
|
||||
else toast.error(message)
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -157,10 +168,12 @@ function AssistantCard({ data }: { data: SettingsResponse }) {
|
||||
? 'off — key still in env'
|
||||
: `configured · not running (${shortModel(effective.model)})`
|
||||
|
||||
const commit = (field: 'agentModel' | 'visionModel', value: string) => {
|
||||
const commit = (field: ModelField, value: string) => {
|
||||
const v = value.trim()
|
||||
if (v !== settings[field]) save({ [field]: v })
|
||||
}
|
||||
const errorFor = (field: ModelField) =>
|
||||
fieldError?.field === field ? <span className="text-accent-700">{fieldError.message}</span> : null
|
||||
|
||||
return (
|
||||
<Card title="Garden assistant" tag={<Tag tone="accent-2">{status}</Tag>}>
|
||||
@@ -180,7 +193,7 @@ function AssistantCard({ data }: { data: SettingsResponse }) {
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
onBlur={() => commit('agentModel', model)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && commit('agentModel', model)}
|
||||
hint="A majordomo model spec; a comma-separated list is a failover chain."
|
||||
hint={errorFor('agentModel') ?? 'A majordomo model spec; a comma-separated list is a failover chain.'}
|
||||
/>
|
||||
<TextField
|
||||
label="Vision model — reads seed packets; must be vision-capable"
|
||||
@@ -190,7 +203,10 @@ function AssistantCard({ data }: { data: SettingsResponse }) {
|
||||
onChange={(e) => setVModel(e.target.value)}
|
||||
onBlur={() => commit('visionModel', vModel)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && commit('visionModel', vModel)}
|
||||
hint={effective.visionReady ? `Scanning is on with ${shortModel(effective.visionModel)}.` : 'Scanning is off until a model and a key are both present.'}
|
||||
hint={
|
||||
errorFor('visionModel') ??
|
||||
(effective.visionReady ? `Scanning is on with ${shortModel(effective.visionModel)}.` : 'Scanning is off until a model and a key are both present.')
|
||||
}
|
||||
/>
|
||||
<div className="text-xs leading-relaxed text-ink-mute">
|
||||
The API key stays in the environment on purpose — a secret in the database would ride along in every backup.
|
||||
|
||||
@@ -21,8 +21,11 @@
|
||||
--color-accent: #c67139;
|
||||
--color-accent-2: #7a8a5e;
|
||||
--color-divider: color-mix(in srgb, #201e1d 16%, transparent);
|
||||
/* The monogram ink on plant markers; does not change between modes. */
|
||||
/* Monogram lettering on plant markers: paper on dark colors, marker-ink on
|
||||
pale ones (lib/monogram.ts picks). Neither changes between modes — the
|
||||
marker's own color doesn't either. */
|
||||
--color-paper: #fffaf1;
|
||||
--color-marker-ink: #201e1d;
|
||||
|
||||
--color-neutral-100: #f9f4ed;
|
||||
--color-neutral-200: #eee7db;
|
||||
|
||||
Reference in New Issue
Block a user