@astronox/core 0.5.13 → 0.5.14
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/package.json +1 -1
- package/src/components/actions/ThemeToggle/ThemeToggle.astro +11 -0
- package/src/components/actions/ThemeToggle/ThemeToggle.css +4 -1
- package/src/components/actions/ThemeToggle/themeToggleController.ts +1 -0
- package/src/components/content/CardLink/CardLink.astro +2 -0
- package/src/components/content/CardLink/CardLink.css +50 -5
- package/src/components/feedback/SeasonalDecorations/SeasonalDecorations.astro +46 -1
- package/src/components/feedback/SeasonalDecorations/SeasonalDecorations.css +51 -1
- package/src/components/feedback/SeasonalDecorations/snowfall.ts +38 -0
- package/src/components/feedback/ThemeBootstrap/ThemeBootstrap.astro +51 -26
- package/src/components/icons/CurrencyIcon.css +2 -2
- package/src/components/icons/FirTreeIcon.astro +32 -0
- package/src/components/icons/GiftIcon.astro +34 -0
- package/src/components/index.ts +2 -0
- package/src/components/navigation/Header.css +22 -3
- package/src/components/navigation/NavigationBrand.astro +38 -19
- package/src/components/navigation/NavigationBrand.css +12 -2
- package/src/config/siteConfig.ts +24 -8
- package/src/display/contracts.ts +2 -1
- package/src/layouts/BaseLayout.astro +6 -2
- package/src/patterns/Modal/modalController.ts +16 -0
- package/src/patterns/RewardCalendar/RewardCalendar.astro +174 -0
- package/src/patterns/RewardCalendar/RewardCalendar.css +406 -0
- package/src/patterns/RewardCalendar/RewardPreview.astro +18 -0
- package/src/patterns/RewardCalendar/contracts.ts +55 -0
- package/src/patterns/RewardCalendar/rewardCalendarController.ts +268 -0
- package/src/patterns/index.ts +3 -0
- package/src/styles/base.css +6 -3
- package/src/styles/style.css +2 -0
- package/src/styles/tokens.css +6 -0
- package/src/theme/christmas-garland.svg +28 -0
- package/src/theme/christmas.css +25 -0
- package/src/theme/contracts.ts +7 -3
- package/src/theme/halloween.css +1 -0
|
@@ -38,26 +38,10 @@ const logoAspectRatio = logo ? `${logo.width} / ${logo.height}` : undefined;
|
|
|
38
38
|
style={`--site-nav-brand-logo-aspect-ratio: ${logoAspectRatio};`}
|
|
39
39
|
data-brand-variants={Object.keys(logo.variants ?? {}).join(' ')}
|
|
40
40
|
>
|
|
41
|
-
<
|
|
42
|
-
class="site-nav__brand-logo"
|
|
43
|
-
data-brand-appearance="default"
|
|
44
|
-
media={{
|
|
45
|
-
src: logo.src,
|
|
46
|
-
alt: logo.alt,
|
|
47
|
-
width: logo.width,
|
|
48
|
-
height: logo.height,
|
|
49
|
-
loading: 'eager',
|
|
50
|
-
decoding: 'sync',
|
|
51
|
-
fetchPriority: 'high',
|
|
52
|
-
}}
|
|
53
|
-
fit="contain"
|
|
54
|
-
/>
|
|
55
|
-
{Object.entries(logo.variants ?? {}).map(([appearance, src]) => (
|
|
41
|
+
<span class="site-nav__brand-logo" data-brand-appearance="default">
|
|
56
42
|
<Media
|
|
57
|
-
class="site-nav__brand-logo"
|
|
58
|
-
data-brand-appearance={appearance}
|
|
59
43
|
media={{
|
|
60
|
-
src,
|
|
44
|
+
src: logo.src,
|
|
61
45
|
alt: logo.alt,
|
|
62
46
|
width: logo.width,
|
|
63
47
|
height: logo.height,
|
|
@@ -67,7 +51,42 @@ const logoAspectRatio = logo ? `${logo.width} / ${logo.height}` : undefined;
|
|
|
67
51
|
}}
|
|
68
52
|
fit="contain"
|
|
69
53
|
/>
|
|
70
|
-
|
|
54
|
+
</span>
|
|
55
|
+
{Object.entries(logo.variants ?? {}).map(([appearance, variant]) => {
|
|
56
|
+
const {
|
|
57
|
+
top = 0,
|
|
58
|
+
right = 0,
|
|
59
|
+
bottom = 0,
|
|
60
|
+
left = 0,
|
|
61
|
+
} = variant.outset ?? {};
|
|
62
|
+
const width = logo.width + left + right;
|
|
63
|
+
const height = logo.height + top + bottom;
|
|
64
|
+
return (
|
|
65
|
+
<span
|
|
66
|
+
class="site-nav__brand-logo"
|
|
67
|
+
data-brand-appearance={appearance}
|
|
68
|
+
style={[
|
|
69
|
+
`--brand-artwork-width: ${(width / logo.width) * 100}%`,
|
|
70
|
+
`--brand-artwork-height: ${(height / logo.height) * 100}%`,
|
|
71
|
+
`--brand-artwork-left: ${(-left / logo.width) * 100}%`,
|
|
72
|
+
`--brand-artwork-top: ${(-top / logo.height) * 100}%`,
|
|
73
|
+
].join('; ')}
|
|
74
|
+
>
|
|
75
|
+
<Media
|
|
76
|
+
media={{
|
|
77
|
+
src: variant.src,
|
|
78
|
+
alt: logo.alt,
|
|
79
|
+
width,
|
|
80
|
+
height,
|
|
81
|
+
loading: 'eager',
|
|
82
|
+
decoding: 'sync',
|
|
83
|
+
fetchPriority: 'high',
|
|
84
|
+
}}
|
|
85
|
+
fit="contain"
|
|
86
|
+
/>
|
|
87
|
+
</span>
|
|
88
|
+
);
|
|
89
|
+
})}
|
|
71
90
|
</div>
|
|
72
91
|
) : (
|
|
73
92
|
<>
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
.site-nav__brand-logo-frame {
|
|
18
|
+
position: relative;
|
|
18
19
|
display: grid;
|
|
19
20
|
block-size: var(--site-nav-brand-image-scale);
|
|
20
21
|
max-inline-size: 100%;
|
|
@@ -22,7 +23,11 @@
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
.site-nav__brand-logo[data-brand-appearance] {
|
|
25
|
-
|
|
26
|
+
position: absolute;
|
|
27
|
+
left: var(--brand-artwork-left, 0);
|
|
28
|
+
top: var(--brand-artwork-top, 0);
|
|
29
|
+
inline-size: var(--brand-artwork-width, 100%);
|
|
30
|
+
block-size: var(--brand-artwork-height, 100%);
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
.site-nav__brand-logo[data-brand-appearance]:not(
|
|
@@ -36,7 +41,9 @@
|
|
|
36
41
|
:root[data-appearance='dark']
|
|
37
42
|
.site-nav__brand-logo[data-brand-appearance='dark'],
|
|
38
43
|
:root[data-appearance='halloween']
|
|
39
|
-
.site-nav__brand-logo[data-brand-appearance='halloween']
|
|
44
|
+
.site-nav__brand-logo[data-brand-appearance='halloween'],
|
|
45
|
+
:root[data-appearance='christmas']
|
|
46
|
+
.site-nav__brand-logo[data-brand-appearance='christmas'] {
|
|
40
47
|
display: grid;
|
|
41
48
|
}
|
|
42
49
|
|
|
@@ -48,6 +55,9 @@
|
|
|
48
55
|
[data-brand-appearance='default'],
|
|
49
56
|
:root[data-appearance='halloween']
|
|
50
57
|
.site-nav__brand-logo-frame[data-brand-variants~='halloween']
|
|
58
|
+
[data-brand-appearance='default'],
|
|
59
|
+
:root[data-appearance='christmas']
|
|
60
|
+
.site-nav__brand-logo-frame[data-brand-variants~='christmas']
|
|
51
61
|
[data-brand-appearance='default'] {
|
|
52
62
|
display: none;
|
|
53
63
|
}
|
package/src/config/siteConfig.ts
CHANGED
|
@@ -13,6 +13,12 @@ export type SiteLocaleConfig = {
|
|
|
13
13
|
direction: SiteTextDirection;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
export type SiteLogoVariant = {
|
|
17
|
+
src: string;
|
|
18
|
+
/** Extra artwork outside the base logo, in its original image coordinates. */
|
|
19
|
+
outset?: { top?: number; right?: number; bottom?: number; left?: number };
|
|
20
|
+
};
|
|
21
|
+
|
|
16
22
|
export type SiteIdentity = {
|
|
17
23
|
id: string;
|
|
18
24
|
name: string;
|
|
@@ -22,7 +28,7 @@ export type SiteIdentity = {
|
|
|
22
28
|
alt: string;
|
|
23
29
|
width: number;
|
|
24
30
|
height: number;
|
|
25
|
-
variants?: Partial<Record<ThemeAppearance,
|
|
31
|
+
variants?: Partial<Record<ThemeAppearance, SiteLogoVariant>>;
|
|
26
32
|
};
|
|
27
33
|
};
|
|
28
34
|
|
|
@@ -57,7 +63,7 @@ export type SiteSeoConfig = {
|
|
|
57
63
|
|
|
58
64
|
export type SiteConfig = {
|
|
59
65
|
identity: SiteIdentity;
|
|
60
|
-
|
|
66
|
+
seasonalThemes?: readonly SeasonalThemeConfig[];
|
|
61
67
|
defaultLocale: string;
|
|
62
68
|
locales: readonly [SiteLocaleConfig, ...SiteLocaleConfig[]];
|
|
63
69
|
routing: {
|
|
@@ -77,12 +83,22 @@ export function defineSiteConfig<const TConfig extends SiteConfig>(
|
|
|
77
83
|
throw new Error(`Invalid site identity: ${config.identity.id}`);
|
|
78
84
|
}
|
|
79
85
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
const seasonIds = new Set<string>();
|
|
87
|
+
const seasonPriorities = new Set<number>();
|
|
88
|
+
for (const season of config.seasonalThemes ?? []) {
|
|
89
|
+
if (!season.id.trim() || seasonIds.has(season.id)) {
|
|
90
|
+
throw new Error('Seasonal themes require unique, non-empty event ids.');
|
|
91
|
+
}
|
|
92
|
+
if (
|
|
93
|
+
!Number.isFinite(season.priority) ||
|
|
94
|
+
seasonPriorities.has(season.priority)
|
|
95
|
+
) {
|
|
96
|
+
throw new Error('Seasonal themes require distinct, finite priorities.');
|
|
97
|
+
}
|
|
98
|
+
seasonIds.add(season.id);
|
|
99
|
+
seasonPriorities.add(season.priority);
|
|
100
|
+
if (season.mode !== 'annual') continue;
|
|
101
|
+
const { period } = season;
|
|
86
102
|
for (const { month, day } of [period.start, period.end]) {
|
|
87
103
|
const date = new Date(Date.UTC(2001, month - 1, day));
|
|
88
104
|
if (
|
package/src/display/contracts.ts
CHANGED
|
@@ -115,7 +115,8 @@ type DisplayCardLinkContent = {
|
|
|
115
115
|
textTreatment?: 'default' | 'heading';
|
|
116
116
|
media?: DisplayMedia;
|
|
117
117
|
mediaPosition?: 'start' | 'end';
|
|
118
|
-
mediaSize?: 'default' | 'compact' | 'small';
|
|
118
|
+
mediaSize?: 'default' | 'compact' | 'small' | 'large';
|
|
119
|
+
mediaTreatment?: 'panel' | 'inline';
|
|
119
120
|
direction?: 'previous' | 'next';
|
|
120
121
|
full?: boolean;
|
|
121
122
|
tone?: Tone;
|
|
@@ -54,7 +54,7 @@ const resolvedTone = tone ?? dataTone;
|
|
|
54
54
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
55
55
|
<ThemeBootstrap
|
|
56
56
|
storageKey={themeStorageKey}
|
|
57
|
-
|
|
57
|
+
seasonalThemes={site.seasonalThemes}
|
|
58
58
|
/>
|
|
59
59
|
<SeoHead
|
|
60
60
|
breadcrumbs={breadcrumbs}
|
|
@@ -69,7 +69,11 @@ const resolvedTone = tone ?? dataTone;
|
|
|
69
69
|
<div class="site-shell" data-site-scroll-root>
|
|
70
70
|
<slot name="skip-link" />
|
|
71
71
|
<slot name="header" />
|
|
72
|
-
{
|
|
72
|
+
{
|
|
73
|
+
site.seasonalThemes?.some((season) => season.decorations) ? (
|
|
74
|
+
<SeasonalDecorations />
|
|
75
|
+
) : null
|
|
76
|
+
}
|
|
73
77
|
<div class="site-viewport" data-site-viewport>
|
|
74
78
|
<main id="content" class="site-main" tabindex="-1">
|
|
75
79
|
<div class="site-main__content">
|
|
@@ -2,6 +2,14 @@ const initializedAttribute = 'data-modal-initialized';
|
|
|
2
2
|
const portaledAttribute = 'data-modal-portaled';
|
|
3
3
|
const noop = (): void => undefined;
|
|
4
4
|
|
|
5
|
+
/** Open through the shared controller, preserving its focus and portal lifecycle. */
|
|
6
|
+
export function openModal(root: HTMLDialogElement, trigger: HTMLElement): void {
|
|
7
|
+
initModal(root);
|
|
8
|
+
root.dispatchEvent(
|
|
9
|
+
new CustomEvent('modal:request-open', { detail: trigger }),
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
type PortalState = {
|
|
6
14
|
placeholder: HTMLElement;
|
|
7
15
|
source: HTMLElement;
|
|
@@ -103,14 +111,22 @@ export function initModal(root: HTMLDialogElement): () => void {
|
|
|
103
111
|
activeTrigger = null;
|
|
104
112
|
};
|
|
105
113
|
|
|
114
|
+
const handleOpenRequest = (event: Event): void => {
|
|
115
|
+
if (event instanceof CustomEvent && event.detail instanceof HTMLElement) {
|
|
116
|
+
open(event.detail);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
106
120
|
document.addEventListener('click', handleDocumentClick);
|
|
107
121
|
root.addEventListener('click', handleRootClick);
|
|
108
122
|
root.addEventListener('close', handleClose);
|
|
123
|
+
root.addEventListener('modal:request-open', handleOpenRequest);
|
|
109
124
|
|
|
110
125
|
return () => {
|
|
111
126
|
document.removeEventListener('click', handleDocumentClick);
|
|
112
127
|
root.removeEventListener('click', handleRootClick);
|
|
113
128
|
root.removeEventListener('close', handleClose);
|
|
129
|
+
root.removeEventListener('modal:request-open', handleOpenRequest);
|
|
114
130
|
if (root.open) root.close();
|
|
115
131
|
restoreSource();
|
|
116
132
|
root.removeAttribute(initializedAttribute);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
3
|
+
import type { Tone } from '../../theme/contracts';
|
|
4
|
+
import type { RewardCalendarEntry, RewardCalendarLabels } from './contracts';
|
|
5
|
+
import Modal from '../Modal/Modal.astro';
|
|
6
|
+
import ModalClose from '../Modal/ModalClose.astro';
|
|
7
|
+
import Stack from '../../composition/Stack/Stack.astro';
|
|
8
|
+
import SurfaceGroup from '../../composition/SurfaceGroup/SurfaceGroup.astro';
|
|
9
|
+
import CardHeader from '../../components/content/CardHeader/CardHeader.astro';
|
|
10
|
+
import CardBody from '../../components/content/CardBody/CardBody.astro';
|
|
11
|
+
import Button from '../../components/actions/Button/Button.astro';
|
|
12
|
+
import FieldLabel from '../../components/forms/FieldLabel/FieldLabel.astro';
|
|
13
|
+
import TextInput from '../../components/forms/TextInput/TextInput.astro';
|
|
14
|
+
import FieldError from '../../components/forms/FieldError/FieldError.astro';
|
|
15
|
+
import CodeBlock from '../../components/content/CodeBlock/CodeBlock.astro';
|
|
16
|
+
import RewardPreview from './RewardPreview.astro';
|
|
17
|
+
|
|
18
|
+
type Props = HTMLAttributes<'section'> & {
|
|
19
|
+
id: string;
|
|
20
|
+
entries: readonly RewardCalendarEntry[];
|
|
21
|
+
labels: RewardCalendarLabels;
|
|
22
|
+
tone?: Tone;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const {
|
|
26
|
+
id,
|
|
27
|
+
entries,
|
|
28
|
+
labels,
|
|
29
|
+
tone,
|
|
30
|
+
'data-tone': dataTone,
|
|
31
|
+
class: className,
|
|
32
|
+
...attrs
|
|
33
|
+
} = Astro.props;
|
|
34
|
+
const dialogId = `${id}-reward`;
|
|
35
|
+
const inputId = `${id}-identity`;
|
|
36
|
+
const openingDuration = 2400;
|
|
37
|
+
const clientEntries = entries.map(({ id, label, title }) => ({
|
|
38
|
+
id,
|
|
39
|
+
label,
|
|
40
|
+
title,
|
|
41
|
+
}));
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
<section
|
|
45
|
+
{...attrs}
|
|
46
|
+
id={id}
|
|
47
|
+
class:list={['reward-calendar', className]}
|
|
48
|
+
data-pattern="reward-calendar"
|
|
49
|
+
data-tone={tone ?? dataTone}
|
|
50
|
+
aria-label={labels.calendar}
|
|
51
|
+
style={`--reward-opening-duration: ${openingDuration}ms;`}
|
|
52
|
+
>
|
|
53
|
+
<script
|
|
54
|
+
is:inline
|
|
55
|
+
type="application/json"
|
|
56
|
+
data-reward-calendar-config
|
|
57
|
+
set:html={JSON.stringify({
|
|
58
|
+
entries: clientEntries,
|
|
59
|
+
labels,
|
|
60
|
+
openingDuration,
|
|
61
|
+
}).replace(/</g, '\\u003c')}
|
|
62
|
+
/>
|
|
63
|
+
<p class="reward-calendar__status" role="status" data-calendar-status hidden>
|
|
64
|
+
</p>
|
|
65
|
+
<ol class="reward-calendar__days">
|
|
66
|
+
{
|
|
67
|
+
entries.map((entry) => (
|
|
68
|
+
<li>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
class="reward-calendar__door"
|
|
72
|
+
data-calendar-entry={entry.id}
|
|
73
|
+
data-reward-color={entry.color ?? 'green'}
|
|
74
|
+
data-state="locked"
|
|
75
|
+
aria-label={`${entry.label} — ${labels.states.locked}`}
|
|
76
|
+
disabled
|
|
77
|
+
>
|
|
78
|
+
<span class="reward-calendar__contents" aria-hidden="true">
|
|
79
|
+
<RewardPreview visual={entry.visual} />
|
|
80
|
+
<span class="reward-calendar__reward-name">{entry.title}</span>
|
|
81
|
+
</span>
|
|
82
|
+
<span
|
|
83
|
+
class="reward-calendar__paper reward-calendar__paper--left"
|
|
84
|
+
aria-hidden="true"
|
|
85
|
+
/>
|
|
86
|
+
<span
|
|
87
|
+
class="reward-calendar__paper reward-calendar__paper--right"
|
|
88
|
+
aria-hidden="true"
|
|
89
|
+
/>
|
|
90
|
+
<span
|
|
91
|
+
class="reward-calendar__ribbon reward-calendar__ribbon--vertical"
|
|
92
|
+
aria-hidden="true"
|
|
93
|
+
/>
|
|
94
|
+
<span
|
|
95
|
+
class="reward-calendar__ribbon reward-calendar__ribbon--horizontal"
|
|
96
|
+
aria-hidden="true"
|
|
97
|
+
/>
|
|
98
|
+
<span class="reward-calendar__bow" aria-hidden="true" />
|
|
99
|
+
<span class="reward-calendar__glow" aria-hidden="true" />
|
|
100
|
+
<span class="reward-calendar__number" aria-hidden="true">
|
|
101
|
+
<span>{entry.number}</span>
|
|
102
|
+
</span>
|
|
103
|
+
</button>
|
|
104
|
+
</li>
|
|
105
|
+
))
|
|
106
|
+
}
|
|
107
|
+
</ol>
|
|
108
|
+
<Modal
|
|
109
|
+
id={dialogId}
|
|
110
|
+
label={labels.calendar}
|
|
111
|
+
closeLabel={labels.close}
|
|
112
|
+
closeControl="delegated"
|
|
113
|
+
sizing="confirmation"
|
|
114
|
+
data-calendar-dialog
|
|
115
|
+
>
|
|
116
|
+
<SurfaceGroup>
|
|
117
|
+
<CardHeader title={labels.calendar}>
|
|
118
|
+
<ModalClose slot="action" label={labels.close} />
|
|
119
|
+
</CardHeader>
|
|
120
|
+
<CardBody>
|
|
121
|
+
<Stack gap="md">
|
|
122
|
+
{
|
|
123
|
+
entries.map((entry) => (
|
|
124
|
+
<div
|
|
125
|
+
class="reward-calendar__reward"
|
|
126
|
+
data-calendar-reward={entry.id}
|
|
127
|
+
data-reward-color={entry.color ?? 'green'}
|
|
128
|
+
hidden
|
|
129
|
+
>
|
|
130
|
+
<RewardPreview visual={entry.visual} />
|
|
131
|
+
<h3>{entry.title}</h3>
|
|
132
|
+
</div>
|
|
133
|
+
))
|
|
134
|
+
}
|
|
135
|
+
<Stack as="form" gap="md" data-calendar-form novalidate>
|
|
136
|
+
<FieldLabel for={inputId}>{labels.identity}</FieldLabel>
|
|
137
|
+
<TextInput
|
|
138
|
+
id={inputId}
|
|
139
|
+
name="identity"
|
|
140
|
+
autocomplete="off"
|
|
141
|
+
spellcheck={false}
|
|
142
|
+
autocapitalize="characters"
|
|
143
|
+
inputmode="text"
|
|
144
|
+
maxlength={64}
|
|
145
|
+
placeholder={labels.identityPlaceholder}
|
|
146
|
+
aria-describedby={`${inputId}-error`}
|
|
147
|
+
data-calendar-identity
|
|
148
|
+
/>
|
|
149
|
+
<FieldError id={`${inputId}-error`} data-calendar-error />
|
|
150
|
+
<Button type="submit" full prominent data-calendar-generate>
|
|
151
|
+
{labels.generate}
|
|
152
|
+
</Button>
|
|
153
|
+
</Stack>
|
|
154
|
+
<Stack gap="sm" data-calendar-result hidden>
|
|
155
|
+
<CodeBlock
|
|
156
|
+
text=""
|
|
157
|
+
wrap
|
|
158
|
+
role="status"
|
|
159
|
+
aria-live="polite"
|
|
160
|
+
data-calendar-code
|
|
161
|
+
/>
|
|
162
|
+
<Button full prominent data-calendar-copy>{labels.copy}</Button>
|
|
163
|
+
</Stack>
|
|
164
|
+
<p
|
|
165
|
+
role="status"
|
|
166
|
+
class="reward-calendar__feedback"
|
|
167
|
+
data-calendar-feedback
|
|
168
|
+
>
|
|
169
|
+
</p>
|
|
170
|
+
</Stack>
|
|
171
|
+
</CardBody>
|
|
172
|
+
</SurfaceGroup>
|
|
173
|
+
</Modal>
|
|
174
|
+
</section>
|