@commonpub/layer 0.3.38 → 0.4.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/pages/privacy.vue CHANGED
@@ -62,13 +62,14 @@ const { federation: federationEnabled } = useFeatures();
62
62
  </section>
63
63
 
64
64
  <section class="cpub-legal-section">
65
- <h2>5. Cookies and Local Storage</h2>
66
- <p>We use only <strong>strictly necessary</strong> technologies:</p>
65
+ <h2>5. Cookies</h2>
66
+ <p>We use a small number of cookies to provide and improve the service:</p>
67
67
  <ul>
68
- <li><strong>Session cookie</strong> (<code>better-auth.session_token</code>): an authentication cookie that identifies your login session. It is httpOnly (not accessible to JavaScript), secure (HTTPS-only in production), and expires after 7 days. This cookie is strictly necessary for the service to function and does not require consent.</li>
69
- <li><strong>Theme preference</strong> (<code>cpub-theme</code> in localStorage): stores your light/dark mode choice in your browser. This is a UI preference, not used for tracking.</li>
68
+ <li><strong>Session cookie</strong> (<code>better-auth.session_token</code>): strictly necessary authenticates your login session. HttpOnly, secure, 7-day expiry.</li>
69
+ <li><strong>Consent cookie</strong> (<code>cpub-consent</code>): strictly necessary stores your cookie consent choice.</li>
70
+ <li><strong>Color scheme</strong> (<code>cpub-color-scheme</code>): functional — remembers your light/dark mode preference. Set only with your consent.</li>
70
71
  </ul>
71
- <p>We do not use any analytics, advertising, or tracking cookies. No cookie consent banner is required because we only use strictly necessary cookies.</p>
72
+ <p>We do not use any advertising or tracking cookies. Your instance operator may add analytics cookies — these require your explicit consent. For the full list of cookies and to manage your preferences, visit our <NuxtLink to="/cookies">Cookie Policy</NuxtLink>.</p>
72
73
  </section>
73
74
 
74
75
  <section v-if="federationEnabled" class="cpub-legal-section">
@@ -1,32 +1,112 @@
1
1
  <script setup lang="ts">
2
2
  definePageMeta({ middleware: 'auth' });
3
3
 
4
- const { themeId: theme, setTheme } = useTheme();
5
- const themes = [
6
- { id: 'base', name: 'Light', desc: 'Sharp corners, offset shadows, blue accent' },
7
- { id: 'dark', name: 'Dark', desc: 'Dark surfaces, same offset shadow aesthetic' },
8
- { id: 'generics', name: 'Generics', desc: 'Dark minimal with soft glow' },
9
- ];
4
+ const { themeId, isDark, instanceDefault, setDarkMode } = useTheme();
5
+
6
+ const familyLabels: Record<string, string> = {
7
+ base: 'Classic',
8
+ dark: 'Classic',
9
+ generics: 'Generics',
10
+ agora: 'Agora',
11
+ 'agora-dark': 'Agora',
12
+ };
13
+
14
+ const currentFamily = computed(() => familyLabels[instanceDefault.value] ?? 'Classic');
15
+
16
+ function previewColors(dark: boolean): { bg: string; surface: string; accent: string; text: string; border: string } {
17
+ // Resolve what theme ID would be for this mode
18
+ const families: Record<string, { light: string; dark: string }> = {
19
+ classic: { light: 'base', dark: 'dark' },
20
+ agora: { light: 'agora', dark: 'agora-dark' },
21
+ generics: { light: 'generics', dark: 'generics' },
22
+ };
23
+ const familyMap: Record<string, string> = {
24
+ base: 'classic', dark: 'classic', generics: 'generics',
25
+ agora: 'agora', 'agora-dark': 'agora',
26
+ };
27
+ const family = familyMap[instanceDefault.value] ?? 'classic';
28
+ const id = dark ? families[family]!.dark : families[family]!.light;
29
+
30
+ const palette: Record<string, { bg: string; surface: string; accent: string; text: string; border: string }> = {
31
+ base: { bg: '#fafaf9', surface: '#ffffff', accent: '#5b9cf6', text: '#1a1a1a', border: '#1a1a1a' },
32
+ dark: { bg: '#111111', surface: '#1a1a1a', accent: '#5b9cf6', text: '#e5e5e3', border: '#444440' },
33
+ generics: { bg: '#0c0c0b', surface: '#141413', accent: '#5b9cf6', text: '#d8d5cf', border: '#272725' },
34
+ agora: { bg: '#f7f4ed', surface: '#faf8f3', accent: '#3d8b5e', text: '#1a1a1a', border: '#1a1a1a' },
35
+ 'agora-dark': { bg: '#0d1a12', surface: '#141f17', accent: '#4aa06e', text: '#e8e8e2', border: '#3a4f40' },
36
+ };
37
+ return palette[id] ?? palette.base!;
38
+ }
10
39
  </script>
