@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,106 @@
|
|
|
1
|
+
import type { InjectionKey } from 'vue'
|
|
2
|
+
import type { OnboardingAnswersDraft } from '../components/element/onboarding-answers'
|
|
3
|
+
import { inject, reactive } from 'vue'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The onboarding wizard's data layer as a swappable service, so the wizard +
|
|
7
|
+
* step components stay dumb (render state, call methods — no RPC). The brand-kit
|
|
8
|
+
* showcase drives the flow against `createInMemoryOnboardingService()`: it keeps
|
|
9
|
+
* its own reactive state and resolves everything locally, so the whole wizard
|
|
10
|
+
* runs e2e with zero network / no DB. The live app keeps its own RPC wiring; if
|
|
11
|
+
* it ever adopts this seam it provides an RPC-backed impl of the same interface.
|
|
12
|
+
*
|
|
13
|
+
* Pure data shapes + an in-memory impl — no runtime services — so it's safe in
|
|
14
|
+
* design-system and importable by brand-kit.
|
|
15
|
+
*/
|
|
16
|
+
export interface OnboardingSite {
|
|
17
|
+
id: string
|
|
18
|
+
url: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OnboardingInvite {
|
|
22
|
+
email: string
|
|
23
|
+
role: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface OnboardingState {
|
|
27
|
+
workspaceId: string | null
|
|
28
|
+
workspaceName: string | null
|
|
29
|
+
/** The "About you" Onboarding Answers. */
|
|
30
|
+
answers: OnboardingAnswersDraft
|
|
31
|
+
sites: OnboardingSite[]
|
|
32
|
+
connectedProviders: string[]
|
|
33
|
+
invites: OnboardingInvite[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface OnboardingService {
|
|
37
|
+
readonly state: OnboardingState
|
|
38
|
+
createWorkspace: (name: string) => Promise<{ id: string }>
|
|
39
|
+
validateSite: (url: string) => Promise<{ valid: boolean, normalizedUrl?: string, error?: string }>
|
|
40
|
+
addSites: (urls: string[]) => Promise<{ ids: string[] }>
|
|
41
|
+
saveAnswers: (answers: OnboardingAnswersDraft) => Promise<void>
|
|
42
|
+
connectProvider: (provider: string, payload?: Record<string, unknown>) => Promise<void>
|
|
43
|
+
isConnected: (provider: string) => boolean
|
|
44
|
+
invite: (email: string, role: string) => Promise<void>
|
|
45
|
+
finish: () => Promise<void>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const ONBOARDING_SERVICE: InjectionKey<OnboardingService> = Symbol('onboarding-service')
|
|
49
|
+
|
|
50
|
+
/** Injected service, or `null` when not inside an onboarding wizard. */
|
|
51
|
+
export function useOnboardingService(): OnboardingService | null {
|
|
52
|
+
return inject(ONBOARDING_SERVICE, null)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function emptyState(): OnboardingState {
|
|
56
|
+
return { workspaceId: null, workspaceName: null, answers: { intents: [], other: '' }, sites: [], connectedProviders: [], invites: [] }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* In-memory implementation: mutates its own reactive state, no network. The
|
|
61
|
+
* counter (not Math.random) keeps ids stable across SSR/hydration.
|
|
62
|
+
*/
|
|
63
|
+
export function createInMemoryOnboardingService(): OnboardingService {
|
|
64
|
+
const state = reactive<OnboardingState>(emptyState())
|
|
65
|
+
let seq = 0
|
|
66
|
+
const nextId = (prefix: string) => `${prefix}_mock_${++seq}`
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
state,
|
|
70
|
+
async createWorkspace(name) {
|
|
71
|
+
state.workspaceName = name
|
|
72
|
+
state.workspaceId = nextId('team')
|
|
73
|
+
return { id: state.workspaceId }
|
|
74
|
+
},
|
|
75
|
+
async validateSite(url) {
|
|
76
|
+
const trimmed = url.trim()
|
|
77
|
+
if (!trimmed)
|
|
78
|
+
return { valid: false, error: 'Enter a URL' }
|
|
79
|
+
return { valid: true, normalizedUrl: /^https?:\/\//.test(trimmed) ? trimmed : `https://${trimmed}` }
|
|
80
|
+
},
|
|
81
|
+
async addSites(urls) {
|
|
82
|
+
const ids = urls.map((url) => {
|
|
83
|
+
const id = nextId('site')
|
|
84
|
+
state.sites.push({ id, url })
|
|
85
|
+
return id
|
|
86
|
+
})
|
|
87
|
+
return { ids }
|
|
88
|
+
},
|
|
89
|
+
async saveAnswers(answers) {
|
|
90
|
+
state.answers = { ...answers, intents: [...answers.intents] }
|
|
91
|
+
},
|
|
92
|
+
async connectProvider(provider) {
|
|
93
|
+
if (!state.connectedProviders.includes(provider))
|
|
94
|
+
state.connectedProviders.push(provider)
|
|
95
|
+
},
|
|
96
|
+
isConnected(provider) {
|
|
97
|
+
return state.connectedProviders.includes(provider)
|
|
98
|
+
},
|
|
99
|
+
async invite(email, role) {
|
|
100
|
+
state.invites.push({ email, role })
|
|
101
|
+
},
|
|
102
|
+
async finish() {
|
|
103
|
+
// no-op: the showcase flow ends in place rather than navigating away.
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Ref, WatchSource } from 'vue'
|
|
2
|
+
import { readonly, ref, watch } from 'vue'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* True from the first time `open` reads true, and true after that.
|
|
6
|
+
*
|
|
7
|
+
* An open-on-demand overlay mounts its Lazy component behind this latch. Its
|
|
8
|
+
* chunk then stays out of the page's boot graph and the server render. After
|
|
9
|
+
* the first open the overlay stays mounted, so its leave transition plays and
|
|
10
|
+
* its own state survives a reopen.
|
|
11
|
+
*/
|
|
12
|
+
export function useOpenedOnce(open: WatchSource<boolean>): Readonly<Ref<boolean>> {
|
|
13
|
+
const opened = ref(false)
|
|
14
|
+
watch(open, (value) => {
|
|
15
|
+
if (value)
|
|
16
|
+
opened.value = true
|
|
17
|
+
}, { immediate: true, flush: 'sync' })
|
|
18
|
+
return readonly(opened)
|
|
19
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { MaybeRefOrGetter, Ref } from 'vue'
|
|
2
|
+
import { defaultWindow, useEventListener, useThrottleFn, useTimeoutFn } from '@vueuse/core'
|
|
3
|
+
import { nextTick, onMounted, ref, toValue } from 'vue'
|
|
4
|
+
|
|
5
|
+
// Define types for the sections
|
|
6
|
+
type SectionId = string
|
|
7
|
+
|
|
8
|
+
interface Section {
|
|
9
|
+
id: SectionId
|
|
10
|
+
label?: string
|
|
11
|
+
|
|
12
|
+
[key: string]: unknown
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type SectionInput = SectionId | Section
|
|
16
|
+
|
|
17
|
+
interface ScrollSpyOptions {
|
|
18
|
+
offsetPx?: number
|
|
19
|
+
throttleMs?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Return type of the composable
|
|
23
|
+
interface ScrollSpyReturn {
|
|
24
|
+
activeSection: Ref<SectionId>
|
|
25
|
+
scrollToSection: (sectionId: SectionId) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A composable for tracking which sections are currently visible in the viewport
|
|
30
|
+
* and providing navigation functionality.
|
|
31
|
+
*
|
|
32
|
+
* `sections` accepts a ref/getter so a dynamic section list (e.g. derived from
|
|
33
|
+
* async content) stays live — it is read via `toValue` on every recalculation
|
|
34
|
+
* rather than captured once at call time.
|
|
35
|
+
*
|
|
36
|
+
* @param sections The sections to track (reactive)
|
|
37
|
+
* @param options Configuration options
|
|
38
|
+
* @returns Active section and navigation methods
|
|
39
|
+
*/
|
|
40
|
+
export function useScrollSpy(sections: MaybeRefOrGetter<SectionInput[]>, options: ScrollSpyOptions = {}): ScrollSpyReturn {
|
|
41
|
+
const {
|
|
42
|
+
offsetPx = -10, // Offset from the top (e.g., for fixed headers)
|
|
43
|
+
throttleMs = 100, // Throttle milliseconds
|
|
44
|
+
} = options
|
|
45
|
+
|
|
46
|
+
const activeSection = ref<SectionId>('')
|
|
47
|
+
const isUserScrolling = ref(true) // Assume initial scroll is from user
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get section ID from either string or object
|
|
51
|
+
*/
|
|
52
|
+
function getSectionId(section: SectionInput): SectionId {
|
|
53
|
+
return typeof section === 'string' ? section : section.id
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Re-arm the "programmatic scroll done" flag after a smooth scroll settles.
|
|
57
|
+
// useTimeoutFn auto-cancels on scope dispose so it never fires into a torn-down instance.
|
|
58
|
+
const { start: startResetUserScrolling, stop: stopResetUserScrolling } = useTimeoutFn(() => {
|
|
59
|
+
isUserScrolling.value = true
|
|
60
|
+
}, 1000, { immediate: false })
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Scrolls the page to the selected section
|
|
64
|
+
*/
|
|
65
|
+
function scrollToSection(sectionId: SectionId): void {
|
|
66
|
+
// Set active section immediately on click
|
|
67
|
+
activeSection.value = sectionId
|
|
68
|
+
|
|
69
|
+
// Flag that next scrolls will be programmatic (not user-initiated)
|
|
70
|
+
isUserScrolling.value = false
|
|
71
|
+
|
|
72
|
+
const element = document.getElementById(sectionId)
|
|
73
|
+
if (element) {
|
|
74
|
+
// Get element position
|
|
75
|
+
const rect = element.getBoundingClientRect()
|
|
76
|
+
const absoluteTop = window.scrollY + rect.top - offsetPx
|
|
77
|
+
|
|
78
|
+
// Scroll smoothly, unless the user prefers reduced motion (then jump).
|
|
79
|
+
const prefersReduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true
|
|
80
|
+
window.scrollTo({
|
|
81
|
+
top: absoluteTop,
|
|
82
|
+
behavior: prefersReduced ? 'auto' : 'smooth',
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// Reset the flag after the smooth scroll is likely complete
|
|
86
|
+
stopResetUserScrolling()
|
|
87
|
+
startResetUserScrolling()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Calculates which section should be active based on scroll position
|
|
93
|
+
*/
|
|
94
|
+
function calculateActiveSection(): void {
|
|
95
|
+
// Only calculate if this is user scrolling, not programmatic
|
|
96
|
+
if (!isUserScrolling.value) {
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const sectionList = toValue(sections)
|
|
101
|
+
|
|
102
|
+
// Get all sections and their positions
|
|
103
|
+
const sectionElements: { id: string, top: number }[] = []
|
|
104
|
+
|
|
105
|
+
sectionList.forEach((section) => {
|
|
106
|
+
const id = getSectionId(section)
|
|
107
|
+
const element = document.getElementById(id)
|
|
108
|
+
if (element) {
|
|
109
|
+
const rect = element.getBoundingClientRect()
|
|
110
|
+
sectionElements.push({
|
|
111
|
+
id,
|
|
112
|
+
top: rect.top,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Sort sections by position (top to bottom)
|
|
118
|
+
sectionElements.sort((a, b) => a.top - b.top)
|
|
119
|
+
|
|
120
|
+
// Find the first section that is active
|
|
121
|
+
let activeSectionId: string | null
|
|
122
|
+
|
|
123
|
+
// First check sections that are in the viewport
|
|
124
|
+
const inViewport = sectionElements.filter(
|
|
125
|
+
section => section.top <= offsetPx && section.top > -100,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if (inViewport[0]) {
|
|
129
|
+
// If sections are in viewport, select the first one
|
|
130
|
+
activeSectionId = inViewport[0].id
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
// Otherwise, select the last section that's above the viewport
|
|
134
|
+
const aboveViewport = sectionElements.filter(section => section.top <= 0)
|
|
135
|
+
activeSectionId = aboveViewport.at(-1)?.id ?? sectionElements[0]?.id ?? null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (activeSectionId) {
|
|
139
|
+
activeSection.value = activeSectionId
|
|
140
|
+
}
|
|
141
|
+
else if (sectionList[0]) {
|
|
142
|
+
// Fallback to first section if nothing else is active
|
|
143
|
+
activeSection.value = getSectionId(sectionList[0])
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Throttled version of calculateActiveSection
|
|
148
|
+
const throttledCalculateActiveSection = useThrottleFn(calculateActiveSection, throttleMs)
|
|
149
|
+
|
|
150
|
+
// Handle user interaction with the page
|
|
151
|
+
function handleUserInteraction() {
|
|
152
|
+
isUserScrolling.value = true
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Bind listeners synchronously at setup so useEventListener registers its
|
|
156
|
+
// cleanup against the active effect scope (binding inside the async nextTick
|
|
157
|
+
// below would lose the scope and leak the listeners).
|
|
158
|
+
useEventListener(defaultWindow, 'scroll', throttledCalculateActiveSection, { passive: true })
|
|
159
|
+
useEventListener(defaultWindow, 'mousedown', handleUserInteraction, { passive: true })
|
|
160
|
+
useEventListener(defaultWindow, 'keydown', handleUserInteraction, { passive: true })
|
|
161
|
+
useEventListener(defaultWindow, 'touchstart', handleUserInteraction, { passive: true })
|
|
162
|
+
|
|
163
|
+
// Recalculate shortly after mount to catch dynamic content that shifts offsets.
|
|
164
|
+
const { start: startDeferredRecalc } = useTimeoutFn(calculateActiveSection, 300, { immediate: false })
|
|
165
|
+
|
|
166
|
+
onMounted(() => {
|
|
167
|
+
// Wait for DOM to be fully rendered before the first measurement
|
|
168
|
+
void nextTick(() => {
|
|
169
|
+
calculateActiveSection()
|
|
170
|
+
startDeferredRecalc()
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
activeSection,
|
|
176
|
+
scrollToSection,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { onMounted, ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Squircle fallback gate. Native CSS `corner-shape: squircle` (Chrome 139+)
|
|
5
|
+
* renders the squircle at SSR with a native border and zero hydration morph
|
|
6
|
+
* (see the `@supports` rule in global.css), so it needs no JS. Only where
|
|
7
|
+
* `corner-shape` is unsupported (Safari/Firefox) do consumers fall back to a
|
|
8
|
+
* generated `clip-path` + SVG-border overlay.
|
|
9
|
+
*
|
|
10
|
+
* `enabled` stays `false` on the server and through hydration (SSR can't
|
|
11
|
+
* feature-detect), then flips to `true` after mount on non-supporting
|
|
12
|
+
* browsers — so the fallback is a post-hydration enhancement, never a
|
|
13
|
+
* hydration mismatch.
|
|
14
|
+
*/
|
|
15
|
+
export function useSquircleFallback() {
|
|
16
|
+
const enabled = ref(false)
|
|
17
|
+
onMounted(() => {
|
|
18
|
+
enabled.value = !(typeof CSS !== 'undefined' && CSS.supports?.('corner-shape', 'squircle'))
|
|
19
|
+
})
|
|
20
|
+
return { enabled }
|
|
21
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { MaybeRefOrGetter, Ref, WritableComputedRef } from 'vue'
|
|
2
|
+
import type { LocationQuery } from 'vue-router'
|
|
3
|
+
import { navigateTo, useRoute, useRouter } from 'nuxt/app'
|
|
4
|
+
import { computed, onMounted, onScopeDispose, toValue, watch } from 'vue'
|
|
5
|
+
import { useOpenedOnce } from './useOpenedOnce'
|
|
6
|
+
|
|
7
|
+
export interface WizardRoute {
|
|
8
|
+
/** True while `?setup=<flowId>` is in the query and the flow is allowed. False strips `setup` and `step`; true restores the last step. */
|
|
9
|
+
open: WritableComputedRef<boolean>
|
|
10
|
+
/** `?step=<id>` while this flow is open. Set while closed, it is the step the next open starts at. */
|
|
11
|
+
step: WritableComputedRef<string | undefined>
|
|
12
|
+
/** True from the first open. Mount the Lazy flow behind it. */
|
|
13
|
+
opened: Readonly<Ref<boolean>>
|
|
14
|
+
/** The current path and query, plus `setup=<flowId>&step=<stepId>`. An OAuth flow returns here. */
|
|
15
|
+
returnTo: (stepId: string) => string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const FLOW_KEYS = ['setup', 'step'] as const
|
|
19
|
+
const HISTORY_MARK = 'setupFlow'
|
|
20
|
+
|
|
21
|
+
function withoutFlow(query: LocationQuery): LocationQuery {
|
|
22
|
+
const rest = { ...query }
|
|
23
|
+
for (const key of FLOW_KEYS)
|
|
24
|
+
delete rest[key]
|
|
25
|
+
return rest
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Backs a `UiWizardModal` with the query: `?setup=<flowId>&step=<id>`.
|
|
30
|
+
*
|
|
31
|
+
* A link, a notification, or an OAuth return can then open a flow at a step.
|
|
32
|
+
* An open from the page pushes one marked history entry, so Back closes the
|
|
33
|
+
* flow, on a phone too. A close goes back over a marked entry. Step changes
|
|
34
|
+
* replace it, so stepping through a flow adds no history entries.
|
|
35
|
+
*
|
|
36
|
+
* `allowed` gates write flows. If it is false, the flow never opens and an
|
|
37
|
+
* incoming `?setup=<flowId>` is stripped. If it is undefined, the answer is
|
|
38
|
+
* still loading: the flow stays closed and the deep link stays in the query.
|
|
39
|
+
*/
|
|
40
|
+
export function useWizardRoute(flowId: string, options: { allowed?: MaybeRefOrGetter<boolean | undefined> } = {}): WizardRoute {
|
|
41
|
+
const route = useRoute()
|
|
42
|
+
const router = useRouter()
|
|
43
|
+
|
|
44
|
+
const allowed = computed(() => 'allowed' in options ? toValue(options.allowed) : true)
|
|
45
|
+
const requested = computed(() => route.query.setup === flowId)
|
|
46
|
+
|
|
47
|
+
// An open from the page marks its history entry in history.state. A close
|
|
48
|
+
// then goes back over it, also after a reload or Forward. A deep link opens
|
|
49
|
+
// without the mark, and closes with a replace.
|
|
50
|
+
const onPushedEntry = () => (router.options.history.state as Record<string, unknown> | null)?.[HISTORY_MARK] === flowId
|
|
51
|
+
function replaceQuery(query: LocationQuery): void {
|
|
52
|
+
// A step change keeps the mark on the entry it replaces.
|
|
53
|
+
const state = onPushedEntry() ? { [HISTORY_MARK]: flowId } : undefined
|
|
54
|
+
void navigateTo({ query, hash: route.hash, state }, { replace: true })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// True from a close until a navigation settles. A second close in that time,
|
|
58
|
+
// such as Esc after Done, is dropped, so it cannot go back twice.
|
|
59
|
+
let closing = false
|
|
60
|
+
onScopeDispose(router.afterEach(() => {
|
|
61
|
+
closing = false
|
|
62
|
+
}))
|
|
63
|
+
// The step a reopen resumes, beside the form state the modal kept.
|
|
64
|
+
let lastStep: string | undefined
|
|
65
|
+
const open = computed<boolean>({
|
|
66
|
+
get: () => requested.value && allowed.value === true,
|
|
67
|
+
set: (value) => {
|
|
68
|
+
if (value === open.value || (value && allowed.value !== true))
|
|
69
|
+
return
|
|
70
|
+
if (!value) {
|
|
71
|
+
if (closing)
|
|
72
|
+
return
|
|
73
|
+
closing = true
|
|
74
|
+
if (onPushedEntry())
|
|
75
|
+
router.back()
|
|
76
|
+
else
|
|
77
|
+
replaceQuery(withoutFlow(route.query))
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
void navigateTo({
|
|
81
|
+
query: { ...withoutFlow(route.query), setup: flowId, ...(lastStep ? { step: lastStep } : {}) },
|
|
82
|
+
hash: route.hash,
|
|
83
|
+
state: { [HISTORY_MARK]: flowId },
|
|
84
|
+
})
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const step = computed<string | undefined>({
|
|
89
|
+
get: () => open.value && typeof route.query.step === 'string' ? route.query.step : undefined,
|
|
90
|
+
set: (id) => {
|
|
91
|
+
if (!open.value) {
|
|
92
|
+
lastStep = id
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
if (id === step.value)
|
|
96
|
+
return
|
|
97
|
+
const { step: _step, ...rest } = route.query
|
|
98
|
+
replaceQuery(id === undefined ? rest : { ...rest, step: id })
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
// Every way out, the Close button, Back, or a link, keeps the step the user
|
|
102
|
+
// was on.
|
|
103
|
+
watch(step, (id) => {
|
|
104
|
+
if (id !== undefined)
|
|
105
|
+
lastStep = id
|
|
106
|
+
}, { immediate: true, flush: 'sync' })
|
|
107
|
+
// A denied deep link leaves the query, so a reload or a shared URL cannot
|
|
108
|
+
// open it. On the client only: a server-side replace would redirect.
|
|
109
|
+
const denied = computed(() => requested.value && allowed.value === false)
|
|
110
|
+
const strip = () => {
|
|
111
|
+
if (denied.value)
|
|
112
|
+
replaceQuery(withoutFlow(route.query))
|
|
113
|
+
}
|
|
114
|
+
onMounted(strip)
|
|
115
|
+
watch(denied, strip)
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
open,
|
|
119
|
+
step,
|
|
120
|
+
opened: useOpenedOnce(open),
|
|
121
|
+
returnTo: stepId => router.resolve({ path: route.path, query: { ...withoutFlow(route.query), setup: flowId, step: stepId } }).fullPath,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineNuxtPlugin, useAppConfig, useHead } from 'nuxt/app'
|
|
2
|
+
import { useColorMode } from '#imports'
|
|
3
|
+
import { appFaviconLinks } from '../utils/app-favicon'
|
|
4
|
+
|
|
5
|
+
// Emits the favicon and touch icon links that app.config's `favicon` names,
|
|
6
|
+
// and swaps them with the colour mode. The layer ships no brand images, so an
|
|
7
|
+
// app that sets no `favicon` gets no links from here.
|
|
8
|
+
export default defineNuxtPlugin(() => {
|
|
9
|
+
const { favicon } = useAppConfig()
|
|
10
|
+
const colorMode = useColorMode()
|
|
11
|
+
|
|
12
|
+
useHead(() => ({
|
|
13
|
+
link: appFaviconLinks(favicon, colorMode.value === 'dark'),
|
|
14
|
+
}))
|
|
15
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { domAnimation, LazyMotion } from 'motion-v'
|
|
2
|
+
import { defineNuxtPlugin } from 'nuxt/app'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Animation features for every `m.*` component the layer renders, so an app
|
|
6
|
+
* that extends the layer needs no `<LazyMotion>` wrapper of its own.
|
|
7
|
+
*
|
|
8
|
+
* `vue:setup` runs inside the setup of NuxtRoot, the component above both
|
|
9
|
+
* `app.vue` and `error.vue`. Running LazyMotion's own setup there provides its
|
|
10
|
+
* features context from that component, as the wrapper would. motion-v does not
|
|
11
|
+
* export the context key, so the component's setup is the public way to
|
|
12
|
+
* provide it. A nested `<LazyMotion :features="domMax">` still overrides it for
|
|
13
|
+
* its subtree.
|
|
14
|
+
*/
|
|
15
|
+
export default defineNuxtPlugin({
|
|
16
|
+
name: 'design-system:lazy-motion',
|
|
17
|
+
setup(nuxtApp) {
|
|
18
|
+
nuxtApp.hook('vue:setup', () => {
|
|
19
|
+
LazyMotion.setup!({ features: domAnimation, strict: false }, { slots: {}, attrs: {}, emit: () => {}, expose: () => {} } as never)
|
|
20
|
+
})
|
|
21
|
+
},
|
|
22
|
+
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { useEventListener } from '@vueuse/core'
|
|
2
|
+
import { defineNuxtPlugin } from 'nuxt/app'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Highlighter text selection. Replaces the native highlight bar with a hand-drawn
|
|
6
|
+
* violet marker swipe. `::selection` can't do gradients / slanted ends / rough
|
|
7
|
+
* edges, so instead of styling the selection we paint an OVERLAY of `.mark` divs
|
|
8
|
+
* over the selection's per-line client-rects (`global.css` blanks the native
|
|
9
|
+
* highlight, gated on the `selection-hl-ready` class this plugin adds).
|
|
10
|
+
*
|
|
11
|
+
* Unlike a wrap-the-text-nodes approach, the overlay never mutates the document:
|
|
12
|
+
* the real selection, copy, keyboard extension and live Vue-rendered surfaces are
|
|
13
|
+
* all untouched, and the marker paints live during the drag. Marks are grouped
|
|
14
|
+
* into per-theme isolated sub-layers so a selection spanning a light→dark boundary
|
|
15
|
+
* blends correctly on both sides, and overlapping line-marks (which close the
|
|
16
|
+
* vertical gaps) never double-darken.
|
|
17
|
+
*/
|
|
18
|
+
export default defineNuxtPlugin(() => {
|
|
19
|
+
// Reduced motion: leave the native selection fully intact (no class, no overlay).
|
|
20
|
+
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
// Regions the overlay must skip: form controls + editable text (no stable text
|
|
24
|
+
// rects / shouldn't be marked) and code (keeps its own rangi styling). These
|
|
25
|
+
// keep the native highlight via the re-assert rules in global.css.
|
|
26
|
+
const EXCLUDE = 'input, textarea, [contenteditable], pre, code, .rangi'
|
|
27
|
+
const OX = 5 // horizontal overshoot (px) each side, so the swipe reads hand-drawn
|
|
28
|
+
const HFRAC = 1.06 // mark height vs line-box: >1 so adjacent lines meet
|
|
29
|
+
const TOPFRAC = -0.03 // nudge up so marks overlap and close the white gaps
|
|
30
|
+
|
|
31
|
+
const html = document.documentElement
|
|
32
|
+
html.classList.add('selection-hl-ready')
|
|
33
|
+
|
|
34
|
+
// The rough-edge filter can't live in CSS — inject it once, hidden.
|
|
35
|
+
const defs = document.createElement('div')
|
|
36
|
+
defs.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden'
|
|
37
|
+
defs.setAttribute('aria-hidden', 'true')
|
|
38
|
+
defs.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg">'
|
|
39
|
+
+ '<filter id="selection-hl-rough" x="-15%" y="-60%" width="130%" height="220%" color-interpolation-filters="sRGB">'
|
|
40
|
+
+ '<feTurbulence type="fractalNoise" baseFrequency="0.018 0.13" numOctaves="2" seed="7" result="n"/>'
|
|
41
|
+
+ '<feDisplacementMap in="SourceGraphic" in2="n" scale="3" xChannelSelector="R" yChannelSelector="G"/>'
|
|
42
|
+
+ '</filter></svg>'
|
|
43
|
+
document.body.appendChild(defs)
|
|
44
|
+
|
|
45
|
+
const layer = document.createElement('div')
|
|
46
|
+
layer.id = 'selection-hl-layer'
|
|
47
|
+
document.body.appendChild(layer)
|
|
48
|
+
|
|
49
|
+
let frame = 0
|
|
50
|
+
let settle = 0
|
|
51
|
+
|
|
52
|
+
// Deterministic per-line tilt so the marks don't reshuffle every repaint.
|
|
53
|
+
const tilt = (i: number): number => ((Math.sin(i * 12.9898) * 43758.5453) % 1) * 0.7 - 0.35
|
|
54
|
+
|
|
55
|
+
function isExcluded(node: Node | null): boolean {
|
|
56
|
+
const el = node instanceof Element ? node : node?.parentElement ?? null
|
|
57
|
+
return !!el?.closest(EXCLUDE)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface LineRect { left: number, right: number, top: number, bottom: number, ctx: Element | null }
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One merged rect per line of selected text (never block/margin rects), each
|
|
64
|
+
* tagged with the element it sits over so it can be themed correctly.
|
|
65
|
+
*/
|
|
66
|
+
function lineRects(range: Range): LineRect[] {
|
|
67
|
+
const scope = range.commonAncestorContainer
|
|
68
|
+
const root = scope.nodeType === Node.TEXT_NODE ? scope.parentNode! : scope
|
|
69
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
70
|
+
acceptNode(node) {
|
|
71
|
+
return (!node.nodeValue?.trim() || isExcluded(node) || !range.intersectsNode(node))
|
|
72
|
+
? NodeFilter.FILTER_REJECT
|
|
73
|
+
: NodeFilter.FILTER_ACCEPT
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
const lines = new Map<number, LineRect>()
|
|
77
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
78
|
+
const text = n as Text
|
|
79
|
+
const start = text === range.startContainer ? range.startOffset : 0
|
|
80
|
+
const end = text === range.endContainer ? range.endOffset : text.length
|
|
81
|
+
if (end <= start)
|
|
82
|
+
continue
|
|
83
|
+
const sub = document.createRange()
|
|
84
|
+
sub.setStart(text, start)
|
|
85
|
+
sub.setEnd(text, end)
|
|
86
|
+
for (const r of sub.getClientRects()) {
|
|
87
|
+
if (r.width < 1 || r.height < 2)
|
|
88
|
+
continue
|
|
89
|
+
const key = Math.round((r.top + r.bottom) / 2 / 3)
|
|
90
|
+
const cur = lines.get(key)
|
|
91
|
+
if (!cur) {
|
|
92
|
+
lines.set(key, { left: r.left, right: r.right, top: r.top, bottom: r.bottom, ctx: text.parentElement })
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
cur.left = Math.min(cur.left, r.left)
|
|
96
|
+
cur.right = Math.max(cur.right, r.right)
|
|
97
|
+
cur.top = Math.min(cur.top, r.top)
|
|
98
|
+
cur.bottom = Math.max(cur.bottom, r.bottom)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return [...lines.values()]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface Group { blend: string, op: string, hl: string, hl2: string, pool: string, streak: string, lines: LineRect[] }
|
|
106
|
+
|
|
107
|
+
function paint(): void {
|
|
108
|
+
layer.replaceChildren()
|
|
109
|
+
const sel = window.getSelection()
|
|
110
|
+
if (!sel || sel.isCollapsed || sel.rangeCount === 0)
|
|
111
|
+
return
|
|
112
|
+
const range = sel.getRangeAt(0)
|
|
113
|
+
if (isExcluded(range.commonAncestorContainer))
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
// Theme each line by the element it sits over, then group same-theme lines
|
|
117
|
+
// into one isolated, blended sub-layer.
|
|
118
|
+
const groups = new Map<string, Group>()
|
|
119
|
+
for (const ln of lineRects(range)) {
|
|
120
|
+
const cs = getComputedStyle(ln.ctx ?? html)
|
|
121
|
+
const blend = cs.getPropertyValue('--selection-blend').trim() || 'multiply'
|
|
122
|
+
const op = cs.getPropertyValue('--selection-op').trim() || '0.56'
|
|
123
|
+
const hl = cs.getPropertyValue('--selection-hl').trim()
|
|
124
|
+
const hl2 = cs.getPropertyValue('--selection-hl2').trim() || hl
|
|
125
|
+
const pool = cs.getPropertyValue('--selection-pool').trim()
|
|
126
|
+
const streak = cs.getPropertyValue('--selection-streak').trim()
|
|
127
|
+
const sig = `${blend}|${op}|${hl}|${hl2}|${pool}|${streak}`
|
|
128
|
+
let g = groups.get(sig)
|
|
129
|
+
if (!g) {
|
|
130
|
+
g = { blend, op, hl, hl2, pool, streak, lines: [] }
|
|
131
|
+
groups.set(sig, g)
|
|
132
|
+
}
|
|
133
|
+
g.lines.push(ln)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const g of groups.values()) {
|
|
137
|
+
const cont = document.createElement('div')
|
|
138
|
+
cont.className = 'group'
|
|
139
|
+
cont.style.mixBlendMode = g.blend
|
|
140
|
+
cont.style.opacity = g.op
|
|
141
|
+
let i = 0
|
|
142
|
+
for (const r of g.lines) {
|
|
143
|
+
const rh = r.bottom - r.top
|
|
144
|
+
const rw = r.right - r.left
|
|
145
|
+
const d = document.createElement('div')
|
|
146
|
+
d.className = 'mark'
|
|
147
|
+
d.style.setProperty('--mk-hl', g.hl)
|
|
148
|
+
d.style.setProperty('--mk-hl2', g.hl2)
|
|
149
|
+
d.style.setProperty('--mk-pool', g.pool)
|
|
150
|
+
d.style.setProperty('--mk-streak', g.streak)
|
|
151
|
+
d.style.left = `${r.left - OX}px`
|
|
152
|
+
d.style.top = `${r.top + rh * TOPFRAC}px`
|
|
153
|
+
d.style.width = `${rw + OX * 2}px`
|
|
154
|
+
d.style.height = `${rh * HFRAC}px`
|
|
155
|
+
d.style.transform = `rotate(${tilt(i)}deg)`
|
|
156
|
+
cont.appendChild(d)
|
|
157
|
+
i++
|
|
158
|
+
}
|
|
159
|
+
layer.appendChild(cont)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function schedule(): void {
|
|
164
|
+
cancelAnimationFrame(frame)
|
|
165
|
+
frame = requestAnimationFrame(paint)
|
|
166
|
+
// Smooth (no rough filter) while the selection is actively changing; snap the
|
|
167
|
+
// hand-drawn edge back in once it's been still for a beat.
|
|
168
|
+
layer.classList.remove('rough')
|
|
169
|
+
clearTimeout(settle)
|
|
170
|
+
settle = window.setTimeout(() => layer.classList.add('rough'), 150)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
useEventListener(document, 'selectionchange', schedule)
|
|
174
|
+
useEventListener(window, 'scroll', schedule, { capture: true, passive: true })
|
|
175
|
+
useEventListener(window, 'resize', schedule)
|
|
176
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a printed command invokes the CLI on a machine that may not have it.
|
|
3
|
+
*
|
|
4
|
+
* The package is `@nuxtseo/cli` (bin `nuxtseo`); nothing is published as
|
|
5
|
+
* `nuxtseo`, so `npx nuxtseo` is a 404. Every surface that prints a command
|
|
6
|
+
* with no install step above it spells the invocation this way so the line
|
|
7
|
+
* runs as written. A quick start that already installed the CLI may print the
|
|
8
|
+
* bare `nuxtseo` on the lines after the install.
|
|
9
|
+
*/
|
|
10
|
+
export const CLI_INVOCATION = 'npx @nuxtseo/cli'
|