Skip to content

Commit 7c18542

Browse files
authored
feat(hub-ui): overridable primary color + customizable branding (#177)
1 parent 62ac403 commit 7c18542

26 files changed

Lines changed: 525 additions & 76 deletions

File tree

packages/hub-ui/.storybook/preview.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import type { Decorator, Preview } from '@storybook/vue3-vite'
22
import 'virtual:uno.css'
33
import '@antfu/design/styles.css'
44
import '../src/client/style.css'
5+
// After uno so the primary-ramp override wins, matching the shadow-root build.
6+
import '../src/client/primary-ramp.css'
57

68
// Drive the shared `@antfu/design` tokens off the toolbar theme toggle: dark mode
79
// is the `.dark` class on `<html>`, and the canvas takes the semantic

packages/hub-ui/scripts/build-css.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ const GLOBS = ['components/**/*.{ts,vue}', 'state/**/*.ts', 'embedded/**/*.ts',
1717
// Story-only utility classes must not leak into the shipped stylesheet.
1818
const IGNORE = ['**/*.stories.*', '**/__tests__/**']
1919
const USER_STYLE = join(SRC_DIR, 'style.css')
20+
// The single-overridable-variable primary ramp. Appended AFTER the UnoCSS
21+
// output so its `:host` block wins over Wind4's own `:root, :host` primary
22+
// declarations (kept in its own file so the Storybook preview can import the
23+
// exact same override after `virtual:uno.css`). See the file's own comment.
24+
const PRIMARY_RAMP = join(SRC_DIR, 'primary-ramp.css')
2025
const GENERATED_CSS = join(SRC_DIR, '.generated/css.ts')
2126

2227
export async function buildCSS(): Promise<void> {
@@ -55,11 +60,13 @@ export async function buildCSS(): Promise<void> {
5560
await transformer.transform(userStyle, USER_STYLE, { uno: generator } as any)
5661
}
5762

63+
const primaryRamp = await fs.readFile(PRIMARY_RAMP, 'utf-8')
5864
const unoResult = await generator.generate(tokens)
5965
const css = [
6066
reset,
6167
userStyle.toString(),
6268
unoResult.css,
69+
primaryRamp,
6370
].join('\n')
6471

6572
await fs.mkdir(join(SRC_DIR, '.generated'), { recursive: true })

packages/hub-ui/src/client/.generated/css.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

packages/hub-ui/src/client/components/dock/DockEntry.vue

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22
import type { DevframeDockEntryBase } from '@devframes/hub'
33
import type { DocksContext } from '@devframes/hub/client'
44
import { useEventListener } from '@vueuse/core'
5-
import { computed, useTemplateRef } from 'vue'
5+
import { useTemplateRef } from 'vue'
66
import { setFloatingTooltip } from '../../state/floating-tooltip'
7-
import { accentTextStyle } from '../../utils/accent-color'
87
import { openDockContextMenu } from './DockContextMenu'
98
import DockIcon from './DockIcon.vue'
109
@@ -18,17 +17,12 @@ const props = withDefaults(
1817
isVertical?: boolean
1918
badge?: string
2019
tooltip?: boolean
21-
/** A `group` entry's optional `accentColor`, applied while selected/active. */
22-
accentColor?: string
2320
}>(),
2421
{
2522
tooltip: true,
2623
},
2724
)
2825
29-
// Only kicks in while selected — an idle group button keeps its default look.
30-
const accentStyle = computed(() => (props.isSelected ? accentTextStyle(props.accentColor) : undefined))
31-
3226
const button = useTemplateRef<HTMLButtonElement>('button')
3327
3428
function updateTooltip() {
@@ -84,11 +78,10 @@ useEventListener('pointerdown', () => {
8478
<button
8579
ref="button"
8680
:aria-label="dock.title"
87-
:style="accentStyle"
8881
:class="[
8982
isVertical ? 'rotate-270' : '',
9083
isDimmed ? 'op50 saturate-0' : '',
91-
isSelected ? (accentColor ? 'scale-120' : 'scale-120 text-primary') : '',
84+
isSelected ? 'scale-120 text-primary' : '',
9285
isAction ? 'bg-[#8881] hover:bg-[#8882] rounded-full' : 'rounded-xl',
9386
]"
9487
class="flex items-center justify-center p1.5 hover:bg-[#8881] hover:scale-110 transition-all duration-300 relative outline-none"

packages/hub-ui/src/client/components/dock/DockGroupButton.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { computed, h, ref, useTemplateRef } from 'vue'
66
import { getGroupMembers, getGroupMembersGrouped, resolveGroupDefaultChild } from '../../state/dock-settings'
77
import { sharedStateToRef } from '../../state/docks'
88
import { setDocksGroupPanel, useDocksGroupPanel } from '../../state/floating-tooltip'
9+
import { accentVarStyle } from '../../utils/accent-color'
910
import DockEntry from './DockEntry.vue'
1011
import DockGroupPopover from './DockGroupPopover.vue'
1112
@@ -119,15 +120,14 @@ function onClick() {
119120
</script>
120121

121122
<template>
122-
<div ref="groupButton">
123+
<div ref="groupButton" :class="group.accentColor ? 'devframes-accent-scope' : ''" :style="accentVarStyle(group.accentColor)">
123124
<DockEntry
124125
:context="context"
125126
:dock="group"
126127
:is-vertical="isVertical"
127128
:is-selected="isActive || isPanelVisible"
128129
:is-dimmed="dimInactive && selected ? !isActive : false"
129130
:badge="group.badge"
130-
:accent-color="group.accentColor"
131131
@click="onClick"
132132
/>
133133
</div>

packages/hub-ui/src/client/components/dock/DockGroupPopover.vue

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import type { DevframeDockEntriesGrouped, DevframeDockEntry, DevframeViewGroup } from '@devframes/hub'
33
import type { DocksContext } from '@devframes/hub/client'
44
import { computed } from 'vue'
5-
import { accentActiveStyle, accentTextStyle } from '../../utils/accent-color'
5+
import { accentVarStyle } from '../../utils/accent-color'
66
import DockIcon from './DockIcon.vue'
77
88
const props = defineProps<{
@@ -18,17 +18,22 @@ const emit = defineEmits<{
1818
}>()
1919
2020
const isEmpty = computed(() => props.members.every(([, items]) => items.length === 0))
21-
22-
const headerStyle = computed(() => accentTextStyle(props.group.accentColor))
23-
24-
function memberStyle(memberId: string) {
25-
return props.selectedId === memberId ? accentActiveStyle(props.group.accentColor) : undefined
26-
}
2721
</script>
2822

2923
<template>
30-
<div class="flex flex-col gap-0.5 min-w-44 max-w-64" @mousemove.stop>
31-
<div class="flex items-center gap-1.5 px4 mx--2 pt2 pb2.5 font-bold text-2.75 uppercase tracking-wide border-b border-base" :style="headerStyle">
24+
<!-- The group's accentColor overrides --devframe-primary for the whole
25+
popover, so the header icon and the selected row's text-primary/bg-active
26+
render in the accent. -->
27+
<div
28+
class="flex flex-col gap-0.5 min-w-44 max-w-64"
29+
:class="group.accentColor ? 'devframes-accent-scope' : ''"
30+
:style="accentVarStyle(group.accentColor)"
31+
@mousemove.stop
32+
>
33+
<div
34+
class="flex items-center gap-1.5 px4 mx--2 pt2 pb2.5 font-bold text-2.75 uppercase tracking-wide border-b border-base"
35+
:class="group.accentColor ? 'color-active' : ''"
36+
>
3237
<DockIcon :icon="group.icon" class="w-4.5 h-4.5" />
3338
<span class="truncate">{{ group.title }}</span>
3439
</div>
@@ -39,8 +44,7 @@ function memberStyle(memberId: string) {
3944
v-for="member of items"
4045
:key="member.id"
4146
class="flex items-center gap-2 w-full px2 py1.5 rounded text-sm text-left transition"
42-
:class="selectedId === member.id ? (group.accentColor ? '' : 'text-primary bg-active') : 'op80 hover:op100 hover:bg-active'"
43-
:style="memberStyle(member.id)"
47+
:class="selectedId === member.id ? 'text-primary bg-active' : 'op80 hover:op100 hover:bg-active'"
4448
@click="emit('select', member)"
4549
>
4650
<DockIcon :icon="member.icon" class="w-4.5 h-4.5 flex-none" />

packages/hub-ui/src/client/components/dock/DockGroupSidebar.vue

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { computed, h, ref, useTemplateRef } from 'vue'
66
import { deriveSidebarCapacity, docksSplitGroupsWithCapacity, getGroupMembersGrouped } from '../../state/dock-settings'
77
import { sharedStateToRef } from '../../state/docks'
88
import { setDocksSidebarOverflowPanel, setFloatingTooltip, useDocksSidebarOverflowPanel } from '../../state/floating-tooltip'
9-
import { accentActiveStyle, accentTextStyle } from '../../utils/accent-color'
9+
import { accentVarStyle } from '../../utils/accent-color'
1010
import DockGroupPopover from './DockGroupPopover.vue'
1111
import DockIcon from './DockIcon.vue'
1212
@@ -126,22 +126,21 @@ watchDebounced(
126126
127127
// Highlight the button when the hidden selection lives here, or while open.
128128
const moreButtonActive = computed(() => selectedInOverflow.value || isOverflowPanelVisible.value)
129-
130-
// Accent styling, mirroring DockGroupPopover: falls back to the default
131-
// Uno classes below when the group sets no `accentColor`.
132-
const anchorStyle = computed(() => accentTextStyle(props.group.accentColor))
133-
function memberStyle(memberId: string) {
134-
return props.selectedId === memberId ? accentActiveStyle(props.group.accentColor) : undefined
135-
}
136-
const moreButtonStyle = computed(() => (moreButtonActive.value ? accentActiveStyle(props.group.accentColor) : undefined))
137129
</script>
138130

139131
<template>
140-
<div ref="sidebar" class="devframes-group-sidebar flex flex-col flex-none w-12 h-full border-r border-base of-y-hidden of-x-hidden select-none py1.5 items-center gap-0.5">
132+
<!-- The group's accentColor overrides --devframe-primary for the whole rail,
133+
so the anchor + selected members' text-primary/bg-active render accent. -->
134+
<div
135+
ref="sidebar"
136+
class="devframes-group-sidebar flex flex-col flex-none w-12 h-full border-r border-base of-y-hidden of-x-hidden select-none py1.5 items-center gap-0.5"
137+
:class="group.accentColor ? 'devframes-accent-scope' : ''"
138+
:style="accentVarStyle(group.accentColor)"
139+
>
141140
<!-- Group anchor -->
142141
<div
143142
class="flex items-center justify-center w-8 h-8 op60"
144-
:style="anchorStyle"
143+
:class="group.accentColor ? 'color-active' : ''"
145144
@pointerenter="showTooltip($event, group.title)"
146145
@pointerleave="hideTooltip"
147146
>
@@ -158,8 +157,7 @@ const moreButtonStyle = computed(() => (moreButtonActive.value ? accentActiveSty
158157
:key="member.id"
159158
:aria-label="member.title"
160159
class="relative flex items-center justify-center w-8 h-8 rounded-lg transition"
161-
:class="selectedId === member.id ? (group.accentColor ? '' : 'text-primary bg-active') : 'op60 hover:op100 hover:bg-active'"
162-
:style="memberStyle(member.id)"
160+
:class="selectedId === member.id ? 'text-primary bg-active' : 'op60 hover:op100 hover:bg-active'"
163161
@pointerenter="showTooltip($event, member.title)"
164162
@pointerleave="hideTooltip"
165163
@pointerdown="hideTooltip"
@@ -181,8 +179,7 @@ const moreButtonStyle = computed(() => (moreButtonActive.value ? accentActiveSty
181179
ref="moreButton"
182180
aria-label="Show more"
183181
class="relative flex items-center justify-center w-8 h-8 rounded-lg transition mt-auto flex-none"
184-
:class="moreButtonActive ? (group.accentColor ? '' : 'text-primary bg-active') : 'op60 hover:op100 hover:bg-active'"
185-
:style="moreButtonStyle"
182+
:class="moreButtonActive ? 'text-primary bg-active' : 'op60 hover:op100 hover:bg-active'"
186183
@pointerenter="showTooltip($event, 'Show more')"
187184
@pointerleave="hideTooltip"
188185
@pointerdown="hideTooltip"

packages/hub-ui/src/client/components/icons/Brand.stories.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import type { Meta, StoryObj } from '@storybook/vue3-vite'
2-
import { h } from 'vue'
2+
import { h, onScopeDispose } from 'vue'
3+
import { setBranding } from '../../state/branding'
34
import BrandMark from './BrandMark.vue'
45
import BrandWordmark from './BrandWordmark.vue'
56

7+
// A stand-in consumer logo (inline data-URI SVG) so the branded stories need no
8+
// network. A violet hexagon, echoing the sample `primaryColor` below.
9+
const SAMPLE_LOGO = `data:image/svg+xml,${encodeURIComponent(
10+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#7c3aed" d="M12 2l9 5v10l-9 5-9-5V7z"/></svg>`,
11+
)}`
12+
613
const meta = {
714
title: 'Brand/Logos',
815
tags: ['autodocs'],
@@ -29,3 +36,28 @@ export const Wordmark: Story = {
2936
name: 'BrandWordmark',
3037
render: () => ({ setup: () => () => h(BrandWordmark) }),
3138
}
39+
40+
/**
41+
* A rebrand via `createUi({ branding })` / the host page — a custom product
42+
* name, logo, and primary color. The wrapper sets `--devframe-primary` (what
43+
* the bootstrap applies from `branding.primaryColor`), so the ramp — and the
44+
* mark/wordmark — retint together.
45+
*/
46+
export const Branded: Story = {
47+
name: 'Branded (custom)',
48+
render: () => ({
49+
setup() {
50+
setBranding({ productName: 'Acme DevTools', logo: SAMPLE_LOGO, primaryColor: '#7c3aed' })
51+
// Restore devframe defaults so sibling stories are unaffected.
52+
onScopeDispose(() => setBranding({}))
53+
return () => h('div', { class: 'devframes-accent-scope flex flex-col gap-4 p-6', style: '--devframe-primary: #7c3aed' }, [
54+
h('div', { class: 'w-16 h-16' }, h(BrandMark)),
55+
h(BrandWordmark),
56+
h('div', { class: 'flex items-center gap-3' }, [
57+
h('button', { class: 'btn-primary' }, 'Primary button'),
58+
h('span', { class: 'color-active font-medium' }, 'color-active text'),
59+
]),
60+
])
61+
},
62+
}),
63+
}
Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
<script setup lang="ts">
2+
import { useBranding, useBrandingLogo } from '../../state/branding'
23
import DevframeLogo from './DevframeLogo.vue'
4+
5+
// The brand mark — the consumer's branding logo (per color scheme) when set,
6+
// otherwise devframe's default mark. Sized by the caller's classes.
7+
const branding = useBranding()
8+
const logo = useBrandingLogo(b => b.logo)
39
</script>
410

511
<template>
6-
<!-- The devframe brand mark — sized by the caller's classes. -->
712
<div class="flex items-center justify-center">
8-
<DevframeLogo class="w-full h-full" />
13+
<img
14+
v-if="logo"
15+
:src="logo"
16+
:alt="`${branding.productName} logo`"
17+
class="w-full h-full object-contain"
18+
draggable="false"
19+
>
20+
<DevframeLogo v-else class="w-full h-full" />
921
</div>
1022
</template>
Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,33 @@
11
<script setup lang="ts">
2+
import { useBranding, useBrandingLogo } from '../../state/branding'
23
import DevframeLogo from './DevframeLogo.vue'
4+
5+
// The wordmark — a consumer-supplied wordmark image, or the mark composed with
6+
// the product name. A decorative flourish (e.g. peeking above the palette).
7+
const branding = useBranding()
8+
const wordmark = useBrandingLogo(b => b.wordmark)
9+
const logo = useBrandingLogo(b => b.logo)
310
</script>
411

512
<template>
6-
<!-- The devframe wordmark — a decorative brand flourish (e.g. peeking
7-
above the command palette). -->
813
<div class="flex items-center gap-1.5 select-none" aria-hidden="true">
9-
<DevframeLogo class="w-6 h-6 flex-none" />
10-
<span class="text-xl font-bold tracking-tight color-base">Devframes</span>
14+
<img
15+
v-if="wordmark"
16+
:src="wordmark"
17+
:alt="branding.productName"
18+
class="h-6 flex-none object-contain"
19+
draggable="false"
20+
>
21+
<template v-else>
22+
<img
23+
v-if="logo"
24+
:src="logo"
25+
:alt="`${branding.productName} logo`"
26+
class="w-6 h-6 flex-none object-contain"
27+
draggable="false"
28+
>
29+
<DevframeLogo v-else class="w-6 h-6 flex-none" />
30+
<span class="text-xl font-bold tracking-tight color-base">{{ branding.productName }}</span>
31+
</template>
1132
</div>
1233
</template>

0 commit comments

Comments
 (0)