11
40
 
12
41
  <template>
13
42
  <div>
14
43
  <h2 class="cpub-section-title-lg">Appearance</h2>
15
44
 
16
- <div class="cpub-theme-grid">
45
+ <p class="cpub-appearance-note">
46
+ Your instance uses the <strong>{{ currentFamily }}</strong> theme.
47
+ Choose your preferred color scheme.
48
+ </p>
49
+
50
+ <div class="cpub-scheme-grid">
51
+ <button
52
+ class="cpub-scheme-card"
53
+ :class="{ active: !isDark }"
54
+ @click="setDarkMode(false)"
55
+ >
56
+ <div
57
+ class="cpub-scheme-preview"
58
+ :style="{
59
+ backgroundColor: previewColors(false).bg,
60
+ borderColor: previewColors(false).border,
61
+ }"
62
+ >
63
+ <div
64
+ class="cpub-scheme-preview-card"
65
+ :style="{
66
+ backgroundColor: previewColors(false).surface,
67
+ borderColor: previewColors(false).border,
68
+ boxShadow: `3px 3px 0 ${previewColors(false).border}`,
69
+ }"
70
+ >
71
+ <div class="cpub-preview-heading" :style="{ backgroundColor: previewColors(false).text, opacity: 0.8 }"></div>
72
+ <div class="cpub-preview-line" :style="{ backgroundColor: previewColors(false).text, opacity: 0.3 }"></div>
73
+ <div class="cpub-preview-line short" :style="{ backgroundColor: previewColors(false).text, opacity: 0.2 }"></div>
74
+ <div class="cpub-preview-btn" :style="{ backgroundColor: previewColors(false).accent }"></div>
75
+ </div>
76
+ </div>
77
+ <div class="cpub-scheme-info">
78
+ <span class="cpub-scheme-name">Light</span>
79
+ </div>
80
+ </button>
81
+
17
82
  <button
18
- v-for="t in themes"
19
- :key="t.id"
20
- class="cpub-theme-card"
21
- :class="{ active: theme === t.id }"
22
- @click="setTheme(t.id)"
83
+ class="cpub-scheme-card"
84
+ :class="{ active: isDark }"
85
+ @click="setDarkMode(true)"
23
86
  >
24
- <div class="cpub-theme-preview" :data-theme="t.id">
25
- <div class="cpub-theme-swatch"></div>
87
+ <div
88
+ class="cpub-scheme-preview"
89
+ :style="{
90
+ backgroundColor: previewColors(true).bg,
91
+ borderColor: previewColors(true).border,
92
+ }"
93
+ >
94
+ <div
95
+ class="cpub-scheme-preview-card"
96
+ :style="{
97
+ backgroundColor: previewColors(true).surface,
98
+ borderColor: previewColors(true).border,
99
+ boxShadow: `3px 3px 0 ${previewColors(true).border}`,
100
+ }"
101
+ >
102
+ <div class="cpub-preview-heading" :style="{ backgroundColor: previewColors(true).text, opacity: 0.8 }"></div>
103
+ <div class="cpub-preview-line" :style="{ backgroundColor: previewColors(true).text, opacity: 0.3 }"></div>
104
+ <div class="cpub-preview-line short" :style="{ backgroundColor: previewColors(true).text, opacity: 0.2 }"></div>
105
+ <div class="cpub-preview-btn" :style="{ backgroundColor: previewColors(true).accent }"></div>
106
+ </div>
26
107
  </div>
