Wip stage M4
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# ADR-0016: Option values are retained when variants reference them
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-12
|
||||
|
||||
## Context
|
||||
|
||||
A merchandiser removes the "Red" colourway from a product. What should happen to
|
||||
the three Red variants and to the Red option value itself?
|
||||
|
||||
Three things reference them and each has a different claim:
|
||||
|
||||
- **Order history** — an order line points at a variant id, and reading that
|
||||
order later needs "Red / M" to still mean something.
|
||||
- **The variant matrix** — Red must stop generating combinations.
|
||||
- **The storefront** — a shopper must not see a Red swatch that can never be
|
||||
selected.
|
||||
|
||||
The database already takes a position: `ProductVariantOptionValue.optionValue`
|
||||
is `onDelete: Restrict`, precisely so that deleting a value cannot silently
|
||||
orphan or cascade into history.
|
||||
|
||||
## Decision
|
||||
|
||||
**Variants are archived, never deleted.** A removed combination sets
|
||||
`status = ARCHIVED`. The row, its SKU and its option links all survive, so an
|
||||
order placed last month still resolves.
|
||||
|
||||
**Option values are deleted only when nothing references them.** If any variant
|
||||
— active or archived — still links to a value, the value is _retained_. It stops
|
||||
appearing in the matrix and stops being offered, but it continues to exist so the
|
||||
archived variants remain readable.
|
||||
|
||||
**The matrix is built from the operator's submitted option set, not from the
|
||||
database.** Retained values would otherwise regenerate the very variants that
|
||||
were just archived.
|
||||
|
||||
**The storefront filters option values to those offered by at least one active
|
||||
variant.** Without this last step, a retained value renders as a permanently
|
||||
disabled swatch with no explanation.
|
||||
|
||||
## Consequences
|
||||
|
||||
Order history stays intact and readable, which is the constraint that drove
|
||||
everything else. Re-adding a removed colourway later reuses the retained value
|
||||
and its translations rather than creating a duplicate.
|
||||
|
||||
The cost is that `product_option_values` accumulates rows that are invisible to
|
||||
shoppers, and "delete" is not always literally a delete — a merchandiser who
|
||||
inspects the database will find values they thought they removed. The schema
|
||||
comment and this ADR are the mitigation; a future admin screen could surface
|
||||
retained values explicitly.
|
||||
|
||||
This was found the hard way: the first implementation deleted values before
|
||||
archiving variants and hit the `Restrict` constraint as a 500. The failure was
|
||||
the schema doing its job.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Cascade the delete.** Removes the option value and its variant links, which
|
||||
either breaks order lines or cascades into them. Rejected — this is the outcome
|
||||
`Restrict` exists to prevent.
|
||||
|
||||
**Soft-delete option values with an `isActive` column.** Functionally similar to
|
||||
retention, but adds a column and a filter to every catalog query for a case that
|
||||
is already handled by "is any active variant offering this?". Rejected as
|
||||
redundant state.
|
||||
|
||||
**Refuse to remove a value that has variants.** Simple and safe, but forces the
|
||||
merchandiser to archive six variants by hand before they can drop a colourway —
|
||||
pushing bookkeeping onto the person the tool exists to help.
|
||||
@@ -0,0 +1,90 @@
|
||||
# ADR-0017: shadcn/ui for infrastructure, hand-built for brand
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-12
|
||||
|
||||
## Context
|
||||
|
||||
The component layer started as four hand-rolled primitives in `@sport/ui`
|
||||
(Button, Input, Badge, Skeleton). That was enough while the surface was static
|
||||
text and links. It stopped being enough the moment real interaction arrived: the
|
||||
mobile filter panel needs focus trapping and scroll locking, the product editor
|
||||
needs tabs, row actions need a menu, and the media picker needs a modal.
|
||||
|
||||
Writing those by hand means writing focus management, `aria-modal` wiring,
|
||||
escape handling, portal placement and collision detection. That work is
|
||||
well-understood, easy to get subtly wrong, and worth nothing to this store's
|
||||
customers — nobody chooses a running jacket because the dialog traps focus
|
||||
correctly. They only notice when it doesn't.
|
||||
|
||||
The opposite is true of the product card, the header, the hero and the PDP.
|
||||
Those _are_ the store. A generic card component would make them look like every
|
||||
other template on the internet, which is precisely the outcome the visual
|
||||
direction exists to avoid.
|
||||
|
||||
## Decision
|
||||
|
||||
**Two tiers, split on whether a component carries brand.**
|
||||
|
||||
`packages/ui/src/components/ui/` holds shadcn/ui components, owned as source in
|
||||
this repo and shared by the storefront and the admin: Button, Input, Dialog,
|
||||
Sheet, DropdownMenu, Tabs, plus Badge and Skeleton. Registry components are
|
||||
copied in essentially unedited so that anything else from shadcn drops in later.
|
||||
|
||||
`apps/storefront/src/components/commerce/` holds everything that carries the
|
||||
brand, built by hand: hero, mega menu, site header, product card, product
|
||||
gallery, product detail, filter sheet. These are storefront-only and are never
|
||||
promoted to `@sport/ui`, per the existing rule in architecture §4.
|
||||
|
||||
**A semantic token layer adapts shadcn to the palette.** shadcn writes against
|
||||
role tokens (`--background`, `--primary`, `--ring`, `--radius`); the store's
|
||||
palette is `ink`/`volt`. `packages/config/tailwind/theme.css` maps one onto the
|
||||
other, so a registry component already looks like this store before it is
|
||||
touched. Note that shadcn's `--accent` is a hover surface, not a brand accent —
|
||||
it maps to `ink-100`, and volt stays applied deliberately.
|
||||
|
||||
**Supporting libraries, each for one job.** Lucide for icons, Motion for the
|
||||
hero's entrance, Embla for the PDP gallery's swipe.
|
||||
|
||||
## Consequences
|
||||
|
||||
The dialog/sheet/menu behaviour we would otherwise have written badly is now
|
||||
correct and not our problem. `asChild` removed a real defect class: `<Link>`
|
||||
wrapping `<Button>` was producing `<a><button>`, which is invalid HTML and
|
||||
announces as two nested controls.
|
||||
|
||||
Registry components import `@/lib/utils` and `@/components/ui/*`. A workspace
|
||||
package cannot rely on an app's tsconfig aliases, so those imports are rewritten
|
||||
to relative paths on the way in. That is a one-line edit per component and is
|
||||
documented in the `@sport/ui` barrel.
|
||||
|
||||
Two registry defaults had to be overridden, both because they assume a light
|
||||
page: `outline` shipped with `bg-background`, which rendered a white button with
|
||||
white text on the black hero, and the base sizes are rounded and lowercase where
|
||||
this brand is square and uppercase. Owning the source is what made those
|
||||
one-line fixes rather than a fight.
|
||||
|
||||
The cost is a larger dependency surface — `radix-ui`, `lucide-react`, `motion`,
|
||||
`embla-carousel-react`, `tw-animate-css` — and a standing obligation to keep
|
||||
copied components in step with upstream by choice rather than by `npm update`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep hand-rolling everything.** Total control, and the existing primitives were
|
||||
good. Rejected once the list of things to build became "focus trap, scroll lock,
|
||||
portal, collision detection" — that is a component library, and writing one is
|
||||
not this project.
|
||||
|
||||
**Adopt a full component library (MUI, Mantine, Chakra).** Faster to start and
|
||||
far harder to make look like anything but itself. The visual direction here is
|
||||
the deliverable, and fighting a theme system to reach it is worse than building
|
||||
the branded parts by hand.
|
||||
|
||||
**Use shadcn for everything, including the product card and header.** This is the
|
||||
common failure mode. It produces a store that works correctly and looks like a
|
||||
demo. The split above exists precisely to prevent it.
|
||||
|
||||
**Per-app copies of the shadcn layer instead of a shared package.** Matches
|
||||
shadcn's single-app default and lets the two apps drift freely. Rejected because
|
||||
Button and Input genuinely are identical in both, and two copies means two
|
||||
places to fix the next `bg-background` problem.
|
||||
@@ -24,6 +24,8 @@ An ADR is immutable once accepted. If a decision changes, add a new ADR that sup
|
||||
| [0013](./0013-content-translations-in-typed-tables-ui-strings-in-message-catalogs.md) | Content translations in typed tables, UI strings in message catalogs | Accepted |
|
||||
| [0014](./0014-denormalised-price-projection-on-product.md) | A denormalised price/stock projection on Product | Accepted |
|
||||
| [0015](./0015-frontends-reach-the-api-through-their-own-origin.md) | Frontends reach the API through their own origin | Accepted |
|
||||
| [0016](./0016-option-values-are-retained-when-variants-reference-them.md) | Option values are retained when variants reference them | Accepted |
|
||||
| [0017](./0017-shadcn-for-infrastructure-hand-built-for-brand.md) | shadcn/ui for infrastructure, hand-built for brand | Accepted |
|
||||
|
||||
## Decisions deliberately NOT recorded yet
|
||||
|
||||
|
||||
Reference in New Issue
Block a user