@astronox/core 0.5.12 → 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.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/src/components/actions/ThemeToggle/ThemeToggle.astro +11 -0
  3. package/src/components/actions/ThemeToggle/ThemeToggle.css +4 -1
  4. package/src/components/actions/ThemeToggle/themeToggleController.ts +1 -0
  5. package/src/components/content/CardLink/CardLink.astro +2 -0
  6. package/src/components/content/CardLink/CardLink.css +50 -5
  7. package/src/components/feedback/SeasonalDecorations/SeasonalDecorations.astro +46 -1
  8. package/src/components/feedback/SeasonalDecorations/SeasonalDecorations.css +51 -1
  9. package/src/components/feedback/SeasonalDecorations/snowfall.ts +38 -0
  10. package/src/components/feedback/ThemeBootstrap/ThemeBootstrap.astro +135 -45
  11. package/src/components/icons/CurrencyIcon.css +2 -2
  12. package/src/components/icons/FirTreeIcon.astro +32 -0
  13. package/src/components/icons/GiftIcon.astro +34 -0
  14. package/src/components/index.ts +2 -0
  15. package/src/components/navigation/Header.css +22 -3
  16. package/src/components/navigation/NavigationBrand.astro +38 -19
  17. package/src/components/navigation/NavigationBrand.css +12 -2
  18. package/src/config/siteConfig.ts +44 -4
  19. package/src/display/contracts.ts +2 -1
  20. package/src/layouts/BaseLayout.astro +6 -2
  21. package/src/patterns/Modal/modalController.ts +16 -0
  22. package/src/patterns/RewardCalendar/RewardCalendar.astro +174 -0
  23. package/src/patterns/RewardCalendar/RewardCalendar.css +406 -0
  24. package/src/patterns/RewardCalendar/RewardPreview.astro +18 -0
  25. package/src/patterns/RewardCalendar/contracts.ts +55 -0
  26. package/src/patterns/RewardCalendar/rewardCalendarController.ts +268 -0
  27. package/src/patterns/index.ts +3 -0
  28. package/src/styles/base.css +6 -3
  29. package/src/styles/style.css +2 -0
  30. package/src/styles/tokens.css +6 -0
  31. package/src/theme/christmas-garland.svg +28 -0
  32. package/src/theme/christmas.css +25 -0
  33. package/src/theme/contracts.ts +14 -2
  34. package/src/theme/halloween.css +1 -0
@@ -95,6 +95,8 @@ export { default as MinusIcon } from './icons/MinusIcon.astro';
95
95
  export { default as MountainIcon } from './icons/MountainIcon.astro';
96
96
  export { default as NightIcon } from './icons/NightIcon.astro';
97
97
  export { default as PumpkinIcon } from './icons/PumpkinIcon.astro';
98
+ export { default as FirTreeIcon } from './icons/FirTreeIcon.astro';
99
+ export { default as GiftIcon } from './icons/GiftIcon.astro';
98
100
  export { default as PlayIcon } from './icons/PlayIcon.astro';
99
101
  export { default as PlusIcon } from './icons/PlusIcon.astro';
100
102
  export { default as PoisonIcon } from './icons/PoisonIcon.astro';
@@ -19,7 +19,7 @@
19
19
  position: sticky;
20
20
  }
21
21
 
22
- :root[data-appearance='halloween']:is(
22
+ :root:is([data-appearance='halloween'], [data-appearance='christmas']):is(
23
23
  [data-theme-decorations='static'],
24
24
  [data-theme-decorations='animated']
25
25
  )
@@ -30,7 +30,7 @@
30
30
  --dropdown-attached-border-top-width: var(--site-header-menu-border-width);
31
31
  }
32
32
 
33
- :root[data-appearance='halloween']:is(
33
+ :root:is([data-appearance='halloween'], [data-appearance='christmas']):is(
34
34
  [data-theme-decorations='static'],
35
35
  [data-theme-decorations='animated']
36
36
  )
@@ -39,7 +39,26 @@
39
39
  inset-inline: 0;
40
40
  inset-block-start: 100%;
41
41
  block-size: var(--nox-seasonal-border-size);
42
- background: var(--nox-seasonal-blood);
42
+ background: var(--nox-seasonal-header-border);
43
+ content: '';
44
+ pointer-events: none;
45
+ }
46
+
47
+ :root[data-appearance='christmas']:is(
48
+ [data-theme-decorations='static'],
49
+ [data-theme-decorations='animated']
50
+ )
51
+ .site-header::before {
52
+ position: absolute;
53
+ /* Decorations stay behind every navigation menu in the header context. */
54
+ z-index: -1;
55
+ inset-inline: 0;
56
+ inset-block-start: calc(100% + 1px);
57
+ block-size: var(--nox-seasonal-header-ornament-size);
58
+ background-image: var(--nox-seasonal-header-ornament);
59
+ /* Repeat whole motifs so neither edge cuts through a hanging bulb. */
60
+ background-repeat: round no-repeat;
61
+ background-size: 144px 100%;
43
62
  content: '';
44
63
  pointer-events: none;
45
64
  }
@@ -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
- <Media
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
- grid-area: 1 / 1;
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
  }
@@ -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, string>>;
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
- seasonalTheme?: SeasonalThemeConfig;
66
+ seasonalThemes?: readonly SeasonalThemeConfig[];
61
67
  defaultLocale: string;
62
68
  locales: readonly [SiteLocaleConfig, ...SiteLocaleConfig[]];
63
69
  routing: {
@@ -77,8 +83,42 @@ export function defineSiteConfig<const TConfig extends SiteConfig>(
77
83
  throw new Error(`Invalid site identity: ${config.identity.id}`);
78
84
  }
79
85
 
80
- if (config.seasonalTheme && !config.seasonalTheme.id.trim()) {
81
- throw new Error('A seasonal theme requires a non-empty event id.');
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;
102
+ for (const { month, day } of [period.start, period.end]) {
103
+ const date = new Date(Date.UTC(2001, month - 1, day));
104
+ if (
105
+ !Number.isInteger(month) ||
106
+ !Number.isInteger(day) ||
107
+ date.getUTCFullYear() !== 2001 ||
108
+ date.getUTCMonth() !== month - 1 ||
109
+ date.getUTCDate() !== day
110
+ ) {
111
+ throw new Error(
112
+ 'Annual theme periods require month/day dates valid every year.',
113
+ );
114
+ }
115
+ }
116
+ if (
117
+ period.start.month === period.end.month &&
118
+ period.start.day === period.end.day
119
+ ) {
120
+ throw new Error('An annual theme period requires distinct boundaries.');
121
+ }
82
122
  }
83
123
 
84
124
  const localeIds = new Set<string>();
@@ -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
- seasonalTheme={site.seasonalTheme}
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
- {site.seasonalTheme?.decorations ? <SeasonalDecorations /> : null}
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>