@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,63 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { BrandProduct } from 'nuxtseo-shared/brand'
|
|
3
|
+
import { BRAND_PALETTE_DARK, BRAND_PALETTE_LIGHT, drawingToSvg, markDrawing } from 'nuxtseo-shared/brand'
|
|
4
|
+
import { computed, useId } from 'vue'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The summit: the pen climbs a foothill, then the Nuxt peak, and lands on the
|
|
8
|
+
* apex. Free lands on a green dot. Pro climbs in a violet gradient and lands on
|
|
9
|
+
* a violet dot. Ink follows the text colour. `animate` draws the climb once,
|
|
10
|
+
* then lands the dot.
|
|
11
|
+
*/
|
|
12
|
+
const { product = 'free', size = 28, palette = 'css', animate = false } = defineProps<{
|
|
13
|
+
product?: BrandProduct
|
|
14
|
+
/** Rendered size in px. 18 and below uses the heavier small cut. */
|
|
15
|
+
size?: number
|
|
16
|
+
palette?: 'css' | 'light' | 'dark'
|
|
17
|
+
animate?: boolean
|
|
18
|
+
}>()
|
|
19
|
+
|
|
20
|
+
// A page can show several marks, some hidden; each needs its own gradient id.
|
|
21
|
+
const id = useId()
|
|
22
|
+
const svg = computed(() => {
|
|
23
|
+
const brandPalette = palette === 'css' ? undefined : palette === 'dark' ? BRAND_PALETTE_DARK : BRAND_PALETTE_LIGHT
|
|
24
|
+
return drawingToSvg(markDrawing({ product, size }), { palette: brandPalette, idPrefix: `brand-mark-${id}`, width: size, height: size, attrs: 'aria-hidden="true"' })
|
|
25
|
+
})
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<!-- Trusted markup: drawingToSvg only interpolates the brand's own path data. -->
|
|
30
|
+
<span
|
|
31
|
+
data-ui="UiBrandMark"
|
|
32
|
+
class="ui-brand-mark inline-flex shrink-0 leading-none"
|
|
33
|
+
:class="{ 'ui-brand-mark--draw': animate }"
|
|
34
|
+
role="img"
|
|
35
|
+
:aria-label="product === 'pro' ? 'Nuxt SEO Pro' : 'Nuxt SEO'"
|
|
36
|
+
v-html="svg"
|
|
37
|
+
/>
|
|
38
|
+
</template>
|
|
39
|
+
|
|
40
|
+
<style scoped>
|
|
41
|
+
/* The climb is the second path (after the shadowed flank); the landing dot is the last circle. */
|
|
42
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
43
|
+
.ui-brand-mark--draw :deep(path:nth-of-type(2)) {
|
|
44
|
+
stroke-dasharray: 100;
|
|
45
|
+
stroke-dashoffset: 100;
|
|
46
|
+
animation: ui-brand-mark-draw 0.8s var(--ease-standard, ease-out) forwards;
|
|
47
|
+
}
|
|
48
|
+
.ui-brand-mark--draw :deep(circle:last-of-type) {
|
|
49
|
+
transform-box: fill-box;
|
|
50
|
+
transform-origin: center;
|
|
51
|
+
animation: ui-brand-mark-land 0.45s var(--ease-spring, ease-out) 0.7s both;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@keyframes ui-brand-mark-draw {
|
|
56
|
+
to { stroke-dashoffset: 0; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@keyframes ui-brand-mark-land {
|
|
60
|
+
from { transform: scale(0); }
|
|
61
|
+
to { transform: scale(1); }
|
|
62
|
+
}
|
|
63
|
+
</style>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { BrandLanding } from 'nuxtseo-shared/brand'
|
|
3
|
+
import { BRAND_PALETTE_DARK, BRAND_PALETTE_LIGHT, drawingToSvg, iconDrawing, isBrandIconName, markDrawing } from 'nuxtseo-shared/brand'
|
|
4
|
+
import { computed } from 'vue'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A Nuxt SEO module's hand-drawn icon from `nuxtseo-shared/brand`, or the summit
|
|
8
|
+
* mark for the `nuxt-seo` meta module. One even pen in the text colour; only the
|
|
9
|
+
* landing dot takes colour: `free` green when a module shipped it, `pro` violet
|
|
10
|
+
* when Pro read it back. An unknown slug renders nothing.
|
|
11
|
+
*
|
|
12
|
+
* `palette` swaps the theme-aware CSS paints for static colours, for renderers
|
|
13
|
+
* without CSS variables (OG images).
|
|
14
|
+
*/
|
|
15
|
+
const { module, landing = 'free', size = 20, palette = 'css' } = defineProps<{
|
|
16
|
+
module: string
|
|
17
|
+
landing?: BrandLanding
|
|
18
|
+
/** Rendered size in px. Smaller icons get a heavier pen and a bigger dot. */
|
|
19
|
+
size?: number
|
|
20
|
+
palette?: 'css' | 'light' | 'dark'
|
|
21
|
+
}>()
|
|
22
|
+
|
|
23
|
+
// The summit lands on its own dot; a list that hides inactive dots hides this one too.
|
|
24
|
+
function summit(landing: BrandLanding) {
|
|
25
|
+
const mark = markDrawing({ size })
|
|
26
|
+
if (landing !== 'none')
|
|
27
|
+
return mark
|
|
28
|
+
return { ...mark, groups: mark.groups.map(group => ({ ...group, shapes: group.shapes.filter(shape => shape._tag !== 'Dot') })) }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const svg = computed(() => {
|
|
32
|
+
const drawing = module === 'nuxt-seo'
|
|
33
|
+
? summit(landing)
|
|
34
|
+
: isBrandIconName(module) ? iconDrawing(module, { landing, size }) : undefined
|
|
35
|
+
if (!drawing)
|
|
36
|
+
return ''
|
|
37
|
+
const brandPalette = palette === 'css' ? undefined : palette === 'dark' ? BRAND_PALETTE_DARK : BRAND_PALETTE_LIGHT
|
|
38
|
+
return drawingToSvg(drawing, { palette: brandPalette, width: size, height: size })
|
|
39
|
+
})
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<!-- Trusted markup: drawingToSvg only interpolates the brand's own path data. -->
|
|
44
|
+
<span v-if="svg" data-ui="UiModuleIcon" class="inline-flex shrink-0 leading-none" aria-hidden="true" v-html="svg" />
|
|
45
|
+
</template>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { BRAND_PALETTE_DARK, BRAND_PALETTE_LIGHT, drawingToSvg, PRO_LETTERING_ASPECT, PRO_LETTERING_SCALE, proLetteringDrawing } from 'nuxtseo-shared/brand'
|
|
3
|
+
import { computed } from 'vue'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* "Pro", lettered with the module icon pen and underlined with one swipe.
|
|
7
|
+
* Set `size` to the wordmark's font size; the lettering scales from it.
|
|
8
|
+
*/
|
|
9
|
+
const { size = 18, palette = 'css' } = defineProps<{
|
|
10
|
+
/** Font size of the wordmark it sits beside, in px. */
|
|
11
|
+
size?: number
|
|
12
|
+
/** Static colours for renderers without CSS variables (OG images). */
|
|
13
|
+
palette?: 'css' | 'light' | 'dark'
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const svg = computed(() => {
|
|
17
|
+
const height = Math.round(size * PRO_LETTERING_SCALE)
|
|
18
|
+
const brandPalette = palette === 'css' ? undefined : palette === 'dark' ? BRAND_PALETTE_DARK : BRAND_PALETTE_LIGHT
|
|
19
|
+
return drawingToSvg(proLetteringDrawing(), { palette: brandPalette, width: Math.round(height * PRO_LETTERING_ASPECT), height, attrs: 'aria-hidden="true"' })
|
|
20
|
+
})
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<!-- Trusted markup: drawingToSvg only interpolates the brand's own path data. -->
|
|
25
|
+
<span data-ui="UiProLettering" class="inline-flex shrink-0 leading-none" role="img" aria-label="Pro" v-html="svg" />
|
|
26
|
+
</template>
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useElementSize } from '@vueuse/core'
|
|
3
|
+
import { m, useReducedMotion } from 'motion-v'
|
|
4
|
+
import { computed, onMounted, ref, useTemplateRef } from 'vue'
|
|
5
|
+
import { UiAtmosphere } from '#components'
|
|
6
|
+
import { RANGE_DEFAULT_SERIES, RANGE_RIDGE_OPACITY, RANGE_SHADE } from './range-glyphs'
|
|
7
|
+
|
|
8
|
+
// UiRangeTerrain — "the Range". Growth as a mountain range, drawn IN the same
|
|
9
|
+
// shade glyphs instead of an SVG fill — developed from the
|
|
10
|
+
// brand-kit sandbox's "Terrain" experiment (`apps/brand-kit/app/pages/topo-crt.vue`).
|
|
11
|
+
// It is a chart that happens to be beautiful: every ridge is the real series
|
|
12
|
+
// (later ridges are the same values read at a different phase, for depth —
|
|
13
|
+
// never fabricated), never decoration. Pure function of props for the DATA:
|
|
14
|
+
// no random, no Date, so the silhouette is deterministic. The glyph FONT SIZE
|
|
15
|
+
// is the one client-measured exception — a ResizeObserver sizes glyphs to the
|
|
16
|
+
// container's real pixel width (container-query units broke on the mono
|
|
17
|
+
// fallback stack, see the git history), so first paint briefly uses a static
|
|
18
|
+
// CSS fallback size until the client measurement lands.
|
|
19
|
+
//
|
|
20
|
+
// Layering (back → front): UiAtmosphere sun bloom + haze veil sit behind the
|
|
21
|
+
// glyphs; ridges stack front-over-back at opacity 1 / 0.65 / 0.4 (default 3),
|
|
22
|
+
// each built from the same interpolated series read at a different rotation.
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
series = RANGE_DEFAULT_SERIES,
|
|
26
|
+
ridges = 3,
|
|
27
|
+
columns = 48,
|
|
28
|
+
height = 240,
|
|
29
|
+
sun = 'dusk',
|
|
30
|
+
haze = false,
|
|
31
|
+
animate = true,
|
|
32
|
+
pulse = 0,
|
|
33
|
+
fill = false,
|
|
34
|
+
} = defineProps<{
|
|
35
|
+
/** The trend the range renders. Later ridges are the same values, phase-shifted — real data, never fabricated. */
|
|
36
|
+
series?: number[]
|
|
37
|
+
/** Number of layered ridgelines, front to back. */
|
|
38
|
+
ridges?: number
|
|
39
|
+
/** Grid columns — one sample per column after interpolating `series`. */
|
|
40
|
+
columns?: number
|
|
41
|
+
/** Rendered height in px. */
|
|
42
|
+
height?: number
|
|
43
|
+
/** Sun bloom behind the range. 'none' renders no bloom (for the ink/OG ground). */
|
|
44
|
+
sun?: 'dusk' | 'dawn' | 'none'
|
|
45
|
+
/** Soft mist veil over the whole range, for a hazier read. */
|
|
46
|
+
haze?: boolean
|
|
47
|
+
/** Bottom-up build reveal + slow ridge drift. Reduced motion is always static regardless of this prop. */
|
|
48
|
+
animate?: boolean
|
|
49
|
+
/** Bump this number to fire one shimmer sweep across the range. */
|
|
50
|
+
pulse?: number
|
|
51
|
+
/** Stretch to fill (and bottom-anchor within) the nearest positioned ancestor — for a hero/CTA/OG backdrop use, instead of the default in-flow box. */
|
|
52
|
+
fill?: boolean
|
|
53
|
+
}>()
|
|
54
|
+
|
|
55
|
+
const reduced = useReducedMotion()
|
|
56
|
+
const canAnimate = computed(() => animate && !reduced.value)
|
|
57
|
+
|
|
58
|
+
// Font sizing: measure the real pixel width of the mono glyph stack once
|
|
59
|
+
// (a probe span at a fixed reference size), then derive the exact font-size
|
|
60
|
+
// that makes `columns` characters span the terrain's real measured width.
|
|
61
|
+
// Re-derives on resize via useElementSize — no container-query units.
|
|
62
|
+
const terrainRef = useTemplateRef<HTMLElement>('terrainRef')
|
|
63
|
+
const probeRef = useTemplateRef<HTMLElement>('probeRef')
|
|
64
|
+
const { width: measuredWidth } = useElementSize(terrainRef)
|
|
65
|
+
const GLYPH_PROBE_SIZE = 100
|
|
66
|
+
const glyphWidthRatio = ref<number | null>(null)
|
|
67
|
+
|
|
68
|
+
onMounted(() => {
|
|
69
|
+
const probeWidth = probeRef.value?.getBoundingClientRect().width
|
|
70
|
+
if (probeWidth)
|
|
71
|
+
glyphWidthRatio.value = probeWidth / GLYPH_PROBE_SIZE
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// Width is the only free dimension: `columns` samples must span the measured
|
|
75
|
+
// box width exactly, monospace. (Row COUNT is then derived from this size
|
|
76
|
+
// below — it is not a second independent input, or the two fought each other:
|
|
77
|
+
// a fixed row-count heuristic forced a row height the width-driven font-size
|
|
78
|
+
// couldn't fit, clipping every row or leaving a bare gap down one side.)
|
|
79
|
+
const fontSizePx = computed(() => {
|
|
80
|
+
if (!measuredWidth.value || !glyphWidthRatio.value)
|
|
81
|
+
return null
|
|
82
|
+
const cols = Math.max(1, Math.round(columns))
|
|
83
|
+
const size = measuredWidth.value / cols / glyphWidthRatio.value
|
|
84
|
+
return Math.max(4, Math.min(48, size))
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
function lerp(a: number, b: number, t: number): number {
|
|
88
|
+
return a + (b - a) * t
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Resample `series` (any length) onto exactly `columns` samples — one sample
|
|
92
|
+
// per column, per the contract. Plain linear interpolation, deterministic.
|
|
93
|
+
function interpolateSeries(values: number[], targetLength: number): number[] {
|
|
94
|
+
const n = values.length
|
|
95
|
+
if (n === 0)
|
|
96
|
+
return Array.from<number>({ length: targetLength }).fill(0)
|
|
97
|
+
if (targetLength <= 1)
|
|
98
|
+
return [values[n - 1] ?? 0]
|
|
99
|
+
return Array.from({ length: targetLength }, (_, i) => {
|
|
100
|
+
const t = (i / (targetLength - 1)) * (n - 1)
|
|
101
|
+
const lo = Math.floor(t)
|
|
102
|
+
const hi = Math.min(n - 1, lo + 1)
|
|
103
|
+
const frac = t - lo
|
|
104
|
+
const a = values[lo] ?? 0
|
|
105
|
+
const b = values[hi] ?? a
|
|
106
|
+
return a + (b - a) * frac
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Circular rotation — a ridge "behind" the front one reads the same real
|
|
111
|
+
// values at a different phase, which is what makes the layered silhouette
|
|
112
|
+
// read as depth without inventing a single number.
|
|
113
|
+
function rotate(values: number[], offset: number): number[] {
|
|
114
|
+
const n = values.length
|
|
115
|
+
if (n === 0)
|
|
116
|
+
return values
|
|
117
|
+
return Array.from({ length: n }, (_, i) => values[(((i + offset) % n) + n) % n] ?? 0)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Character rows for the grid — an independent resolution choice, bounded so
|
|
121
|
+
// a tiny/huge `height` still renders sensibly. The CSS grid track height
|
|
122
|
+
// (below) is always exactly `height / rows`, whatever that number is — even
|
|
123
|
+
// where a row's own glyph (sized off `columns`, above) renders taller than
|
|
124
|
+
// its track, `overflow: hidden` crops each row to its own slim band, which
|
|
125
|
+
// for a solid block glyph just reads as a finer-grained texture. Decoupling
|
|
126
|
+
// rows from the glyph's font-size (instead of trying to make one row's track
|
|
127
|
+
// equal the glyph's natural line height) is what gives a wide, short box
|
|
128
|
+
// enough vertical resolution to read as a ridgeline instead of a stair-step.
|
|
129
|
+
const totalRows = computed(() => Math.max(14, Math.min(48, Math.round(height / 8))))
|
|
130
|
+
|
|
131
|
+
interface RidgeLayer {
|
|
132
|
+
rows: string[]
|
|
133
|
+
opacity: number
|
|
134
|
+
z: number
|
|
135
|
+
driftPx: number
|
|
136
|
+
driftDuration: number
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const ridgeLayers = computed<RidgeLayer[]>(() => {
|
|
140
|
+
const count = Math.max(1, Math.round(ridges))
|
|
141
|
+
const interpolated = interpolateSeries(series, Math.max(1, Math.round(columns)))
|
|
142
|
+
const max = Math.max(...series, 1)
|
|
143
|
+
const n = interpolated.length
|
|
144
|
+
const rows = totalRows.value
|
|
145
|
+
|
|
146
|
+
return Array.from({ length: count }, (_, k) => {
|
|
147
|
+
// k: 0 = furthest back, count-1 = front (the undistorted series).
|
|
148
|
+
const t = count > 1 ? k / (count - 1) : 1
|
|
149
|
+
const offset = Math.round((count - 1 - k) * n / (count * 3))
|
|
150
|
+
const values = rotate(interpolated, offset)
|
|
151
|
+
|
|
152
|
+
// Back ridges sit high with little relief (compressed silhouette); the
|
|
153
|
+
// front ridge has a low baseline and the full amplitude (dramatic peaks).
|
|
154
|
+
// Baseline+amplitude never reach 1: sky stays visible above every ridge,
|
|
155
|
+
// even at its own tallest column.
|
|
156
|
+
const baselineFrac = lerp(0.3, 0.08, t)
|
|
157
|
+
const ampFrac = lerp(0.2, 0.55, t)
|
|
158
|
+
|
|
159
|
+
const heights = values.map((v) => {
|
|
160
|
+
const norm = max > 0 ? v / max : 0
|
|
161
|
+
const frac = Math.min(1, baselineFrac + ampFrac * norm)
|
|
162
|
+
return Math.round(frac * rows)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// Build rows top-to-bottom; each filled cell shades denser toward the
|
|
166
|
+
// baseline and lighter toward that column's own peak tip.
|
|
167
|
+
const gridRows = Array.from({ length: rows }, (_, r) => {
|
|
168
|
+
const fromBottom = rows - 1 - r
|
|
169
|
+
return heights
|
|
170
|
+
.map((h) => {
|
|
171
|
+
if (fromBottom >= h)
|
|
172
|
+
return ' '
|
|
173
|
+
const depth = h > 0 ? fromBottom / h : 0
|
|
174
|
+
const shadeIdx = Math.max(0, RANGE_SHADE.length - 1 - Math.floor(depth * RANGE_SHADE.length))
|
|
175
|
+
return RANGE_SHADE[shadeIdx]
|
|
176
|
+
})
|
|
177
|
+
.join('')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const frontRank = count - 1 - k
|
|
181
|
+
return {
|
|
182
|
+
rows: gridRows,
|
|
183
|
+
opacity: RANGE_RIDGE_OPACITY[Math.min(RANGE_RIDGE_OPACITY.length - 1, frontRank)] ?? 0.15,
|
|
184
|
+
z: k + 1,
|
|
185
|
+
driftPx: k % 2 === 0 ? 4 + frontRank : -(4 + frontRank),
|
|
186
|
+
driftDuration: lerp(19, 12, t),
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const terrainStyle = computed(() => ({
|
|
192
|
+
'height': `${height}px`,
|
|
193
|
+
'--terrain-rows': String(totalRows.value),
|
|
194
|
+
...(fontSizePx.value ? { fontSize: `${fontSizePx.value}px` } : {}),
|
|
195
|
+
}))
|
|
196
|
+
</script>
|
|
197
|
+
|
|
198
|
+
<template>
|
|
199
|
+
<div class="range-terrain-wrap" :class="{ 'range-terrain-wrap--fill': fill }">
|
|
200
|
+
<span v-if="$slots.label" class="sr-only"><slot name="label" /></span>
|
|
201
|
+
<div ref="terrainRef" class="range-terrain" aria-hidden="true" :style="terrainStyle">
|
|
202
|
+
<span ref="probeRef" class="terrain-probe" aria-hidden="true">0</span>
|
|
203
|
+
<UiAtmosphere v-if="sun !== 'none'" :palette="sun" geometry="bloom" intensity="subtle" />
|
|
204
|
+
<UiAtmosphere v-if="haze" palette="mist" geometry="veil" intensity="subtle" />
|
|
205
|
+
|
|
206
|
+
<m.div
|
|
207
|
+
v-for="ridge in ridgeLayers"
|
|
208
|
+
:key="ridge.z"
|
|
209
|
+
class="terrain-ridge"
|
|
210
|
+
:style="{ opacity: ridge.opacity, zIndex: ridge.z }"
|
|
211
|
+
:animate="canAnimate ? { x: [0, ridge.driftPx, 0] } : {}"
|
|
212
|
+
:transition="canAnimate ? { repeat: Infinity, duration: ridge.driftDuration, ease: 'easeInOut' } : { duration: 0 }"
|
|
213
|
+
>
|
|
214
|
+
<m.div
|
|
215
|
+
v-for="(row, r) in ridge.rows"
|
|
216
|
+
:key="r"
|
|
217
|
+
class="terrain-row"
|
|
218
|
+
:initial="canAnimate ? { opacity: 0 } : false"
|
|
219
|
+
:while-in-view="{ opacity: 1 }"
|
|
220
|
+
:in-view-options="{ once: true }"
|
|
221
|
+
:transition="canAnimate ? { delay: (ridge.rows.length - 1 - r) * 0.018, duration: 0.25 } : { duration: 0 }"
|
|
222
|
+
>
|
|
223
|
+
{{ row }}
|
|
224
|
+
</m.div>
|
|
225
|
+
</m.div>
|
|
226
|
+
|
|
227
|
+
<m.div
|
|
228
|
+
v-if="pulse > 0"
|
|
229
|
+
:key="`shimmer-${pulse}`"
|
|
230
|
+
class="terrain-shimmer"
|
|
231
|
+
:initial="{ x: '-40%', opacity: 1 }"
|
|
232
|
+
:animate="{ x: '140%' }"
|
|
233
|
+
:transition="{ duration: 1.1, ease: 'easeInOut' }"
|
|
234
|
+
/>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
</template>
|
|
238
|
+
|
|
239
|
+
<style scoped>
|
|
240
|
+
.range-terrain-wrap {
|
|
241
|
+
position: relative;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* `fill` prop — stretch to the nearest positioned ancestor, bottom-anchoring
|
|
245
|
+
the (fixed-height) terrain box inside it. A modifier class defined here,
|
|
246
|
+
not fought over with a consumer's own scoped override: two same-specificity
|
|
247
|
+
selectors from different SFCs race on load order, and the LATER-loaded
|
|
248
|
+
stylesheet wins regardless of which file the consumer expects to win. */
|
|
249
|
+
.range-terrain-wrap--fill {
|
|
250
|
+
position: absolute;
|
|
251
|
+
inset: 0;
|
|
252
|
+
display: flex;
|
|
253
|
+
flex-direction: column;
|
|
254
|
+
justify-content: flex-end;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.range-terrain {
|
|
258
|
+
position: relative;
|
|
259
|
+
overflow: hidden;
|
|
260
|
+
isolation: isolate;
|
|
261
|
+
font-size: 0.85rem; /* static fallback until the client measurement lands */
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.terrain-probe {
|
|
265
|
+
position: absolute;
|
|
266
|
+
visibility: hidden;
|
|
267
|
+
pointer-events: none;
|
|
268
|
+
top: 0;
|
|
269
|
+
left: 0;
|
|
270
|
+
font-family: var(--font-mono);
|
|
271
|
+
font-size: 100px;
|
|
272
|
+
line-height: 1;
|
|
273
|
+
white-space: pre;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.terrain-ridge {
|
|
277
|
+
position: absolute;
|
|
278
|
+
inset: 0;
|
|
279
|
+
display: grid;
|
|
280
|
+
/* Explicit even row tracks — a flex column with percentage flex-basis
|
|
281
|
+
resolves against an INDEFINITE parent height as content-sized (each row
|
|
282
|
+
falls back to its own font-metric height), so the ridge's natural height
|
|
283
|
+
silently overflowed the box by 2x+ and got clipped to only its densest
|
|
284
|
+
baseline rows, reading as one solid fill with no sky. Grid tracks divide
|
|
285
|
+
the box's real height exactly, regardless of font metrics. */
|
|
286
|
+
grid-template-rows: repeat(var(--terrain-rows), 1fr);
|
|
287
|
+
pointer-events: none;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.terrain-row {
|
|
291
|
+
display: flex;
|
|
292
|
+
align-items: center;
|
|
293
|
+
overflow: hidden;
|
|
294
|
+
font-family: var(--font-mono);
|
|
295
|
+
font-variant-ligatures: none;
|
|
296
|
+
font-size: 1em;
|
|
297
|
+
line-height: 1;
|
|
298
|
+
white-space: pre;
|
|
299
|
+
color: var(--ui-color-primary-400);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.terrain-shimmer {
|
|
303
|
+
position: absolute;
|
|
304
|
+
inset: 0;
|
|
305
|
+
z-index: 20;
|
|
306
|
+
width: 30%;
|
|
307
|
+
pointer-events: none;
|
|
308
|
+
background: linear-gradient(105deg, transparent, color-mix(in oklch, white 45%, transparent), transparent);
|
|
309
|
+
mix-blend-mode: overlay;
|
|
310
|
+
}
|
|
311
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Shared glyph vocabulary + defaults for "the Range" brand system
|
|
2
|
+
// (UiRangeTerrain). Kept in a plain module (not the `<script
|
|
3
|
+
// setup>` block) so these can be referenced as prop defaults without hitting
|
|
4
|
+
// the temporal-dead-zone ordering `defineProps` compiles to when it must lead
|
|
5
|
+
// the script block.
|
|
6
|
+
|
|
7
|
+
// Four mono shade characters, lightest to densest — the vocabulary the
|
|
8
|
+
// brand-kit sandbox established (`apps/brand-kit/app/pages/topo-crt.vue`,
|
|
9
|
+
// "ASCII mark" + "Terrain" sections).
|
|
10
|
+
export const RANGE_SHADE = ['░', '▒', '▓', '█'] as const
|
|
11
|
+
|
|
12
|
+
// The 18-point click trend from the sandbox's Terrain experiment — the
|
|
13
|
+
// default series UiRangeTerrain renders when a host doesn't pass its own.
|
|
14
|
+
export const RANGE_DEFAULT_SERIES = [12, 18, 15, 22, 28, 24, 33, 30, 38, 44, 40, 52, 48, 61, 58, 73, 69, 88]
|
|
15
|
+
|
|
16
|
+
// Front-to-back ridge opacity ladder — exact for the default 3 ridges,
|
|
17
|
+
// extends gracefully for a caller that asks for more.
|
|
18
|
+
export const RANGE_RIDGE_OPACITY = [1, 0.65, 0.4, 0.28, 0.18]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Browser-like presentation shell shared by product and tools surfaces.
|
|
3
|
+
interface Tab {
|
|
4
|
+
id: string
|
|
5
|
+
label: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
filename?: string
|
|
10
|
+
tabs?: Tab[]
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const activeTab = defineModel<string>('activeTab', { default: '' })
|
|
14
|
+
|
|
15
|
+
// Initialize activeTab to first tab if not set
|
|
16
|
+
if (!activeTab.value && props.tabs?.[0]) {
|
|
17
|
+
activeTab.value = props.tabs[0].id
|
|
18
|
+
}
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<div>
|
|
23
|
+
<div class="relative">
|
|
24
|
+
<div class="absolute -inset-8 -z-10 pointer-events-none">
|
|
25
|
+
<div class="blob blob-1" />
|
|
26
|
+
<div class="blob blob-2" />
|
|
27
|
+
<div class="blob blob-3" />
|
|
28
|
+
<div class="blob blob-4" />
|
|
29
|
+
</div>
|
|
30
|
+
<div class="bg-default border border-default rounded-2xl overflow-hidden">
|
|
31
|
+
<div class="bg-elevated px-4 py-3 border-b border-default flex items-center gap-2">
|
|
32
|
+
<div class="flex gap-1.5">
|
|
33
|
+
<div class="size-3 rounded-full bg-red-400/60" />
|
|
34
|
+
<div class="size-3 rounded-full bg-yellow-400/60" />
|
|
35
|
+
<div class="size-3 rounded-full bg-green-400/60" />
|
|
36
|
+
</div>
|
|
37
|
+
<template v-if="tabs?.length">
|
|
38
|
+
<div class="flex ml-2">
|
|
39
|
+
<button
|
|
40
|
+
v-for="tab in tabs"
|
|
41
|
+
:key="tab.id"
|
|
42
|
+
type="button"
|
|
43
|
+
class="px-3 py-1 text-xs font-mono rounded-t border-x border-t transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-border-accented)]"
|
|
44
|
+
:class="activeTab === tab.id
|
|
45
|
+
? 'bg-default border-default text-default'
|
|
46
|
+
: 'bg-transparent border-transparent text-dimmed hover:text-muted'"
|
|
47
|
+
@click="activeTab = tab.id"
|
|
48
|
+
>
|
|
49
|
+
{{ tab.label }}
|
|
50
|
+
</button>
|
|
51
|
+
</div>
|
|
52
|
+
</template>
|
|
53
|
+
<span v-else-if="filename" class="text-xs text-dimmed font-mono ml-2">{{ filename }}</span>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="px-6 py-4 font-mono text-sm text-muted space-y-2">
|
|
56
|
+
<template v-if="tabs?.length">
|
|
57
|
+
<template v-for="tab in tabs" :key="tab.id">
|
|
58
|
+
<div v-show="activeTab === tab.id">
|
|
59
|
+
<slot :name="tab.id" />
|
|
60
|
+
</div>
|
|
61
|
+
</template>
|
|
62
|
+
</template>
|
|
63
|
+
<slot v-else />
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</template>
|
|
69
|
+
|
|
70
|
+
<style scoped>
|
|
71
|
+
.blob {
|
|
72
|
+
position: absolute;
|
|
73
|
+
border-radius: 9999px;
|
|
74
|
+
filter: blur(48px);
|
|
75
|
+
opacity: 0.2;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.blob-1 {
|
|
79
|
+
width: 300px;
|
|
80
|
+
height: 300px;
|
|
81
|
+
left: -50px;
|
|
82
|
+
top: -50px;
|
|
83
|
+
background-color: var(--ui-color-info-500);
|
|
84
|
+
animation: blob1 8s ease-in-out infinite;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.blob-2 {
|
|
88
|
+
width: 350px;
|
|
89
|
+
height: 350px;
|
|
90
|
+
right: -80px;
|
|
91
|
+
bottom: -80px;
|
|
92
|
+
background-color: var(--ui-color-primary-500);
|
|
93
|
+
animation: blob2 10s ease-in-out infinite;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.blob-3 {
|
|
97
|
+
width: 250px;
|
|
98
|
+
height: 250px;
|
|
99
|
+
left: 30%;
|
|
100
|
+
top: 20%;
|
|
101
|
+
background-color: var(--ui-color-info-400);
|
|
102
|
+
animation: blob3 12s ease-in-out infinite;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.blob-4 {
|
|
106
|
+
width: 200px;
|
|
107
|
+
height: 200px;
|
|
108
|
+
right: 20%;
|
|
109
|
+
top: -30px;
|
|
110
|
+
background-color: var(--ui-color-primary-400);
|
|
111
|
+
animation: blob4 9s ease-in-out infinite;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* The blobs loop forever beside content (WCAG 2.2.2, 2.3.3): reduced motion
|
|
115
|
+
holds them still at their first frame. */
|
|
116
|
+
@media (prefers-reduced-motion: reduce) {
|
|
117
|
+
.blob-1,
|
|
118
|
+
.blob-2,
|
|
119
|
+
.blob-3,
|
|
120
|
+
.blob-4 {
|
|
121
|
+
animation: none;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@keyframes blob1 {
|
|
126
|
+
0%, 100% { transform: translate(0, 0) scale(1); }
|
|
127
|
+
50% { transform: translate(60px, 40px) scale(1.3); }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
@keyframes blob2 {
|
|
131
|
+
0%, 100% { transform: translate(0, 0) scale(1.2); }
|
|
132
|
+
50% { transform: translate(-50px, -60px) scale(0.9); }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@keyframes blob3 {
|
|
136
|
+
0%, 100% { transform: translate(0, 0) scale(0.9); }
|
|
137
|
+
33% { transform: translate(40px, -30px) scale(1.2); }
|
|
138
|
+
66% { transform: translate(-40px, 30px) scale(0.9); }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@keyframes blob4 {
|
|
142
|
+
0%, 100% { transform: translate(0, 0) scale(1); }
|
|
143
|
+
50% { transform: translate(-40px, 50px) scale(0.8); }
|
|
144
|
+
}
|
|
145
|
+
</style>
|