27
- <div class="cpub-theme-info">
28
- <span class="cpub-theme-name">{{ t.name }}</span>
29
- <span class="cpub-theme-desc">{{ t.desc }}</span>
108
+ <div class="cpub-scheme-info">
109
+ <span class="cpub-scheme-name">Dark</span>
30
110
  </div>
31
111
  </button>
32
112
  </div>
@@ -34,48 +114,73 @@ const themes = [
34
114
  </template>
35
115
 
36
116
  <style scoped>
37
- .cpub-theme-grid {
117
+ .cpub-appearance-note {
118
+ font-size: var(--text-sm);
119
+ color: var(--text-dim);
120
+ margin-bottom: var(--space-4);
121
+ }
122
+
123
+ .cpub-scheme-grid {
38
124
  display: grid;
39
- grid-template-columns: repeat(3, 1fr);
40
- gap: 12px;
125
+ grid-template-columns: repeat(2, 1fr);
126
+ gap: var(--space-3);
127
+ max-width: 440px;
41
128
  }
42
129
 
43
- .cpub-theme-card {
130
+ .cpub-scheme-card {
44
131
  background: var(--surface);
45
132
  border: var(--border-width-default) solid var(--border2);
46
- padding: 12px;
133
+ padding: 0;
47
134
  cursor: pointer;
48
135
  text-align: left;
136
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast);
49
137
  }
50
138
 
51
- .cpub-theme-card.active {
139
+ .cpub-scheme-card.active {
52
140
  border-color: var(--accent);
53
141
  box-shadow: var(--shadow-accent);
54
142
  }
55
143
 
56
- .cpub-theme-card:hover {
144
+ .cpub-scheme-card:hover {
57
145
  border-color: var(--border);
146
+ transform: translate(-1px, -1px);
147
+ box-shadow: var(--shadow-sm);
58
148
  }
59
149
 
60
- .cpub-theme-preview {
61
- height: 60px;
62
- background: var(--surface2);
63
- border: var(--border-width-default) solid var(--border2);
64
- margin-bottom: 8px;
150
+ .cpub-scheme-preview {
151
+ height: 80px;
152
+ padding: var(--space-3);
153
+ border-bottom: var(--border-width-default) solid var(--border2);
154
+ display: flex;
155
+ align-items: center;
156
+ justify-content: center;
65
157
  }
66
158
 
67
- .cpub-theme-name {
68
- font-size: 13px;
69
- font-weight: 600;
70
- display: block;
159
+ .cpub-scheme-preview-card {
160
+ width: 75%;
161
+ padding: var(--space-2);
162
+ border-width: 2px;
163
+ border-style: solid;
164
+ display: flex;
165
+ flex-direction: column;
166
+ gap: 3px;
71
167
  }
72
168
 
73
- .cpub-theme-desc {
74
- font-size: 11px;
75
- color: var(--text-dim);
169
+ .cpub-preview-heading { height: 5px; width: 55%; }
170
+ .cpub-preview-line { height: 3px; width: 85%; }
171
+ .cpub-preview-line.short { width: 45%; }
172
+ .cpub-preview-btn { height: 12px; width: 35%; margin-top: 3px; }
173
+
174
+ .cpub-scheme-info {
175
+ padding: var(--space-3);
176
+ }
177
+
178
+ .cpub-scheme-name {
179
+ font-size: var(--text-sm);
180
+ font-weight: var(--font-weight-semibold);
76
181
  }
77
182
 
78
- @media (max-width: 640px) {
79
- .cpub-theme-grid { grid-template-columns: 1fr; }
183
+ @media (max-width: 480px) {
184
+ .cpub-scheme-grid { grid-template-columns: 1fr; }
80
185
  }
81
186
  </style>
@@ -0,0 +1,32 @@
1
+ // Theme plugin — resolves theme on server (zero flash) and hydrates on client.
2
+ //
3
+ // The admin picks the instance theme (family + default mode).
4
+ // Users only toggle light/dark. Server middleware resolves the final theme ID.
5
+ // This plugin reads that resolved value and sets data-theme on <html>.
6
+
7
+ export default defineNuxtPlugin(() => {
8
+ const themeId = useState<string>('cpub-theme', () => 'base');
9
+ const instanceTheme = useState<string>('cpub-instance-theme', () => 'base');
10
+ const isDark = useState<boolean>('cpub-dark-mode', () => false);
11
+
12
+ if (import.meta.server) {
13
+ const event = useRequestEvent();
14
+ if (event?.context) {
15
+ themeId.value = event.context.resolvedTheme ?? 'base';
16
+ instanceTheme.value = event.context.instanceTheme ?? 'base';
17
+ isDark.value = event.context.isDarkMode ?? false;
18
+ }
19
+ }
20
+
21
+ if (import.meta.client) {
22
+ // One-time migration: clear old localStorage theme (replaced by server-side resolution)
23
+ localStorage.removeItem('cpub-theme');
24
+ }
25
+
26
+ // Set data-theme on <html> during SSR — first paint has the correct theme
27
+ if (themeId.value && themeId.value !== 'base') {
28
+ useHead({
29
+ htmlAttrs: { 'data-theme': themeId.value },
30
+ });
31
+ }
32
+ });
@@ -6,11 +6,15 @@ export default defineEventHandler(async (event) => {
6
6
  const db = useDB();
7
7
  const config = useConfig();
8
8
 
9
- // Get DB-stored settings
9
+ // Get DB-stored settings (returns Map — convert to plain object)
10
10
  const dbSettings = await getInstanceSettings(db);
11
+ const stored: Record<string, unknown> = {};
12
+ for (const [key, value] of dbSettings) {
13
+ stored[key] = value;
14
+ }
11
15
 
12
16
  // Merge with running config defaults so the UI shows actual values
13
- const defaults: Record<string, string> = {
17
+ const defaults: Record<string, unknown> = {
14
18
  'instance.name': config.instance.name,
15
19
  'instance.description': config.instance.description,
16
20
  'instance.registrationOpen': 'true',
@@ -18,5 +22,5 @@ export default defineEventHandler(async (event) => {
18
22
  'instance.contactEmail': config.instance.contactEmail ?? '',
19
23
  };
20
24
 
21
- return { ...defaults, ...dbSettings };
25
+ return { ...defaults, ...stored };
22
26
  });
@@ -7,5 +7,10 @@ export default defineEventHandler(async (event): Promise<void> => {
7
7
  const db = useDB();
8
8
  const input = await parseBody(event, adminSettingSchema);
9
9
 
10
- return setInstanceSetting(db, input.key, input.value, admin.id);
10
+ await setInstanceSetting(db, input.key, input.value, admin.id);
11
+
12
+ // Invalidate server-side theme cache when the default changes
13
+ if (input.key === 'theme.default') {
14
+ invalidateThemeCache();
15
+ }
11
16
  });
@@ -1,4 +1,4 @@
1
- import { deleteUser, federateDelete, listContent } from '@commonpub/server';
1
+ import { deleteUser, federateDelete } from '@commonpub/server';
2
2
  import { contentItems } from '@commonpub/schema';
3
3
  import { eq, and } from 'drizzle-orm';
4
4
 
@@ -0,0 +1,34 @@
1
+ // Resolves the active theme for SSR and injects it into the event context.
2
+ // Runs before page rendering so the theme plugin can set data-theme on <html>.
3
+ //
4
+ // Admin picks instance theme → user only toggles light/dark → server resolves.
5
+
6
+ declare module 'h3' {
7
+ interface H3EventContext {
8
+ /** Final resolved theme ID for this request */
9
+ resolvedTheme: string;
10
+ /** The admin-configured instance default theme */
11
+ instanceTheme: string;
12
+ /** Whether the resolved theme is dark */
13
+ isDarkMode: boolean;
14
+ }
15
+ }
16
+
17
+ export default defineEventHandler(async (event) => {
18
+ // Only resolve theme for SSR page requests — skip API, assets, and internal routes
19
+ const path = getRequestURL(event).pathname;
20
+ if (path.startsWith('/api') || path.startsWith('/_nuxt') || path.startsWith('/__nuxt')) return;
21
+
22
+ const instanceTheme = await getInstanceDefaultTheme();
23
+ event.context.instanceTheme = instanceTheme;
24
+
25
+ // Read user's light/dark preference from cookie
26
+ const schemeCookie = getCookie(event, 'cpub-color-scheme');
27
+ const userScheme = schemeCookie === 'light' || schemeCookie === 'dark'
28
+ ? schemeCookie
29
+ : null;
30
+
31
+ const resolved = resolveThemeForUser(instanceTheme, userScheme);
32
+ event.context.resolvedTheme = resolved;
33
+ event.context.isDarkMode = isDefaultDark(resolved);
34
+ });
@@ -0,0 +1,67 @@
1
+ // Cached instance-level theme resolution for SSR.
2
+ // The admin sets a theme (determines family + default mode).
3
+ // Users only toggle light/dark within that family.
4
+
5
+ import { eq } from 'drizzle-orm';
6
+ import { instanceSettings } from '@commonpub/schema';
7
+ import { THEME_TO_FAMILY, FAMILY_VARIANTS, IS_DARK, VALID_THEME_IDS } from '../../utils/themeConfig';
8
+
9
+ const CACHE_TTL = 60_000; // 1 minute
10
+
11
+ let cached: string | null = null;
12
+ let cacheTime = 0;
13
+
14
+ export async function getInstanceDefaultTheme(): Promise<string> {
15
+ const now = Date.now();
16
+ if (cached !== null && now - cacheTime < CACHE_TTL) return cached;
17
+
18
+ try {
19
+ const db = useDB();
20
+ const [row] = await db
21
+ .select({ value: instanceSettings.value })
22
+ .from(instanceSettings)
23
+ .where(eq(instanceSettings.key, 'theme.default'));
24
+
25
+ if (row?.value && typeof row.value === 'string' && VALID_THEME_IDS.has(row.value)) {
26
+ cached = row.value;
27
+ } else {
28
+ cached = 'base';
29
+ }
30
+ } catch (err) {
31
+ console.warn('[theme] Failed to read instance theme from DB, using fallback:', err instanceof Error ? err.message : String(err));
32
+ cached = 'base';
33
+ }
34
+
35
+ cacheTime = now;
36
+ return cached;
37
+ }
38
+
39
+ /**
40
+ * Resolve the final theme ID given the admin's chosen default and the
41
+ * user's light/dark preference.
42
+ */
43
+ export function resolveThemeForUser(
44
+ adminTheme: string,
45
+ userScheme: 'light' | 'dark' | null,
46
+ ): string {
47
+ const family = THEME_TO_FAMILY[adminTheme] ?? 'classic';
48
+ const variants = FAMILY_VARIANTS[family] ?? FAMILY_VARIANTS.classic!;
49
+
50
+ if (userScheme === null) return adminTheme;
51
+ return userScheme === 'dark' ? variants.dark : variants.light;
52
+ }
53
+
54
+ /** Whether a given theme ID is dark */
55
+ export function isDefaultDark(themeId: string): boolean {
56
+ return IS_DARK[themeId] ?? false;
57
+ }
58
+
59
+ /** Call after admin changes the instance default theme */
60
+ export function invalidateThemeCache(): void {
61
+ cached = null;
62
+ cacheTime = 0;
63
+ }
64
+
65
+ export function isServerValidThemeId(id: string): boolean {
66
+ return VALID_THEME_IDS.has(id);
67
+ }
@@ -0,0 +1,157 @@
1
+ @layer commonpub {
2
+ /* ===========================================
3
+ CommonPub Agora Theme — Dark (Grove)
4
+ Warm dark surfaces with green-tinted blacks,
5
+ green accent, serif display font.
6
+ =========================================== */
7
+
8
+ [data-theme="agora-dark"] {
9
+ /* === SURFACES (grove-tinted warm darks) === */
10
+ --bg: #0d1a12;
11
+ --surface: #141f17;
12
+ --surface2: #1a261e;
13
+ --surface3: #213028;
14
+
15
+ --color-surface: var(--surface);
16
+ --color-surface-alt: var(--surface2);
17
+ --color-surface-raised: var(--surface2);
18
+ --color-surface-overlay: rgba(0, 0, 0, 0.7);
19
+ --color-surface-overlay-light: rgba(0, 0, 0, 0.6);
20
+ --color-surface-scrim: rgba(13, 26, 18, 0.85);
21
+ --color-surface-hover: var(--surface3);
22
+ --color-bg-subtle: var(--bg);
23
+
24
+ /* === TEXT (warm light on dark) === */
25
+ --text: #e8e8e2;
26
+ --text-dim: #b0b0a6;
27
+ --text-faint: #7a7a72;
28
+
29
+ --color-text: var(--text);
30
+ --color-text-secondary: var(--text-dim);
31
+ --color-text-muted: var(--text-faint);
32
+ --color-text-inverse: #0d1a12;
33
+ --color-on-accent: #ffffff;
34
+ --color-on-primary: #ffffff;
35
+ --color-primary-hover: #4aa06e;
36
+
37
+ /* === BORDERS (grove-tinted) === */
38
+ --border: #3a4f40;
39
+ --border2: #2a3a2f;
40
+
41
+ --color-border: var(--border2);
42
+ --color-border-strong: var(--border);
43
+ --color-border-focus: var(--accent);
44
+
45
+ /* === ACCENT (commons green, brighter for dark bg) === */
46
+ --accent: #4aa06e;
47
+ --accent-bg: rgba(74, 160, 110, 0.12);
48
+ --accent-bg-strong: rgba(74, 160, 110, 0.25);
49
+ --accent-bg-heavy: rgba(74, 160, 110, 0.45);
50
+ --accent-bg-solid: rgba(74, 160, 110, 0.65);
51
+ --accent-border: rgba(74, 160, 110, 0.3);
52
+ --accent-focus-ring: 0 0 0 3px rgba(74, 160, 110, 0.2);
53
+ --shadow-accent: 4px 4px 0 var(--accent);
54
+
55
+ --color-primary: var(--accent);
56
+ --color-accent: var(--accent);
57
+ --color-accent-hover: #5ab87e;
58
+ --color-accent-text: #0d1a12;
59
+ --color-primary-text: #ffffff;
60
+ --color-accent-bg: var(--accent-bg);
61
+ --color-accent-border: var(--accent-border);
62
+
63
+ /* === SEMANTIC COLORS (brighter for dark bg) === */
64
+ --green: #4aa06e;
65
+ --green-bg: rgba(74, 160, 110, 0.12);
66
+ --green-border: rgba(74, 160, 110, 0.3);
67
+
68
+ --yellow: #daa040;
69
+ --yellow-bg: rgba(218, 160, 64, 0.12);
70
+ --yellow-border: rgba(218, 160, 64, 0.3);
71
+
72
+ --red: #d85e55;
73
+ --red-bg: rgba(216, 94, 85, 0.12);
74
+ --red-border: rgba(216, 94, 85, 0.3);
75
+
76
+ --purple: #9a7ec4;
77
+ --purple-bg: rgba(154, 126, 196, 0.12);
78
+ --purple-border: rgba(154, 126, 196, 0.3);
79
+
80
+ --teal: #3dbcab;
81
+ --teal-bg: rgba(61, 188, 171, 0.12);
82
+ --teal-border: rgba(61, 188, 171, 0.3);
83
+
84
+ --pink: #d07090;
85
+ --pink-bg: rgba(208, 112, 144, 0.12);
86
+ --pink-border: rgba(208, 112, 144, 0.3);
87
+
88
+ --color-success: var(--green);
89
+ --color-warning: var(--yellow);
90
+ --color-error: var(--red);
91
+ --color-info: #6a9fd0;
92
+ --color-success-bg: var(--green-bg);
93
+ --color-warning-bg: var(--yellow-bg);
94
+ --color-error-bg: var(--red-bg);
95
+ --color-info-bg: rgba(106, 159, 208, 0.12);
96
+
97
+ /* === OVERLAYS === */
98
+ --color-badge-overlay: rgba(0, 0, 0, 0.75);
99
+
100
+ /* === INTERACTIVE === */
101
+ --color-link: var(--accent);
102
+ --color-link-hover: #5ab87e;
103
+
104
+ /* === TYPOGRAPHY (warm, distinctive) === */
105
+ --font-sans: 'Work Sans', system-ui, -apple-system, sans-serif;
106
+ --font-mono: 'JetBrains Mono', ui-monospace, monospace;
107
+ --font-display: 'Fraunces', Georgia, 'Times New Roman', serif;
108
+
109
+ --font-heading: var(--font-display);
110
+ --font-body: var(--font-sans);
111
+
112
+ /* Font Sizes (Agora scale) */
113
+ --text-xs: 0.6875rem;
114
+ --text-sm: 0.8125rem;
115
+ --text-base: 0.9375rem;
116
+ --text-md: 1.0625rem;
117
+ --text-lg: 1.25rem;
118
+ --text-xl: 1.5rem;
119
+ --text-2xl: 1.875rem;
120
+ --text-3xl: 2.375rem;
121
+ --text-4xl: 3rem;
122
+ --text-5xl: 4rem;
123
+ --text-label: 0.625rem;
124
+
125
+ /* Line Heights */
126
+ --leading-tight: 1.15;
127
+ --leading-snug: 1.35;
128
+ --leading-normal: 1.7;
129
+ --leading-relaxed: 1.9;
130
+
131
+ /* Letter Spacing */
132
+ --tracking-tight: -0.02em;
133
+ --tracking-normal: 0;
134
+ --tracking-wide: 0.06em;
135
+ --tracking-wider: 0.1em;
136
+ --tracking-widest: 0.16em;
137
+
138
+ /* === SHADOWS (softer for dark mode) === */
139
+ --shadow-sm: 2px 2px 0 rgba(0, 0, 0, 0.4);
140
+ --shadow-md: 4px 4px 0 rgba(0, 0, 0, 0.4);
141
+ --shadow-lg: 6px 6px 0 rgba(0, 0, 0, 0.4);
142
+ --shadow-xl: 8px 8px 0 rgba(0, 0, 0, 0.4);
143
+
144
+ /* === FOCUS === */
145
+ --focus-ring: var(--shadow-accent);
146
+
147
+ color-scheme: dark;
148
+ }
149
+
150
+ /* === CONTENT TYPE BADGE COLORS (Agora dark palette) === */
151
+ [data-theme="agora-dark"] [data-content-type="article"] { --badge-color: #6a9fd0; --badge-bg: rgba(106, 159, 208, 0.12); }
152
+ [data-theme="agora-dark"] [data-content-type="blog"] { --badge-color: #4aa06e; --badge-bg: rgba(74, 160, 110, 0.12); }
153
+ [data-theme="agora-dark"] [data-content-type="project"] { --badge-color: #9a7ec4; --badge-bg: rgba(154, 126, 196, 0.12); }
154
+ [data-theme="agora-dark"] [data-content-type="explainer"] { --badge-color: #3dbcab; --badge-bg: rgba(61, 188, 171, 0.12); }
155
+ [data-theme="agora-dark"] [data-content-type="video"] { --badge-color: #d85e55; --badge-bg: rgba(216, 94, 85, 0.12); }
156
+ [data-theme="agora-dark"] [data-content-type="tutorial"] { --badge-color: #daa040; --badge-bg: rgba(218, 160, 64, 0.12); }
157
+ }