@hjmds/design-contracts 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/image.d.ts CHANGED
@@ -108,5 +108,41 @@ export declare const imageViewerTriggerRecipe: {
108
108
  readonly disabledOpacity: 0.5;
109
109
  };
110
110
  export declare function validateImageViewerTriggerLabel(label: string): void;
111
+ /**
112
+ * Circular profile-photo crop (2026-10-29 user rule: profile photos are framed in a circle like Instagram before
113
+ * upload). Web and Native draw the same square viewport with a circular mask; the product crops the returned
114
+ * source-pixel square and uploads it. The square keeps the circle's bounding box, so a cover-fitted avatar shows
115
+ * exactly the framed circle.
116
+ */
117
+ export declare const circularPhotoCropRecipe: {
118
+ /** Largest zoom over the cover scale; beyond this a small photo turns into blur. */
119
+ readonly maxZoom: 4;
120
+ /** Mask outside the circle (on the black editing surface). */
121
+ readonly maskOpacity: 0.6;
122
+ /** Hairline around the circle so its edge reads on dark and light photos. */
123
+ readonly ringWidth: 1;
124
+ /** Horizontal space kept around the viewport, in px/pt. */
125
+ readonly gutter: 24;
126
+ };
127
+ export type CircularPhotoCropState = Readonly<{
128
+ zoom: number;
129
+ x: number;
130
+ y: number;
131
+ }>;
132
+ export type CircularPhotoCropRect = Readonly<{
133
+ originX: number;
134
+ originY: number;
135
+ width: number;
136
+ height: number;
137
+ }>;
138
+ /** Display px per source px at zoom 1: the photo covers the viewport on its short side. */
139
+ export declare function circularPhotoCoverScale(sourceWidth: number, sourceHeight: number, viewport: number): number;
140
+ /**
141
+ * Keeps the photo covering the viewport: zoom within [1, maxZoom] and the offset (photo centre from viewport
142
+ * centre, display px) within the overhang, so the circle never shows empty space.
143
+ */
144
+ export declare function clampCircularPhotoCrop(state: CircularPhotoCropState, sourceWidth: number, sourceHeight: number, viewport: number): CircularPhotoCropState;
145
+ /** Source-pixel square under the viewport, rounded inside the photo bounds. */
146
+ export declare function resolveCircularPhotoCrop(state: CircularPhotoCropState, sourceWidth: number, sourceHeight: number, viewport: number): CircularPhotoCropRect;
111
147
  export {};
112
148
  //# sourceMappingURL=image.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"image.d.ts","sourceRoot":"","sources":["../src/image.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,CAAC;AACpD,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,SAAS,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEtE,MAAM,MAAM,mBAAmB,GAAG,KAAK,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE9D,sGAAsG;AACtG,wBAAgB,8BAA8B,CAC5C,KAAK,EAAE,QAAQ,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,EAClD,QAAQ,EAAE,QAAQ,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,EACrD,IAAI,EAAE,mBAAmB;;;;;;;;EAiB1B;AAED,eAAO,MAAM,aAAa;;CAEsB,CAAC;AAEjD,KAAK,eAAe,GAAG,QAAQ,CAAC;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,wEAAwE;IACxE,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,QAAQ,CAAC;CAChB,CAAC,CAAC;AAEH;;;;GAIG;AACH,MAAM,MAAM,yBAAyB,GAAG,eAAe,GACrD,QAAQ,CAAC;IAAE,UAAU,CAAC,EAAE,IAAI,CAAC;IAAC,kBAAkB,CAAC,EAAE,KAAK,CAAA;CAAE,CAAC,CAAC;AAE9D,MAAM,MAAM,0BAA0B,GAAG,eAAe,GACtD,QAAQ,CAAC;IAAE,UAAU,EAAE,KAAK,CAAC;IAAC,kBAAkB,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAE9D,MAAM,MAAM,eAAe,GACvB,yBAAyB,GACzB,0BAA0B,CAAC;AAE/B,MAAM,MAAM,uBAAuB,GAC/B,CAAC,QAAQ,CAAC,IAAI,CAAC,yBAAyB,EAAE,oBAAoB,CAAC,CAAC,GAC9D,QAAQ,CAAC;IAAE,kBAAkB,CAAC,EAAE,KAAK,CAAA;CAAE,CAAC,CAAC,GAC3C,QAAQ,CAAC,0BAA0B,CAAC,CAAC;AAgBzC,wBAAgB,uBAAuB,CAAC,UAAU,EAAE,eAAe,GAAG,IAAI,CA6BzE;AAED,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,GAC1B,uBAAuB,CAgBzB;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAI7E;AAED;;;;;GAKG;AACH,wBAAgB,sCAAsC,CACpD,UAAU,EAAE,uBAAuB,GAClC,MAAM,GAAG,SAAS,CAEpB;AAKD;;;;GAIG;AACH,eAAO,MAAM,iBAAiB;;;;CAIe,CAAC;AAE9C,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;CAYd,CAAC;AAEX;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB;;;;;CAK3B,CAAC;AAEX,wBAAgB,+BAA+B,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAEnE"}
1
+ {"version":3,"file":"image.d.ts","sourceRoot":"","sources":["../src/image.ts"],"names":[],"mappings":"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"}
package/dist/image.js CHANGED
@@ -133,4 +133,48 @@ export function validateImageViewerTriggerLabel(label) {
133
133
  if (typeof label !== "string" || label.trim().length === 0)
134
134
  throw new TypeError("ImageViewerTrigger needs a localized label");
135
135
  }
136
+ /**
137
+ * Circular profile-photo crop (2026-10-29 user rule: profile photos are framed in a circle like Instagram before
138
+ * upload). Web and Native draw the same square viewport with a circular mask; the product crops the returned
139
+ * source-pixel square and uploads it. The square keeps the circle's bounding box, so a cover-fitted avatar shows
140
+ * exactly the framed circle.
141
+ */
142
+ export const circularPhotoCropRecipe = {
143
+ /** Largest zoom over the cover scale; beyond this a small photo turns into blur. */
144
+ maxZoom: 4,
145
+ /** Mask outside the circle (on the black editing surface). */
146
+ maskOpacity: 0.6,
147
+ /** Hairline around the circle so its edge reads on dark and light photos. */
148
+ ringWidth: 1,
149
+ /** Horizontal space kept around the viewport, in px/pt. */
150
+ gutter: 24,
151
+ };
152
+ /** Display px per source px at zoom 1: the photo covers the viewport on its short side. */
153
+ export function circularPhotoCoverScale(sourceWidth, sourceHeight, viewport) {
154
+ if (!(sourceWidth > 0 && sourceHeight > 0 && viewport > 0))
155
+ throw new RangeError("Crop needs positive source and viewport sizes");
156
+ return viewport / Math.min(sourceWidth, sourceHeight);
157
+ }
158
+ /**
159
+ * Keeps the photo covering the viewport: zoom within [1, maxZoom] and the offset (photo centre from viewport
160
+ * centre, display px) within the overhang, so the circle never shows empty space.
161
+ */
162
+ export function clampCircularPhotoCrop(state, sourceWidth, sourceHeight, viewport) {
163
+ const zoom = Math.min(circularPhotoCropRecipe.maxZoom, Math.max(1, state.zoom));
164
+ const scale = circularPhotoCoverScale(sourceWidth, sourceHeight, viewport) * zoom;
165
+ const limitX = Math.max(0, (sourceWidth * scale - viewport) / 2);
166
+ const limitY = Math.max(0, (sourceHeight * scale - viewport) / 2);
167
+ return { zoom, x: Math.min(limitX, Math.max(-limitX, state.x)), y: Math.min(limitY, Math.max(-limitY, state.y)) };
168
+ }
169
+ /** Source-pixel square under the viewport, rounded inside the photo bounds. */
170
+ export function resolveCircularPhotoCrop(state, sourceWidth, sourceHeight, viewport) {
171
+ const clamped = clampCircularPhotoCrop(state, sourceWidth, sourceHeight, viewport);
172
+ const scale = circularPhotoCoverScale(sourceWidth, sourceHeight, viewport) * clamped.zoom;
173
+ const size = Math.min(sourceWidth, sourceHeight, Math.round(viewport / scale));
174
+ const centreX = sourceWidth / 2 - clamped.x / scale;
175
+ const centreY = sourceHeight / 2 - clamped.y / scale;
176
+ const originX = Math.min(sourceWidth - size, Math.max(0, Math.round(centreX - size / 2)));
177
+ const originY = Math.min(sourceHeight - size, Math.max(0, Math.round(centreY - size / 2)));
178
+ return { originX, originY, width: size, height: size };
179
+ }
136
180
  //# sourceMappingURL=image.js.map
