@byline/admin 6.0.1 → 6.0.2

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.
Files changed (92) hide show
  1. package/dist/modules/admin-preferences/index.d.ts +7 -3
  2. package/dist/modules/admin-preferences/index.js +1 -1
  3. package/dist/modules/admin-preferences/schemas.d.ts +19 -16
  4. package/dist/modules/admin-preferences/schemas.js +46 -2
  5. package/dist/modules/analytics/components/timeseries.d.ts +28 -0
  6. package/dist/modules/analytics/components/timeseries.js +107 -35
  7. package/dist/modules/analytics/components/timeseries.module.js +15 -0
  8. package/dist/modules/analytics/components/timeseries_module.css +110 -3
  9. package/package.json +7 -7
  10. package/src/modules/admin-preferences/index.ts +7 -2
  11. package/src/modules/admin-preferences/schemas.test.node.ts +74 -0
  12. package/src/modules/admin-preferences/schemas.ts +85 -8
  13. package/src/modules/analytics/components/dashboard.test.node.ts +51 -0
  14. package/src/modules/analytics/components/timeseries.module.css +176 -3
  15. package/src/modules/analytics/components/timeseries.tsx +153 -40
  16. package/dist/exports-parity.test.node.js +0 -37
  17. package/dist/fields/array/array-field.jsx +0 -209
  18. package/dist/fields/blocks/blocks-field.jsx +0 -248
  19. package/dist/fields/checkbox/checkbox-field.jsx +0 -24
  20. package/dist/fields/code/code-editor.jsx +0 -211
  21. package/dist/fields/code/code-field.jsx +0 -109
  22. package/dist/fields/column-formatter.jsx +0 -25
  23. package/dist/fields/date-time-formatter.jsx +0 -18
  24. package/dist/fields/datetime/datetime-field.jsx +0 -22
  25. package/dist/fields/draggable-context-menu.jsx +0 -50
  26. package/dist/fields/field-admin.test.node.js +0 -40
  27. package/dist/fields/field-renderer.jsx +0 -136
  28. package/dist/fields/field-services-context.jsx +0 -17
  29. package/dist/fields/file/file-field.jsx +0 -176
  30. package/dist/fields/file/file-upload-field.jsx +0 -89
  31. package/dist/fields/group/group-field.jsx +0 -46
  32. package/dist/fields/image/image-field.jsx +0 -183
  33. package/dist/fields/image/image-upload-field.jsx +0 -157
  34. package/dist/fields/local-date-time.jsx +0 -69
  35. package/dist/fields/locale-badge.jsx +0 -19
  36. package/dist/fields/numerical/numerical-field.jsx +0 -93
  37. package/dist/fields/relation/relation-column-formatter.jsx +0 -74
  38. package/dist/fields/relation/relation-display.jsx +0 -118
  39. package/dist/fields/relation/relation-field.jsx +0 -108
  40. package/dist/fields/relation/relation-many-field.jsx +0 -142
  41. package/dist/fields/relation/relation-picker.jsx +0 -240
  42. package/dist/fields/relation/relation-summary.jsx +0 -45
  43. package/dist/fields/select/select-field.jsx +0 -27
  44. package/dist/fields/sortable-item.jsx +0 -54
  45. package/dist/fields/text/text-field.jsx +0 -79
  46. package/dist/fields/text-area/text-area-field.jsx +0 -79
  47. package/dist/forms/available-locales-reconcile.test.node.js +0 -43
  48. package/dist/forms/available-locales-widget.jsx +0 -63
  49. package/dist/forms/document-actions.jsx +0 -486
  50. package/dist/forms/form-context.jsx +0 -598
  51. package/dist/forms/form-modals.jsx +0 -128
  52. package/dist/forms/form-renderer.jsx +0 -532
  53. package/dist/forms/form-status-display.jsx +0 -69
  54. package/dist/forms/navigation-guard.jsx +0 -68
  55. package/dist/forms/nested-path.test.node.js +0 -115
  56. package/dist/forms/path-widget.jsx +0 -125
  57. package/dist/forms/pending-uploads.test.node.js +0 -19
  58. package/dist/forms/repeating-items.test.node.js +0 -29
  59. package/dist/forms/scheduled-publication-control.jsx +0 -389
  60. package/dist/forms/scheduled-publication-state.test.node.js +0 -121
  61. package/dist/forms/scheduled-publication-time.test.node.js +0 -70
  62. package/dist/forms/status-transitions.test.node.js +0 -76
  63. package/dist/forms/tree-placement-widget.jsx +0 -160
  64. package/dist/forms/upload-executor.test.node.js +0 -368
  65. package/dist/forms/use-form-layout.test.node.js +0 -69
  66. package/dist/modules/admin-account/components/change-password.jsx +0 -169
  67. package/dist/modules/admin-account/components/container.jsx +0 -164
  68. package/dist/modules/admin-account/components/preferences.jsx +0 -148
  69. package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
  70. package/dist/modules/admin-account/components/update.jsx +0 -186
  71. package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
  72. package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
  73. package/dist/modules/admin-roles/components/create.jsx +0 -155
  74. package/dist/modules/admin-roles/components/permissions.jsx +0 -239
  75. package/dist/modules/admin-roles/components/update.jsx +0 -148
  76. package/dist/modules/admin-users/components/create.jsx +0 -206
  77. package/dist/modules/admin-users/components/roles.jsx +0 -126
  78. package/dist/modules/admin-users/components/set-password.jsx +0 -133
  79. package/dist/modules/admin-users/components/update.jsx +0 -215
  80. package/dist/modules/analytics/components/dashboard.jsx +0 -208
  81. package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
  82. package/dist/modules/analytics/components/timeseries.jsx +0 -193
  83. package/dist/modules/auth/components/sign-in-form.jsx +0 -89
  84. package/dist/modules/auth/safe-redirect.test.node.js +0 -40
  85. package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
  86. package/dist/presentation/group.jsx +0 -26
  87. package/dist/presentation/row.jsx +0 -23
  88. package/dist/presentation/tabs.jsx +0 -39
  89. package/dist/services/admin-services-context.jsx +0 -17
  90. package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
  91. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
  92. package/dist/widgets/status-badge/status-badge.jsx +0 -46
