@pagix/pdp-color-selector
v0.1.0
Published
Anvilkit Puck-native PDP color selector — large rounded product image card with circular color swatches (clickable to switch the card image and its name/description strip) and a fully Puck-configurable look: card colour, size, padding and radius, text col
Readme
@pagix/pdp-color-selector
Puck-native PDP colour selector — a large rounded card holding the product
image, a row of circular colour swatches beneath it, and a name / divider /
description strip under those. Replicates Huawei's section-design-color /
.design-color-* module on a product detail page, with two axes the original
does not have: every style is authorable (card colour, size, padding and
radius; each text run's colour, size and box; swatch size, gap and ring) and
the colourways are a list — add, remove and reorder them at will.
Usage
import { createPdpColorSelectorConfig } from "@pagix/pdp-color-selector";
const config = createPdpColorSelectorConfig({ locale: "zh" });| Export | Purpose |
| --- | --- |
| componentConfig | The Puck ComponentConfig (English default) |
| pdpColorSelectorConfig | Convenience alias of componentConfig |
| createPdpColorSelectorConfig(options?) | Locale-aware factory ({ locale?, messages? }) |
| createComponentConfig(options?) | Alias of the factory |
| defaultProps / fields / metadata | Package contract |
| PdpColorSelectorView | The RSC-safe component |
| PdpColorSelectorInitScript | The colour-switch controller, as source text |
| colorField / gradientField / imageUrlField / dimensionField / textStyleField | Puck field factories. All five stay exported; gradientField (raw CSS linear-gradient text) is no longer used by this package's own fields — see "Swatch colours" |
Layout
Four regions, in this DOM order:
| Region | Note | Style target |
| --- | --- | --- |
| Card frame | One frame per section — cardStyle is section-level, not per colourway | card |
| Image box | One per colourway, sharing a single grid cell | image |
| Swatch row | role="group", one control per colourway | swatches → swatch |
| Caption row | Name · divider · description, one caption per colourway | textStrip → name · line · detail |
The image boxes share one grid cell (grid-cols-1 grid-rows-1 +
col-start-1 row-start-1) rather than being absolutely positioned, so the
authored cardStyle.padding actually insets them — an inset-0 child fills
the padding box and would silently ignore it.
Clicking a swatch switches the image and the text
The section is a Server Component: its config flows into the Studio demo
config, which reaches Puck's RSC <Render>, and that injects non-serializable
values (puck.renderDropZone, dragRef, …) into every component's props.
Crossing a "use client" boundary there is a hard RSC violation, so the view
holds no state and mounts no hooks.
Switching is therefore progressive enhancement, not React state:
- The server renders every colourway's image and caption, with
hiddenon all but the active one, anddata-pcs-activeon the matching swatch. The no-JS paint is already correct. - After hydration a small vanilla-JS controller (
PdpColorSelectorScript) appends a<script>to its own section and re-points those two attributes on click. It never invents state —activeIndexstays the declared Puck field, and the server-rendered DOM is the truth it mutates. activeIndexis the initial colour: swap it in the inspector and the server-rendered default changes with it.
The controller finds its own section by walking up from its <script> to the
nearest [data-pcs-color-selector], so one constant script body drives every
instance on the page and no per-render instance id is needed. A random id
would differ between the server and client renders and cause a hydration
mismatch.
In editMode the swatches render as inert <span>s and no controller is
mounted — a click in the canvas selects the node instead of changing colour,
and activeIndex is how another colourway is previewed. The target stamps are
identical in both modes; only the affordance differs.
Image art direction
Each colourway carries three sources, rendered as a <picture>:
| Field | Served when |
| --- | --- |
| pcSrc | Default / widest band |
| padSrc | (max-aspect-ratio: 6/5) |
| mobileSrc | (max-aspect-ratio: 7/10) |
The <source> list is ordered narrowest first and the media-less desktop
entry comes last, because a <picture> picks the first matching source — put
the desktop entry earlier and it would shadow both narrower bands. An empty
band emits no <source> at all, and the <img> falls back to the widest
authored band.
The product image can carry the original's edge fade
(radial-gradient(farthest-side, #FFFFFF 80%, #FFFFFF00 100%)) with its
-webkit-mask-image twin, since older Safari honours only the prefixed form.
It ships off (cardStyle.imageFade) because the mask is an ellipse: how
much of the picture it dissolves depends on the frame's aspect ratio rather
than on the authored stop, so at the default 67.5vw × 35vw it cuts the sides
and the image reads as an oval crop rather than a vignette.
pcSrc accepts a pasted URL or a file upload; an upload becomes a
session-scoped URL.createObjectURL — it previews immediately but is gone
after a reload. Paste a hosted URL for anything that must persist.
Props
| Prop | Type | Notes |
| --- | --- | --- |
| colors | ColorItem[] | { name, description, image, swatchColorFrom, swatchColorTo }; see below |
| cardStyle | { bgColor, width, height, padding, borderRadius, align, imageFade } | Card frame; value <= 0 means "not authored" |
| nameTextStyle | NameTextStyle | Name run; a TextStyle without color — see below |
| lineTextStyle | TextStyle | Divider; color/width/height are the ones that draw it |
| detailTextStyle | TextStyle | Description run |
| swatchStyle | { size, gap, borderWidth, borderColor } | Disc geometry and ring |
| activeIndex | number | Colour shown on first paint; clamped to the list |
| classNames | Record<string, string> | Tailwind passthrough, one key per style target |
| animation | AnimationProps | Entrance animation, applied to the root |
A Dimension is { value, unit } with unit one of px / vw / % / rem
(em is deliberately absent — it resolves against the parent's font size,
and the parent is itself authorable here, so an em text size would resolve
against a moving target). value <= 0 on a box dimension means "size to
content" and emits no property at all.
A TextStyle is
{ fontSize, color, fontWeight, lineHeight, textAlign, borderRadius, width, height },
edited as a single inspector control. width / height size that run's own
box, which is what makes "set the divider's width" a meaningful control;
0 leaves the run sized to its content.
nameTextStyle is a NameTextStyle — the same shape with no color
member. The name is the text that identifies the swatch, so its colour is
the colourway's colour (see "Swatch colours"); an authored one would have
nothing left to mean, so the member, the inspector row and the view all go
without it rather than leaving a control the render happens to ignore. A
stored document that still carries nameTextStyle.color renders the swatch
colour anyway, because the view sets color after spreading the style.
lineTextStyle / detailTextStyle keep color with the ordinary "empty means
unset" reading.
Card defaults
| Knob | Default | Note |
| --- | --- | --- |
| bgColor | transparent | Keeps the page background |
| width | 67.5vw | The original's PC width |
| height | 35vw | 0 falls back to a 16 / 9 frame |
| padding | 0 | Insets the image — see the grid-cell note above |
| borderRadius | 1.66667vw | The original's PC radius |
| align | center | Where the card sits across the section: left / center / right → mr-auto / mx-auto / ml-auto. Only horizontal — the card's vertical position is its flow position |
| imageFade | off | off / on. on masks the hero image's outer edge; off keeps the whole picture — see "Image art direction" |
A stored document from before align (or imageFade) existed renders centred
(unfaded): the adapter merges defaultProps.cardStyle under whatever the node
stored, so a missing key falls back rather than crashing (Puck does not
re-apply defaultProps at render time — it passes the node's stored props
through verbatim).
Swatch defaults are 2.8125vw discs, 1.25vw apart, with a 0.10416666667vw
white ring — all three from the original.
Swatch colours
A colourway authors two colours, swatchColorFrom and swatchColorTo,
each a picker + hex input. Equal values paint a flat disc; different values
paint a top-to-bottom linear-gradient.
Two authored stops rather than one CSS string, because a raw
linear-gradient text field is a textarea over CSS syntax — it cannot offer a
picker and it cannot validate. The trade-off is that the original's
3–5 stop gradients collapse to their two endpoints; the middle stops are
listed under "Default content" for anyone restoring them.
swatchColorFrom (falling back to swatchColorTo) also doubles as the
colour of the colourway: it is what the caption's name run uses as its colour,
via colorwayColor. Both empty paints nothing — the fleet-wide "not authored"
convention — and leaves the name inheriting the page colour rather than
painting black.
Style targets
root · card · image · swatches · swatch · textStrip · name ·
line · detail
All nine are declared in metadata.anvilkit.editor.styleTargets and stamped in
every render mode, including edit mode. image, swatch, name, line
and detail repeat per colourway — a target is a class of element, so one
authored rule reaches all of them.
image stamps the box around the <img>, not the <img> itself, so the
target survives a colourway with no authored source (the box renders a muted
placeholder) and a section with no colourways at all. swatch stamps the live
<button> in production and the inert <span> in edit mode.
Default content and the original's assets
defaultProps ships five colourways with English names. The Huawei original's
copy, for anyone restoring it:
| Default name | Original name | Original description | | --- | --- | --- | | Orange Ocean | 橘子海 | 橘光跃动海面,假期正式加载。 | | Glimmer Purple | 霞光紫 | 霓虹与晚霞相拥,谁在悄悄心动。 | | Emerald Lake | 翡翠湖 | 湖面清透如镜,此刻只想放空。 | | Morning Gold | 晨曦金 | 阳光照进窗台,今日元气满满。 | | Obsidian Black | 曜石黑 | 繁星划开夜色,世界安静下来。 |
The swatch colours are the original gradients' two endpoints, not the
gradients themselves. The originals carry three to five stops; the editor
authors two, so the defaults are #…From / #…To taken from the first and
last stop. For Orange Ocean that means the midpoint is a straight
#5DC6E5 → #F96517 interpolation instead of the original's pale
#FCE1C7 — a visible, deliberate trade-off (see "Swatch colours" above).
The full originals, verbatim, for anyone who needs them:
| Default name | Original linear-gradient |
| --- | --- |
| Orange Ocean | linear-gradient(to bottom, #5DC6E5 0%, #B6E7F2 30%, #FCE1C7 50%, #FF9D4E 70%, #F96517 100%) |
| Glimmer Purple | linear-gradient(to bottom, #7E78C0 0%, #9B86B3 30%, #C6999B 50%, #F4C6A1 70%, #FDE9D1 100%) |
| Emerald Lake | linear-gradient(to bottom, #49582B 0%, #5C6549 30%, #858B7A 50%, #B8BAB2 70%, #E9E9EA 100%) |
| Morning Gold | linear-gradient(to bottom, #F4F4F4 0%, #FFFFFF 45%, #F2E5CE 100%) |
| Obsidian Black | linear-gradient(to bottom, #A9A7A3 0%, #403E3A 50%, #191713 100%) |
gradientField (raw CSS text) is still exported and is what you'd use to
restore them exactly; no field of this package uses it any more.
The image sources ship empty on purpose — this repo carries no
content/dam assets, and an empty source is the fleet-wide "not authored"
convention. The originals were /content/dam/.../design-color-{1..5}.webp
with alt text HUAWEI Pura 90 Pro Max <name>.
i18n
Editor chrome is translated (en / zh / ja / ko, 72 keys per locale):
field and row labels, style-target labels, the swatch group's aria label, the
per-swatch "Show {name}" template, and the empty-state hint. Default page
content stays English, per the workspace i18n scope. The factory injects the
localized strings; the static componentConfig export stays English, and
createComponentConfig() deep-equals it.
Styling
styles.css imports @anvilkit/tailwind-config/component and scans this
package's sources. The active swatch's scale is driven by Tailwind data
variants (data-[pcs-active=true]:scale-110) rather than a conditional class
list, so the controller's attribute write and a fresh server render produce
byte-identical styling.
License
MIT
