@commonpub/layer 0.21.22 → 0.22.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/components/admin/theme/AdminThemeFamilyCard.vue +277 -0
- package/components/admin/theme/AdminThemeOverridesPanel.vue +222 -0
- package/components/admin/theme/AdminThemePreviewPane.vue +187 -0
- package/components/admin/theme/AdminThemeSceneAdmin.vue +189 -0
- package/components/admin/theme/AdminThemeSceneGallery.vue +353 -0
- package/components/admin/theme/AdminThemeSceneProse.vue +140 -0
- package/components/admin/theme/AdminThemeTokenGroup.vue +98 -0
- package/components/admin/theme/AdminThemeTokenInput.vue +278 -0
- package/composables/useTheme.ts +24 -14
- package/composables/useThemeAdmin.ts +167 -0
- package/package.json +7 -7
- package/pages/admin/theme/edit/[id].vue +547 -0
- package/pages/admin/theme/index.vue +424 -0
- package/plugins/theme.ts +25 -7
- package/server/api/admin/themes/[id].delete.ts +40 -0
- package/server/api/admin/themes/[id].get.ts +20 -0
- package/server/api/admin/themes/[id].put.ts +45 -0
- package/server/api/admin/themes/discover.get.ts +22 -0
- package/server/api/admin/themes/index.get.ts +40 -0
- package/server/api/admin/themes/index.post.ts +46 -0
- package/server/api/profile/theme.put.ts +2 -1
- package/server/middleware/theme.ts +23 -9
- package/server/utils/instanceTheme.ts +145 -25
- package/types/theme.ts +54 -0
- package/utils/themeDiscovery.ts +67 -0
- package/utils/themeIO.ts +79 -0
- package/utils/themeIds.ts +25 -0
- package/pages/admin/theme.vue +0 -502
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Article-style preview. Mimics the long-form prose layout used by
|
|
4
|
+
* blogs, projects, explainers, and docs pages. Useful for tuning
|
|
5
|
+
* typography, link colors, blockquote, list, table, and prose code.
|
|
6
|
+
*/
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<article class="scene-prose">
|
|
11
|
+
<header class="scene-prose-header">
|
|
12
|
+
<span class="scene-prose-eyebrow">PROJECT · OPEN</span>
|
|
13
|
+
<h1 class="scene-prose-title">Building a federated maker community without a platform</h1>
|
|
14
|
+
<p class="scene-prose-deck">
|
|
15
|
+
How CommonPub instances stay sovereign while still talking to Mastodon, Lemmy,
|
|
16
|
+
and each other — and what we learned shipping the first three live sites.
|
|
17
|
+
</p>
|
|
18
|
+
<div class="scene-prose-byline">
|
|
19
|
+
<span class="scene-prose-avatar">M</span>
|
|
20
|
+
<span><strong>moheeb</strong> · <a href="#" class="scene-prose-link">deveco.io</a></span>
|
|
21
|
+
<span class="scene-prose-dot">·</span>
|
|
22
|
+
<span>8 min read</span>
|
|
23
|
+
</div>
|
|
24
|
+
</header>
|
|
25
|
+
|
|
26
|
+
<p>
|
|
27
|
+
Most maker communities live on someone else's platform. The platform owns the
|
|
28
|
+
identity, the content, the moderation, and the moment the platform changes
|
|
29
|
+
direction, the community goes with it. That's the failure mode CommonPub is
|
|
30
|
+
built around — every instance is a complete site, federation is opt-in, and
|
|
31
|
+
moving your community is a database export, not a migration ticket.
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
<h2 class="scene-prose-h2">Three sites, one codebase</h2>
|
|
35
|
+
<p>
|
|
36
|
+
The reference deployment runs <a href="#" class="scene-prose-link">commonpub.io</a>,
|
|
37
|
+
<a href="#" class="scene-prose-link">deveco.io</a>, and
|
|
38
|
+
<a href="#" class="scene-prose-link">heatsynclabs.io</a> off the same Nuxt layer.
|
|
39
|
+
Each one extends <code class="scene-prose-code-inline">@commonpub/layer</code> and
|
|
40
|
+
overrides only what's specific to the community.
|
|
41
|
+
</p>
|
|
42
|
+
|
|
43
|
+
<blockquote class="scene-prose-quote">
|
|
44
|
+
The schema is the work — everything else follows from it.
|
|
45
|
+
</blockquote>
|
|
46
|
+
|
|
47
|
+
<h3 class="scene-prose-h3">What the layer ships</h3>
|
|
48
|
+
<ul class="scene-prose-list">
|
|
49
|
+
<li>Content types: project, blog, explainer, video, doc page</li>
|
|
50
|
+
<li>Federation: ActivityPub via Fedify, instance + actor signing</li>
|
|
51
|
+
<li>Hubs: local-first groups, with a Group-actor escape hatch</li>
|
|
52
|
+
<li>Admin panel: feature flags, theme picker, audit log, mirror config</li>
|
|
53
|
+
</ul>
|
|
54
|
+
|
|
55
|
+
<h3 class="scene-prose-h3">Sample query</h3>
|
|
56
|
+
<p>The hub feed is built from this Drizzle query:</p>
|
|
57
|
+
<pre class="scene-prose-pre"><code>const items = await db
|
|
58
|
+
.select()
|
|
59
|
+
.from(content)
|
|
60
|
+
.where(eq(content.hubId, hub.id))
|
|
61
|
+
.orderBy(desc(content.publishedAt))
|
|
62
|
+
.limit(20);</code></pre>
|
|
63
|
+
|
|
64
|
+
<h3 class="scene-prose-h3">Deploy targets</h3>
|
|
65
|
+
<table class="scene-prose-table">
|
|
66
|
+
<thead>
|
|
67
|
+
<tr>
|
|
68
|
+
<th>Site</th>
|
|
69
|
+
<th>Federation</th>
|
|
70
|
+
<th>Custom theme</th>
|
|
71
|
+
</tr>
|
|
72
|
+
</thead>
|
|
73
|
+
<tbody>
|
|
74
|
+
<tr><td>commonpub.io</td><td>Yes</td><td>Classic</td></tr>
|
|
75
|
+
<tr><td>deveco.io</td><td>Yes</td><td>devEco brand</td></tr>
|
|
76
|
+
<tr><td>heatsynclabs.io</td><td>Soon</td><td>Hacker green</td></tr>
|
|
77
|
+
</tbody>
|
|
78
|
+
</table>
|
|
79
|
+
|
|
80
|
+
<hr class="scene-prose-hr" />
|
|
81
|
+
<p class="scene-prose-foot">Last updated 2026-05-26 · 8-minute read · Tagged <a href="#" class="scene-prose-link">#federation</a> <a href="#" class="scene-prose-link">#open</a></p>
|
|
82
|
+
</article>
|
|
83
|
+
</template>
|
|
84
|
+
|
|
85
|
+
<style scoped>
|
|
86
|
+
.scene-prose {
|
|
87
|
+
max-width: 640px;
|
|
88
|
+
margin: 0 auto;
|
|
89
|
+
font-family: var(--font-body);
|
|
90
|
+
font-size: var(--text-base);
|
|
91
|
+
line-height: var(--leading-normal);
|
|
92
|
+
color: var(--text);
|
|
93
|
+
}
|
|
94
|
+
.scene-prose-header { margin-bottom: var(--space-8); }
|
|
95
|
+
.scene-prose-eyebrow {
|
|
96
|
+
font-family: var(--font-mono);
|
|
97
|
+
font-size: var(--text-label);
|
|
98
|
+
letter-spacing: var(--tracking-widest);
|
|
99
|
+
text-transform: uppercase;
|
|
100
|
+
color: var(--accent);
|
|
101
|
+
}
|
|
102
|
+
.scene-prose-title { font-family: var(--font-heading); font-size: var(--text-3xl); font-weight: var(--font-weight-bold); letter-spacing: var(--tracking-tight); line-height: var(--leading-tight); color: var(--text); margin: var(--space-3) 0 var(--space-3); }
|
|
103
|
+
.scene-prose-deck { font-size: var(--text-md); color: var(--text-dim); line-height: var(--leading-snug); margin: 0 0 var(--space-4); }
|
|
104
|
+
.scene-prose-byline { display: flex; align-items: center; gap: 8px; color: var(--text-dim); font-size: var(--text-sm); }
|
|
105
|
+
.scene-prose-avatar { width: 28px; height: 28px; border-radius: var(--radius-full); background: var(--accent); color: var(--color-on-accent); display: inline-flex; align-items: center; justify-content: center; font-weight: var(--font-weight-bold); font-size: 12px; }
|
|
106
|
+
.scene-prose-dot { color: var(--text-faint); }
|
|
107
|
+
.scene-prose-h2 { font-family: var(--font-heading); font-size: var(--text-2xl); font-weight: var(--font-weight-semibold); color: var(--text); margin: var(--space-8) 0 var(--space-3); letter-spacing: var(--tracking-tight); }
|
|
108
|
+
.scene-prose-h3 { font-family: var(--font-heading); font-size: var(--text-lg); font-weight: var(--font-weight-semibold); color: var(--text); margin: var(--space-6) 0 var(--space-2); }
|
|
109
|
+
.scene-prose p { margin: 0 0 var(--space-4); }
|
|
110
|
+
.scene-prose-link { color: var(--color-link); text-decoration: underline; text-underline-offset: 2px; }
|
|
111
|
+
.scene-prose-link:hover { color: var(--color-link-hover); }
|
|
112
|
+
.scene-prose-quote {
|
|
113
|
+
border-left: 4px solid var(--accent);
|
|
114
|
+
margin: var(--space-6) 0;
|
|
115
|
+
padding: var(--space-2) var(--space-5);
|
|
116
|
+
font-family: var(--font-heading);
|
|
117
|
+
font-size: var(--text-lg);
|
|
118
|
+
font-style: italic;
|
|
119
|
+
color: var(--text-dim);
|
|
120
|
+
background: var(--accent-bg);
|
|
121
|
+
}
|
|
122
|
+
.scene-prose-code-inline { font-family: var(--font-mono); font-size: 0.9em; padding: 1px 6px; background: var(--surface2); border: var(--border-width-thin) solid var(--border2); color: var(--accent); }
|
|
123
|
+
.scene-prose-pre {
|
|
124
|
+
background: var(--code-bg);
|
|
125
|
+
color: var(--code-text);
|
|
126
|
+
padding: var(--space-4);
|
|
127
|
+
margin: var(--space-4) 0 var(--space-6);
|
|
128
|
+
font-family: var(--font-mono);
|
|
129
|
+
font-size: var(--text-sm);
|
|
130
|
+
overflow: auto;
|
|
131
|
+
border: var(--border-width-thin) solid var(--code-border);
|
|
132
|
+
}
|
|
133
|
+
.scene-prose-list { padding-left: var(--space-5); margin: 0 0 var(--space-4); }
|
|
134
|
+
.scene-prose-list li { margin-bottom: var(--space-2); }
|
|
135
|
+
.scene-prose-table { width: 100%; border-collapse: collapse; margin: var(--space-4) 0 var(--space-6); }
|
|
136
|
+
.scene-prose-table th, .scene-prose-table td { padding: var(--space-2) var(--space-3); border-bottom: var(--border-width-thin) solid var(--border2); text-align: left; font-size: var(--text-sm); }
|
|
137
|
+
.scene-prose-table th { font-family: var(--font-mono); font-size: var(--text-label); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-dim); background: var(--surface2); border-bottom: var(--border-width-default) solid var(--border); }
|
|
138
|
+
.scene-prose-hr { border: 0; border-top: var(--border-width-default) solid var(--border2); margin: var(--space-8) 0; }
|
|
139
|
+
.scene-prose-foot { color: var(--text-faint); font-size: var(--text-sm); }
|
|
140
|
+
</style>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Collapsible group of token rows. Renders the group header + a list of
|
|
4
|
+
* AdminThemeTokenInput inside an open <details>. The "modified" count
|
|
5
|
+
* badge on the header tells the user which groups they've touched.
|
|
6
|
+
*/
|
|
7
|
+
import { computed } from 'vue';
|
|
8
|
+
import type { TokenSpec, TokenGroup } from '@commonpub/ui';
|
|
9
|
+
|
|
10
|
+
const props = defineProps<{
|
|
11
|
+
group: TokenGroup;
|
|
12
|
+
label: string;
|
|
13
|
+
icon: string;
|
|
14
|
+
description: string;
|
|
15
|
+
specs: TokenSpec[];
|
|
16
|
+
tokens: Record<string, string>;
|
|
17
|
+
/** Default open state. Surfaces (top group) opens by default. */
|
|
18
|
+
open?: boolean;
|
|
19
|
+
}>();
|
|
20
|
+
|
|
21
|
+
const emit = defineEmits<{
|
|
22
|
+
update: [key: string, value: string];
|
|
23
|
+
reset: [key: string];
|
|
24
|
+
}>();
|
|
25
|
+
|
|
26
|
+
const modifiedCount = computed(() =>
|
|
27
|
+
props.specs.reduce((acc, s) => acc + (props.tokens[s.key] && props.tokens[s.key] !== s.default ? 1 : 0), 0),
|
|
28
|
+
);
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<details class="token-group" :open="open">
|
|
33
|
+
<summary class="token-group-header">
|
|
34
|
+
<i :class="['fa-solid', icon, 'token-group-icon']" aria-hidden="true" />
|
|
35
|
+
<div class="token-group-meta">
|
|
36
|
+
<span class="token-group-label">{{ label }}</span>
|
|
37
|
+
<span class="token-group-desc">{{ description }}</span>
|
|
38
|
+
</div>
|
|
39
|
+
<span v-if="modifiedCount > 0" class="token-group-count">{{ modifiedCount }}</span>
|
|
40
|
+
<i class="fa-solid fa-chevron-right token-group-chevron" aria-hidden="true" />
|
|
41
|
+
</summary>
|
|
42
|
+
|
|
43
|
+
<div class="token-group-body">
|
|
44
|
+
<AdminThemeTokenInput
|
|
45
|
+
v-for="spec in specs"
|
|
46
|
+
:key="spec.key"
|
|
47
|
+
:spec="spec"
|
|
48
|
+
:value="tokens[spec.key] ?? ''"
|
|
49
|
+
@update="(v) => emit('update', spec.key, v)"
|
|
50
|
+
@reset="emit('reset', spec.key)"
|
|
51
|
+
/>
|
|
52
|
+
</div>
|
|
53
|
+
</details>
|
|
54
|
+
</template>
|
|
55
|
+
|
|
56
|
+
<style scoped>
|
|
57
|
+
.token-group {
|
|
58
|
+
border-bottom: var(--border-width-default) solid var(--border2);
|
|
59
|
+
}
|
|
60
|
+
.token-group:last-of-type { border-bottom: 0; }
|
|
61
|
+
|
|
62
|
+
.token-group-header {
|
|
63
|
+
display: flex;
|
|
64
|
+
align-items: center;
|
|
65
|
+
gap: var(--space-3);
|
|
66
|
+
padding: var(--space-3) var(--space-3);
|
|
67
|
+
background: var(--surface);
|
|
68
|
+
cursor: pointer;
|
|
69
|
+
user-select: none;
|
|
70
|
+
list-style: none;
|
|
71
|
+
}
|
|
72
|
+
.token-group-header::-webkit-details-marker { display: none; }
|
|
73
|
+
.token-group-header:hover { background: var(--surface2); }
|
|
74
|
+
|
|
75
|
+
.token-group-icon { color: var(--text-dim); font-size: 14px; width: 16px; text-align: center; flex-shrink: 0; }
|
|
76
|
+
.token-group-meta { flex: 1; display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
77
|
+
.token-group-label { font-size: var(--text-sm); font-weight: var(--font-weight-semibold); color: var(--text); }
|
|
78
|
+
.token-group-desc { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-snug); }
|
|
79
|
+
|
|
80
|
+
.token-group-count {
|
|
81
|
+
font-family: var(--font-mono);
|
|
82
|
+
font-size: 10px;
|
|
83
|
+
letter-spacing: var(--tracking-wide);
|
|
84
|
+
padding: 1px 6px;
|
|
85
|
+
background: var(--accent-bg);
|
|
86
|
+
color: var(--accent);
|
|
87
|
+
border: var(--border-width-thin) solid var(--accent-border);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.token-group-chevron {
|
|
91
|
+
font-size: 11px;
|
|
92
|
+
color: var(--text-faint);
|
|
93
|
+
transition: transform var(--transition-fast);
|
|
94
|
+
}
|
|
95
|
+
[open] > .token-group-header .token-group-chevron { transform: rotate(90deg); }
|
|
96
|
+
|
|
97
|
+
.token-group-body { background: var(--bg); }
|
|
98
|
+
</style>
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* One token row in the editor. Picks the input control based on the token
|
|
4
|
+
* spec's `kind` and emits updates upward. Shows:
|
|
5
|
+
* • token name (mono)
|
|
6
|
+
* • description (faint, on its own line)
|
|
7
|
+
* • the appropriate input
|
|
8
|
+
* • a "reset to default" button when the value differs
|
|
9
|
+
*
|
|
10
|
+
* No prop drilling, no internal state — this is a pure controlled component.
|
|
11
|
+
*/
|
|
12
|
+
import type { TokenSpec } from '@commonpub/ui';
|
|
13
|
+
import { computed } from 'vue';
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
spec: TokenSpec;
|
|
17
|
+
value: string;
|
|
18
|
+
/** Resolved value (after CSS resolution) for color preview when `value` is a var()
|
|
19
|
+
* or rgba expression. Optional — falls back to `value`. */
|
|
20
|
+
resolvedValue?: string;
|
|
21
|
+
}>();
|
|
22
|
+
|
|
23
|
+
const emit = defineEmits<{
|
|
24
|
+
update: [value: string];
|
|
25
|
+
reset: [];
|
|
26
|
+
}>();
|
|
27
|
+
|
|
28
|
+
const isModified = computed(() => props.value !== props.spec.default && props.value !== '');
|
|
29
|
+
|
|
30
|
+
/** Returns a hex/rgb color that <input type="color"> understands; null if it can't. */
|
|
31
|
+
const colorPickerValue = computed<string | null>(() => {
|
|
32
|
+
const v = (props.value || props.resolvedValue || props.spec.default).trim();
|
|
33
|
+
if (/^#[0-9a-f]{3}$/i.test(v)) {
|
|
34
|
+
// Expand 3-digit hex to 6-digit
|
|
35
|
+
return '#' + v.slice(1).split('').map((c) => c + c).join('');
|
|
36
|
+
}
|
|
37
|
+
if (/^#[0-9a-f]{6}$/i.test(v)) return v.toLowerCase();
|
|
38
|
+
// rgb/rgba: extract first three numbers
|
|
39
|
+
const m = v.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
|
|
40
|
+
if (m) {
|
|
41
|
+
const hex = '#' + [m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join('');
|
|
42
|
+
return hex;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function onColorPick(e: Event): void {
|
|
48
|
+
const next = (e.target as HTMLInputElement).value;
|
|
49
|
+
emit('update', next);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function onTextChange(e: Event): void {
|
|
53
|
+
emit('update', (e.target as HTMLInputElement | HTMLSelectElement).value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Number tokens (lengths) split into magnitude + unit for nicer editing
|
|
57
|
+
const NUMBER_UNITS = ['rem', 'px', 'em', '%', 'vh', 'vw', 'ch'] as const;
|
|
58
|
+
type LengthUnit = typeof NUMBER_UNITS[number] | '';
|
|
59
|
+
const lengthParts = computed<{ num: string; unit: LengthUnit }>(() => {
|
|
60
|
+
const v = (props.value || props.spec.default).trim();
|
|
61
|
+
const m = v.match(/^(-?\d*\.?\d+)\s*(rem|px|em|%|vh|vw|ch)?$/);
|
|
62
|
+
if (m) return { num: m[1] ?? '', unit: (m[2] ?? '') as LengthUnit };
|
|
63
|
+
return { num: '', unit: '' };
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
function commitLengthParts(num: string, unit: LengthUnit): void {
|
|
67
|
+
if (num === '') return;
|
|
68
|
+
emit('update', unit === '' ? num : `${num}${unit}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Shadow tokens — exposed as raw string editing (composer is future work)
|
|
72
|
+
// Font weights — restricted dropdown
|
|
73
|
+
const WEIGHTS = ['100', '200', '300', '400', '500', '600', '700', '800', '900'];
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<template>
|
|
77
|
+
<div class="token-row" :class="{ 'is-modified': isModified }">
|
|
78
|
+
<div class="token-row-head">
|
|
79
|
+
<code class="token-name">--{{ spec.key }}</code>
|
|
80
|
+
<button
|
|
81
|
+
v-if="isModified"
|
|
82
|
+
type="button"
|
|
83
|
+
class="token-reset"
|
|
84
|
+
:title="`Reset to ${spec.default}`"
|
|
85
|
+
@click="emit('reset')"
|
|
86
|
+
>
|
|
87
|
+
<i class="fa-solid fa-rotate-left" aria-hidden="true" />
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<p v-if="spec.description" class="token-desc">{{ spec.description }}</p>
|
|
92
|
+
|
|
93
|
+
<!-- COLOR -->
|
|
94
|
+
<div v-if="spec.kind === 'color'" class="token-input-color">
|
|
95
|
+
<input
|
|
96
|
+
v-if="colorPickerValue"
|
|
97
|
+
type="color"
|
|
98
|
+
class="token-color-swatch"
|
|
99
|
+
:value="colorPickerValue"
|
|
100
|
+
:aria-label="`${spec.key} color`"
|
|
101
|
+
@input="onColorPick"
|
|
102
|
+
/>
|
|
103
|
+
<div
|
|
104
|
+
v-else
|
|
105
|
+
class="token-color-swatch-fallback"
|
|
106
|
+
:style="{ background: value || spec.default }"
|
|
107
|
+
:title="value || spec.default"
|
|
108
|
+
aria-hidden="true"
|
|
109
|
+
/>
|
|
110
|
+
<input
|
|
111
|
+
class="token-input"
|
|
112
|
+
type="text"
|
|
113
|
+
:value="value"
|
|
114
|
+
:placeholder="spec.default"
|
|
115
|
+
@input="onTextChange"
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<!-- LENGTH -->
|
|
120
|
+
<div v-else-if="spec.kind === 'length'" class="token-input-length">
|
|
121
|
+
<input
|
|
122
|
+
class="token-input token-input-num"
|
|
123
|
+
type="text"
|
|
124
|
+
inputmode="decimal"
|
|
125
|
+
:value="lengthParts.num"
|
|
126
|
+
:placeholder="spec.default"
|
|
127
|
+
@change="(e) => commitLengthParts((e.target as HTMLInputElement).value, lengthParts.unit)"
|
|
128
|
+
/>
|
|
129
|
+
<select
|
|
130
|
+
class="token-input token-input-unit"
|
|
131
|
+
:value="lengthParts.unit"
|
|
132
|
+
@change="(e) => commitLengthParts(lengthParts.num, (e.target as HTMLSelectElement).value as never)"
|
|
133
|
+
>
|
|
134
|
+
<option v-for="u in NUMBER_UNITS" :key="u" :value="u">{{ u }}</option>
|
|
135
|
+
<option value="">—</option>
|
|
136
|
+
</select>
|
|
137
|
+
<input
|
|
138
|
+
class="token-input token-input-raw"
|
|
139
|
+
type="text"
|
|
140
|
+
:value="value"
|
|
141
|
+
:placeholder="spec.default"
|
|
142
|
+
title="Or enter raw CSS"
|
|
143
|
+
@change="onTextChange"
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<!-- NUMBER (unitless: weight, z-index, leading) -->
|
|
148
|
+
<input
|
|
149
|
+
v-else-if="spec.kind === 'number'"
|
|
150
|
+
class="token-input"
|
|
151
|
+
type="text"
|
|
152
|
+
inputmode="decimal"
|
|
153
|
+
:value="value"
|
|
154
|
+
:placeholder="spec.default"
|
|
155
|
+
@input="onTextChange"
|
|
156
|
+
/>
|
|
157
|
+
|
|
158
|
+
<!-- FONT WEIGHT -->
|
|
159
|
+
<select
|
|
160
|
+
v-else-if="spec.kind === 'font-weight'"
|
|
161
|
+
class="token-input"
|
|
162
|
+
:value="value || spec.default"
|
|
163
|
+
@change="onTextChange"
|
|
164
|
+
>
|
|
165
|
+
<option v-for="w in WEIGHTS" :key="w" :value="w">{{ w }}</option>
|
|
166
|
+
</select>
|
|
167
|
+
|
|
168
|
+
<!-- FONT FAMILY -->
|
|
169
|
+
<input
|
|
170
|
+
v-else-if="spec.kind === 'font-family'"
|
|
171
|
+
class="token-input token-input-font"
|
|
172
|
+
type="text"
|
|
173
|
+
:value="value"
|
|
174
|
+
:placeholder="spec.default"
|
|
175
|
+
:style="{ fontFamily: value || spec.default }"
|
|
176
|
+
@input="onTextChange"
|
|
177
|
+
/>
|
|
178
|
+
|
|
179
|
+
<!-- SHADOW / TRANSITION / STRING — raw text -->
|
|
180
|
+
<input
|
|
181
|
+
v-else
|
|
182
|
+
class="token-input token-input-mono"
|
|
183
|
+
type="text"
|
|
184
|
+
:value="value"
|
|
185
|
+
:placeholder="spec.default"
|
|
186
|
+
@input="onTextChange"
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
</template>
|
|
190
|
+
|
|
191
|
+
<style scoped>
|
|
192
|
+
.token-row {
|
|
193
|
+
display: flex;
|
|
194
|
+
flex-direction: column;
|
|
195
|
+
gap: 4px;
|
|
196
|
+
padding: var(--space-2) var(--space-3);
|
|
197
|
+
border-bottom: var(--border-width-thin) solid var(--border2);
|
|
198
|
+
}
|
|
199
|
+
.token-row:last-child { border-bottom: 0; }
|
|
200
|
+
.token-row.is-modified { background: var(--accent-bg); }
|
|
201
|
+
|
|
202
|
+
.token-row-head {
|
|
203
|
+
display: flex;
|
|
204
|
+
align-items: center;
|
|
205
|
+
gap: var(--space-2);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.token-name {
|
|
209
|
+
flex: 1;
|
|
210
|
+
font-family: var(--font-mono);
|
|
211
|
+
font-size: var(--text-sm);
|
|
212
|
+
color: var(--text);
|
|
213
|
+
font-weight: var(--font-weight-medium);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.token-reset {
|
|
217
|
+
width: 22px;
|
|
218
|
+
height: 22px;
|
|
219
|
+
background: none;
|
|
220
|
+
border: var(--border-width-thin) solid var(--border2);
|
|
221
|
+
color: var(--text-dim);
|
|
222
|
+
cursor: pointer;
|
|
223
|
+
display: inline-flex;
|
|
224
|
+
align-items: center;
|
|
225
|
+
justify-content: center;
|
|
226
|
+
font-size: 10px;
|
|
227
|
+
border-radius: 0;
|
|
228
|
+
}
|
|
229
|
+
.token-reset:hover { color: var(--accent); border-color: var(--accent); }
|
|
230
|
+
|
|
231
|
+
.token-desc {
|
|
232
|
+
font-size: var(--text-xs);
|
|
233
|
+
color: var(--text-faint);
|
|
234
|
+
margin: 0;
|
|
235
|
+
line-height: var(--leading-snug);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.token-input {
|
|
239
|
+
background: var(--surface2);
|
|
240
|
+
color: var(--text);
|
|
241
|
+
border: var(--border-width-thin) solid var(--border2);
|
|
242
|
+
padding: 6px 8px;
|
|
243
|
+
font-size: var(--text-sm);
|
|
244
|
+
font-family: var(--font-mono);
|
|
245
|
+
width: 100%;
|
|
246
|
+
}
|
|
247
|
+
.token-input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
|
|
248
|
+
|
|
249
|
+
.token-input-color { display: flex; align-items: center; gap: var(--space-2); }
|
|
250
|
+
.token-color-swatch {
|
|
251
|
+
width: 36px;
|
|
252
|
+
height: 30px;
|
|
253
|
+
border: var(--border-width-thin) solid var(--border2);
|
|
254
|
+
padding: 0;
|
|
255
|
+
background: transparent;
|
|
256
|
+
cursor: pointer;
|
|
257
|
+
flex-shrink: 0;
|
|
258
|
+
}
|
|
259
|
+
.token-color-swatch-fallback {
|
|
260
|
+
width: 36px;
|
|
261
|
+
height: 30px;
|
|
262
|
+
border: var(--border-width-thin) solid var(--border2);
|
|
263
|
+
flex-shrink: 0;
|
|
264
|
+
background-image:
|
|
265
|
+
linear-gradient(45deg, var(--border2) 25%, transparent 25%),
|
|
266
|
+
linear-gradient(-45deg, var(--border2) 25%, transparent 25%),
|
|
267
|
+
linear-gradient(45deg, transparent 75%, var(--border2) 75%),
|
|
268
|
+
linear-gradient(-45deg, transparent 75%, var(--border2) 75%);
|
|
269
|
+
background-size: 8px 8px;
|
|
270
|
+
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.token-input-length { display: grid; grid-template-columns: 1fr 70px; gap: 4px; }
|
|
274
|
+
.token-input-length .token-input-raw { grid-column: 1 / -1; font-size: var(--text-xs); }
|
|
275
|
+
|
|
276
|
+
.token-input-font { font-size: var(--text-sm); }
|
|
277
|
+
.token-input-mono { font-family: var(--font-mono); font-size: var(--text-xs); }
|
|
278
|
+
</style>
|
package/composables/useTheme.ts
CHANGED
|
@@ -10,6 +10,11 @@ import { THEME_TO_FAMILY, FAMILY_VARIANTS } from '../utils/themeConfig';
|
|
|
10
10
|
*
|
|
11
11
|
* The dark mode preference cookie (`cpub-color-scheme`) is only persisted
|
|
12
12
|
* when the user has accepted functional cookies via the consent banner.
|
|
13
|
+
*
|
|
14
|
+
* Custom themes (`cpub-custom-*`) and code-registered themes pass through —
|
|
15
|
+
* the user's cookie toggle is recorded but the server picks the actual variant
|
|
16
|
+
* using the custom theme's `pairId` (if declared). For built-in family pairs,
|
|
17
|
+
* the variant flip happens client-side immediately for snappy UX.
|
|
13
18
|
*/
|
|
14
19
|
export function useTheme(): {
|
|
15
20
|
/** Current active theme ID (resolved from instance default + dark mode) */
|
|
@@ -39,23 +44,28 @@ export function useTheme(): {
|
|
|
39
44
|
schemeCookie.value = dark ? 'dark' : 'light';
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
applyThemeToElement(document.documentElement, newTheme);
|
|
47
|
+
// Built-in family flip is purely client-side for snappy UX.
|
|
48
|
+
// Custom/registered themes need a server round-trip on next nav
|
|
49
|
+
// (the server reads the new cookie and picks the right pair).
|
|
50
|
+
if (THEME_TO_FAMILY[instanceDefault.value]) {
|
|
51
|
+
const family = THEME_TO_FAMILY[instanceDefault.value]!;
|
|
52
|
+
const variants = FAMILY_VARIANTS[family] ?? FAMILY_VARIANTS.classic!;
|
|
53
|
+
const newTheme = dark ? variants.dark : variants.light;
|
|
54
|
+
themeId.value = newTheme;
|
|
51
55
|
|
|
52
|
-
|
|
56
|
+
if (import.meta.client) {
|
|
57
|
+
applyThemeToElement(document.documentElement, newTheme);
|
|
58
|
+
$fetch('/api/profile/theme', {
|
|
59
|
+
method: 'PUT',
|
|
60
|
+
body: { themeId: newTheme },
|
|
61
|
+
}).catch(() => {});
|
|
62
|
+
}
|
|
63
|
+
} else if (import.meta.client) {
|
|
64
|
+
// Custom theme: just persist preference; server will pick the variant on next request
|
|
53
65
|
$fetch('/api/profile/theme', {
|
|
54
66
|
method: 'PUT',
|
|
55
|
-
body: { themeId:
|
|
56
|
-
}).catch(() => {
|
|
57
|
-
// Not logged in or network error — cookie preference is sufficient
|
|
58
|
-
});
|
|
67
|
+
body: { themeId: instanceDefault.value },
|
|
68
|
+
}).catch(() => {});
|
|
59
69
|
}
|
|
60
70
|
}
|
|
61
71
|
|