@@ -6,11 +6,14 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
+ import { ANALYTICS_DASHBOARD_PERIODS } from '@byline/analytics/config'
9
10
  import { describe, expect, it } from 'vitest'
10
11
 
11
12
  import {
13
+ analyticsViewPreferenceValueSchema,
12
14
  listViewPreferenceValueSchema,
13
15
  preferenceScopeSchema,
16
+ resolvePreferenceValueSchema,
14
17
  setPreferenceRequestSchema,
15
18
  } from './schemas.js'
16
19
 
@@ -52,6 +55,49 @@ describe('listViewPreferenceValueSchema', () => {
52
55
  })
53
56
  })
54
57
 
58
+ describe('analyticsViewPreferenceValueSchema', () => {
59
+ it('accepts every period the dashboard offers', () => {
60
+ for (const period of ANALYTICS_DASHBOARD_PERIODS) {
61
+ expect(analyticsViewPreferenceValueSchema.safeParse({ period }).success).toBe(true)
62
+ }
63
+ })
64
+
65
+ it('rejects periods the dashboard does not offer', () => {
66
+ expect(analyticsViewPreferenceValueSchema.safeParse({ period: 45 }).success).toBe(false)
67
+ // The URL carries `?period=30`; the stored value is the parsed number.
68
+ expect(analyticsViewPreferenceValueSchema.safeParse({ period: '30' }).success).toBe(false)
69
+ expect(analyticsViewPreferenceValueSchema.safeParse({ period: 'lifetime' }).success).toBe(false)
70
+ })
71
+
72
+ it('rejects an empty payload and unknown keys', () => {
73
+ expect(analyticsViewPreferenceValueSchema.safeParse({}).success).toBe(false)
74
+ expect(analyticsViewPreferenceValueSchema.safeParse({ period: 7, page_size: 30 }).success).toBe(
75
+ false
76
+ )
77
+ })
78
+ })
79
+
80
+ describe('resolvePreferenceValueSchema', () => {
81
+ it('routes each registered scope family to its own value schema', () => {
82
+ expect(resolvePreferenceValueSchema('collections.docs.list')).toBe(
83
+ listViewPreferenceValueSchema
84
+ )
85
+ expect(resolvePreferenceValueSchema('collections.media-items.list')).toBe(
86
+ listViewPreferenceValueSchema
87
+ )
88
+ expect(resolvePreferenceValueSchema('analytics.dashboard')).toBe(
89
+ analyticsViewPreferenceValueSchema
90
+ )
91
+ })
92
+
93
+ it('returns undefined for an unregistered scope family', () => {
94
+ expect(resolvePreferenceValueSchema('collections.docs')).toBeUndefined()
95
+ expect(resolvePreferenceValueSchema('collections.docs.list.extra')).toBeUndefined()
96
+ expect(resolvePreferenceValueSchema('analytics')).toBeUndefined()
97
+ expect(resolvePreferenceValueSchema('made.up.scope')).toBeUndefined()
98
+ })
99
+ })
100
+
55
101
  describe('setPreferenceRequestSchema', () => {
56
102
  it('requires both scope and a non-empty value', () => {
57
103
  expect(
@@ -64,4 +110,32 @@ describe('setPreferenceRequestSchema', () => {
64
110
  setPreferenceRequestSchema.safeParse({ scope: 'collections.docs.list', value: {} }).success
65
111
  ).toBe(false)
66
112
  })
113
+
114
+ it('validates the value against the schema its scope selects', () => {
115
+ expect(
116
+ setPreferenceRequestSchema.safeParse({
117
+ scope: 'analytics.dashboard',
118
+ value: { period: 'ytd' },
119
+ }).success
120
+ ).toBe(true)
121
+ // Each family rejects the other's keys — the point of keying by scope.
122
+ expect(
123
+ setPreferenceRequestSchema.safeParse({
124
+ scope: 'collections.docs.list',
125
+ value: { period: 'ytd' },
126
+ }).success
127
+ ).toBe(false)
128
+ expect(
129
+ setPreferenceRequestSchema.safeParse({
130
+ scope: 'analytics.dashboard',
131
+ value: { page_size: 30 },
132
+ }).success
133
+ ).toBe(false)
134
+ })
135
+
136
+ it('refuses to mint rows for an unregistered scope family', () => {
137
+ expect(
138
+ setPreferenceRequestSchema.safeParse({ scope: 'made.up.scope', value: { period: 7 } }).success
139
+ ).toBe(false)
140
+ })
67
141
  })
@@ -12,12 +12,19 @@
12
12
  * Self-service, like admin-account: none of the request schemas accept a
13
13
  * user id — the command resolves the target from `actor.id`.
14
14
  *
15
- * The `value` payload is validated against the list-view shape because
16
- * that is the only registered scope family today
17
- * (`collections.<path>.list`). When a second scope family arrives, this
18
- * becomes a scope-keyed selection of value schemas.
15
+ * The `value` payload is validated against a schema chosen by the scope
16
+ * key, so each registered family enforces its own shape: a list scope
17
+ * takes only list keys, the analytics dashboard takes only a period. A
18
+ * scope matching no registered family is rejected on write, so a
19
+ * scripted caller cannot mint junk rows even under its own actor id.
20
+ * Reads are deliberately unconstrained — `getPreference` on an unknown
21
+ * scope simply finds nothing.
22
+ *
23
+ * Registering a third family is two lines: a value schema, and an entry
24
+ * in `PREFERENCE_VALUE_SCHEMAS`.
19
25
  */
20
26
 
27
+ import { ANALYTICS_DASHBOARD_PERIODS } from '@byline/analytics/config'
21
28
  import { z } from 'zod'
22
29
 
23
30
  /** Dot-separated segment key, e.g. `collections.docs.list`. */
@@ -43,15 +50,85 @@ export const listViewPreferenceValueSchema = z
43
50
  .strict()
44
51
  .refine((v) => Object.keys(v).length > 0, { message: 'value cannot be empty' })
45
52
 
53
+ /**
54
+ * Sticky analytics-dashboard keys. The period list is sourced from
55
+ * `@byline/analytics/config` rather than restated, so the stored value
56
+ * can only ever be one the dashboard actually offers — adding a sixth
57
+ * period there admits it here with no edit.
58
+ *
59
+ * Note the stored `period` is the *parsed* value (`30`, `'ytd'`), not
60
+ * the `?period=30` URL string the route validates on the way in.
61
+ */
62
+ export const analyticsViewPreferenceValueSchema = z
63
+ .object({
64
+ period: z.union([
65
+ z.literal(ANALYTICS_DASHBOARD_PERIODS[0]),
66
+ z.literal(ANALYTICS_DASHBOARD_PERIODS[1]),
67
+ z.literal(ANALYTICS_DASHBOARD_PERIODS[2]),
68
+ z.literal(ANALYTICS_DASHBOARD_PERIODS[3]),
69
+ z.literal(ANALYTICS_DASHBOARD_PERIODS[4]),
70
+ ]),
71
+ })
72
+ .strict()
73
+
74
+ /**
75
+ * The registered scope families, each a matcher over the scope key. A
76
+ * list scope carries the collection path as its middle segment, so it
77
+ * matches by shape; the analytics dashboard is a singleton and matches
78
+ * literally.
79
+ */
80
+ const PREFERENCE_VALUE_SCHEMAS: ReadonlyArray<{
81
+ matches: (scope: string) => boolean
82
+ schema: z.ZodType
83
+ }> = [
84
+ {
85
+ matches: (scope) => {
86
+ const segments = scope.split('.')
87
+ return segments.length === 3 && segments[0] === 'collections' && segments[2] === 'list'
88
+ },
89
+ schema: listViewPreferenceValueSchema,
90
+ },
91
+ {
92
+ matches: (scope) => scope === 'analytics.dashboard',
93
+ schema: analyticsViewPreferenceValueSchema,
94
+ },
95
+ ]
96
+
97
+ /**
98
+ * The value schema a scope selects, or `undefined` when the scope
99
+ * belongs to no registered family. Exported so callers (and tests) can
100
+ * ask the same question the write path asks.
101
+ */
102
+ export function resolvePreferenceValueSchema(scope: string): z.ZodType | undefined {
103
+ return PREFERENCE_VALUE_SCHEMAS.find((family) => family.matches(scope))?.schema
104
+ }
105
+
46
106
  export const getPreferenceRequestSchema = z.object({
47
107
  scope: preferenceScopeSchema,
48
108
  })
49
109
  export type GetPreferenceRequest = z.infer<typeof getPreferenceRequestSchema>
50
110
 
51
- export const setPreferenceRequestSchema = z.object({
52
- scope: preferenceScopeSchema,
53
- value: listViewPreferenceValueSchema,
54
- })
111
+ export const setPreferenceRequestSchema = z
112
+ .object({
113
+ scope: preferenceScopeSchema,
114
+ value: z.record(z.string(), z.unknown()),
115
+ })
116
+ .superRefine((input, ctx) => {
117
+ const schema = resolvePreferenceValueSchema(input.scope)
118
+ if (schema == null) {
119
+ ctx.addIssue({
120
+ code: 'custom',
121
+ path: ['scope'],
122
+ message: 'scope does not belong to a registered preference family',
123
+ })
124
+ return
125
+ }
126
+ const result = schema.safeParse(input.value)
127
+ if (result.success) return
128
+ for (const issue of result.error.issues) {
129
+ ctx.addIssue({ ...issue, path: ['value', ...issue.path] })
130
+ }
131
+ })
55
132
  export type SetPreferenceRequest = z.infer<typeof setPreferenceRequestSchema>
56
133
 
57
134
  /** `value` is `null` when the user has no stored preference for the scope. */
@@ -14,6 +14,7 @@ import {
14
14
  bucketAnalyticsTimeseries,
15
15
  buildAnalyticsColumns,
16
16
  resolveAnalyticsChartGranularity,
17
+ resolveHoverCardPlacement,
17
18
  } from './timeseries.js'
18
19
 
19
20
  function day(date: string, views: number, visitors: number): AnalyticsSummaryDay {
@@ -191,3 +192,53 @@ describe('partialCoverageFrom', () => {
191
192
  expect(partialCoverageFrom('2025-01-01', null)).toBeUndefined()
192
193
  })
193
194
  })
195
+
196
+ describe('resolveHoverCardPlacement', () => {
197
+ /** Columns as `buildAnalyticsColumns` lays them out, for `count` buckets. */
198
+ const columnsFor = (count: number, views: number[]) =>
199
+ buildAnalyticsColumns(views.map((v, i) => day(`2026-08-${10 + i}`, v, 0)).slice(0, count))
200
+
201
+ it('centres the card on an interior column', () => {
202
+ const columns = columnsFor(5, [10, 10, 10, 10, 10])
203
+ const placement = resolveHoverCardPlacement(columns[2]!)
204
+ // Third of five columns: centre of the plot.
205
+ expect(placement.leftPercent).toBeCloseTo(50)
206
+ expect(placement.align).toBe('center')
207
+ })
208
+
209
+ it('anchors the leading and trailing columns to the plot edges', () => {
210
+ const columns = columnsFor(5, [10, 10, 10, 10, 10])
211
+ expect(resolveHoverCardPlacement(columns[0]!).align).toBe('start')
212
+ expect(resolveHoverCardPlacement(columns[4]!).align).toBe('end')
213
+ })
214
+
215
+ it('centres a lone column rather than treating it as an edge', () => {
216
+ const columns = columnsFor(1, [10])
217
+ const placement = resolveHoverCardPlacement(columns[0]!)
218
+ expect(placement.leftPercent).toBeCloseTo(50)
219
+ expect(placement.align).toBe('center')
220
+ })
221
+
222
+ it('sits above a short column and flips below a tall one', () => {
223
+ // Against a ceiling of 100, a 10-view column is short and a 100-view
224
+ // column reaches the top of the plot — where a card above would clip.
225
+ const columns = columnsFor(3, [10, 100, 55])
226
+ expect(resolveHoverCardPlacement(columns[0]!).side).toBe('above')
227
+ expect(resolveHoverCardPlacement(columns[1]!).side).toBe('below')
228
+ })
229
+
230
+ it('reports the bar top as a percentage of plot height', () => {
231
+ const columns = columnsFor(2, [50, 100])
232
+ // Half-height column: its top is halfway down the plot.
233
+ expect(resolveHoverCardPlacement(columns[0]!).topPercent).toBeCloseTo(50)
234
+ // Full-height column: its top is the top of the plot.
235
+ expect(resolveHoverCardPlacement(columns[1]!).topPercent).toBeCloseTo(0)
236
+ })
237
+
238
+ it('keeps a zero-value column anchored on the baseline', () => {
239
+ const columns = columnsFor(2, [0, 10])
240
+ const placement = resolveHoverCardPlacement(columns[0]!)
241
+ expect(placement.topPercent).toBeCloseTo(100)
242
+ expect(placement.side).toBe('above')
243
+ })
244
+ })
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * Override handles:
5
5
  * .byline-analytics-timeseries — the chart, axis, and readout as one block
6
+ * .byline-analytics-plot — the plot box (SVG + hover card)
6
7
  * .byline-analytics-chart — the SVG plot itself
8
+ * .byline-analytics-hovercard — the card shown over the hovered column
7
9
  * .byline-analytics-axis — first/last day labels beneath the plot
8
10
  * .byline-analytics-readout — hovered-day figures, or the range hint
9
11
  *
@@ -23,6 +25,13 @@
23
25
  outline-offset: 4px;
24
26
  }