package/dist/image.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"image.js","sourceRoot":"","sources":["../src/image.ts"],"names":[],"mappings":"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","sourcesContent":["import type { SemanticIconName } from \"./icon.js\";\nimport type { IconTone } from \"./component-recipes.js\";\nimport { semanticColors } from \"./semantic-colors.js\";\nimport { opacity } from \"./foundations.js\";\n\nexport type ImageFit = \"cover\" | \"contain\" | \"fill\";\nexport type ImageLoadStatus = \"idle\" | \"loading\" | \"loaded\" | \"error\";\n\nexport type ImageInspectionMode = \"fit\" | \"double\" | \"pixels\";\n\n/** Exact image inspection sizes use layout units, not device physical pixels (Utilverse ADR-0020). */\nexport function resolveImageInspectionGeometry(\n image: Readonly<{ width: number; height: number }>,\n viewport: Readonly<{ width: number; height: number }>,\n mode: ImageInspectionMode,\n) {\n if (![image.width, image.height, viewport.width, viewport.height].every(value => Number.isFinite(value) && value > 0)) {\n throw new RangeError(\"Image inspection needs positive finite image and viewport sizes\");\n }\n if (mode !== \"fit\" && mode !== \"double\" && mode !== \"pixels\") throw new TypeError(\"Unknown image inspection mode\");\n // Fit can enlarge a small result, as the existing product preview does. Clamping\n // it to 1 would make fit and pixels indistinguishable for those images.\n const scale = mode === \"pixels\" ? 1 : Math.min(viewport.width / image.width, viewport.height / image.height) * (mode === \"double\" ? 2 : 1);\n const width = image.width * scale, height = image.height * scale;\n if (![scale, width, height].every(value => Number.isFinite(value) && value > 0)) {\n throw new RangeError(\"Image inspection geometry exceeds finite layout range\");\n }\n return { scale, width, height, panBounds: {\n x: Math.max(0, width - viewport.width) / 2,\n y: Math.max(0, height - viewport.height) / 2,\n } };\n}\n\nexport const imageDefaults = {\n fit: \"cover\",\n} as const satisfies Readonly<{ fit: ImageFit }>;\n\ntype ImageAppearance = Readonly<{\n src: string;\n /** Intrinsic dimensions reserve layout space before the asset loads. */\n width: number;\n height: number;\n fit?: ImageFit;\n}>;\n\n/**\n * Same shape as Icon's decorative/informative split: a picture that only\n * repeats a caption or label already visible nearby stays decorative, and\n * only a self-sufficient picture requires localized alt copy.\n */\nexport type DecorativeImageDescriptor = ImageAppearance &\n Readonly<{ decorative?: true; accessibilityLabel?: never }>;\n\nexport type InformativeImageDescriptor = ImageAppearance &\n Readonly<{ decorative: false; accessibilityLabel: string }>;\n\nexport type ImageDescriptor =\n | DecorativeImageDescriptor\n | InformativeImageDescriptor;\n\nexport type ResolvedImageDescriptor =\n | (Required<Omit<DecorativeImageDescriptor, \"accessibilityLabel\">> &\n Readonly<{ accessibilityLabel?: never }>)\n | Required<InformativeImageDescriptor>;\n\nconst fits: readonly ImageFit[] = [\"cover\", \"contain\", \"fill\"];\n\nfunction assertCopy(value: string, field: string): void {\n if (value.trim().length === 0) {\n throw new TypeError(`Image ${field} must not be empty`);\n }\n}\n\nfunction assertDimension(value: number, field: string): void {\n if (!Number.isFinite(value) || value <= 0) {\n throw new RangeError(`Image ${field} must be a positive finite number`);\n }\n}\n\nexport function validateImageDescriptor(descriptor: ImageDescriptor): void {\n assertCopy(descriptor.src, \"src\");\n assertDimension(descriptor.width, \"width\");\n assertDimension(descriptor.height, \"height\");\n if (descriptor.fit !== undefined && !fits.includes(descriptor.fit)) {\n throw new TypeError(`Unsupported Image fit: ${String(descriptor.fit)}`);\n }\n const runtimeDecorative = (\n descriptor as Readonly<{ decorative?: unknown }>\n ).decorative;\n const runtimeLabel = (\n descriptor as Readonly<{ accessibilityLabel?: unknown }>\n ).accessibilityLabel;\n if (\n runtimeDecorative !== undefined &&\n runtimeDecorative !== true &&\n runtimeDecorative !== false\n ) {\n throw new TypeError(\"Image decorative must be a boolean when provided\");\n }\n if (runtimeDecorative === false) {\n if (typeof runtimeLabel !== \"string\" || runtimeLabel.trim().length === 0) {\n throw new TypeError(\n \"Informative Image accessibilityLabel must not be empty\",\n );\n }\n } else if (runtimeLabel !== undefined) {\n throw new TypeError(\"Decorative Image must not provide accessibilityLabel\");\n }\n}\n\nexport function resolveImageDescriptor(\n descriptor: ImageDescriptor,\n): ResolvedImageDescriptor {\n validateImageDescriptor(descriptor);\n const common = {\n src: descriptor.src,\n width: descriptor.width,\n height: descriptor.height,\n fit: descriptor.fit ?? imageDefaults.fit,\n } as const;\n if (descriptor.decorative === false) {\n return {\n ...common,\n decorative: false,\n accessibilityLabel: descriptor.accessibilityLabel,\n } as ResolvedImageDescriptor;\n }\n return { ...common, decorative: true } as ResolvedImageDescriptor;\n}\n\n/**\n * Reserves layout space ahead of load so a slow asset never shifts\n * surrounding content — the same problem `width`/`height` solve for\n * `next/image`, translated without a bundler dependency.\n */\nexport function resolveImageAspectRatio(width: number, height: number): number {\n assertDimension(width, \"width\");\n assertDimension(height, \"height\");\n return width / height;\n}\n\n/**\n * The fallback node on `error` always carries the resolved\n * `accessibilityLabel`, never a generic \"broken image\" string. An\n * informative picture's meaning does not disappear because the asset\n * failed to load; only its visual form changes.\n */\nexport function resolveImageFallbackAccessibilityLabel(\n descriptor: ResolvedImageDescriptor,\n): string | undefined {\n return descriptor.decorative ? undefined : descriptor.accessibilityLabel;\n}\n\nconst errorIconName: SemanticIconName = \"error\";\nconst fallbackIconTone: IconTone = \"secondary\";\n\n/**\n * Web `object-fit` and RN `resizeMode` diverge only in the `fill` case;\n * `fills` is the platform-neutral name and `nativeResizeModes` is the one\n * translation renderers need, kept here instead of re-derived per product.\n */\nexport const nativeResizeModes = {\n cover: \"cover\",\n contain: \"contain\",\n fill: \"stretch\",\n} as const satisfies Record<ImageFit, string>;\n\nexport const imageRecipe = {\n slots: [\"root\", \"image\", \"placeholder\", \"fallbackIcon\"] as const,\n defaults: imageDefaults,\n fits,\n placeholder: {\n background: semanticColors.canvas,\n },\n fallback: {\n background: semanticColors.canvas,\n icon: { name: errorIconName, tone: fallbackIconTone },\n },\n radius: \"md\",\n} as const;\n\n/**\n * Press target that opens ImageViewer from product media (ImageViewerTrigger). 2026-10-23: the portfolio site\n * (`App.tsx` raw button) and Spint (`MarkPhoto`, `ComposeScreen` Pressable + Image) each wrapped an image to open the\n * viewer, with their own name, focus ring and cursor. The trigger owns the button semantics, the image-frame\n * radius for the focus ring, the zoom cursor and the pressed opacity; the media stays decorative inside it.\n */\nexport const imageViewerTriggerRecipe = {\n radius: imageRecipe.radius,\n cursor: \"zoom-in\",\n pressedOpacity: opacity.pressed,\n disabledOpacity: opacity.disabled,\n} as const;\n\nexport function validateImageViewerTriggerLabel(label: string): void {\n if (typeof label !== \"string\" || label.trim().length === 0) throw new TypeError(\"ImageViewerTrigger needs a localized label\");\n}\n"]}
1
+ {"version":3,"file":"image.js","sourceRoot":"","sources":["../src/image.ts"],"names":[],"mappings":"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","sourcesContent":["import type { SemanticIconName } from \"./icon.js\";\nimport type { IconTone } from \"./component-recipes.js\";\nimport { semanticColors } from \"./semantic-colors.js\";\nimport { opacity } from \"./foundations.js\";\n\nexport type ImageFit = \"cover\" | \"contain\" | \"fill\";\nexport type ImageLoadStatus = \"idle\" | \"loading\" | \"loaded\" | \"error\";\n\nexport type ImageInspectionMode = \"fit\" | \"double\" | \"pixels\";\n\n/** Exact image inspection sizes use layout units, not device physical pixels (Utilverse ADR-0020). */\nexport function resolveImageInspectionGeometry(\n image: Readonly<{ width: number; height: number }>,\n viewport: Readonly<{ width: number; height: number }>,\n mode: ImageInspectionMode,\n) {\n if (![image.width, image.height, viewport.width, viewport.height].every(value => Number.isFinite(value) && value > 0)) {\n throw new RangeError(\"Image inspection needs positive finite image and viewport sizes\");\n }\n if (mode !== \"fit\" && mode !== \"double\" && mode !== \"pixels\") throw new TypeError(\"Unknown image inspection mode\");\n // Fit can enlarge a small result, as the existing product preview does. Clamping\n // it to 1 would make fit and pixels indistinguishable for those images.\n const scale = mode === \"pixels\" ? 1 : Math.min(viewport.width / image.width, viewport.height / image.height) * (mode === \"double\" ? 2 : 1);\n const width = image.width * scale, height = image.height * scale;\n if (![scale, width, height].every(value => Number.isFinite(value) && value > 0)) {\n throw new RangeError(\"Image inspection geometry exceeds finite layout range\");\n }\n return { scale, width, height, panBounds: {\n x: Math.max(0, width - viewport.width) / 2,\n y: Math.max(0, height - viewport.height) / 2,\n } };\n}\n\nexport const imageDefaults = {\n fit: \"cover\",\n} as const satisfies Readonly<{ fit: ImageFit }>;\n\ntype ImageAppearance = Readonly<{\n src: string;\n /** Intrinsic dimensions reserve layout space before the asset loads. */\n width: number;\n height: number;\n fit?: ImageFit;\n}>;\n\n/**\n * Same shape as Icon's decorative/informative split: a picture that only\n * repeats a caption or label already visible nearby stays decorative, and\n * only a self-sufficient picture requires localized alt copy.\n */\nexport type DecorativeImageDescriptor = ImageAppearance &\n Readonly<{ decorative?: true; accessibilityLabel?: never }>;\n\nexport type InformativeImageDescriptor = ImageAppearance &\n Readonly<{ decorative: false; accessibilityLabel: string }>;\n\nexport type ImageDescriptor =\n | DecorativeImageDescriptor\n | InformativeImageDescriptor;\n\nexport type ResolvedImageDescriptor =\n | (Required<Omit<DecorativeImageDescriptor, \"accessibilityLabel\">> &\n Readonly<{ accessibilityLabel?: never }>)\n | Required<InformativeImageDescriptor>;\n\nconst fits: readonly ImageFit[] = [\"cover\", \"contain\", \"fill\"];\n\nfunction assertCopy(value: string, field: string): void {\n if (value.trim().length === 0) {\n throw new TypeError(`Image ${field} must not be empty`);\n }\n}\n\nfunction assertDimension(value: number, field: string): void {\n if (!Number.isFinite(value) || value <= 0) {\n throw new RangeError(`Image ${field} must be a positive finite number`);\n }\n}\n\nexport function validateImageDescriptor(descriptor: ImageDescriptor): void {\n assertCopy(descriptor.src, \"src\");\n assertDimension(descriptor.width, \"width\");\n assertDimension(descriptor.height, \"height\");\n if (descriptor.fit !== undefined && !fits.includes(descriptor.fit)) {\n throw new TypeError(`Unsupported Image fit: ${String(descriptor.fit)}`);\n }\n const runtimeDecorative = (\n descriptor as Readonly<{ decorative?: unknown }>\n ).decorative;\n const runtimeLabel = (\n descriptor as Readonly<{ accessibilityLabel?: unknown }>\n ).accessibilityLabel;\n if (\n runtimeDecorative !== undefined &&\n runtimeDecorative !== true &&\n runtimeDecorative !== false\n ) {\n throw new TypeError(\"Image decorative must be a boolean when provided\");\n }\n if (runtimeDecorative === false) {\n if (typeof runtimeLabel !== \"string\" || runtimeLabel.trim().length === 0) {\n throw new TypeError(\n \"Informative Image accessibilityLabel must not be empty\",\n );\n }\n } else if (runtimeLabel !== undefined) {\n throw new TypeError(\"Decorative Image must not provide accessibilityLabel\");\n }\n}\n\nexport function resolveImageDescriptor(\n descriptor: ImageDescriptor,\n): ResolvedImageDescriptor {\n validateImageDescriptor(descriptor);\n const common = {\n src: descriptor.src,\n width: descriptor.width,\n height: descriptor.height,\n fit: descriptor.fit ?? imageDefaults.fit,\n } as const;\n if (descriptor.decorative === false) {\n return {\n ...common,\n decorative: false,\n accessibilityLabel: descriptor.accessibilityLabel,\n } as ResolvedImageDescriptor;\n }\n return { ...common, decorative: true } as ResolvedImageDescriptor;\n}\n\n/**\n * Reserves layout space ahead of load so a slow asset never shifts\n * surrounding content — the same problem `width`/`height` solve for\n * `next/image`, translated without a bundler dependency.\n */\nexport function resolveImageAspectRatio(width: number, height: number): number {\n assertDimension(width, \"width\");\n assertDimension(height, \"height\");\n return width / height;\n}\n\n/**\n * The fallback node on `error` always carries the resolved\n * `accessibilityLabel`, never a generic \"broken image\" string. An\n * informative picture's meaning does not disappear because the asset\n * failed to load; only its visual form changes.\n */\nexport function resolveImageFallbackAccessibilityLabel(\n descriptor: ResolvedImageDescriptor,\n): string | undefined {\n return descriptor.decorative ? undefined : descriptor.accessibilityLabel;\n}\n\nconst errorIconName: SemanticIconName = \"error\";\nconst fallbackIconTone: IconTone = \"secondary\";\n\n/**\n * Web `object-fit` and RN `resizeMode` diverge only in the `fill` case;\n * `fills` is the platform-neutral name and `nativeResizeModes` is the one\n * translation renderers need, kept here instead of re-derived per product.\n */\nexport const nativeResizeModes = {\n cover: \"cover\",\n contain: \"contain\",\n fill: \"stretch\",\n} as const satisfies Record<ImageFit, string>;\n\nexport const imageRecipe = {\n slots: [\"root\", \"image\", \"placeholder\", \"fallbackIcon\"] as const,\n defaults: imageDefaults,\n fits,\n placeholder: {\n background: semanticColors.canvas,\n },\n fallback: {\n background: semanticColors.canvas,\n icon: { name: errorIconName, tone: fallbackIconTone },\n },\n radius: \"md\",\n} as const;\n\n/**\n * Press target that opens ImageViewer from product media (ImageViewerTrigger). 2026-10-23: the portfolio site\n * (`App.tsx` raw button) and Spint (`MarkPhoto`, `ComposeScreen` Pressable + Image) each wrapped an image to open the\n * viewer, with their own name, focus ring and cursor. The trigger owns the button semantics, the image-frame\n * radius for the focus ring, the zoom cursor and the pressed opacity; the media stays decorative inside it.\n */\nexport const imageViewerTriggerRecipe = {\n radius: imageRecipe.radius,\n cursor: \"zoom-in\",\n pressedOpacity: opacity.pressed,\n disabledOpacity: opacity.disabled,\n} as const;\n\nexport function validateImageViewerTriggerLabel(label: string): void {\n if (typeof label !== \"string\" || label.trim().length === 0) throw new TypeError(\"ImageViewerTrigger needs a localized label\");\n}\n\n/**\n * Circular profile-photo crop (2026-10-29 user rule: profile photos are framed in a circle like Instagram before\n * upload). Web and Native draw the same square viewport with a circular mask; the product crops the returned\n * source-pixel square and uploads it. The square keeps the circle's bounding box, so a cover-fitted avatar shows\n * exactly the framed circle.\n */\nexport const circularPhotoCropRecipe = {\n /** Largest zoom over the cover scale; beyond this a small photo turns into blur. */\n maxZoom: 4,\n /** Mask outside the circle (on the black editing surface). */\n maskOpacity: 0.6,\n /** Hairline around the circle so its edge reads on dark and light photos. */\n ringWidth: 1,\n /** Horizontal space kept around the viewport, in px/pt. */\n gutter: 24,\n} as const;\n\nexport type CircularPhotoCropState = Readonly<{ zoom: number; x: number; y: number }>;\nexport type CircularPhotoCropRect = Readonly<{ originX: number; originY: number; width: number; height: number }>;\n\n/** Display px per source px at zoom 1: the photo covers the viewport on its short side. */\nexport function circularPhotoCoverScale(sourceWidth: number, sourceHeight: number, viewport: number): number {\n if (!(sourceWidth > 0 && sourceHeight > 0 && viewport > 0)) throw new RangeError(\"Crop needs positive source and viewport sizes\");\n return viewport / Math.min(sourceWidth, sourceHeight);\n}\n\n/**\n * Keeps the photo covering the viewport: zoom within [1, maxZoom] and the offset (photo centre from viewport\n * centre, display px) within the overhang, so the circle never shows empty space.\n */\nexport function clampCircularPhotoCrop(state: CircularPhotoCropState, sourceWidth: number, sourceHeight: number, viewport: number): CircularPhotoCropState {\n const zoom = Math.min(circularPhotoCropRecipe.maxZoom, Math.max(1, state.zoom));\n const scale = circularPhotoCoverScale(sourceWidth, sourceHeight, viewport) * zoom;\n const limitX = Math.max(0, (sourceWidth * scale - viewport) / 2);\n const limitY = Math.max(0, (sourceHeight * scale - viewport) / 2);\n return { zoom, x: Math.min(limitX, Math.max(-limitX, state.x)), y: Math.min(limitY, Math.max(-limitY, state.y)) };\n}\n\n/** Source-pixel square under the viewport, rounded inside the photo bounds. */\nexport function resolveCircularPhotoCrop(state: CircularPhotoCropState, sourceWidth: number, sourceHeight: number, viewport: number): CircularPhotoCropRect {\n const clamped = clampCircularPhotoCrop(state, sourceWidth, sourceHeight, viewport);\n const scale = circularPhotoCoverScale(sourceWidth, sourceHeight, viewport) * clamped.zoom;\n const size = Math.min(sourceWidth, sourceHeight, Math.round(viewport / scale));\n const centreX = sourceWidth / 2 - clamped.x / scale;\n const centreY = sourceHeight / 2 - clamped.y / scale;\n const originX = Math.min(sourceWidth - size, Math.max(0, Math.round(centreX - size / 2)));\n const originY = Math.min(sourceHeight - size, Math.max(0, Math.round(centreY - size / 2)));\n return { originX, originY, width: size, height: size };\n}\n"]}
package/dist/tag.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { type ColorReferencePalette } from "./color-references.js";
2
2
  export type TagTone = "neutral" | "info" | "success" | "attention" | "brand";
