@commonpub/layer 0.71.2 → 0.72.1

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.
@@ -141,6 +141,8 @@ function formatCount(n: number | undefined): string {
141
141
  border: var(--border-width-default) solid var(--border);
142
142
  overflow: hidden;
143
143
  transition: transform 0.15s, box-shadow 0.15s;
144
+ -webkit-backdrop-filter: var(--surface-backdrop, none);
145
+ backdrop-filter: var(--surface-backdrop, none);
144
146
  }
145
147
 
146
148
  .cpub-cc:hover {
@@ -164,6 +166,10 @@ function formatCount(n: number | undefined): string {
164
166
  align-items: center;
165
167
  justify-content: center;
166
168
  overflow: hidden;
169
+ /* Edge-spanning section inside the card's overflow:hidden — the container
170
+ clips the outer corners; rounding HERE creates wedge gaps on rounded
171
+ themes (universal radius leak). */
172
+ border-radius: 0;
167
173
  }
168
174
 
169
175
  .cpub-cc-cover {
@@ -114,6 +114,8 @@ useFocusTrap(dialogRef, () => props.open, close);
114
114
  background: var(--surface);
115
115
  border: var(--border-width-default) solid var(--border);
116
116
  box-shadow: var(--shadow-xl);
117
+ -webkit-backdrop-filter: var(--surface-backdrop, none);
118
+ backdrop-filter: var(--surface-backdrop, none);
117
119
  width: 520px;
118
120
  max-width: 90vw;
119
121
  max-height: 70vh;
@@ -193,6 +193,8 @@ useFocusTrap(dialogRef, () => props.show, handleClose);
193
193
  display: flex;
194
194
  flex-direction: column;
195
195
  max-height: 80vh;
196
+ -webkit-backdrop-filter: var(--surface-backdrop, none);
197
+ backdrop-filter: var(--surface-backdrop, none);
196
198
  }
197
199
 
198
200
  .cpub-import-header {
@@ -52,6 +52,8 @@ useFocusTrap(dialogRef, () => props.show, () => emit('dismiss'));
52
52
  max-width: 420px;
53
53
  width: 100%;
54
54
  box-shadow: var(--shadow-md);
55
+ -webkit-backdrop-filter: var(--surface-backdrop, none);
56
+ backdrop-filter: var(--surface-backdrop, none);
55
57
  }
56
58
 
57
59
  .cpub-publish-errors-title {
@@ -87,6 +87,8 @@ useFocusTrap(dialogRef, () => open.value, close);
87
87
  background: var(--bg); border: var(--border-width-default) solid var(--border);
88
88
  width: 100%; max-width: 420px; padding: 24px;
89
89
  box-shadow: var(--shadow-md);
90
+ -webkit-backdrop-filter: var(--surface-backdrop, none);
91
+ backdrop-filter: var(--surface-backdrop, none);
90
92
  }
91
93
  .cpub-rfd-header {
92
94
  display: flex; align-items: center; justify-content: space-between;
@@ -100,6 +100,8 @@ async function handleShare(): Promise<void> {
100
100
  background: var(--surface);
101
101
  border: var(--border-width-default) solid var(--border);
102
102
  box-shadow: var(--shadow-lg);
103
+ -webkit-backdrop-filter: var(--surface-backdrop, none);
104
+ backdrop-filter: var(--surface-backdrop, none);
103
105
  padding: 24px;
104
106
  max-width: 420px;
105
107
  width: 90vw;
@@ -55,6 +55,9 @@ function formatDuration(seconds: number | null | undefined): string {
55
55
  background: var(--surface2);
56
56
  border-bottom: var(--border-width-default) solid var(--border);
57
57
  overflow: hidden;
58
+ /* Edge-spanning section inside the card's overflow:hidden — rounding here
59
+ creates wedge gaps on rounded themes; the container clips the corners. */
60
+ border-radius: 0;
58
61
  }
59
62
 
60
63
  .cpub-video-thumb img {
@@ -83,8 +83,30 @@ const SCHEMES: { val: HarmonyScheme; label: string }[] = [
83
83
  // --- Design-ethos archetype (Phase 3) ----------------------------------
84
84
  // Applies a whole structural preset (shape/shadow/border/type/density/texture)
85
85
  // while preserving the user's chosen color + mode, then tags the recipe.
86
+ // `treatment` is REPLACED, not merged: switching Glass → Brutalist must clear
87
+ // the translucency, otherwise the ethos switch is incoherent.
86
88
  function applyArchetype(a: DesignArchetype): void {
87
- recipe.value = { ...recipe.value, ...a.patch, archetype: a.k };
89
+ const { treatment, ...rest } = a.patch;
90
+ recipe.value = { ...recipe.value, ...rest, treatment, archetype: a.k };
91
+ }
92
+
93
+ // --- Surface treatment (glass + page gradient) --------------------------
94
+ // Normalized so "all off" stores `undefined` (keeps legacy recipes and the
95
+ // no-treatment projection byte-identical).
96
+ const glassPct = computed(() => Math.round((recipe.value.treatment?.glass ?? 0) * 100));
97
+ function setTreatment(patch: { glass?: number; bgGradient?: boolean }): void {
98
+ const next = {
99
+ glass: recipe.value.treatment?.glass ?? 0,
100
+ bgGradient: recipe.value.treatment?.bgGradient ?? false,
101
+ ...patch,
102
+ };
103
+ recipe.value.treatment =
104
+ next.glass > 0 || next.bgGradient
105
+ ? {
106
+ ...(next.glass > 0 ? { glass: next.glass } : {}),
107
+ ...(next.bgGradient ? { bgGradient: true } : {}),
108
+ }
109
+ : undefined;
88
110
  }
89
111
 
90
112
  // --- Neutral temperature (Phase 2) -------------------------------------
@@ -529,6 +551,14 @@ function finishWith(apply: boolean): void {
529
551
  <span class="cpub-studio-lbl">Grain <span class="cpub-studio-val">{{ Math.round(recipe.texture * 100) }}%</span></span>
530
552
  <input type="range" min="0" max="12" :value="Math.round(recipe.texture * 100)" class="cpub-studio-range" @input="recipe.texture = Number(($event.target as HTMLInputElement).value) / 100" />
531
553
  </label>
554
+ <label class="cpub-studio-field">
555
+ <span class="cpub-studio-lbl">Glass <span class="cpub-studio-val">{{ glassPct }}%</span></span>
556
+ <input type="range" min="0" max="30" :value="glassPct" class="cpub-studio-range" aria-label="Glass strength" @input="setTreatment({ glass: Number(($event.target as HTMLInputElement).value) / 100 })" />
557
+ </label>
558
+ <div class="cpub-studio-toggle-line">
559
+ <span class="cpub-studio-lbl">Background gradient <span class="cpub-studio-hint">tints toward accent</span></span>
560
+ <button type="button" class="cpub-studio-switch" :class="{ on: recipe.treatment?.bgGradient }" :aria-pressed="Boolean(recipe.treatment?.bgGradient)" aria-label="Toggle background gradient" @click="setTreatment({ bgGradient: !recipe.treatment?.bgGradient })" />
561
+ </div>
532
562
  <label class="cpub-studio-field">
533
563
  <span class="cpub-studio-lbl">Motion</span>
534
564
  <span class="cpub-studio-seg">
@@ -150,6 +150,8 @@ async function readFile(file: File): Promise<void> {
150
150
  box-shadow: var(--shadow-xl);
151
151
  display: flex; flex-direction: column;
152
152
  max-height: 80vh;
153
+ -webkit-backdrop-filter: var(--surface-backdrop, none);
154
+ backdrop-filter: var(--surface-backdrop, none);
153
155
  }
154
156
 
155
157
  .md-import-header {
@@ -67,6 +67,9 @@ const isCompanyHub = computed(() => hubType.value === 'company');
67
67
  position: relative;
68
68
  overflow: hidden;
69
69
  border-bottom: var(--border-width-default) solid var(--border);
70
+ /* Edge-spanning band inside the hero's overflow:hidden — rounding here
71
+ creates wedge gaps on rounded themes; the container clips the corners. */
72
+ border-radius: 0;
70
73
  }
71
74
 
72
75
  .cpub-hub-banner-pattern {
@@ -281,6 +281,7 @@ const userUsername = computed(() => user.value?.username ?? '');
281
281
  border-bottom-left-radius: var(--cpub-topbar-radius, 0);
282
282
  border-bottom-right-radius: var(--cpub-topbar-radius, 0);
283
283
  box-shadow: var(--cpub-topbar-shadow, none);
284
+ -webkit-backdrop-filter: var(--cpub-topbar-blur, none);
284
285
  backdrop-filter: var(--cpub-topbar-blur, none);
285
286
  display: flex; align-items: center;
286
287
  padding: 0 var(--cpub-topbar-padding-x, 20px); gap: 0; z-index: 100;
@@ -322,6 +323,8 @@ const userUsername = computed(() => user.value?.username ?? '');
322
323
  background: var(--surface); border: var(--border-width-default) solid var(--border);
323
324
  box-shadow: var(--shadow-md); z-index: 200; display: flex; flex-direction: column; padding: 4px 0;
324
325
  margin-top: 4px;
326
+ -webkit-backdrop-filter: var(--surface-backdrop, none);
327
+ backdrop-filter: var(--surface-backdrop, none);
325
328
  }
326
329
  :deep(.cpub-nav-panel-item) {
327
330
  display: flex; align-items: center; gap: 8px; padding: 8px 14px;
@@ -362,7 +365,7 @@ const userUsername = computed(() => user.value?.username ?? '');
362
365
  .cpub-user-avatar { width: 28px; height: 28px; border-radius: 50%; background: var(--purple-bg); border: var(--border-width-default) solid var(--purple); display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 700; color: var(--purple); font-family: var(--font-mono); overflow: hidden; }
363
366
  .cpub-user-avatar-img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
364
367
  .cpub-user-menu-wrapper { position: relative; }
365
- .cpub-user-dropdown { position: absolute; top: calc(100% + 6px); right: 0; min-width: 180px; background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); z-index: 200; display: flex; flex-direction: column; padding: 4px 0; }
368
+ .cpub-user-dropdown { position: absolute; top: calc(100% + 6px); right: 0; min-width: 180px; background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); z-index: 200; display: flex; flex-direction: column; padding: 4px 0; -webkit-backdrop-filter: var(--surface-backdrop, none); backdrop-filter: var(--surface-backdrop, none); }
366
369
  .cpub-dropdown-item { display: flex; align-items: center; gap: 8px; padding: 8px 16px; font-size: 12px; color: var(--text-dim); text-decoration: none; background: none; border: none; cursor: pointer; font-family: inherit; width: 100%; text-align: left; transition: all 0.15s; }
367
370
  .cpub-dropdown-item:hover { background: var(--surface2); color: var(--text); }
368
371
  .cpub-dropdown-item i { width: 14px; text-align: center; font-size: 11px; }
@@ -374,7 +377,7 @@ const userUsername = computed(() => user.value?.username ?? '');
374
377
 
375
378
  .cpub-mobile-toggle { display: none; width: 32px; height: 32px; background: none; border: var(--border-width-default) solid transparent; color: var(--text-dim); font-size: 16px; cursor: pointer; align-items: center; justify-content: center; }
376
379
  .cpub-mobile-menu { display: none; position: fixed; inset: 0; top: var(--cpub-topbar-height, 48px); z-index: 99; background: var(--color-surface-overlay-light); }
377
- :deep(.cpub-mobile-nav) { background: var(--surface); border-bottom: var(--border-width-default) solid var(--border); padding: 8px 0; display: flex; flex-direction: column; box-shadow: var(--shadow-md); }
380
+ :deep(.cpub-mobile-nav) { background: var(--surface); border-bottom: var(--border-width-default) solid var(--border); padding: 8px 0; display: flex; flex-direction: column; box-shadow: var(--shadow-md); -webkit-backdrop-filter: var(--surface-backdrop, none); backdrop-filter: var(--surface-backdrop, none); }
378
381
  :deep(.cpub-mobile-link) { display: flex; align-items: center; gap: 10px; padding: 10px 20px; font-size: 13px; color: var(--text-dim); text-decoration: none; transition: background 0.1s; }
379
382
  :deep(.cpub-mobile-link:hover) { background: var(--surface2); color: var(--text); }
380
383
  :deep(.cpub-mobile-link i) { width: 16px; text-align: center; font-size: 12px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.71.2",
3
+ "version": "0.72.1",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -57,13 +57,13 @@
57
57
  "@commonpub/config": "0.21.0",
58
58
  "@commonpub/docs": "0.6.3",
59
59
  "@commonpub/editor": "0.7.11",
60
- "@commonpub/explainer": "0.7.15",
61
- "@commonpub/learning": "0.5.2",
62
60
  "@commonpub/protocol": "0.13.0",
63
- "@commonpub/schema": "0.39.0",
61
+ "@commonpub/schema": "0.40.0",
62
+ "@commonpub/theme-studio": "0.6.1",
63
+ "@commonpub/ui": "0.13.1",
64
+ "@commonpub/explainer": "0.7.15",
64
65
  "@commonpub/server": "2.84.1",
65
- "@commonpub/theme-studio": "0.5.1",
66
- "@commonpub/ui": "0.12.2"
66
+ "@commonpub/learning": "0.5.2"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@testing-library/jest-dom": "^6.9.1",
@@ -3,7 +3,7 @@
3
3
  // Users only toggle light/dark within that family.
4
4
 
5
5
  import { eq } from 'drizzle-orm';
6
- import { instanceSettings } from '@commonpub/schema';
6
+ import { instanceSettings, isSafeBgImageValue } from '@commonpub/schema';
7
7
  import {
8
8
  getCustomTokenOverrides,
9
9
  listCustomThemes,
@@ -15,6 +15,21 @@ import { THEME_TO_FAMILY, FAMILY_VARIANTS, IS_DARK, VALID_THEME_IDS } from '../.
15
15
 
16
16
  const CACHE_TTL = 60_000; // 1 minute, admin changes propagate fast
17
17
 
18
+ /**
19
+ * Sink-side guard for the one token whose VALUE can fetch when rendered:
20
+ * `--bg-image` feeds `background-image`, so a `url(...)` is a beacon/exfil
21
+ * channel. The themes POST/PUT already reject unsafe values, but the token
22
+ * map has other write paths (the generic admin settings route writes
23
+ * `instance_settings` keys wholesale), so the render sink enforces the same
24
+ * allowlist: a non-gradient bg-image is dropped, never injected.
25
+ */
26
+ export function sanitizeRenderTokens(tokens: Record<string, string>): Record<string, string> {
27
+ const v = tokens['bg-image'];
28
+ if (v === undefined || isSafeBgImageValue(v)) return tokens;
29
+ const { 'bg-image': _dropped, ...rest } = tokens;
30
+ return rest;
31
+ }
32
+
18
33
  interface CachedThemeState {
19
34
  /** The admin's chosen default theme (built-in id, custom data-attr, or registered id) */
20
35
  defaultTheme: string;
@@ -138,7 +153,7 @@ export async function resolveThemeContext(
138
153
  const sib = state.customByAttr.get(sibAttr);
139
154
  if (sib) members.push({ attr: sibAttr, rec: sib });
140
155
  }
141
- themeVariants = members.map((m) => ({ attr: m.attr, tokens: m.rec.tokens }));
156
+ themeVariants = members.map((m) => ({ attr: m.attr, tokens: sanitizeRenderTokens(m.rec.tokens) }));
142
157
  const lightM = members.find((m) => !m.rec.isDark);
143
158
  const darkM = members.find((m) => m.rec.isDark);
144
159
  if (members.length === 2 && lightM && darkM) {
@@ -167,7 +182,7 @@ export async function resolveThemeContext(
167
182
  instanceTheme: admin,
168
183
  isDark,
169
184
  themeVariants,
170
- overrides: { ...state.tokenOverrides },
185
+ overrides: sanitizeRenderTokens({ ...state.tokenOverrides }),
171
186
  pair,
172
187
  fontHref,
173
188
  };
package/theme/base.css CHANGED
@@ -216,6 +216,15 @@
216
216
  --shadow-block: 4px 4px 0 var(--border);
217
217
  --shadow-block-sm: 2px 2px 0 var(--border);
218
218
 
219
+ /* === TREATMENTS ===
220
+ Surface effects. Defaults are TRUE no-ops: `none`, never `blur(0)` — any
221
+ non-none backdrop-filter creates a stacking context and becomes the
222
+ containing block for fixed/absolute descendants, which would move
223
+ dropdowns/modals on every existing theme. Built-in themes don't set
224
+ these; Theme Studio emits them for glass recipes. */
225
+ --surface-backdrop: none; /* backdrop-filter for cards/panels (glass) */
226
+ --bg-image: none; /* page background gradient (gradients only) */
227
+
219
228
  /* === TRANSITIONS === */
220
229
  --transition-fast: 0.1s ease;
221
230
  --transition-default: 0.15s ease;
@@ -298,6 +307,7 @@ body {
298
307
  line-height: var(--leading-normal);
299
308
  color: var(--text);
300
309
  background-color: var(--bg);
310
+ background-image: var(--bg-image, none);
301
311
  -webkit-font-smoothing: antialiased;
302
312
  -moz-osx-font-smoothing: grayscale;
303
313
  }
@@ -90,6 +90,10 @@
90
90
  padding: 16px;
91
91
  margin-bottom: 12px;
92
92
  box-shadow: var(--shadow-block);
93
+ /* Glass treatment hook — `none` by default (a true no-op; non-none values
94
+ create a stacking context, so only opted-in themes pay that cost). */
95
+ -webkit-backdrop-filter: var(--surface-backdrop, none);
96
+ backdrop-filter: var(--surface-backdrop, none);
93
97
  }
94
98
 
95
99
  .cpub-sb-title {
package/theme/layouts.css CHANGED
@@ -283,6 +283,9 @@
283
283
  border: var(--border-width-default) solid var(--border);
284
284
  overflow: hidden;
285
285
  transition: transform var(--transition-fast), box-shadow var(--transition-fast);
286
+ /* Glass treatment hook — `none` default is a true no-op. */
287
+ -webkit-backdrop-filter: var(--surface-backdrop, none);
288
+ backdrop-filter: var(--surface-backdrop, none);
286
289
  }
287
290
 
288
291
  .cpub-card:hover {
@@ -15,6 +15,25 @@ export interface DiscoveredTheme {
15
15
  isDark: boolean;
16
16
  }
17
17
 
18
+ /**
19
+ * Substitute `var(--x)` / `var(--x, fallback)` references in a default
20
+ * value using a property getter (computed style). getComputedStyle returns
21
+ * custom properties with their var() references ALREADY substituted, so a
22
+ * spec default like `var(--surface)` must be resolved the same way before
23
+ * diffing — otherwise every var()-defaulted token (font-heading, the chrome
24
+ * family) reads as "overridden" on a stock site.
25
+ */
26
+ export function resolveVarRefs(get: (name: string) => string, value: string): string {
27
+ let out = value;
28
+ for (let i = 0; i < 4 && out.includes('var('); i++) {
29
+ out = out.replace(/var\((--[a-zA-Z0-9_-]+)(?:,\s*([^()]*))?\)/g, (_, name: string, fb?: string) => {
30
+ const v = get(name).trim();
31
+ return v || fb || '';
32
+ });
33
+ }
34
+ return out;
35
+ }
36
+
18
37
  /**
19
38
  * Read `getComputedStyle(:root)` for every canonical token and return
20
39
  * the subset that differs from `TOKEN_SPECS[i].default`.
@@ -23,11 +42,12 @@ export function detectAppliedOverrides(): DiscoveredTheme {
23
42
  if (typeof window === 'undefined') return { count: 0, tokens: {}, isDark: false };
24
43
  const root = document.documentElement;
25
44
  const cs = getComputedStyle(root);
45
+ const get = (name: string): string => cs.getPropertyValue(name);
26
46
  const overrides: Record<string, string> = {};
27
47
  for (const spec of TOKEN_SPECS) {
28
48
  const actual = cs.getPropertyValue(`--${spec.key}`).trim();
29
49
  if (!actual) continue;
30
- if (normalize(actual) !== normalize(spec.default)) {
50
+ if (normalize(actual) !== normalize(resolveVarRefs(get, spec.default))) {
31
51
  overrides[spec.key] = actual;
32
52
  }
33
53
  }