@byline/admin 6.0.0 → 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.
- package/dist/modules/admin-preferences/index.d.ts +7 -3
- package/dist/modules/admin-preferences/index.js +1 -1
- package/dist/modules/admin-preferences/schemas.d.ts +19 -16
- package/dist/modules/admin-preferences/schemas.js +46 -2
- package/dist/modules/analytics/components/timeseries.d.ts +28 -0
- package/dist/modules/analytics/components/timeseries.js +107 -35
- package/dist/modules/analytics/components/timeseries.module.js +15 -0
- package/dist/modules/analytics/components/timeseries_module.css +110 -3
- package/package.json +7 -7
- package/src/modules/admin-preferences/index.ts +7 -2
- package/src/modules/admin-preferences/schemas.test.node.ts +74 -0
- package/src/modules/admin-preferences/schemas.ts +85 -8
- package/src/modules/analytics/components/dashboard.test.node.ts +51 -0
- package/src/modules/analytics/components/timeseries.module.css +176 -3
- package/src/modules/analytics/components/timeseries.tsx +153 -40
- package/dist/exports-parity.test.node.js +0 -37
- package/dist/fields/array/array-field.jsx +0 -209
- package/dist/fields/blocks/blocks-field.jsx +0 -248
- package/dist/fields/checkbox/checkbox-field.jsx +0 -24
- package/dist/fields/code/code-editor.jsx +0 -211
- package/dist/fields/code/code-field.jsx +0 -109
- package/dist/fields/column-formatter.jsx +0 -25
- package/dist/fields/date-time-formatter.jsx +0 -18
- package/dist/fields/datetime/datetime-field.jsx +0 -22
- package/dist/fields/draggable-context-menu.jsx +0 -50
- package/dist/fields/field-admin.test.node.js +0 -40
- package/dist/fields/field-renderer.jsx +0 -136
- package/dist/fields/field-services-context.jsx +0 -17
- package/dist/fields/file/file-field.jsx +0 -176
- package/dist/fields/file/file-upload-field.jsx +0 -89
- package/dist/fields/group/group-field.jsx +0 -46
- package/dist/fields/image/image-field.jsx +0 -183
- package/dist/fields/image/image-upload-field.jsx +0 -157
- package/dist/fields/local-date-time.jsx +0 -69
- package/dist/fields/locale-badge.jsx +0 -19
- package/dist/fields/numerical/numerical-field.jsx +0 -93
- package/dist/fields/relation/relation-column-formatter.jsx +0 -74
- package/dist/fields/relation/relation-display.jsx +0 -118
- package/dist/fields/relation/relation-field.jsx +0 -108
- package/dist/fields/relation/relation-many-field.jsx +0 -142
- package/dist/fields/relation/relation-picker.jsx +0 -240
- package/dist/fields/relation/relation-summary.jsx +0 -45
- package/dist/fields/select/select-field.jsx +0 -27
- package/dist/fields/sortable-item.jsx +0 -54
- package/dist/fields/text/text-field.jsx +0 -79
- package/dist/fields/text-area/text-area-field.jsx +0 -79
- package/dist/forms/available-locales-reconcile.test.node.js +0 -43
- package/dist/forms/available-locales-widget.jsx +0 -63
- package/dist/forms/document-actions.jsx +0 -486
- package/dist/forms/form-context.jsx +0 -598
- package/dist/forms/form-modals.jsx +0 -128
- package/dist/forms/form-renderer.jsx +0 -532
- package/dist/forms/form-status-display.jsx +0 -69
- package/dist/forms/navigation-guard.jsx +0 -68
- package/dist/forms/nested-path.test.node.js +0 -115
- package/dist/forms/path-widget.jsx +0 -125
- package/dist/forms/pending-uploads.test.node.js +0 -19
- package/dist/forms/repeating-items.test.node.js +0 -29
- package/dist/forms/scheduled-publication-control.jsx +0 -389
- package/dist/forms/scheduled-publication-state.test.node.js +0 -121
- package/dist/forms/scheduled-publication-time.test.node.js +0 -70
- package/dist/forms/status-transitions.test.node.js +0 -76
- package/dist/forms/tree-placement-widget.jsx +0 -160
- package/dist/forms/upload-executor.test.node.js +0 -368
- package/dist/forms/use-form-layout.test.node.js +0 -69
- package/dist/modules/admin-account/components/change-password.jsx +0 -169
- package/dist/modules/admin-account/components/container.jsx +0 -164
- package/dist/modules/admin-account/components/preferences.jsx +0 -148
- package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
- package/dist/modules/admin-account/components/update.jsx +0 -186
- package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
- package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
- package/dist/modules/admin-roles/components/create.jsx +0 -155
- package/dist/modules/admin-roles/components/permissions.jsx +0 -239
- package/dist/modules/admin-roles/components/update.jsx +0 -148
- package/dist/modules/admin-users/components/create.jsx +0 -206
- package/dist/modules/admin-users/components/roles.jsx +0 -126
- package/dist/modules/admin-users/components/set-password.jsx +0 -133
- package/dist/modules/admin-users/components/update.jsx +0 -215
- package/dist/modules/analytics/components/dashboard.jsx +0 -208
- package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
- package/dist/modules/analytics/components/timeseries.jsx +0 -193
- package/dist/modules/auth/components/sign-in-form.jsx +0 -89
- package/dist/modules/auth/safe-redirect.test.node.js +0 -40
- package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
- package/dist/presentation/group.jsx +0 -26
- package/dist/presentation/row.jsx +0 -23
- package/dist/presentation/tabs.jsx +0 -39
- package/dist/services/admin-services-context.jsx +0 -17
- package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
- package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
- 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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
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) {
|