@commonpub/layer 0.71.1 → 0.72.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.
@@ -164,6 +164,10 @@ function formatCount(n: number | undefined): string {
164
164
  align-items: center;
165
165
  justify-content: center;
166
166
  overflow: hidden;
167
+ /* Edge-spanning section inside the card's overflow:hidden — the container
168
+ clips the outer corners; rounding HERE creates wedge gaps on rounded
169
+ themes (universal radius leak). */
170
+ border-radius: 0;
167
171
  }
168
172
 
169
173
  .cpub-cc-cover {
@@ -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">
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.71.1",
3
+ "version": "0.72.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -54,16 +54,16 @@
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
56
  "@commonpub/auth": "0.8.0",
57
- "@commonpub/editor": "0.7.11",
58
- "@commonpub/learning": "0.5.2",
59
- "@commonpub/explainer": "0.7.15",
60
- "@commonpub/protocol": "0.13.0",
61
57
  "@commonpub/config": "0.21.0",
62
- "@commonpub/schema": "0.39.0",
63
58
  "@commonpub/server": "2.84.1",
59
+ "@commonpub/ui": "0.13.0",
60
+ "@commonpub/learning": "0.5.2",
61
+ "@commonpub/theme-studio": "0.6.0",
64
62
  "@commonpub/docs": "0.6.3",
65
- "@commonpub/theme-studio": "0.5.1",
66
- "@commonpub/ui": "0.12.2"
63
+ "@commonpub/schema": "0.40.0",
64
+ "@commonpub/editor": "0.7.11",
65
+ "@commonpub/protocol": "0.13.0",
66
+ "@commonpub/explainer": "0.7.15"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@testing-library/jest-dom": "^6.9.1",
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 {
@@ -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
  }
@@ -1,150 +0,0 @@
1
- /**
2
- * Component tests for the contest entry-detail page (artifact timeline).
3
- *
4
- * Locks: the content summary card, the stage-ordered artifact timeline with
5
- * template-labelled fields, url fields rendered as safe links, orphaned values
6
- * (template field later removed) still rendering, artifact section hidden when
7
- * the server stripped artifacts (unprivileged viewer), and an axe scan.
8
- *
9
- * Page uses Nuxt auto-imports (useRoute, useLazyFetch, useSeoMeta, useSiteName,
10
- * plus the auto-imported contestStages utils) — stub them on globalThis.
11
- */
12
- import { describe, it, expect, vi, beforeEach } from 'vitest';
13
- import { render } from '@testing-library/vue';
14
- import { defineComponent, h, ref } from 'vue';
15
- import axe from 'axe-core';
16
- import EntryDetailPage from '../[entryId].vue';
17
- import { normalizeStages, currentStageId } from '../../../../../utils/contestStages';
18
-
19
- const NuxtLink = defineComponent({
20
- name: 'NuxtLink',
21
- props: { to: String },
22
- setup(props, { slots }) {
23
- return () => h('a', { href: props.to }, slots.default?.());
24
- },
25
- });
26
- const stubs = { NuxtLink };
27
-
28
- const STAGES = [
29
- { id: 'prop', name: 'Proposals', kind: 'submission', submissionTemplate: [
30
- { key: 'summary', label: 'Summary', type: 'textarea', required: true },
31
- ] },
32
- { id: 'rev1', name: 'Screening', kind: 'review' },
33
- { id: 'proto', name: 'Prototype', kind: 'submission', submissionTemplate: [
34
- { key: 'repo_url', label: 'Repository URL', type: 'url', required: true },
35
- ] },
36
- ];
37
-
38
- function makeContest(overrides: Record<string, unknown> = {}) {
39
- return {
40
- title: 'Resilient America',
41
- status: 'active',
42
- startDate: '2026-04-01T00:00:00.000Z',
43
- endDate: '2026-08-01T00:00:00.000Z',
44
- judgingEndDate: null,
45
- stages: STAGES,
46
- currentStageId: 'proto',
47
- ...overrides,
48
- };
49
- }
50
-
51
- function makeEntry(overrides: Record<string, unknown> = {}) {
52
- return {
53
- id: 'e1',
54
- contestId: 'c1',
55
- contentId: 'ct1',
56
- userId: 'u1',
57
- score: 88,
58
- rank: 2,
59
- stageState: [{ stageId: 'rev1', status: 'advanced' }],
60
- eliminated: false,
61
- stageSubmissions: [
62
- // Deliberately out of stage order — the timeline must sort by stage.
63
- { stageId: 'proto', fields: { repo_url: 'https://github.com/x/y' }, submittedAt: '2026-07-01T12:00:00.000Z' },
64
- { stageId: 'prop', fields: { summary: 'A mesh network.', legacy_field: 'kept' }, submittedAt: '2026-05-01T12:00:00.000Z' },
65
- ],
66
- submittedAt: '2026-04-20T12:00:00.000Z',
67
- contentTitle: 'Solar Mesh Node',
68
- contentSlug: 'solar-mesh-node',
69
- contentType: 'project',
70
- contentCoverImageUrl: null,
71
- authorName: 'Ada Maker',
72
- authorUsername: 'ada',
73
- authorAvatarUrl: null,
74
- };
75
- }
76
-
77
- let contestData: Record<string, unknown> | null = makeContest();
78
- let entryData: Record<string, unknown> | null = makeEntry();
79
-
80
- Object.assign(globalThis, {
81
- useRoute: () => ({ params: { slug: 'resilient', entryId: 'e1' } }),
82
- useLazyFetch: vi.fn((url: string) => ({
83
- data: ref(String(url).includes('/entries/') ? entryData : contestData),
84
- error: ref(null),
85
- })),
86
- useSeoMeta: () => {},
87
- useSiteName: () => 'Test',
88
- normalizeStages,
89
- currentStageId,
90
- });
91
-
92
- function mount() {
93
- return render(EntryDetailPage, { global: { stubs } });
94
- }
95
-
96
- beforeEach(() => {
97
- contestData = makeContest();
98
- entryData = makeEntry();
99
- });
100
-
101
- describe('entry detail page', () => {
102
- it('shows the content summary with author, status badges, and a project link', () => {
103
- const { container } = mount();
104
- expect(container.querySelector('.cpub-ed-title')?.textContent).toBe('Solar Mesh Node');
105
- expect(container.textContent).toContain('Ada Maker');
106
- expect(container.textContent).toContain('Advanced');
107
- expect(container.textContent).toContain('#2');
108
- expect(container.textContent).toContain('Score 88');
109
- const projectLink = Array.from(container.querySelectorAll('a')).find((a) => a.textContent?.includes('View the project'));
110
- expect(projectLink?.getAttribute('href')).toBe('/u/ada/project/solar-mesh-node');
111
- });
112
-
113
- it('renders the artifact timeline in stage order with template labels', () => {
114
- const { container } = mount();
115
- const names = Array.from(container.querySelectorAll('.cpub-ed-stagename')).map((n) => n.textContent);
116
- expect(names).toEqual(['Proposals', 'Prototype']); // stage order, not submit order
117
- expect(container.textContent).toContain('Summary');
118
- expect(container.textContent).toContain('A mesh network.');
119
- });
120
-
121
- it('renders url fields as hardened external links', () => {
122
- const { container } = mount();
123
- const link = Array.from(container.querySelectorAll('.cpub-ed-fields a')).find((a) => a.textContent === 'https://github.com/x/y');
124
- expect(link).toBeTruthy();
125
- expect(link!.getAttribute('rel')).toContain('noopener');
126
- });
127
-
128
- it('still renders values whose template field was later removed (never drop data)', () => {
129
- const { container } = mount();
130
- expect(container.textContent).toContain('legacy_field');
131
- expect(container.textContent).toContain('kept');
132
- });
133
-
134
- it('hides the artifact section entirely when the server stripped artifacts', () => {
135
- // The route handler `delete`s the key for unprivileged viewers — mirror that.
136
- const stripped = makeEntry();
137
- delete (stripped as Record<string, unknown>).stageSubmissions;
138
- entryData = stripped;
139
- const { container } = mount();
140
- expect(container.querySelector('.cpub-ed-stages')).toBeNull();
141
- // The content card still shows — the page is useful to the public.
142
- expect(container.querySelector('.cpub-ed-title')?.textContent).toBe('Solar Mesh Node');
143
- });
144
-
145
- it('passes an axe scan', async () => {
146
- const { container } = mount();
147
- const results = await axe.run(container);
148
- expect(results.violations).toEqual([]);
149
- });
150
- });