3
+ export type TagVariant = "default" | "outline";
3
4
  export declare const tagDefaults: {
4
5
  readonly tone: "neutral";
6
+ readonly variant: "outline";
5
7
  };
6
8
  /**
7
9
  * A single piece of static metadata (a position, a grade, a season). Tag has
@@ -11,23 +13,21 @@ export declare const tagDefaults: {
11
13
  export type TagDescriptor = Readonly<{
12
14
  label: string;
13
15
  tone?: TagTone;
16
+ variant?: TagVariant;
14
17
  }>;
15
18
  export type ResolvedTagDescriptor = Readonly<{
16
19
  label: string;
17
20
  tone: TagTone;
21
+ variant: TagVariant;
18
22
  }>;
19
23
  export declare function validateTagDescriptor(descriptor: TagDescriptor): void;
20
24
  export declare function resolveTagDescriptor(descriptor: TagDescriptor): ResolvedTagDescriptor;
21
- /**
22
- * Rectangular, not the pill `radius.full` that `chipRecipe`
23
- * use, so a static Tag never reads as a pressable Chip or a status Badge at a
24
- * glance. No `states` and no `focus` contract: nothing here is ever pressed
25
- * or focused.
26
- */
25
+ /** Static metadata has no focus or press contract, including the outline capsule. */
27
26
  export declare const tagRecipe: {
28
27
  readonly slots: readonly ["root", "label"];
29
28
  readonly defaults: {
30
29
  readonly tone: "neutral";
30
+ readonly variant: "outline";
31
31
  };
32
32
  readonly tones: {
33
33
  readonly neutral: {
@@ -123,6 +123,19 @@ export declare const tagRecipe: {
123
123
  readonly textVariant: "caption";
124
124
  readonly fontWeight: "500";
125
125
  };
126
+ readonly outline: {
127
+ readonly minHeight: 32;
128
+ readonly paddingHorizontal: 10;
129
+ readonly paddingVertical: 6;
130
+ readonly radius: "full";
131
+ readonly fontWeight: "400";
132
+ readonly background: "transparent";
133
+ readonly content: Readonly<{
134
+ source: "theme";
135
+ key: "text";
136
+ alpha?: number;
137
+ }>;
138
+ };
126
139
  readonly radius: "sm";
127
140
  readonly borderWidth: 1;
128
141
  };
@@ -132,5 +145,5 @@ export type ResolvedTagPresentation = Readonly<{
132
145
  border: string | null;
133
146
  }>;
134
147
  /** Resolve the same Tag recipe for non-CSS renderers. */
135
- export declare function resolveTagPresentation(tone: TagTone, palette: ColorReferencePalette): ResolvedTagPresentation;
148
+ export declare function resolveTagPresentation(tone: TagTone, palette: ColorReferencePalette, variant?: TagVariant): ResolvedTagPresentation;
136
149
  //# sourceMappingURL=tag.d.ts.map
package/dist/tag.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"tag.d.ts","sourceRoot":"","sources":["../src/tag.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,qBAAqB,EAC3B,MAAM,uBAAuB,CAAC;AAI/B,MAAM,MAAM,OAAO,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,GAAG,WAAW,GAAG,OAAO,CAAC;AAE7E,eAAO,MAAM,WAAW;;CAEwB,CAAC;AAEjD;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,QAAQ,CAAC;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB,CAAC,CAAC;AAEH,MAAM,MAAM,qBAAqB,GAAG,QAAQ,CAAC;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,OAAO,CAAC;CACf,CAAC,CAAC;AAgBH,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,aAAa,GAAG,IAAI,CAQrE;AAED,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,aAAa,GACxB,qBAAqB,CAMvB;AAED;;;;;GAKG;AACH,eAAO,MAAM,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2CZ,CAAC;AAEX,MAAM,MAAM,uBAAuB,GAAG,QAAQ,CAAC;IAC7C,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,CAAC,CAAC;AAEH,yDAAyD;AACzD,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,OAAO,EACb,OAAO,EAAE,qBAAqB,GAC7B,uBAAuB,CAUzB"}
1
+ {"version":3,"file":"tag.d.ts","sourceRoot":"","sources":["../src/tag.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,qBAAqB,EAC3B,MAAM,uBAAuB,CAAC;AAI/B,MAAM,MAAM,OAAO,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,GAAG,WAAW,GAAG,OAAO,CAAC;AAE7E,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,SAAS,CAAC;AAE/C,eAAO,MAAM,WAAW;;;CAI6C,CAAC;AAEtE;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,QAAQ,CAAC;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,OAAO,CAAC,EAAE,UAAU,CAAC;CACtB,CAAC,CAAC;AAEH,MAAM,MAAM,qBAAqB,GAAG,QAAQ,CAAC;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,UAAU,CAAC;CACrB,CAAC,CAAC;AAgBH,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,aAAa,GAAG,IAAI,CAWrE;AAED,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,aAAa,GACxB,qBAAqB,CAOvB;AAED,qFAAqF;AACrF,eAAO,MAAM,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqDZ,CAAC;AAEX,MAAM,MAAM,uBAAuB,GAAG,QAAQ,CAAC;IAC7C,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,CAAC,CAAC;AAEH,yDAAyD;AACzD,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,OAAO,EACb,OAAO,EAAE,qBAAqB,EAC9B,OAAO,GAAE,UAAgC,GACxC,uBAAuB,CAUzB"}
package/dist/tag.js CHANGED
@@ -3,6 +3,8 @@ import { semanticColors } from "./semantic-colors.js";
3
3
  import { fontWeight, spacing, stroke } from "./foundations.js";
4
4
  export const tagDefaults = {
5
5
  tone: "neutral",
6
+ // The approved border-only capsule is the shared default (2026-10-29).
7
+ variant: "outline",
6
8
  };
7
9
  function assertCopy(value, field) {
8
10
  if (value.trim().length === 0) {
@@ -18,6 +20,9 @@ const supportedTones = [
18
20
  ];
19
21
  export function validateTagDescriptor(descriptor) {
20
22
  assertCopy(descriptor.label, "label");
23
+ if (descriptor.variant !== undefined && !["default", "outline"].includes(descriptor.variant)) {
24
+ throw new TypeError(`Unsupported Tag variant: ${String(descriptor.variant)}`);
25
+ }
21
26
  if (descriptor.tone !== undefined &&
22
27
  !supportedTones.includes(descriptor.tone)) {
23
28
  throw new TypeError(`Unsupported Tag tone: ${String(descriptor.tone)}`);
@@ -28,14 +33,10 @@ export function resolveTagDescriptor(descriptor) {
28
33
  return {
29
34
  label: descriptor.label,
30
35
  tone: descriptor.tone ?? tagDefaults.tone,
36
+ variant: descriptor.variant ?? tagDefaults.variant,
31
37
  };
32
38
  }
33
- /**
34
- * Rectangular, not the pill `radius.full` that `chipRecipe`
35
- * use, so a static Tag never reads as a pressable Chip or a status Badge at a
36
- * glance. No `states` and no `focus` contract: nothing here is ever pressed
37
- * or focused.
38
- */
39
+ /** Static metadata has no focus or press contract, including the outline capsule. */
39
40
  export const tagRecipe = {
40
41
  slots: ["root", "label"],
41
42
  defaults: tagDefaults,
@@ -77,15 +78,25 @@ export const tagRecipe = {
77
78
  textVariant: "caption",
78
79
  fontWeight: fontWeight.medium,
79
80
  },
81
+ // Profile fact rails need breathing room without a colored plate (2026-10-29 approved preview).
82
+ outline: {
83
+ minHeight: 32,
84
+ paddingHorizontal: 10,
85
+ paddingVertical: 6,
86
+ radius: "full",
87
+ fontWeight: fontWeight.regular,
88
+ background: "transparent",
89
+ content: semanticColors.content.primary,
90
+ },
80
91
  radius: "sm",
81
92
  borderWidth: stroke.default,
82
93
  };
83
94
  /** Resolve the same Tag recipe for non-CSS renderers. */
84
- export function resolveTagPresentation(tone, palette) {
95
+ export function resolveTagPresentation(tone, palette, variant = tagDefaults.variant) {
85
96
  const contract = tagRecipe.tones[tone];
86
97
  return {
87
- background: resolveColorReference(contract.background, palette),
88
- content: resolveColorReference(contract.content, palette),
98
+ background: variant === "outline" ? "transparent" : resolveColorReference(contract.background, palette),
99
+ content: resolveColorReference(variant === "outline" && tone === "neutral" ? tagRecipe.outline.content : contract.content, palette),
89
100
  border: contract.border === null
90
101
  ? null
91
102
  : resolveColorReference(contract.border, palette),
package/dist/tag.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"tag.js","sourceRoot":"","sources":["../src/tag.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n resolveColorReference,\n type ColorReferencePalette,\n} from \"./color-references.js\";\nimport { semanticColors } from \"./semantic-colors.js\";\nimport { fontWeight, spacing, stroke } from \"./foundations.js\";\n\nexport type TagTone = \"neutral\" | \"info\" | \"success\" | \"attention\" | \"brand\";\n\nexport const tagDefaults = {\n tone: \"neutral\",\n} as const satisfies Readonly<{ tone: TagTone }>;\n\n/**\n * A single piece of static metadata (a position, a grade, a season). Tag has\n * no interaction axis at all — no selected, no pressed, no closable. Removing\n * or toggling a tag is deselection, which belongs to `Chip`.\n */\nexport type TagDescriptor = Readonly<{\n label: string;\n tone?: TagTone;\n}>;\n\nexport type ResolvedTagDescriptor = Readonly<{\n label: string;\n tone: TagTone;\n}>;\n\nfunction assertCopy(value: string, field: string): void {\n if (value.trim().length === 0) {\n throw new TypeError(`Tag ${field} must not be empty`);\n }\n}\n\nconst supportedTones: readonly TagTone[] = [\n \"neutral\",\n \"info\",\n \"success\",\n \"attention\",\n \"brand\",\n];\n\nexport function validateTagDescriptor(descriptor: TagDescriptor): void {\n assertCopy(descriptor.label, \"label\");\n if (\n descriptor.tone !== undefined &&\n !supportedTones.includes(descriptor.tone)\n ) {\n throw new TypeError(`Unsupported Tag tone: ${String(descriptor.tone)}`);\n }\n}\n\nexport function resolveTagDescriptor(\n descriptor: TagDescriptor,\n): ResolvedTagDescriptor {\n validateTagDescriptor(descriptor);\n return {\n label: descriptor.label,\n tone: descriptor.tone ?? tagDefaults.tone,\n };\n}\n\n/**\n * Rectangular, not the pill `radius.full` that `chipRecipe`\n * use, so a static Tag never reads as a pressable Chip or a status Badge at a\n * glance. No `states` and no `focus` contract: nothing here is ever pressed\n * or focused.\n */\nexport const tagRecipe = {\n slots: [\"root\", \"label\"] as const,\n defaults: tagDefaults,\n tones: {\n // Same as badgeRecipe: a canvas plate without a border vanished on the canvas (2026-10-17 review E3).\n neutral: {\n background: semanticColors.canvas,\n content: semanticColors.content.secondary,\n border: semanticColors.border.default,\n },\n brand: {\n background: semanticColors.canvas,\n content: semanticColors.content.brand,\n border: semanticColors.border.default,\n },\n info: {\n background: semanticColors.canvas,\n content: semanticColors.feedback.info.foreground,\n border: semanticColors.feedback.info.border,\n },\n success: {\n background: semanticColors.feedback.success.badgeBackground,\n content: semanticColors.feedback.success.foreground,\n border: semanticColors.feedback.success.border,\n },\n attention: {\n background: semanticColors.feedback.attention.badgeBackground,\n content: semanticColors.feedback.attention.foreground,\n border: semanticColors.feedback.attention.border,\n },\n },\n size: {\n // Static metadata still participates in Dynamic Type. A fixed 20px frame\n // clips the label at accessibility sizes; minHeight preserves the compact\n // base shape while allowing the intrinsic line box to grow.\n minHeight: 20,\n paddingHorizontal: spacing.xxs,\n gap: spacing.xxs,\n textVariant: \"caption\",\n fontWeight: fontWeight.medium,\n },\n radius: \"sm\",\n borderWidth: stroke.default,\n} as const;\n\nexport type ResolvedTagPresentation = Readonly<{\n background: string;\n content: string;\n border: string | null;\n}>;\n\n/** Resolve the same Tag recipe for non-CSS renderers. */\nexport function resolveTagPresentation(\n tone: TagTone,\n palette: ColorReferencePalette,\n): ResolvedTagPresentation {\n const contract = tagRecipe.tones[tone];\n return {\n background: resolveColorReference(contract.background, palette),\n content: resolveColorReference(contract.content, palette),\n border:\n contract.border === null\n ? null\n : resolveColorReference(contract.border, palette),\n };\n}\n"]}
1
+ {"version":3,"file":"tag.js","sourceRoot":"","sources":["../src/tag.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n resolveColorReference,\n type ColorReferencePalette,\n} from \"./color-references.js\";\nimport { semanticColors } from \"./semantic-colors.js\";\nimport { fontWeight, spacing, stroke } from \"./foundations.js\";\n\nexport type TagTone = \"neutral\" | \"info\" | \"success\" | \"attention\" | \"brand\";\n\nexport type TagVariant = \"default\" | \"outline\";\n\nexport const tagDefaults = {\n tone: \"neutral\",\n // The approved border-only capsule is the shared default (2026-10-29).\n variant: \"outline\",\n} as const satisfies Readonly<{ tone: TagTone; variant: TagVariant }>;\n\n/**\n * A single piece of static metadata (a position, a grade, a season). Tag has\n * no interaction axis at all — no selected, no pressed, no closable. Removing\n * or toggling a tag is deselection, which belongs to `Chip`.\n */\nexport type TagDescriptor = Readonly<{\n label: string;\n tone?: TagTone;\n variant?: TagVariant;\n}>;\n\nexport type ResolvedTagDescriptor = Readonly<{\n label: string;\n tone: TagTone;\n variant: TagVariant;\n}>;\n\nfunction assertCopy(value: string, field: string): void {\n if (value.trim().length === 0) {\n throw new TypeError(`Tag ${field} must not be empty`);\n }\n}\n\nconst supportedTones: readonly TagTone[] = [\n \"neutral\",\n \"info\",\n \"success\",\n \"attention\",\n \"brand\",\n];\n\nexport function validateTagDescriptor(descriptor: TagDescriptor): void {\n assertCopy(descriptor.label, \"label\");\n if (descriptor.variant !== undefined && ![\"default\", \"outline\"].includes(descriptor.variant)) {\n throw new TypeError(`Unsupported Tag variant: ${String(descriptor.variant)}`);\n }\n if (\n descriptor.tone !== undefined &&\n !supportedTones.includes(descriptor.tone)\n ) {\n throw new TypeError(`Unsupported Tag tone: ${String(descriptor.tone)}`);\n }\n}\n\nexport function resolveTagDescriptor(\n descriptor: TagDescriptor,\n): ResolvedTagDescriptor {\n validateTagDescriptor(descriptor);\n return {\n label: descriptor.label,\n tone: descriptor.tone ?? tagDefaults.tone,\n variant: descriptor.variant ?? tagDefaults.variant,\n };\n}\n\n/** Static metadata has no focus or press contract, including the outline capsule. */\nexport const tagRecipe = {\n slots: [\"root\", \"label\"] as const,\n defaults: tagDefaults,\n tones: {\n // Same as badgeRecipe: a canvas plate without a border vanished on the canvas (2026-10-17 review E3).\n neutral: {\n background: semanticColors.canvas,\n content: semanticColors.content.secondary,\n border: semanticColors.border.default,\n },\n brand: {\n background: semanticColors.canvas,\n content: semanticColors.content.brand,\n border: semanticColors.border.default,\n },\n info: {\n background: semanticColors.canvas,\n content: semanticColors.feedback.info.foreground,\n border: semanticColors.feedback.info.border,\n },\n success: {\n background: semanticColors.feedback.success.badgeBackground,\n content: semanticColors.feedback.success.foreground,\n border: semanticColors.feedback.success.border,\n },\n attention: {\n background: semanticColors.feedback.attention.badgeBackground,\n content: semanticColors.feedback.attention.foreground,\n border: semanticColors.feedback.attention.border,\n },\n },\n size: {\n // Static metadata still participates in Dynamic Type. A fixed 20px frame\n // clips the label at accessibility sizes; minHeight preserves the compact\n // base shape while allowing the intrinsic line box to grow.\n minHeight: 20,\n paddingHorizontal: spacing.xxs,\n gap: spacing.xxs,\n textVariant: \"caption\",\n fontWeight: fontWeight.medium,\n },\n // Profile fact rails need breathing room without a colored plate (2026-10-29 approved preview).\n outline: {\n minHeight: 32,\n paddingHorizontal: 10,\n paddingVertical: 6,\n radius: \"full\",\n fontWeight: fontWeight.regular,\n background: \"transparent\",\n content: semanticColors.content.primary,\n },\n radius: \"sm\",\n borderWidth: stroke.default,\n} as const;\n\nexport type ResolvedTagPresentation = Readonly<{\n background: string;\n content: string;\n border: string | null;\n}>;\n\n/** Resolve the same Tag recipe for non-CSS renderers. */\nexport function resolveTagPresentation(\n tone: TagTone,\n palette: ColorReferencePalette,\n variant: TagVariant = tagDefaults.variant,\n): ResolvedTagPresentation {\n const contract = tagRecipe.tones[tone];\n return {\n background: variant === \"outline\" ? \"transparent\" : resolveColorReference(contract.background, palette),\n content: resolveColorReference(variant === \"outline\" && tone === \"neutral\" ? tagRecipe.outline.content : contract.content, palette),\n border:\n contract.border === null\n ? null\n : resolveColorReference(contract.border, palette),\n };\n}\n"]}
package/dist/version.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  /** Package release shown by documentation surfaces. Kept in sync by a test. */
2
- export declare const designSystemVersion: "2.0.0";
2
+ export declare const designSystemVersion: "2.1.0";
3
3
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  /** Package release shown by documentation surfaces. Kept in sync by a test. */
2
- export const designSystemVersion = "2.0.0";
2
+ export const designSystemVersion = "2.1.0";
3
3
  //# sourceMappingURL=version.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,MAAM,CAAC,MAAM,mBAAmB,GAAG,OAAgB,CAAC","sourcesContent":["/** Package release shown by documentation surfaces. Kept in sync by a test. */\nexport const designSystemVersion = \"2.0.0\" as const;\n"]}
1
+ {"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,MAAM,CAAC,MAAM,mBAAmB,GAAG,OAAgB,CAAC","sourcesContent":["/** Package release shown by documentation surfaces. Kept in sync by a test. */\nexport const designSystemVersion = \"2.1.0\" as const;\n"]}
@@ -1,6 +1,6 @@
1
1
  # Component maturity and evidence manifest
2
2
 
3
- > Generated from `@hjmds/design-contracts` 2.0.0. Do not edit this file directly; run `pnpm contracts:sync`.
3
+ > Generated from `@hjmds/design-contracts` 2.1.0. Do not edit this file directly; run `pnpm contracts:sync`.
4
4
 
5
5
  This document is the human-readable projection of the same catalog and Showcase manifest consumed by product CI.
6
6
 
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "designSystemVersion": "2.0.0",
3
+ "designSystemVersion": "2.1.0",
4
4
  "source": "packages/design-contracts/docs/generated/showcase-manifest.json",
5
5
  "surfaces": {
6
6
  "web": {
7
7
  "packageName": "@hjmds/react",
8
- "packageVersion": "2.0.0",
8
+ "packageVersion": "2.1.0",
9
9
  "activeComponents": 132,
10
10
  "claimedComponents": 132,
11
11
  "completeScenarioComponents": 131,
@@ -7114,7 +7114,7 @@
7114
7114
  },
7115
7115
  "native": {
7116
7116
  "packageName": "@hjmds/react-native",
7117
- "packageVersion": "2.0.0",
7117
+ "packageVersion": "2.1.0",
7118
7118
  "activeComponents": 115,
7119
7119
  "claimedComponents": 115,
7120
7120
  "completeScenarioComponents": 112,
@@ -1,6 +1,6 @@
1
1
  # Renderer evidence coverage
2
2
 
3
- > Generated for HJM 2.0.0. Do not edit directly; run `pnpm evidence:sync`.
3
+ > Generated for HJM 2.1.0. Do not edit directly; run `pnpm evidence:sync`.
4
4
 
5
5
  This projection joins the canonical surface maturity manifest with first-party renderer claims. Missing scenarios are explicit beta promotion debt; stable surfaces are blocked by CI until none remain.
6
6
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "designSystemVersion": "2.0.0",
3
+ "designSystemVersion": "2.1.0",
4
4
  "packageName": "@hjmds/design-contracts",
5
5
  "components": [
6
6
  {
@@ -4295,7 +4295,8 @@
4295
4295
  "extensions": [
4296
4296
  "GridReveal",
4297
4297
  "ImageViewer",
4298
- "ImageViewerTrigger"
4298
+ "ImageViewerTrigger",
4299
+ "CircularPhotoCropper"
4299
4300
  ],
4300
4301
  "category": "data-display",
4301
4302
  "platform": "shared",
package/docs/tag.md CHANGED
@@ -4,7 +4,7 @@
4
4
  `2026 시즌` — 에 일관된 이름과 톤을 붙입니다. 이 라벨은 누를 수 없고, 선택되지도 않고,
5
5
  지워지지도 않습니다.
6
6
 
7
- **일반화한 계약.** 필수 `label`과 선택적 `tone`만 받는 표현 계약입니다(Statistic처럼
7
+ **일반화한 계약.** 필수 `label`과 선택적 `tone`·`variant`를 받는 표현 계약입니다(Statistic처럼
8
8
  제품이 완성한 문자열을 받습니다). id, collection 멤버십, 선택 state는 없습니다 — Tag는
9
9
  Collection 기본 계약의 대상이 아니라 낱개 표시 단위입니다.
10
10
 
@@ -28,9 +28,10 @@ Notice/Badge의 역할이고, Tag는 사실을 나열할 뿐 위협하지 않습
28
28
  - `closable`(지울 수 있는 태그)은 이 계약에 **없습니다**. 태그를 지우는 것은 선택 해제이고,
29
29
  선택 해제는 Chip의 `selected` 축이 이미 소유한 문제입니다. Tag에 별도의 dismiss 축을
30
30
  추가하면 같은 "선택 해제"를 두 컴포넌트가 서로 다른 이름으로 계약하게 됩니다.
31
- - 모양도 다릅니다. `chipRecipe`와 `badgeRecipe`는 `radius: "full"`(pill)을 쓰지만
32
- `tagRecipe`는 `radius: "sm"`의 사각형입니다. 정적 라벨을 pill로 그리면 누를 수 있다는
33
- 암묵적 신호를 주므로, 형태 자체로도 상호작용 가능성을 부정합니다.
31
+ - 기본은 `variant="outline"`인 배경 없는 capsule(`full`)입니다.
32
+ 작은 사각형은 명시적인 `variant="default"`로 유지합니다.
33
+ 2026-10-29 선수 프로필 정보 시안 승인에 따라 선택 UI와 분리된 읽기 전용 형태로 추가했습니다.
34
+ 형태와 무관하게 버튼 role·선택 상태·포커스·활성화 이벤트를 추가하지 않습니다.
34
35
  - 야잘알의 `AppBadge`가 이 역할(포지션·등급·시즌 라벨)을 수행한 제품 근거에서 Tag 계약을
35
36
  추출했습니다. Web/RN 공식 renderer는 이 계약을 직접 소비하며, 제품의 기존 이름을 Tag로
36
37
  바꾸는 일 자체는 소비 앱의 후속 migration입니다.
@@ -42,7 +43,7 @@ keyboard, focus, accessibilityState 계약이 필요 없습니다 — 접근성
42
43
  노드 하나로 충분합니다. 여러 Tag를 한 줄에 나열할 때 간격 조합은 나중에 Stack/Inline
43
44
  recipe가 안정화되면 그쪽에 위임하고, 이 계약에는 넣지 않습니다.
44
45
 
45
- 기본 높이는 20px이지만 고정 높이가 아닙니다. Dynamic Type에서 한 줄 라벨의 고유 높이만큼
46
+ 기본 최소 높이는 32px이며 고정 높이가 아닙니다. Dynamic Type에서 한 줄 라벨의 고유 높이만큼
46
47
  늘어나며, renderer는 라벨을 고정 프레임 안에서 자르지 않습니다.
47
48
 
48
49
  ## 현재 maturity와 남은 증거
@@ -54,3 +55,11 @@ scenario matrix가 dark(라이트 전용 색 누수 없음), 2배 글자, RTL,
54
55
 
55
56
  stable은 전체 환경 인증을 뜻하지 않습니다. screen reader 실사용과 실제 device screenshot은
56
57
  제품 기기 QA의 범위입니다.
58
+
59
+ ## Outline 표현
60
+
61
+ `variant`는 `default | outline`, 기본값은 `outline`입니다.
62
+ 2026-10-29 사용자가 승인한 테두리형을 기본으로 지정했습니다. Outline은 투명 배경·1px 테두리·
63
+ `radius.full`, 최소 높이 32, 좌우 10·상하 6, caption regular(400)입니다.
64
+ neutral 글자는 `content.primary`, 다른 tone은 기존 글자·테두리 의미를 유지합니다.
65
+ 흰 배경 전용 색을 하드코딩하지 않고 Provider의 light/dark 및 제품 팔레트를 따릅니다.
@@ -180,6 +180,10 @@ Avatar를 쓰지 못한 이유(이름 필수, Web 이미지 호스트 없음, 4
180
180
  일반 목록은 `ListRow.leadingAction`, 카드 작성자는 `Card.leadingAction`, 대화 목록은 `ConversationRow.avatarAction` + `renderBody`, 참여자는 `ChatRoomMembers.memberProfile`이다.
181
181
  같은 사진에 `Avatar.action`과 상위 `avatarAction`을 함께 넣지 않는다. 본문 링크/버튼과 독립 프로필 버튼은 형제다.
182
182
 
183
+ 사진 옆에 따로 보이는 회원 닉네임도 같은 프로필로 이동한다(2026-10-29 사용자 지시: Spint 댓글에서 닉네임을 눌러도 반응이 없었다).
184
+ 댓글 `CommentThreadRow`는 `avatarAction`이 있으면 작성자 이름도 같은 행동·접근성 이름의 글자 버튼으로 그린다(Web `hjm-identity-name-action`,
185
+ Native 중첩 `Text` press). 제품은 추가 prop 없이 `avatarAction`만 공급한다. 행 전체가 대화방·알림 목적지인 글자 안의 이름은 버튼을 중첩하지 않는다.
186
+
183
187
  회원 식별자가 없는 장식·방 사진을 회원 프로필로 연결하지 않는다. 명시적인 사진 편집·공유 대상 선택은 원래 작업을 유지한다.
184
188
 
185
189
  내 프로필 화면의 내 사진은 예외로 그 자체가 사진 변경 버튼이다(2026-10-25 사용자 지시). `action={{label: t("photo.change"), onPress: openPhotoPicker}}`를 연결하고 사진 고르기·자르기·업로드는 제품이 소유한다. 같은 화면에 중복 "사진 바꾸기" 버튼을 두지 않는다.
@@ -196,3 +200,8 @@ Avatar를 쓰지 못한 이유(이름 필수, Web 이미지 호스트 없음, 4
196
200
  ### 그림 전체 표시 (2026-10-28)
197
201
 
198
202
  스핀트에서 직접 그린 정사각형 그림의 모서리가 원형 프로필에서 잘린 사례로 `imageFit="contain"`을 추가했다. 기본 `cover`는 기존 사진 채움을 유지한다. `contain`은 사진·그림 전체를 프레임 안에 넣고 여백을 남긴다. 공통 recipe의 `1 / √2` 비율로 정사각형 대각선까지 원 안에 들어오며 이미지의 모서리를 다시 둥글게 자르지 않는다. Web·Native와 기존 저장 이미지에 동일하게 적용한다. Native `renderImage`의 `size`에는 실제 이미지 영역 크기가 전달된다.
203
+
204
+ **회원 프로필 사진은 원을 꽉 채운다(`cover`, 기본값) — 2026-10-29 사용자 지시.** `contain`은 작은 원 안에 네모난 사진이 작게 들어가고
205
+ 빈 테두리가 남아 사진처럼 보이지 않았다(Spint). 사진 전체는 프로필의 "사진 크게 보기"(`ProfileSummary.avatarView` + `ImageViewer`)에서
206
+ 본다. 회원 아바타·댓글·채팅·알림·목록의 사진에 `contain`을 쓰지 않는다. `contain`은 잘리면 뜻이 바뀌는 비사진 그림(로고·스티커·도감
207
+ 일러스트)에만 쓰고 그 이유를 사용처 주석에 남긴다.
@@ -116,6 +116,7 @@ import { BarChart, Sparkline } from "@hjmds/react-native/chart";
116
116
  | `focusIndex` | `number \| null` | — | 제어 포커스 |
117
117
  | `defaultFocusIndex` | `number \| null` | 마지막 관측 index | 비제어 시작 포커스 |
118
118
  | `onFocusChange` | `(index: number \| null, detail: { scrubbing: boolean }) => void` | — | 스크럽 중 `scrubbing: true`, 손을 떼면 `false` |
119
+ | `showFocusLabel` | `boolean` | `false` | LineChart·BarChart에서 누름을 놓은 뒤에도 선택한 점의 말풍선 유지 (2026-10-29 기록 차트 요청) |
119
120
  | `cursorLabel` | `(index: number) => string` | `categories[index]` | 스크럽 중 커서 위 말풍선 |
120
121
 
121
122
  ### BarChart
@@ -65,10 +65,12 @@ import { CommentThreadRow } from "@hjmds/react-native/screen-flows";
65
65
  | `id`·`parentId`·`author`·`body`·`timeLabel`·`likeCountLabel`·`likeIcon`·`likeLabel` | 필수 | `parentId`가 `null`이면 최상위 댓글 |
66
66
  | `bodyText` | 없음 | 주면 `author`와 본문을 한 문장으로, 시각은 아래 행동 줄로 옮긴다 |
67
67
  | `timePlacement` | `"auto"` | 미게시. `"auto"`는 `bodyText`가 없으면 시각을 작성자 옆에 둔다. `"actions"`는 글이 아닌 본문(꽃 도장·카드)에서도 시각을 본문 아래 반응 요약·답글과 같은 줄 맨 앞으로 옮긴다 |
68
+ | `textActions` | 없음 | 미게시. 답글 줄의 답글 뒤에 붙는 제품 문구 행동 `{ id, label, onPress, disabled? }[]`. 답글과 같은 muted caption 문구 버튼이며 순서대로 놓인다(예: 번역 댓글의 "원본 보기"/"번역 보기"). 문구·상태는 제품이 소유한다 |
68
69
  | `panel` | 없음 | 미게시. 댓글별 인라인 패널(수정·신고·차단 양식). 답글 줄 아래, 길게 누르기 대상 밖에 `panelGap` 8 간격으로 놓인다. 열림 상태·초점·제출·닫기는 제품이 소유한다 |
69
70
  | `mediaAlbum` | 없음 | 미게시. 한 장씩 넘기는 사진 묶음 `{ count, renderPage(index), pageLabel(index, count), previousLabel, nextLabel, aspectRatio?, onPress?(index), index?·onIndexChange?·defaultIndex? }`. `renderPage`는 화면에 보이는 장에만 호출되어 제품이 그 사진만 조회·디코딩한다. 불러오는 중·실패 장은 `MediaAlbumPageStatus`(`@hjmds/react(-native)/screens`, [ChatMessage](chat-message.md#축과-기본값))로 그려 안쪽 내용이 낭독기에 닿게 한다. `media`와 함께 주면 `TypeError`. 본문 아래, 길게 누르기 대상 밖에 놓인다 |
70
71
  | `authorBadge` | 없음 | 미게시. 닉네임 바로 뒤, 같은 줄에 놓는 제품 배지. 간격 `spacing.xxs` 4, 좁은 폭에서는 줄바꿈한다. `bodyText` 모드에서도 작성자·배지·본문 순서로 배치한다. 배지 문구·노출 조건은 제품이 소유한다 |
71
72
  | `avatar` | 없음 | 제품 아바타. 최상위 32·답글 24 폭 칸에 둔다 |
73
+ | `avatarAction` | 없음 | 회원 프로필 이동 `{ label, onPress, disabled? }`. 사진과 **작성자 이름**이 같은 행동을 쓴다(이름은 2026-10-29부터, [Avatar 지침](avatar.md#회원-프로필-이동-2026-10-25)) |
72
74
  | `canReply` | `parentId === null` | 답글 행동 표시 여부 |
73
75
  | `replyDisabled` | false | 답글 행동 비활성 |
74
76
  | `likeAction`·`actions` | 없음 | 기본 좋아요 버튼 대체·옆에 추가 |
@@ -92,7 +94,7 @@ import { CommentThreadRow } from "@hjmds/react-native/screen-flows";
92
94
 
93
95
  - 가상화 host에서 행 사이 간격(`commentThreadRecipe.rowGap`)과 답글 들여쓰기(`replyIndent`)를 직접 줄 때도 recipe 값을 쓴다. 제품 숫자로 다시 정하지 않는다.
94
96
  - 답글 펼치기·정렬·권한·좋아요 저장은 제품이 소유한다. 행은 콜백만 호출한다.
95
- - 행 안에 두 번째 레이아웃을 만들지 말고 `actions`·`likeAction`·`media`·`mediaAlbum`·`panel` 슬롯으로 추가한다. 시각·답글 줄을 본문 노드 안에 다시 그리지 말고 `timePlacement: "actions"`와 `canReply`를 쓴다.
97
+ - 행 안에 두 번째 레이아웃을 만들지 말고 `actions`·`likeAction`·`media`·`mediaAlbum`·`panel` 슬롯으로 추가한다. 시각·답글 줄을 본문 노드 안에 다시 그리지 말고 `timePlacement: "actions"`와 `canReply`를 쓴다. 답글 옆 문구 행동도 본문에 버튼을 두지 말고 `textActions`로 준다.
96
98
  - `mediaAlbum`의 사진 조회·권한 확인·캐시 수명은 제품이 `renderPage` 안에서 소유한다. HJM은 보이는 장만 마운트하고 넘김 상태를 행 안에 둔다(제어형은 `index`+`onIndexChange`).
97
99
 
98
100
  ## 상태와 상호작용
@@ -106,6 +108,7 @@ import { CommentThreadRow } from "@hjmds/react-native/screen-flows";
106
108
  - 2026-10-20 Spint의 “나” 배지가 본문 첫 줄을 차지하던 문제를 해결하기 위해 `authorBadge` 슬롯을 추가했다. 작성자에 붙는 표시는 HJM이 닉네임 옆에 배치하고 제품은 배지 내용만 공급한다. 개발 소스 변경이며 미게시다.
107
109
 
108
110
  - 2026-10-20 Spint 댓글이 촘촘하다는 사용자 피드백에 따라 공통 행 사이 간격을 12→20, 내부 줄 사이 간격을 2→4로 조정했다. Web·Native가 같은 recipe를 사용한다. 개발 소스 변경이며 미게시다.
111
+ - `textActions`: 2026-10-29 Spint 댓글 번역. 사용자가 Instagram처럼 "답글 달기" 옆에 "원본 보기"/"번역 보기"를 두라고 요청했는데, 답글 줄에 제품 행동 자리가 없어 본문 아래 별도 줄로 그리고 있었다.
109
112
  - `timePlacement`·`panel`·`mediaAlbum`: 2026-10-18 HJM 1.30.0 채택 감사. Spint는 꽃 도장 본문에서 시각을 답글 줄에 두려고 시각·답글 행을 본문 안에 다시 그렸고, Utilverse는 수정·신고·차단 양식을 본문 노드 안(길게 누르기 대상 안)에 넣었으며, 사진 묶음은 선택한 장만 조회하는 제품 앨범을 따로 만들었다.
110
113
 
111
114
  ## 플랫폼 차이
@@ -66,6 +66,13 @@ import { ImageViewer } from "@hjmds/react-native/image-viewer";
66
66
  errorLabel={t("viewer.loadFailed")} retryLabel={t("common.retry")} />
67
67
  ```
68
68
 
69
+ ### 아래로 끌어 닫기 (기본 동작, 2026-10-30)
70
+
71
+ 다른 사진 뷰어처럼 확대하지 않은 사진을 아래로 끌면 `onClose`가 불린다(사용자 요청). 사진이 손가락을 따라
72
+ 내려오고 위아래 조작부가 흐려지며, 120pt 이상 끌거나 빠르게 내리면 닫힌다. 짧게 끌거나 위로 끌면 제자리로
73
+ 돌아온다. 확대한 사진은 끌면 이동하므로 닫히지 않는다. 제품은 따로 연결할 것이 없고, 닫기 버튼은 접근성 경로로 남는다.
74
+ Web도 같은 기준으로 터치·펜으로 끌면 닫히고, 마우스는 닫기 버튼과 Esc를 쓴다. 사용자가 모든 앱·웹의 기본 지침으로 정했으므로 제품이 이 동작을 끄거나 자체 뷰어로 대체하지 않는다.
75
+
69
76
  ### 사진을 눌러 크게 보기 (`ImageViewerTrigger`, 2026-10-23)
70
77
 
71
78
  사진을 원시 `button`·`Pressable`로 감싸 뷰어를 열지 않는다(포트폴리오 사이트 `App.tsx`, Spint `MarkPhoto`·`ComposeScreen`이던 우회).
@@ -103,6 +103,31 @@ Web의 텍스트/이모지 artwork는 원형72 프레임 안에서 glyph.xxl48
103
103
  - 남의 프로필에는 `nameEdit`를 넣지 않고, 사진은 회원 아바타 기준(확대·신고)을 따른다.
104
104
  - 제품이 ProfileSummary 대신 고유 머리를 그리는 경우에도 사진 = 사진 변경, 이름 = 닉네임 변경을 지킨다. 이름이 그림(예: Spint 내 정원의 나무 팻말)이면 연필을 따로 두지 않고 그 이름 그림 자체를 닉네임 변경 버튼으로 한다(2026-10-25 사용자 결정).
105
105
 
106
+ ### 프로필 사진 올리기: 원으로 맞추기 (2026-10-29)
107
+
108
+ 사용자 지시로 모든 앱·웹에서 프로필 사진을 올릴 때 **인스타그램처럼 원 안에서 위치와 크기를 맞춘다.** 시스템 정사각 자르기
109
+ (`allowsEditing`, `aspect: [1, 1]`)는 원형 아바타와 모양이 달라 결과를 예측할 수 없었다.
110
+
111
+ - 사진을 고른 뒤(시스템 편집 끔) `image-viewer` 하위 경로의 `CircularPhotoCropper`를 연다. 끌어서 옮기고 두 손가락(웹은 휠·두 손가락)으로
112
+ 확대한다. 원 밖은 어둡게 가리고, 놓으면 원 안에 빈 곳이 생기지 않게 되돌아간다(`clampCircularPhotoCrop`, 최대 `maxZoom` 4배).
113
+ - `onConfirm(crop)`은 원본 픽셀 정사각형 `{ originX, originY, width, height }`이다. 제품이 그 영역을 자르고(Native `expo-image-manipulator`
114
+ `crop`, Web canvas) 기존 크기 제한·압축으로 올린다. 자르기·업로드 중에는 `busy`로 두 버튼을 잠근다.
115
+ - 정사각형을 저장하고 원 모양은 표시(`cover`)가 만든다. 원형 투명 PNG를 만들지 않는다.
116
+ - 글 첨부 사진처럼 원형이 아닌 사진에는 쓰지 않는다.
117
+
118
+ ### 남의 프로필: 사진을 누르면 크게 보기 (2026-10-29)
119
+
120
+ 사용자 지시로 모든 앱·웹의 회원 프로필 화면에서 **그 회원의 프로필 사진을 누르면 사진이 크게 열린다.** Spint 회원 프로필에서
121
+ 사진을 눌러도 반응이 없었고, Utilverse만 제품 코드로 확대를 따로 만들어 앱마다 동작이 달랐다.
122
+
123
+ - `avatarView={{ label: t("photo.view"), onPress: () => setViewerOpen(true) }}`를 준다. HJM이 아바타 틀 전체를 이미지 버튼
124
+ (`ImageViewerTrigger`, Native `imagebutton` 역할)으로 만들고 크기·원형·누름 상태를 소유한다.
125
+ - 제품은 `image-viewer` 하위 경로의 `ImageViewer`를 연다(Native는 gesture-handler·zoom-toolkit 선택 의존성이 그 경로에만 있다).
126
+ 사진 조회·권한·캐시는 `items`/`renderImage`로 공급한다. 사진 신고는 제품의 기존 경로를 유지하며 확대 화면에 둘 때는 `actions`를 쓴다.
127
+ - 사진이 없어 이니셜·기본 얼굴만 있으면 `avatarView`를 넣지 않는다(크게 볼 사진이 없다).
128
+ - 내 프로필은 사진 = 사진 변경(`Avatar.action`)이므로 `avatarView`를 넣지 않는다. 두 행동을 한 사진에 같이 넣지 않는다.
129
+ - 제품 고유 머리(ProfileSummary 대신 그린 카드)도 같은 규칙을 `ImageViewerTrigger` + `ImageViewer`로 지킨다.
130
+
106
131
  ### 프로필 카드 틀 (2026-10-25)
107
132
 
108
133
  같은 날 사용자 지적: Utilverse 프로필 카드(평평한 `default`, radius `lg`)가 BurnTok 카드(`raised` 그림자, 테두리, radius `xl`)와
@@ -171,3 +171,13 @@ Chip/직접 wrap으로 조립하고 있었다. `presentation="chips"`는 기존
171
171
  공통 레일이 가로 스크롤과 선택 항목 노출을 소유한다. 제품은 `renderLabel`로 요일·일자 등
172
172
  콘텐츠만 공급한다. 비활성인 선택 날짜도 표시할 수 있다. 조회 실패를 비활성 날짜로 바꾸지 않는다.
173
173
  이 API는 아직 게시하지 않은 개발 소스 변경이다.
174
+
175
+ ### 선택 면의 브랜드 강조 (2026-10-29 개발 소스 · 미게시)
176
+
177
+ `selectionTone="neutral" | "brand" | "brand-outline"`를 Web/Native에서 지정한다. 기본값은 `neutral`이다.
178
+ 오늘야구 날짜 카드의 선택일을 파란 배경·흰 글자로 강조해 달라는 요청이 계기다.
179
+ `brand`는 테마의 `primary`/`onPrimary` 쌍을 사용하고 미선택 면은 기존 canvas를 유지한다(light는 흰색).
180
+ 라벨·설명과 Native `renderLabel` 안의 중립 Text는 선택 면의 글자색을 상속한다.
181
+ 브랜드색·상태색을 명시한 미리보기는 제품이 의미에 맞게 공급한다.
182
+
183
+ 같은 날 사용자 후속 요청으로 `brand-outline`은 선택 배경도 canvas(light 흰색)로 유지하고 테두리만 대표색 primary로 강조한다. 일반 글자색과 비활성 계약은 유지한다.