Wip stage M4

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 5386bc51d1
commit d5cacc1208
113 changed files with 9486 additions and 919 deletions
@@ -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.
+2
View File
@@ -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