25
27
 
28
+ /* Positioning context for the hover card, so the card measures against the
29
+ plot rather than the whole block (which also holds the axis and readout). */
30
+ .plot,
31
+ :global(.byline-analytics-plot) {
32
+ position: relative;
33
+ }
34
+
26
35
  .chart,
27
36
  :global(.byline-analytics-chart) {
28
37
  display: block;
@@ -30,6 +39,159 @@
30
39
  height: 11rem;
31
40
  }
32
41
 
42
+ /* Hover card ------------------------------------------------------------
43
+ An HTML overlay rather than SVG: the plot is drawn with
44
+ preserveAspectRatio="none" and stretched to the container, so SVG text
45
+ inside it would be horizontally distorted (the same reason the axis
46
+ labels below are HTML).
47
+
48
+ Surfaces follow the admin's popover recipe (the tokens @byline/ui's
49
+ dropdown uses), so the card inherits both themes from the token layer
50
+ instead of needing its own dark-mode block. */
51
+ .hoverCard,
52
+ :global(.byline-analytics-hovercard) {
53
+ position: absolute;
54
+ z-index: 1;
55
+ display: grid;
56
+ gap: 0.125rem 0.5rem;
57
+ padding: 0.4rem 0.55rem;
58
+ background-color: var(--surface-panel-elevated);
59
+ border-width: var(--border-width-thin);
60
+ border-style: var(--border-style-solid);
61
+ border-color: var(--surface-panel-border);
62
+ border-radius: var(--border-radius-sm);
63
+ box-shadow: var(--shadow-sm);
64
+ font-size: 0.6875rem;
65
+ font-variant-numeric: tabular-nums;
66
+ line-height: 1.35;
67
+ white-space: nowrap;
68
+ /* The plot owns pointer tracking; the card must never intercept it or
69
+ hovering would flicker as the pointer crossed the card. */
70
+ pointer-events: none;
71
+
72
+ /* Resting state is hidden-and-just-below; `.hoverCardVisible` is the shown
73
+ state. The card stays mounted either way, because a transition needs a
74
+ previous state to animate from.
75
+
76
+ The lift rides on `translate` rather than `transform` so it composes with
77
+ the positioning transforms below instead of overwriting them — the two
78
+ properties multiply, and keeping them separate means the anchor maths and
79
+ the animation never have to know about each other.
80
+
81
+ The exit curve lives here and the entrance curve on the shown state: a
82
+ transition is read from the state being entered, so gaining the class
83
+ animates with the eased-out entrance and losing it with the quicker
84
+ ease-in exit.
85
+
86
+ On durations: the curve matters as much as the number. The popover
87
+ `cubic-bezier(0.16, 1, 0.3, 1)` puts both y control points at 1, so it is
88
+ ~88% complete at 30% of its duration — over a short transition that reads
89
+ as an instant pop no matter what the duration says. A plain `ease-out`
90
+ spends its time far more evenly (50% progress at ~34% of the time), so
91
+ the motion is actually visible. */
92
+ opacity: 0;
93
+ translate: 0 6px;
94
+ transition:
95
+ opacity 160ms ease-in,
96
+ translate 160ms ease-in;
97
+ }
98
+
99
+ .hoverCardVisible {
100
+ opacity: 1;
101
+ translate: 0 0;
102
+ transition:
103
+ opacity 240ms ease-out,
104
+ translate 240ms ease-out;
105
+ }
106
+
107
+ /* Horizontal anchoring. Interior columns centre; columns near an edge pin
108
+ that edge so the card cannot overhang the plot. */
109
+ .hoverCard.center {
110
+ transform: translateX(-50%);
111
+ }
112
+
113
+ .hoverCard.start {
114
+ transform: translateX(-0.5rem);
115
+ }
116
+
117
+ .hoverCard.end {
118
+ transform: translateX(calc(-100% + 0.5rem));
119
+ }
120
+
121
+ /* Vertical side. `top` is the hovered bar's own top edge, so the card sits
122
+ just clear of the mark it describes. */
123
+ .hoverCard.above {
124
+ margin-top: -0.5rem;
125
+ }
126
+
127
+ .hoverCard.above.center {
128
+ transform: translate(-50%, -100%);
129
+ }
130
+
131
+ .hoverCard.above.start {
132
+ transform: translate(-0.5rem, -100%);
133
+ }
134
+
135
+ .hoverCard.above.end {
136
+ transform: translate(calc(-100% + 0.5rem), -100%);
137
+ }
138
+
139
+ .hoverCard.below {
140
+ margin-top: 0.5rem;
141
+ }
142
+
143
+ .hoverCardDate {
144
+ color: var(--text);
145
+ font-weight: 600;
146
+ letter-spacing: 0.01em;
147
+ }
148
+
149
+ .hoverCardRow {
150
+ display: grid;
151
+ grid-template-columns: 0.625rem 1fr auto;
152
+ align-items: center;
153
+ gap: 0.4rem;
154
+ }
155
+
156
+ /* A short stroke, not a filled box: at this density a solid swatch is
157
+ data-weight ink doing a label's job. */
158
+ .keyLine {
159
+ display: inline-block;
160
+ width: 0.625rem;
161
+ height: 2px;
162
+ border-radius: 1px;
163
+ }
164
+
165
+ .keyViews {
166
+ background-color: #38bdf8;
167
+ /* Matches `.columnActive .views`, not the resting fill: the card is only
168
+ ever on screen while its column is hovered, so the key should look like
169
+ the mark actually looks at that moment. A 2px line also carries far less
170
+ ink than the readout's filled swatch, so the same opacity would read
171
+ fainter here than it does there. */
172
+ opacity: 0.62;
173
+ }
174
+
175
+ .keyVisitors {
176
+ background-color: #7c3aed;
177
+ }
178
+
179
+ /* The reader already knows the series; they came for the number — so the
180
+ value carries the emphasis and the metric name recedes.
181
+
182
+ Muted ink here is the same `--gray-*` pair the axis and readout use, not
183
+ `--text-secondary`: in this theme that token resolves to the amber brand
184
+ secondary, which reads as emphasis rather than recession. */
185
+ .hoverCardLabel {
186
+ color: var(--gray-500);
187
+ }
188
+
189
+ .hoverCardValue {
190
+ color: var(--text);
191
+ font-weight: 600;
192
+ text-align: right;
193
+ }
194
+
33
195
  .gridline {
34
196
  stroke: var(--gray-200);
35
197
  stroke-width: 1;
@@ -120,7 +282,8 @@
120
282
  :is([data-theme="dark"], :global(.dark)) .axis,
121
283
  :is([data-theme="dark"], :global(.dark)) :global(.byline-analytics-axis),
122
284
  :is([data-theme="dark"], :global(.dark)) .readout,
123
- :is([data-theme="dark"], :global(.dark)) :global(.byline-analytics-readout) {
285
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-analytics-readout),
286
+ :is([data-theme="dark"], :global(.dark)) .hoverCardLabel {
124
287
  color: var(--gray-400);
125
288
  }
126
289
 
@@ -128,7 +291,8 @@
128
291
  fill: #a78bfa;
129
292
  }
130
293
 
131
- :is([data-theme="dark"], :global(.dark)) .swatchVisitors {
294
+ :is([data-theme="dark"], :global(.dark)) .swatchVisitors,
295
+ :is([data-theme="dark"], :global(.dark)) .keyVisitors {
132
296
  background-color: #a78bfa;
133
297
  }
134
298
 
@@ -138,9 +302,18 @@
138
302
 
139
303
  @media (prefers-reduced-motion: reduce) {
140
304
  .column,
141
- .views {
305
+ .views,
306
+ .hoverCard,
307
+ .hoverCardVisible {
142
308
  transition: none;
143
309
  }
310
+
311
+ /* No transition means the lift would read as a jump rather than motion, so
312
+ the card simply appears in place. */
313
+ .hoverCard,
314
+ .hoverCardVisible {
315
+ translate: none;
316
+ }
144
317
  }
145
318
 
146
319
  @media (max-width: 720px) {