@nuxtseo/design-system 0.0.0-stage → 0.1.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.
- package/README.md +181 -2
- package/app/app.config.ts +592 -0
- package/app/component-attributes.d.ts +8 -0
- package/app/components/README.md +38 -0
- package/app/components/brand/UiBrandMark.vue +63 -0
- package/app/components/brand/UiModuleIcon.vue +45 -0
- package/app/components/brand/UiProLettering.vue +26 -0
- package/app/components/brand/UiRangeTerrain.vue +311 -0
- package/app/components/brand/range-glyphs.ts +18 -0
- package/app/components/container/BrowserTab.vue +145 -0
- package/app/components/container/IntegrationRow.vue +120 -0
- package/app/components/container/KnowDoRails.vue +40 -0
- package/app/components/container/RailPanel.vue +93 -0
- package/app/components/container/UiAlert.vue +120 -0
- package/app/components/container/UiAppShell.vue +165 -0
- package/app/components/container/UiCard.vue +204 -0
- package/app/components/container/UiDisclosure.vue +43 -0
- package/app/components/container/UiEmptyState.vue +102 -0
- package/app/components/container/UiErrorState.vue +61 -0
- package/app/components/container/UiLoadingState.vue +20 -0
- package/app/components/container/UiPageHeader.vue +95 -0
- package/app/components/container/UiPopover.vue +102 -0
- package/app/components/container/UiSampleDataOverlay.vue +47 -0
- package/app/components/container/UiSectionHeader.vue +83 -0
- package/app/components/container/UiSummaryStrip.vue +40 -0
- package/app/components/container/UiToast.vue +230 -0
- package/app/components/container/UiToaster.vue +271 -0
- package/app/components/container/UiWidgetState.vue +143 -0
- package/app/components/container/ui-nav-drawer.ts +48 -0
- package/app/components/data/ActionRail.vue +152 -0
- package/app/components/data/FacetStrip.vue +93 -0
- package/app/components/data/UiBarCell.vue +37 -0
- package/app/components/data/UiBarChart.vue +281 -0
- package/app/components/data/UiChartAnnotationControls.vue +74 -0
- package/app/components/data/UiChartAnnotations.vue +163 -0
- package/app/components/data/UiChartBrushOverlay.vue +96 -0
- package/app/components/data/UiChartDataReveal.vue +81 -0
- package/app/components/data/UiChartFrame.vue +123 -0
- package/app/components/data/UiChartTooltip.vue +81 -0
- package/app/components/data/UiCoverageNote.vue +90 -0
- package/app/components/data/UiCwvSparkline.vue +188 -0
- package/app/components/data/UiDataList.vue +276 -0
- package/app/components/data/UiDataTableSection.vue +233 -0
- package/app/components/data/UiDistributionBars.vue +164 -0
- package/app/components/data/UiDonutChart.vue +132 -0
- package/app/components/data/UiDoorwayColumn.vue +108 -0
- package/app/components/data/UiDoorwayGrid.vue +58 -0
- package/app/components/data/UiDoorwayRow.vue +83 -0
- package/app/components/data/UiEntitySummary.vue +261 -0
- package/app/components/data/UiFactsGrid.vue +107 -0
- package/app/components/data/UiImpactMeter.vue +33 -0
- package/app/components/data/UiIssueRow.vue +149 -0
- package/app/components/data/UiLineChart.vue +431 -0
- package/app/components/data/UiMetricBarPopover.vue +121 -0
- package/app/components/data/UiMetricLabel.vue +30 -0
- package/app/components/data/UiMetricStat.vue +73 -0
- package/app/components/data/UiMetricToggle.vue +156 -0
- package/app/components/data/UiMetricsRow.vue +64 -0
- package/app/components/data/UiPositionMetric.vue +53 -0
- package/app/components/data/UiRelatedFixes.vue +113 -0
- package/app/components/data/UiRichText.vue +325 -0
- package/app/components/data/UiScatterPlot.vue +144 -0
- package/app/components/data/UiSeverityMarker.vue +30 -0
- package/app/components/data/UiSparkColumns.vue +160 -0
- package/app/components/data/UiSparkline.vue +681 -0
- package/app/components/data/UiStat.vue +445 -0
- package/app/components/data/UiStats.vue +76 -0
- package/app/components/data/UiTable.vue +517 -0
- package/app/components/data/UiTableExport.vue +40 -0
- package/app/components/data/UiTableFrame.vue +149 -0
- package/app/components/data/UiTablePagination.vue +37 -0
- package/app/components/data/UiTableShell.vue +48 -0
- package/app/components/data/UiTableTd.vue +39 -0
- package/app/components/data/UiTableTh.vue +61 -0
- package/app/components/data/UiTopEntityStackChart.vue +393 -0
- package/app/components/data/cells/UiTableDash.vue +3 -0
- package/app/components/data/cells/UiTableHeaderCell.vue +72 -0
- package/app/components/data/cells/UiTableMetricCell.vue +72 -0
- package/app/components/data/cells/UiTablePathCell.vue +76 -0
- package/app/components/data/cells/UiTableScoreTile.vue +49 -0
- package/app/components/data/cells/UiTableTrendCell.vue +41 -0
- package/app/components/element/UiAtmosphere.vue +64 -0
- package/app/components/element/UiAuthHeading.vue +24 -0
- package/app/components/element/UiAuthProviders.vue +46 -0
- package/app/components/element/UiBrandIcon.vue +39 -0
- package/app/components/element/UiButton.vue +549 -0
- package/app/components/element/UiCelebration.vue +65 -0
- package/app/components/element/UiChannelChips.vue +62 -0
- package/app/components/element/UiChatQuickLink.vue +319 -0
- package/app/components/element/UiChip.vue +101 -0
- package/app/components/element/UiConfirmDialog.vue +84 -0
- package/app/components/element/UiCopyButton.vue +90 -0
- package/app/components/element/UiDitherAvatar.vue +177 -0
- package/app/components/element/UiDotLabel.vue +19 -0
- package/app/components/element/UiEjectMenu.vue +181 -0
- package/app/components/element/UiEjectOverlay.vue +29 -0
- package/app/components/element/UiFavicon.vue +235 -0
- package/app/components/element/UiFaviconStack.vue +84 -0
- package/app/components/element/UiFilterMenu.vue +324 -0
- package/app/components/element/UiFocusedSurface.vue +129 -0
- package/app/components/element/UiHealthDot.vue +32 -0
- package/app/components/element/UiHelpLabel.vue +28 -0
- package/app/components/element/UiIcon.vue +22 -0
- package/app/components/element/UiInput.vue +35 -0
- package/app/components/element/UiLogoPlausible.vue +12 -0
- package/app/components/element/UiLogoPro.vue +25 -0
- package/app/components/element/UiLogoUmami.vue +12 -0
- package/app/components/element/UiNavIcon.vue +107 -0
- package/app/components/element/UiNavList.vue +156 -0
- package/app/components/element/UiOnboardingAnswers.vue +127 -0
- package/app/components/element/UiOverflowCount.vue +31 -0
- package/app/components/element/UiProBadge.vue +19 -0
- package/app/components/element/UiProgressCircle.vue +79 -0
- package/app/components/element/UiProgressPercent.vue +35 -0
- package/app/components/element/UiRelativeTime.vue +58 -0
- package/app/components/element/UiScrollFade.vue +16 -0
- package/app/components/element/UiSelect.vue +68 -0
- package/app/components/element/UiSeverityDot.vue +27 -0
- package/app/components/element/UiSiteFavicon.vue +24 -0
- package/app/components/element/UiSkeleton.vue +148 -0
- package/app/components/element/UiSlotText.vue +65 -0
- package/app/components/element/UiSourceLogos.vue +113 -0
- package/app/components/element/UiSourceRail.vue +71 -0
- package/app/components/element/UiStaticTerminal.vue +107 -0
- package/app/components/element/UiStatusBadge.vue +62 -0
- package/app/components/element/UiSyncDot.vue +46 -0
- package/app/components/element/UiTabs.vue +245 -0
- package/app/components/element/UiTogglePill.vue +86 -0
- package/app/components/element/UiTooltip.vue +390 -0
- package/app/components/element/UiTooltipGrid.vue +457 -0
- package/app/components/element/UiTrend.vue +184 -0
- package/app/components/element/UiTrialBenefits.vue +71 -0
- package/app/components/element/UiUrlLabel.vue +75 -0
- package/app/components/element/UiValenceDot.vue +37 -0
- package/app/components/element/UiWizardModal.vue +284 -0
- package/app/components/element/UiWizardNav.vue +59 -0
- package/app/components/element/UiWizardProgress.vue +78 -0
- package/app/components/element/UiWizardShell.vue +43 -0
- package/app/components/element/eject-menu.ts +359 -0
- package/app/components/element/eject-tracker.ts +34 -0
- package/app/components/element/onboarding-answers.ts +93 -0
- package/app/components/element/onboarding-intents.ts +19 -0
- package/app/composables/dataVizColors.ts +350 -0
- package/app/composables/formatting.ts +252 -0
- package/app/composables/semanticColors.ts +80 -0
- package/app/composables/useActivityOverlay.ts +55 -0
- package/app/composables/useAppShellNav.ts +21 -0
- package/app/composables/useChartBrush.ts +221 -0
- package/app/composables/useChartHover.ts +153 -0
- package/app/composables/useChartTickPlan.ts +186 -0
- package/app/composables/useCloseOnNavigation.ts +7 -0
- package/app/composables/useConfirm.ts +38 -0
- package/app/composables/useEdgeFade.ts +37 -0
- package/app/composables/useEject.ts +69 -0
- package/app/composables/useEntranceMotion.ts +22 -0
- package/app/composables/useFaviconBacking.ts +164 -0
- package/app/composables/useGridCols.ts +39 -0
- package/app/composables/useIssueOverlay.ts +43 -0
- package/app/composables/useOnboardingService.ts +106 -0
- package/app/composables/useOpenedOnce.ts +19 -0
- package/app/composables/useScrollSpy.ts +178 -0
- package/app/composables/useSquircleFallback.ts +21 -0
- package/app/composables/useWizardRoute.ts +123 -0
- package/app/plugins/favicon.ts +15 -0
- package/app/plugins/lazy-motion.ts +22 -0
- package/app/plugins/selection-highlight.client.ts +176 -0
- package/app/shared/cli.ts +10 -0
- package/app/shared/error-state.ts +150 -0
- package/app/shared/icons.ts +1 -0
- package/app/shared/motion.ts +1 -0
- package/app/shared/nav.ts +38 -0
- package/app/shared/navigation.ts +5 -0
- package/app/shared/static-terminal.ts +14 -0
- package/app/shared/table-export.ts +36 -0
- package/app/shared/table.ts +105 -0
- package/app/shared/ui-icons.ts +1 -0
- package/app/shared/view-state.ts +85 -0
- package/app/shared/widget-failure.ts +126 -0
- package/app/utils/app-favicon.ts +64 -0
- package/app/utils/chartAnnotations.ts +326 -0
- package/app/utils/countries.ts +337 -0
- package/app/utils/export-download.ts +100 -0
- package/app/utils/favicon-fallback.ts +12 -0
- package/app/utils/icon-color.ts +105 -0
- package/app/utils/number.ts +74 -0
- package/app/utils/render-link.ts +14 -0
- package/app/utils/slot-text.ts +18 -0
- package/app/utils/squircle.ts +154 -0
- package/app/utils/topEntityStack.ts +197 -0
- package/app/utils/vue.ts +57 -0
- package/build/design-system.ts +69 -0
- package/build/external-links.ts +38 -0
- package/build/fonts.ts +39 -0
- package/build/icon-server-bundle.ts +276 -0
- package/build/runtime/icon-server-bundle.ts +29 -0
- package/css/global.css +1896 -0
- package/nuxt.config.ts +143 -0
- package/package.json +93 -3
- package/public/fonts/README.md +33 -0
- package/public/fonts/fira-code-OFL.txt +93 -0
- package/public/fonts/fira-code-var-latin.woff2 +0 -0
- package/public/fonts/hubot-sans-OFL.txt +93 -0
- package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
- package/server/api/favicon.get.ts +122 -0
- package/server/utils/favicon-source.ts +51 -0
- package/shared/contracts/feedback.ts +42 -0
- package/shared/contracts/index.ts +1 -0
- package/shared/delivery-channels.ts +53 -0
- package/shared/icons/index.ts +143 -0
- package/shared/icons/registry.ts +347 -0
- package/shared/icons/ui-slots.ts +58 -0
- package/shared/markdown.ts +44 -0
- package/shared/motion.ts +120 -0
- package/shared/rangi.ts +60 -0
- package/shared/rich-text.ts +34 -0
- package/shared/ui-icons.ts +6 -0
- package/shared/utils/urls.ts +64 -0
- package/types/index.ts +1 -0
- package/types/ui.ts +339 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, useId } from 'vue'
|
|
3
|
+
|
|
4
|
+
const { seed, size = 32, label, colors } = defineProps<{
|
|
5
|
+
seed: string
|
|
6
|
+
size?: number
|
|
7
|
+
label?: string
|
|
8
|
+
/**
|
|
9
|
+
* Hex palette the avatar draws from. Defaults to the Nuxt SEO palette; a site
|
|
10
|
+
* with its own brand passes its own. Six-digit hex only: the face colour is
|
|
11
|
+
* picked for contrast from it.
|
|
12
|
+
*/
|
|
13
|
+
colors?: readonly string[]
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
/*
|
|
17
|
+
* Beam generator adapted from Boring Avatars:
|
|
18
|
+
* https://github.com/boringdesigners/boring-avatars
|
|
19
|
+
*
|
|
20
|
+
* MIT License
|
|
21
|
+
*
|
|
22
|
+
* Copyright (c) 2021 boringdesigners
|
|
23
|
+
*
|
|
24
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
25
|
+
* of this software and associated documentation files (the "Software"), to deal
|
|
26
|
+
* in the Software without restriction, including without limitation the rights
|
|
27
|
+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
28
|
+
* copies of the Software, and to permit persons to whom the Software is
|
|
29
|
+
* furnished to do so, subject to the following conditions:
|
|
30
|
+
*
|
|
31
|
+
* The above copyright notice and this permission notice shall be included in all
|
|
32
|
+
* copies or substantial portions of the Software.
|
|
33
|
+
*
|
|
34
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
35
|
+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
36
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
37
|
+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
38
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
39
|
+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
40
|
+
* SOFTWARE.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const SIZE = 36
|
|
44
|
+
const AVATAR_COLORS = ['#8B5CF6', '#00DC82', '#F59E0B', '#EC4899', '#3B82F6'] as const
|
|
45
|
+
|
|
46
|
+
function hashCode(name: string): number {
|
|
47
|
+
let hash = 0
|
|
48
|
+
for (let index = 0; index < name.length; index++) {
|
|
49
|
+
const character = name.charCodeAt(index)
|
|
50
|
+
hash = ((hash << 5) - hash) + character
|
|
51
|
+
hash &= hash
|
|
52
|
+
}
|
|
53
|
+
return Math.abs(hash)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getDigit(number: number, position: number): number {
|
|
57
|
+
return Math.floor((number / 10 ** position) % 10)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getBoolean(number: number, position: number): boolean {
|
|
61
|
+
return getDigit(number, position) % 2 === 0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getUnit(number: number, range: number, position?: number): number {
|
|
65
|
+
const value = number % range
|
|
66
|
+
return position && getDigit(number, position) % 2 === 0 ? -value : value
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getRandomColor(number: number, colors: readonly string[]): string {
|
|
70
|
+
return colors[number % colors.length]!
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getContrast(hexColor: string): string {
|
|
74
|
+
const color = hexColor.startsWith('#') ? hexColor.slice(1) : hexColor
|
|
75
|
+
const red = Number.parseInt(color.slice(0, 2), 16)
|
|
76
|
+
const green = Number.parseInt(color.slice(2, 4), 16)
|
|
77
|
+
const blue = Number.parseInt(color.slice(4, 6), 16)
|
|
78
|
+
const yiq = (red * 299 + green * 587 + blue * 114) / 1000
|
|
79
|
+
return yiq >= 128 ? '#000000' : '#FFFFFF'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function generateBeamData(name: string, colors: readonly string[]) {
|
|
83
|
+
const number = hashCode(name)
|
|
84
|
+
const wrapperColor = getRandomColor(number, colors)
|
|
85
|
+
const preTranslateX = getUnit(number, 10, 1)
|
|
86
|
+
const wrapperTranslateX = preTranslateX < 5 ? preTranslateX + SIZE / 9 : preTranslateX
|
|
87
|
+
const preTranslateY = getUnit(number, 10, 2)
|
|
88
|
+
const wrapperTranslateY = preTranslateY < 5 ? preTranslateY + SIZE / 9 : preTranslateY
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
wrapperColor,
|
|
92
|
+
faceColor: getContrast(wrapperColor),
|
|
93
|
+
backgroundColor: getRandomColor(number + 13, colors),
|
|
94
|
+
wrapperTranslateX,
|
|
95
|
+
wrapperTranslateY,
|
|
96
|
+
wrapperRotate: getUnit(number, 360),
|
|
97
|
+
wrapperScale: 1 + getUnit(number, SIZE / 12) / 10,
|
|
98
|
+
isMouthOpen: getBoolean(number, 2),
|
|
99
|
+
isCircle: getBoolean(number, 1),
|
|
100
|
+
eyeSpread: getUnit(number, 5),
|
|
101
|
+
mouthSpread: getUnit(number, 3),
|
|
102
|
+
faceRotate: getUnit(number, 10, 3),
|
|
103
|
+
faceTranslateX: wrapperTranslateX > SIZE / 6 ? wrapperTranslateX / 2 : getUnit(number, 8, 1),
|
|
104
|
+
faceTranslateY: wrapperTranslateY > SIZE / 6 ? wrapperTranslateY / 2 : getUnit(number, 7, 2),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const data = computed(() => generateBeamData(seed, colors?.length ? colors : AVATAR_COLORS))
|
|
109
|
+
const id = useId().replace(/[^a-z0-9]/gi, '')
|
|
110
|
+
const maskId = `avatar-mask-${id}`
|
|
111
|
+
const ditherId = `avatar-dither-${id}`
|
|
112
|
+
const wrapperTransform = computed(() => [
|
|
113
|
+
`translate(${data.value.wrapperTranslateX} ${data.value.wrapperTranslateY})`,
|
|
114
|
+
`rotate(${data.value.wrapperRotate} ${SIZE / 2} ${SIZE / 2})`,
|
|
115
|
+
`scale(${data.value.wrapperScale})`,
|
|
116
|
+
].join(' '))
|
|
117
|
+
const faceTransform = computed(() => [
|
|
118
|
+
`translate(${data.value.faceTranslateX} ${data.value.faceTranslateY})`,
|
|
119
|
+
`rotate(${data.value.faceRotate} ${SIZE / 2} ${SIZE / 2})`,
|
|
120
|
+
].join(' '))
|
|
121
|
+
</script>
|
|
122
|
+
|
|
123
|
+
<template>
|
|
124
|
+
<span
|
|
125
|
+
class="ui-dither-avatar inline-flex shrink-0 overflow-hidden rounded-full"
|
|
126
|
+
:style="{ width: `${size}px`, height: `${size}px` }"
|
|
127
|
+
:role="label ? 'img' : undefined"
|
|
128
|
+
:aria-label="label"
|
|
129
|
+
:aria-hidden="label ? undefined : 'true'"
|
|
130
|
+
>
|
|
131
|
+
<svg viewBox="0 0 36 36" fill="none" class="size-full" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
|
|
132
|
+
<defs>
|
|
133
|
+
<mask :id="maskId" maskUnits="userSpaceOnUse" x="0" y="0" width="36" height="36">
|
|
134
|
+
<rect width="36" height="36" rx="72" fill="white" />
|
|
135
|
+
</mask>
|
|
136
|
+
<pattern :id="ditherId" width="4" height="4" patternUnits="userSpaceOnUse">
|
|
137
|
+
<circle cx="0.8" cy="0.8" r="0.55" :fill="data.faceColor" opacity="0.2" />
|
|
138
|
+
<circle cx="2.8" cy="2.8" r="0.4" :fill="data.faceColor" opacity="0.12" />
|
|
139
|
+
</pattern>
|
|
140
|
+
</defs>
|
|
141
|
+
|
|
142
|
+
<g :mask="`url(#${maskId})`">
|
|
143
|
+
<rect width="36" height="36" :fill="data.backgroundColor" />
|
|
144
|
+
<rect
|
|
145
|
+
width="36"
|
|
146
|
+
height="36"
|
|
147
|
+
:transform="wrapperTransform"
|
|
148
|
+
:fill="data.wrapperColor"
|
|
149
|
+
:rx="data.isCircle ? 36 : 6"
|
|
150
|
+
/>
|
|
151
|
+
<g :transform="faceTransform">
|
|
152
|
+
<path
|
|
153
|
+
v-if="data.isMouthOpen"
|
|
154
|
+
:d="`M15 ${19 + data.mouthSpread}c2 1 4 1 6 0`"
|
|
155
|
+
:stroke="data.faceColor"
|
|
156
|
+
fill="none"
|
|
157
|
+
stroke-linecap="round"
|
|
158
|
+
/>
|
|
159
|
+
<path
|
|
160
|
+
v-else
|
|
161
|
+
:d="`M13,${19 + data.mouthSpread} a1,0.75 0 0,0 10,0`"
|
|
162
|
+
:fill="data.faceColor"
|
|
163
|
+
/>
|
|
164
|
+
<rect :x="14 - data.eyeSpread" y="14" width="1.5" height="2" rx="1" :fill="data.faceColor" />
|
|
165
|
+
<rect :x="20 + data.eyeSpread" y="14" width="1.5" height="2" rx="1" :fill="data.faceColor" />
|
|
166
|
+
</g>
|
|
167
|
+
<rect width="36" height="36" :fill="`url(#${ditherId})`" />
|
|
168
|
+
</g>
|
|
169
|
+
</svg>
|
|
170
|
+
</span>
|
|
171
|
+
</template>
|
|
172
|
+
|
|
173
|
+
<style scoped>
|
|
174
|
+
.ui-dither-avatar {
|
|
175
|
+
background: var(--ui-bg-elevated);
|
|
176
|
+
}
|
|
177
|
+
</style>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { dotClass, dotSize = '1.5', label } = defineProps<{
|
|
3
|
+
/** Tailwind bg class for the dot, e.g. 'bg-blue-500' or vizDotColor key result */
|
|
4
|
+
dotClass: string
|
|
5
|
+
/** Dot size token: '1.5' (default, inline/legend), '2' (medium), '2.5' (hero) */
|
|
6
|
+
dotSize?: '1.5' | '2' | '2.5'
|
|
7
|
+
/** Optional text label rendered after the dot */
|
|
8
|
+
label?: string
|
|
9
|
+
}>()
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<span class="inline-flex items-center gap-1.5">
|
|
14
|
+
<span class="rounded-full shrink-0" :class="[dotClass, `size-${dotSize}`]" aria-hidden="true" />
|
|
15
|
+
<slot>
|
|
16
|
+
<span v-if="label">{{ label }}</span>
|
|
17
|
+
</slot>
|
|
18
|
+
</span>
|
|
19
|
+
</template>
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
3
|
+
import type { RouteLocationRaw } from 'vue-router'
|
|
4
|
+
import type { EjectAction, EjectChat, EjectLink, EjectMcp } from './eject-menu'
|
|
5
|
+
import type { EjectTrackAction } from './eject-tracker'
|
|
6
|
+
import { useClipboard } from '@vueuse/core'
|
|
7
|
+
import { useRoute, useState } from 'nuxt/app'
|
|
8
|
+
import { computed } from 'vue'
|
|
9
|
+
import { UDropdownMenu, UiButton } from '#components'
|
|
10
|
+
import { useToast } from '#imports'
|
|
11
|
+
import { buildChatDeepLinkQuery, serializeCliCommand, serializeMcpCall } from './eject-menu'
|
|
12
|
+
import { useEjectTracker } from './eject-tracker'
|
|
13
|
+
|
|
14
|
+
const {
|
|
15
|
+
mcp,
|
|
16
|
+
chat,
|
|
17
|
+
schema,
|
|
18
|
+
actions,
|
|
19
|
+
track,
|
|
20
|
+
size = 'xs',
|
|
21
|
+
align = 'end',
|
|
22
|
+
} = defineProps<{
|
|
23
|
+
/**
|
|
24
|
+
* The eject payload. Always the structured shape: the menu derives the CLI
|
|
25
|
+
* command from `tool` + `site` through `serializeCliCommand`, so a
|
|
26
|
+
* pre-serialised string would silently cost the row its CLI item. There is
|
|
27
|
+
* deliberately no `cli` prop — every copied command comes from the one
|
|
28
|
+
* serialiser, which is what keeps `--site` on all of them.
|
|
29
|
+
*/
|
|
30
|
+
mcp?: EjectMcp
|
|
31
|
+
chat?: EjectChat
|
|
32
|
+
schema?: EjectLink
|
|
33
|
+
actions?: EjectAction[]
|
|
34
|
+
/** Optional call-site enrichment for eject telemetry (the menu fills action + surface). */
|
|
35
|
+
track?: { kind?: string, variant?: string }
|
|
36
|
+
size?: 'xs' | 'sm' | 'md'
|
|
37
|
+
align?: 'start' | 'center' | 'end'
|
|
38
|
+
}>()
|
|
39
|
+
|
|
40
|
+
const emit = defineEmits<{
|
|
41
|
+
select: [action: EjectTrackAction]
|
|
42
|
+
}>()
|
|
43
|
+
|
|
44
|
+
const toast = useToast()
|
|
45
|
+
const { copy } = useClipboard({ legacy: true })
|
|
46
|
+
|
|
47
|
+
// Auto-report every code-eject through the design-system telemetry seam.
|
|
48
|
+
// pro-features provides the sink (→ trackPromptEject); absent a provider this
|
|
49
|
+
// no-ops, so the menu stays usable outside the pro app.
|
|
50
|
+
const trackEject = useEjectTracker()
|
|
51
|
+
function fire(action: EjectTrackAction) {
|
|
52
|
+
trackEject({ action, kind: track?.kind, variant: track?.variant })
|
|
53
|
+
emit('select', action)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Per-user AI opt-out (written by useAiSettings via the dashboard layouts). When
|
|
57
|
+
// off, drop the LLM-bound eject items (MCP call + AI chat); cURL/CLI/schema stay.
|
|
58
|
+
const aiEnabled = useState<boolean>('pro:ai-enabled', () => true)
|
|
59
|
+
// Site layouts set this from the same monitoring policy used by navigation and
|
|
60
|
+
// server work fences. Non-site surfaces retain the compatibility default.
|
|
61
|
+
const siteAiAvailable = useState<boolean>('pro:ai-site-available', () => true)
|
|
62
|
+
|
|
63
|
+
async function copyAs(label: string, payload: string) {
|
|
64
|
+
await copy(payload)
|
|
65
|
+
toast.add({
|
|
66
|
+
title: `${label} copied`,
|
|
67
|
+
icon: 'check',
|
|
68
|
+
color: 'success',
|
|
69
|
+
duration: 2000,
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// URL contract is owned by layers/chat/app/internal/schemas/chat-deep-link.ts;
|
|
74
|
+
// the query itself is built by buildChatDeepLinkQuery so the surface's own query
|
|
75
|
+
// state (tabs, filters, sort) survives the eject.
|
|
76
|
+
const route = useRoute()
|
|
77
|
+
|
|
78
|
+
// Author-time guard for the entity mistake the ChatEntityRef template type
|
|
79
|
+
// can't catch: a raw URL, whose "https" prefix satisfies `${string}:${string}`
|
|
80
|
+
// but is no registered hydrate scheme — the eject would silently lose its
|
|
81
|
+
// grounding. Wrap with chatEntity.page(url) instead.
|
|
82
|
+
if (import.meta.dev && chat?.entity) {
|
|
83
|
+
const scheme = chat.entity.slice(0, chat.entity.indexOf(':'))
|
|
84
|
+
if (!/^[a-z][a-z-]*$/.test(scheme) || scheme === 'http' || scheme === 'https')
|
|
85
|
+
console.warn(`[UiEjectMenu] chat.entity "${chat.entity}" has no registered hydrate scheme — build it via chatEntity.* (eject-menu.ts) or hydration silently skips.`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildChatTarget(c: EjectChat): RouteLocationRaw {
|
|
89
|
+
// No explicit target: navigate to the current route with the deep-link keys
|
|
90
|
+
// merged over its existing query, which the in-place slide-over picks up and
|
|
91
|
+
// self-opens on.
|
|
92
|
+
if (!c.to)
|
|
93
|
+
return { query: buildChatDeepLinkQuery(route.query, c) }
|
|
94
|
+
if (typeof c.to === 'string')
|
|
95
|
+
return { path: c.to, query: buildChatDeepLinkQuery({}, c) }
|
|
96
|
+
return { ...c.to, query: buildChatDeepLinkQuery(c.to.query ?? {}, c) }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ADR-0124: the CLI leads the eject set. It is NOT gated on `aiEnabled` —
|
|
100
|
+
// a terminal command is not an LLM call, the same reason cURL/schema stay.
|
|
101
|
+
const cliCommand = computed<string | null>(() => mcp ? serializeCliCommand(mcp) : null)
|
|
102
|
+
|
|
103
|
+
const ejectItems = computed<DropdownMenuItem[]>(() => {
|
|
104
|
+
const out: DropdownMenuItem[] = []
|
|
105
|
+
if (cliCommand.value) {
|
|
106
|
+
const command = cliCommand.value
|
|
107
|
+
out.push({
|
|
108
|
+
label: 'Copy as CLI command',
|
|
109
|
+
icon: 'terminal',
|
|
110
|
+
onSelect: () => {
|
|
111
|
+
fire('copy-cli')
|
|
112
|
+
copyAs('CLI command', command)
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
if (mcp && aiEnabled.value) {
|
|
117
|
+
const payload = serializeMcpCall(mcp)
|
|
118
|
+
out.push({
|
|
119
|
+
label: 'Copy as MCP call',
|
|
120
|
+
icon: 'plug',
|
|
121
|
+
onSelect: () => {
|
|
122
|
+
fire('copy-mcp')
|
|
123
|
+
copyAs('MCP call', payload)
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
if (chat && aiEnabled.value && siteAiAvailable.value) {
|
|
128
|
+
out.push({
|
|
129
|
+
label: 'Open in AI Chat',
|
|
130
|
+
icon: 'bot',
|
|
131
|
+
to: buildChatTarget(chat),
|
|
132
|
+
onSelect: () => fire('open-chat'),
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
if (schema) {
|
|
136
|
+
out.push({
|
|
137
|
+
label: schema.label ?? 'View API schema',
|
|
138
|
+
icon: 'braces',
|
|
139
|
+
to: schema.to,
|
|
140
|
+
onSelect: () => fire('view-schema'),
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const actionItems = computed<DropdownMenuItem[]>(() =>
|
|
147
|
+
(actions ?? []).map(a => ({ label: a.label, icon: a.icon, disabled: a.disabled, onSelect: a.onSelect })),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
// Actions lead, eject-to-code follows; grouped (nested arrays) so @nuxt/ui draws
|
|
151
|
+
// a separator between them. Either group alone renders flat.
|
|
152
|
+
const items = computed<DropdownMenuItem[] | DropdownMenuItem[][]>(() => {
|
|
153
|
+
if (actionItems.value.length && ejectItems.value.length)
|
|
154
|
+
return [actionItems.value, ejectItems.value]
|
|
155
|
+
return actionItems.value.length ? actionItems.value : ejectItems.value
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// Render whenever there are items. We deliberately DON'T gate on clipboard
|
|
159
|
+
// `isSupported`: it's false during SSR and true on the client, so gating here
|
|
160
|
+
// makes the menu (and every reka dropdown id after it) diverge across hydration.
|
|
161
|
+
// `useClipboard({ legacy: true })` falls back to execCommand, so copy still works
|
|
162
|
+
// where the async Clipboard API is unavailable.
|
|
163
|
+
const hasAny = computed(() => actionItems.value.length > 0 || ejectItems.value.length > 0)
|
|
164
|
+
</script>
|
|
165
|
+
|
|
166
|
+
<template>
|
|
167
|
+
<UDropdownMenu
|
|
168
|
+
v-if="hasAny"
|
|
169
|
+
:items="items"
|
|
170
|
+
:content="{ align, sideOffset: 6 }"
|
|
171
|
+
:ui="{ content: 'ui-popover-content' }"
|
|
172
|
+
>
|
|
173
|
+
<UiButton
|
|
174
|
+
purpose="quiet"
|
|
175
|
+
:size="size"
|
|
176
|
+
icon="more-horizontal"
|
|
177
|
+
aria-label="Eject this view to code"
|
|
178
|
+
class="min-h-11 min-w-11 sm:min-h-0 sm:min-w-0"
|
|
179
|
+
/>
|
|
180
|
+
</UDropdownMenu>
|
|
181
|
+
</template>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { EjectChat, EjectLink, EjectMcp } from './eject-menu'
|
|
3
|
+
import { UiEjectMenu } from '#components'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hover-revealed eject affordance pinned to the top-right corner of a chart or
|
|
7
|
+
* card that has no header to host a `<UiEjectMenu>` (ProCardGsc, the CWV grid,
|
|
8
|
+
* funnels, …). Wrap the visual; the `⋯` overlays its corner, fading in on hover
|
|
9
|
+
* so it doesn't spend layout or attention until wanted.
|
|
10
|
+
*
|
|
11
|
+
* Touch devices (no hover) always show it — `[@media(hover:none)]` — and keyboard
|
|
12
|
+
* users get it via `focus-within`. The wrapper owns the positioning context, so
|
|
13
|
+
* the wrapped element must fill it (the default block flow does).
|
|
14
|
+
*/
|
|
15
|
+
defineProps<{
|
|
16
|
+
chat?: EjectChat
|
|
17
|
+
mcp?: EjectMcp
|
|
18
|
+
schema?: EjectLink
|
|
19
|
+
}>()
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<div class="group/eject relative">
|
|
24
|
+
<slot />
|
|
25
|
+
<div class="absolute top-2 right-2 z-10 opacity-0 transition-opacity group-hover/eject:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100">
|
|
26
|
+
<UiEjectMenu :chat="chat" :mcp="mcp" :schema="schema" />
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</template>
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, useAttrs, useTemplateRef, watch } from 'vue'
|
|
3
|
+
import { UiIcon } from '#components'
|
|
4
|
+
import { useFaviconBacking } from '../../composables/useFaviconBacking'
|
|
5
|
+
import { faviconFallbackInitial, faviconProxyPath, isBlankFaviconSize } from '../../utils/favicon-fallback'
|
|
6
|
+
|
|
7
|
+
// Three conditional root branches below (loading / fallback / favicon) mean
|
|
8
|
+
// Vue's automatic single-root attrs fallthrough is unreliable across SSR vs
|
|
9
|
+
// client hydration — callers commonly pass `class="shrink-0 rounded ..."`
|
|
10
|
+
// straight onto `<UiFavicon>`, and the SSR render silently dropped it while
|
|
11
|
+
// the client vdom kept it, producing a "Hydration class mismatch" warning on
|
|
12
|
+
// every favicon call site that passes a class (UiSkeleton uses the same fix
|
|
13
|
+
// for the same reason). Forward `class` explicitly and identically on every
|
|
14
|
+
// branch instead of relying on implicit fallthrough.
|
|
15
|
+
defineOptions({ inheritAttrs: false })
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Site favicon with a deterministic initials fallback. Google s2 favicons fail,
|
|
19
|
+
* 404 to a generic globe, or are indistinguishable across related domains, so on
|
|
20
|
+
* load error (or a missing domain) we render a neutral box with the registrable
|
|
21
|
+
* name's first letter. Always pair with the site/group label in the caller — the
|
|
22
|
+
* favicon is identity reinforcement, never the sole identifier.
|
|
23
|
+
*
|
|
24
|
+
* Accepts a bare hostname, a full URL, or an `sc-domain:` GSC property; all are
|
|
25
|
+
* normalised to a hostname, so callers don't need to pre-strip.
|
|
26
|
+
*
|
|
27
|
+
* Served through `/api/favicon` (same-origin proxy) so the image can be sampled
|
|
28
|
+
* on a canvas for an adaptive contrast tile — see useFaviconBacking. Favicons
|
|
29
|
+
* whose ink would vanish against the current theme (e.g. a black glyph on the
|
|
30
|
+
* dark sidebar) get a `bg-inverted` tile in that theme only; everything else
|
|
31
|
+
* renders bare and keeps its brand colour.
|
|
32
|
+
*/
|
|
33
|
+
const { domain, size = 20, alt = '', loading = false, decorative = false, fallbackLabel = '', fallbackSurface = 'neutral', dither = false } = defineProps<{
|
|
34
|
+
domain: string
|
|
35
|
+
size?: number
|
|
36
|
+
alt?: string
|
|
37
|
+
/** Render a spinner in place of the favicon (e.g. while the site is being
|
|
38
|
+
* validated/added). Keeps the same footprint so the row doesn't shift. */
|
|
39
|
+
loading?: boolean
|
|
40
|
+
/** The site name is already shown as adjacent text — mark the favicon
|
|
41
|
+
* `aria-hidden` so screen readers don't announce the host twice. */
|
|
42
|
+
decorative?: boolean
|
|
43
|
+
/** Prefer this visible Site label when deriving the fallback initial. */
|
|
44
|
+
fallbackLabel?: string
|
|
45
|
+
/** Let a first-party identity wrapper supply the fallback surface. */
|
|
46
|
+
fallbackSurface?: 'neutral' | 'transparent'
|
|
47
|
+
/**
|
|
48
|
+
* Quiet monochrome mark for rows that shouldn't compete with a heavier
|
|
49
|
+
* sibling column (Overview's Next Actions / Recent Activity beside Sites).
|
|
50
|
+
* CSS-only: desaturates and softens the image and stipples it with a fine
|
|
51
|
+
* dot mask. The fallback initial keeps the same opacity but no mask — a
|
|
52
|
+
* single letter has nothing to lose by disappearing into dots.
|
|
53
|
+
*/
|
|
54
|
+
dither?: boolean
|
|
55
|
+
}>()
|
|
56
|
+
|
|
57
|
+
const cleanDomain = computed(() =>
|
|
58
|
+
(domain ?? '')
|
|
59
|
+
.replace(/^sc-domain:/, '')
|
|
60
|
+
.replace(/^https?:\/\//, '')
|
|
61
|
+
.replace(/\/.*$/, '')
|
|
62
|
+
.trim(),
|
|
63
|
+
)
|
|
64
|
+
const src = computed(() => faviconProxyPath(cleanDomain.value))
|
|
65
|
+
|
|
66
|
+
// Decorative favicons (the caller already shows the site name as text) go
|
|
67
|
+
// `aria-hidden` so the host isn't announced twice; otherwise expose it as an image.
|
|
68
|
+
const a11y = computed(() =>
|
|
69
|
+
decorative
|
|
70
|
+
? { 'aria-hidden': 'true' as const }
|
|
71
|
+
: { 'role': 'img', 'aria-label': alt || cleanDomain.value || 'Unknown site' },
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
const failed = ref(false)
|
|
75
|
+
const faviconImage = useTemplateRef<HTMLImageElement>('faviconImage')
|
|
76
|
+
// SSR images can finish before hydration attaches load and error listeners.
|
|
77
|
+
// Reconcile their actual DOM state after each source update: a blank image
|
|
78
|
+
// falls back, and a loaded one is sampled for its backing, because its `load`
|
|
79
|
+
// event never reaches `handleLoad`.
|
|
80
|
+
watch([cleanDomain, faviconImage], () => {
|
|
81
|
+
const image = faviconImage.value
|
|
82
|
+
if (!image?.complete)
|
|
83
|
+
return
|
|
84
|
+
if (image.naturalWidth === 0 || isBlankFaviconSize(image.naturalWidth, image.naturalHeight))
|
|
85
|
+
failed.value = true
|
|
86
|
+
else
|
|
87
|
+
sample(image)
|
|
88
|
+
}, { flush: 'post' })
|
|
89
|
+
// Reset the error state if the domain changes (list rows reuse the node).
|
|
90
|
+
watch(cleanDomain, () => { failed.value = false })
|
|
91
|
+
|
|
92
|
+
const initial = computed(() => faviconFallbackInitial(fallbackLabel, cleanDomain.value))
|
|
93
|
+
const showFallback = computed(() => failed.value || !cleanDomain.value)
|
|
94
|
+
|
|
95
|
+
// We never touch the favicon — it always renders at full size and pops. The
|
|
96
|
+
// contrast fix is purely a background placed *behind* it, which shows through the
|
|
97
|
+
// favicon's transparent areas. `bg-inverted` is theme-aware (light on dark themes,
|
|
98
|
+
// dark on light themes) and we only ever render the chip in the theme where the
|
|
99
|
+
// favicon would vanish, so a single softened token covers both cases: the 80%
|
|
100
|
+
// opacity keeps it a muted chip rather than a stark card while staying light/dark
|
|
101
|
+
// enough for the glyph to read.
|
|
102
|
+
const { backing, sample } = useFaviconBacking(() => cleanDomain.value)
|
|
103
|
+
function handleLoad(event: Event) {
|
|
104
|
+
const image = event.currentTarget
|
|
105
|
+
if (!(image instanceof HTMLImageElement))
|
|
106
|
+
return
|
|
107
|
+
if (isBlankFaviconSize(image.naturalWidth, image.naturalHeight)) {
|
|
108
|
+
failed.value = true
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
sample(image)
|
|
112
|
+
}
|
|
113
|
+
const attrs = useAttrs()
|
|
114
|
+
</script>
|
|
115
|
+
|
|
116
|
+
<template>
|
|
117
|
+
<span
|
|
118
|
+
v-if="loading"
|
|
119
|
+
v-bind="attrs"
|
|
120
|
+
class="inline-flex items-center justify-center shrink-0 text-muted"
|
|
121
|
+
:style="{ width: `${size}px`, height: `${size}px` }"
|
|
122
|
+
role="img"
|
|
123
|
+
:aria-label="alt || cleanDomain || 'Loading'"
|
|
124
|
+
>
|
|
125
|
+
<UiIcon name="loading" class="animate-spin motion-reduce:animate-none" :style="{ width: `${size}px`, height: `${size}px` }" />
|
|
126
|
+
</span>
|
|
127
|
+
<span
|
|
128
|
+
v-else-if="showFallback"
|
|
129
|
+
v-bind="{ ...attrs, ...a11y }"
|
|
130
|
+
class="inline-flex items-center justify-center rounded border font-semibold shrink-0 leading-none select-none"
|
|
131
|
+
:class="[
|
|
132
|
+
fallbackSurface === 'transparent'
|
|
133
|
+
? 'border-transparent bg-transparent'
|
|
134
|
+
: 'border-default bg-elevated text-muted',
|
|
135
|
+
dither ? 'ui-favicon-dither-fallback' : '',
|
|
136
|
+
]"
|
|
137
|
+
:style="{ width: `${size}px`, height: `${size}px`, fontSize: `${Math.round(size * 0.5)}px` }"
|
|
138
|
+
>{{ initial }}</span>
|
|
139
|
+
<!--
|
|
140
|
+
Always the SAME element shape (span > img) regardless of `backing` — only
|
|
141
|
+
its classes react. `backing` flips from a client-only canvas sample
|
|
142
|
+
(`useFaviconBacking`) that can resolve via `onMounted`/`load` before a
|
|
143
|
+
later same-domain favicon elsewhere on the page finishes its OWN hydration
|
|
144
|
+
compare (Suspense-chunked hydration interleaves mount callbacks between
|
|
145
|
+
chunks). A `v-if`/`v-else` swap between a bare `<img>` and a wrapping
|
|
146
|
+
`<span>` turned that race into a STRUCTURAL SSR/client mismatch — extra
|
|
147
|
+
wrapper element, not just a class — which cascades into 6-7 "Hydration
|
|
148
|
+
node/children mismatch" warnings per row (every repeated favicon site
|
|
149
|
+
board / portfolio queue row renders). A class-only toggle can never
|
|
150
|
+
mismatch this way: attribute differences patch silently, node-count
|
|
151
|
+
differences don't happen because the node never comes or goes.
|
|
152
|
+
-->
|
|
153
|
+
<span
|
|
154
|
+
v-else
|
|
155
|
+
v-bind="attrs"
|
|
156
|
+
class="inline-grid place-items-center rounded shrink-0"
|
|
157
|
+
:class="[backing !== 'none' ? 'bg-inverted/80 ring-1 ring-default' : '', dither ? 'ui-favicon-dithered' : '']"
|
|
158
|
+
:style="{ width: `${size}px`, height: `${size}px` }"
|
|
159
|
+
>
|
|
160
|
+
<img
|
|
161
|
+
ref="faviconImage"
|
|
162
|
+
:src="src"
|
|
163
|
+
:alt="decorative ? '' : alt"
|
|
164
|
+
:aria-hidden="decorative ? 'true' : undefined"
|
|
165
|
+
:width="size"
|
|
166
|
+
:height="size"
|
|
167
|
+
loading="lazy"
|
|
168
|
+
decoding="async"
|
|
169
|
+
class="rounded"
|
|
170
|
+
:class="dither ? 'ui-favicon-dither' : ''"
|
|
171
|
+
@load="handleLoad"
|
|
172
|
+
@error="failed = true"
|
|
173
|
+
>
|
|
174
|
+
</span>
|
|
175
|
+
</template>
|
|
176
|
+
|
|
177
|
+
<style scoped>
|
|
178
|
+
/*
|
|
179
|
+
* `dither` — a quiet mark for rows beside a heavier sibling column. Two
|
|
180
|
+
* layers, both transitionable: the image loses most of its saturation and
|
|
181
|
+
* some contrast, and a 2px stipple of the page background sits over it so a
|
|
182
|
+
* saturated brand mark reads as texture rather than a pop of colour. Colour
|
|
183
|
+
* is muted, never removed: at a glance the mark stays recognisable. Hovering
|
|
184
|
+
* the row (any `a`, `button`, or `.group` ancestor) fades both layers away,
|
|
185
|
+
* so the favicon comes back to full colour under the pointer.
|
|
186
|
+
*/
|
|
187
|
+
.ui-favicon-dithered {
|
|
188
|
+
position: relative;
|
|
189
|
+
isolation: isolate;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.ui-favicon-dithered::after {
|
|
193
|
+
content: '';
|
|
194
|
+
position: absolute;
|
|
195
|
+
inset: 0;
|
|
196
|
+
border-radius: inherit;
|
|
197
|
+
background-image: radial-gradient(circle, var(--ui-bg) 38%, transparent 44%);
|
|
198
|
+
background-size: 2px 2px;
|
|
199
|
+
opacity: .55;
|
|
200
|
+
pointer-events: none;
|
|
201
|
+
transition: opacity 180ms var(--ease-standard, ease);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.ui-favicon-dither {
|
|
205
|
+
filter: saturate(.35) contrast(.9) opacity(.85);
|
|
206
|
+
transition: filter 180ms var(--ease-standard, ease);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
:is(a, button, .group):hover .ui-favicon-dithered::after {
|
|
210
|
+
opacity: 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
:is(a, button, .group):hover .ui-favicon-dither {
|
|
214
|
+
filter: none;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
@media (prefers-reduced-motion: reduce) {
|
|
218
|
+
.ui-favicon-dithered::after,
|
|
219
|
+
.ui-favicon-dither {
|
|
220
|
+
transition: none;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* The fallback initial is text, not an image — dropping its opacity to match
|
|
225
|
+
is enough to read as "quiet"; stippling a single letter makes it illegible
|
|
226
|
+
instead, which the dither treatment must never do. */
|
|
227
|
+
.ui-favicon-dither-fallback {
|
|
228
|
+
opacity: .7;
|
|
229
|
+
transition: opacity 180ms var(--ease-standard, ease);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
:is(a, button, .group):hover .ui-favicon-dither-fallback {
|
|
233
|
+
opacity: 1;
|
|
234
|
+
}
|
|
235
|
+
</style>
|