@moonarc/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/package.json +228 -0
- package/src/components/Accordion.astro +110 -0
- package/src/components/Accordion.meta.ts +33 -0
- package/src/components/AnimatedBeam.astro +217 -0
- package/src/components/AnimatedBeam.meta.ts +39 -0
- package/src/components/AnimatedIcons.astro +193 -0
- package/src/components/AnimatedIcons.meta.ts +34 -0
- package/src/components/AnimatedList.astro +305 -0
- package/src/components/AnimatedList.meta.ts +36 -0
- package/src/components/AnimatedSelect.astro +227 -0
- package/src/components/AnimatedSelect.meta.ts +38 -0
- package/src/components/ArrowButton.astro +133 -0
- package/src/components/ArrowButton.meta.ts +32 -0
- package/src/components/Aurora.astro +188 -0
- package/src/components/Aurora.meta.ts +36 -0
- package/src/components/AvatarStack.astro +205 -0
- package/src/components/AvatarStack.meta.ts +37 -0
- package/src/components/BarGrow.astro +123 -0
- package/src/components/BarGrow.meta.ts +42 -0
- package/src/components/BorderBeam.astro +75 -0
- package/src/components/BorderBeam.meta.ts +33 -0
- package/src/components/BrowserFrame.astro +172 -0
- package/src/components/BrowserFrame.meta.ts +36 -0
- package/src/components/BubbleText.astro +90 -0
- package/src/components/BubbleText.meta.ts +38 -0
- package/src/components/CardStack.astro +79 -0
- package/src/components/CardStack.meta.ts +33 -0
- package/src/components/Carousel.astro +147 -0
- package/src/components/Carousel.meta.ts +35 -0
- package/src/components/CheckDraw.astro +159 -0
- package/src/components/CheckDraw.meta.ts +37 -0
- package/src/components/CircularText.astro +103 -0
- package/src/components/CircularText.meta.ts +38 -0
- package/src/components/CodeTabs.astro +213 -0
- package/src/components/CodeTabs.meta.ts +36 -0
- package/src/components/CommandPalette.astro +363 -0
- package/src/components/CommandPalette.meta.ts +40 -0
- package/src/components/Confetti.astro +143 -0
- package/src/components/Confetti.meta.ts +40 -0
- package/src/components/CopyButton.astro +189 -0
- package/src/components/CopyButton.meta.ts +41 -0
- package/src/components/CountUp.astro +134 -0
- package/src/components/CountUp.meta.ts +40 -0
- package/src/components/CountdownFlip.astro +244 -0
- package/src/components/CountdownFlip.meta.ts +39 -0
- package/src/components/CounterPreloader.astro +122 -0
- package/src/components/CounterPreloader.meta.ts +34 -0
- package/src/components/Cursor.astro +177 -0
- package/src/components/Cursor.meta.ts +37 -0
- package/src/components/CursorGrid.astro +113 -0
- package/src/components/CursorGrid.meta.ts +37 -0
- package/src/components/Curtain.astro +305 -0
- package/src/components/Curtain.meta.ts +38 -0
- package/src/components/Dialog.astro +156 -0
- package/src/components/Dialog.meta.ts +37 -0
- package/src/components/Dock.astro +193 -0
- package/src/components/Dock.meta.ts +35 -0
- package/src/components/DotPattern.astro +58 -0
- package/src/components/DotPattern.meta.ts +32 -0
- package/src/components/DottedMap.astro +153 -0
- package/src/components/DottedMap.meta.ts +38 -0
- package/src/components/Drawer.astro +196 -0
- package/src/components/Drawer.meta.ts +35 -0
- package/src/components/DynamicIsland.astro +122 -0
- package/src/components/DynamicIsland.meta.ts +35 -0
- package/src/components/FileTree.astro +207 -0
- package/src/components/FileTree.meta.ts +34 -0
- package/src/components/FilterGrid.astro +170 -0
- package/src/components/FilterGrid.meta.ts +38 -0
- package/src/components/FooterReveal.astro +54 -0
- package/src/components/FooterReveal.meta.ts +31 -0
- package/src/components/GlitchText.astro +145 -0
- package/src/components/GlitchText.meta.ts +39 -0
- package/src/components/Globe.astro +304 -0
- package/src/components/Globe.meta.ts +41 -0
- package/src/components/GlyphField.astro +363 -0
- package/src/components/GlyphField.meta.ts +53 -0
- package/src/components/GradientText.astro +63 -0
- package/src/components/GradientText.meta.ts +35 -0
- package/src/components/GridPattern.astro +55 -0
- package/src/components/GridPattern.meta.ts +32 -0
- package/src/components/HighlightMarker.astro +68 -0
- package/src/components/HighlightMarker.meta.ts +37 -0
- package/src/components/HorizontalScroll.astro +112 -0
- package/src/components/HorizontalScroll.meta.ts +34 -0
- package/src/components/HoverImageLinks.astro +219 -0
- package/src/components/HoverImageLinks.meta.ts +34 -0
- package/src/components/HoverVideo.astro +179 -0
- package/src/components/HoverVideo.meta.ts +37 -0
- package/src/components/ImageCompare.astro +212 -0
- package/src/components/ImageCompare.meta.ts +39 -0
- package/src/components/ImageTrail.astro +169 -0
- package/src/components/ImageTrail.meta.ts +36 -0
- package/src/components/Lightbox.astro +236 -0
- package/src/components/Lightbox.meta.ts +40 -0
- package/src/components/LinkPreview.astro +234 -0
- package/src/components/LinkPreview.meta.ts +39 -0
- package/src/components/LiquidGlass.astro +148 -0
- package/src/components/LiquidGlass.meta.ts +37 -0
- package/src/components/Magnetic.astro +92 -0
- package/src/components/Magnetic.meta.ts +31 -0
- package/src/components/Marquee.astro +165 -0
- package/src/components/Marquee.meta.ts +38 -0
- package/src/components/Meteors.astro +156 -0
- package/src/components/Meteors.meta.ts +36 -0
- package/src/components/Noise.astro +61 -0
- package/src/components/Noise.meta.ts +33 -0
- package/src/components/NumberFlow.astro +143 -0
- package/src/components/NumberFlow.meta.ts +42 -0
- package/src/components/Orb.astro +130 -0
- package/src/components/Orb.meta.ts +39 -0
- package/src/components/OrbitingCircles.astro +119 -0
- package/src/components/OrbitingCircles.meta.ts +36 -0
- package/src/components/OtpInput.astro +215 -0
- package/src/components/OtpInput.meta.ts +42 -0
- package/src/components/PageTransition.astro +121 -0
- package/src/components/PageTransition.meta.ts +33 -0
- package/src/components/Parallax.astro +69 -0
- package/src/components/Parallax.meta.ts +32 -0
- package/src/components/Particles.astro +210 -0
- package/src/components/Particles.meta.ts +37 -0
- package/src/components/PersistPlayer.astro +126 -0
- package/src/components/PersistPlayer.meta.ts +35 -0
- package/src/components/PhoneFrame.astro +161 -0
- package/src/components/PhoneFrame.meta.ts +35 -0
- package/src/components/PillNav.astro +170 -0
- package/src/components/PillNav.meta.ts +37 -0
- package/src/components/Popover.astro +95 -0
- package/src/components/Popover.meta.ts +34 -0
- package/src/components/PriceSwitch.astro +251 -0
- package/src/components/PriceSwitch.meta.ts +41 -0
- package/src/components/Probe.astro +84 -0
- package/src/components/Progress.astro +165 -0
- package/src/components/Progress.meta.ts +40 -0
- package/src/components/ProgressiveBlur.astro +95 -0
- package/src/components/ProgressiveBlur.meta.ts +36 -0
- package/src/components/PromptInput.astro +273 -0
- package/src/components/PromptInput.meta.ts +46 -0
- package/src/components/ReadingToc.astro +153 -0
- package/src/components/ReadingToc.meta.ts +34 -0
- package/src/components/RetroGrid.astro +84 -0
- package/src/components/RetroGrid.meta.ts +36 -0
- package/src/components/Reveal.astro +292 -0
- package/src/components/Reveal.meta.ts +48 -0
- package/src/components/Ripple.astro +94 -0
- package/src/components/Ripple.meta.ts +34 -0
- package/src/components/RotatingText.astro +170 -0
- package/src/components/RotatingText.meta.ts +35 -0
- package/src/components/ScrollExpand.astro +90 -0
- package/src/components/ScrollExpand.meta.ts +35 -0
- package/src/components/ScrollProgress.astro +73 -0
- package/src/components/ScrollProgress.meta.ts +32 -0
- package/src/components/ScrollScrub.astro +96 -0
- package/src/components/ScrollScrub.meta.ts +37 -0
- package/src/components/ShimmerButton.astro +109 -0
- package/src/components/ShimmerButton.meta.ts +37 -0
- package/src/components/ShinyText.astro +72 -0
- package/src/components/ShinyText.meta.ts +35 -0
- package/src/components/Skeleton.astro +76 -0
- package/src/components/Skeleton.meta.ts +32 -0
- package/src/components/Sparkline.astro +168 -0
- package/src/components/Sparkline.meta.ts +43 -0
- package/src/components/Spinner.astro +79 -0
- package/src/components/Spinner.meta.ts +33 -0
- package/src/components/SplitFlap.astro +157 -0
- package/src/components/SplitFlap.meta.ts +39 -0
- package/src/components/SplitText.astro +208 -0
- package/src/components/SplitText.meta.ts +50 -0
- package/src/components/Spotlight.astro +92 -0
- package/src/components/Spotlight.meta.ts +34 -0
- package/src/components/StaggerMenu.astro +301 -0
- package/src/components/StaggerMenu.meta.ts +39 -0
- package/src/components/StatefulButton.astro +148 -0
- package/src/components/StatefulButton.meta.ts +35 -0
- package/src/components/StickyBanner.astro +168 -0
- package/src/components/StickyBanner.meta.ts +45 -0
- package/src/components/StickyReveal.astro +256 -0
- package/src/components/StickyReveal.meta.ts +33 -0
- package/src/components/StrokeDraw.astro +110 -0
- package/src/components/StrokeDraw.meta.ts +43 -0
- package/src/components/Switch.astro +165 -0
- package/src/components/Switch.meta.ts +37 -0
- package/src/components/Tabs.astro +194 -0
- package/src/components/Tabs.meta.ts +47 -0
- package/src/components/TextRoll.astro +78 -0
- package/src/components/TextRoll.meta.ts +36 -0
- package/src/components/TextScramble.astro +107 -0
- package/src/components/TextScramble.meta.ts +35 -0
- package/src/components/ThemeScript.astro +17 -0
- package/src/components/ThemeToggle.astro +182 -0
- package/src/components/ThemeToggle.meta.ts +34 -0
- package/src/components/Tilt.astro +97 -0
- package/src/components/Tilt.meta.ts +34 -0
- package/src/components/Toast.astro +184 -0
- package/src/components/Toast.meta.ts +33 -0
- package/src/components/TracingBeam.astro +113 -0
- package/src/components/TracingBeam.meta.ts +34 -0
- package/src/components/Typewriter.astro +124 -0
- package/src/components/Typewriter.meta.ts +39 -0
- package/src/components/UnderlineHover.astro +61 -0
- package/src/components/UnderlineHover.meta.ts +34 -0
- package/src/components/VanishInput.astro +240 -0
- package/src/components/VanishInput.meta.ts +41 -0
- package/src/components/VideoDialog.astro +230 -0
- package/src/components/VideoDialog.meta.ts +40 -0
- package/src/components/WeightWave.astro +70 -0
- package/src/components/WeightWave.meta.ts +36 -0
- package/src/components/internal/DialogMorph.astro +97 -0
- package/src/components/internal/PromptInputSubmitOnEnter.astro +32 -0
- package/src/index.d.ts +43 -0
- package/src/index.js +60 -0
- package/src/lib/canvas.ts +124 -0
- package/src/lib/confetti.ts +127 -0
- package/src/lib/contrast.ts +116 -0
- package/src/lib/form.ts +231 -0
- package/src/lib/geo.ts +77 -0
- package/src/lib/gsap.ts +58 -0
- package/src/lib/land.ts +11 -0
- package/src/lib/lenis.ts +112 -0
- package/src/lib/meta.ts +79 -0
- package/src/lib/morph.ts +74 -0
- package/src/lib/reduced-motion-plugin.js +299 -0
- package/src/lib/runtime.ts +215 -0
- package/src/lib/support.ts +102 -0
- package/src/lib/theme.ts +18 -0
- package/src/lib/toast.ts +17 -0
- package/src/styles/base.css +218 -0
- package/src/styles/motion.css +43 -0
- package/src/styles/theme.css +397 -0
- package/src/styles/theme.tailwind.css +75 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* PromptInput — the AI prompt box, zero JS by default. A small <form>: a
|
|
4
|
+
* <textarea> that grows with its content (`field-sizing: content`, capped at
|
|
5
|
+
* `maxRows`, scrolling past it; without field-sizing it keeps `rows` and a
|
|
6
|
+
* vertical resize handle), a tool row with a model select (AnimatedSelect,
|
|
7
|
+
* `name="model"`), an attachment button that is a <label> around a real
|
|
8
|
+
* `<input type="file" multiple>` — visually hidden, still focusable — and
|
|
9
|
+
* a send button that dims through `:has(textarea:placeholder-shown)` and
|
|
10
|
+
* shows a spinner while the form carries `data-pending` (the
|
|
11
|
+
* NewsletterSection contract). `submitOnEnter` renders one internal
|
|
12
|
+
* component with a measured script: Enter sends, Shift+Enter breaks the
|
|
13
|
+
* line, an IME composition is never interrupted.
|
|
14
|
+
*/
|
|
15
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
16
|
+
import AnimatedSelect from './AnimatedSelect.astro';
|
|
17
|
+
import Spinner from './Spinner.astro';
|
|
18
|
+
import PromptInputSubmitOnEnter from './internal/PromptInputSubmitOnEnter.astro';
|
|
19
|
+
|
|
20
|
+
interface Model {
|
|
21
|
+
value: string;
|
|
22
|
+
label: string;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
group?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Props extends HTMLAttributes<'form'> {
|
|
28
|
+
/** The textarea's name. */
|
|
29
|
+
name?: string;
|
|
30
|
+
placeholder?: string;
|
|
31
|
+
/** Initial text. */
|
|
32
|
+
value?: string;
|
|
33
|
+
/** Rows shown when empty; the minimum height. */
|
|
34
|
+
rows?: number;
|
|
35
|
+
/** Rows the box grows to before it scrolls. */
|
|
36
|
+
maxRows?: number;
|
|
37
|
+
/** Models for the select (name="model"); none hides the select. */
|
|
38
|
+
models?: Model[];
|
|
39
|
+
/** The selected model. */
|
|
40
|
+
model?: string;
|
|
41
|
+
/** Show the attachment button (a real file input, name="files", multiple). */
|
|
42
|
+
attachments?: boolean;
|
|
43
|
+
/** Accept list for the file input. */
|
|
44
|
+
accept?: string;
|
|
45
|
+
/** Enter submits, Shift+Enter breaks the line; adds the measured script. */
|
|
46
|
+
submitOnEnter?: boolean;
|
|
47
|
+
/** Accessible name of the textarea. */
|
|
48
|
+
label?: string;
|
|
49
|
+
required?: boolean;
|
|
50
|
+
disabled?: boolean;
|
|
51
|
+
/** Send button label (visually hidden). */
|
|
52
|
+
sendLabel?: string;
|
|
53
|
+
/** Attachment button label (visually hidden). */
|
|
54
|
+
attachLabel?: string;
|
|
55
|
+
/** Accessible name of the model select. */
|
|
56
|
+
modelLabel?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const {
|
|
60
|
+
name = 'prompt',
|
|
61
|
+
placeholder = 'Ask anything',
|
|
62
|
+
value,
|
|
63
|
+
rows = 1,
|
|
64
|
+
maxRows = 8,
|
|
65
|
+
models,
|
|
66
|
+
model,
|
|
67
|
+
attachments = false,
|
|
68
|
+
accept,
|
|
69
|
+
submitOnEnter = false,
|
|
70
|
+
label = 'Message',
|
|
71
|
+
required = true,
|
|
72
|
+
disabled = false,
|
|
73
|
+
sendLabel = 'Send',
|
|
74
|
+
attachLabel = 'Attach files',
|
|
75
|
+
modelLabel = 'Model',
|
|
76
|
+
class: className,
|
|
77
|
+
style,
|
|
78
|
+
...rest
|
|
79
|
+
} = Astro.props;
|
|
80
|
+
const vars = [`--ma-prompt-rows:${Math.max(1, rows)}`, `--ma-prompt-max:${Math.max(rows, maxRows)}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
<form class:list={['ma-prompt', className]} style={vars} {...rest}>
|
|
84
|
+
<textarea class="ma-prompt__area" name={name} rows={Math.max(1, rows)} placeholder={placeholder} aria-label={label} required={required} disabled={disabled} set:text={value ?? ''} />
|
|
85
|
+
<div class="ma-prompt__bar">
|
|
86
|
+
{models && models.length > 0 && <AnimatedSelect class="ma-prompt__model" name="model" options={models} value={model} size="sm" aria-label={modelLabel} disabled={disabled} />}
|
|
87
|
+
{attachments && (
|
|
88
|
+
<label class="ma-prompt__attach">
|
|
89
|
+
<input type="file" name="files" class="ma-prompt__file" multiple accept={accept} disabled={disabled} />
|
|
90
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.5 12.2 21.3a5.5 5.5 0 0 1-7.8-7.8L13 4.9a3.7 3.7 0 0 1 5.2 5.2l-8.6 8.6a1.8 1.8 0 0 1-2.6-2.6l7.9-7.9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" /></svg>
|
|
91
|
+
<span class="ma-prompt__sr">{attachLabel}</span>
|
|
92
|
+
</label>
|
|
93
|
+
)}
|
|
94
|
+
<button type="submit" class="ma-prompt__send" disabled={disabled}>
|
|
95
|
+
<svg class="ma-prompt__arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5m0 0-6 6m6-6 6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>
|
|
96
|
+
<span class="ma-prompt__busy"><Spinner size={16} width={2} label="" /></span>
|
|
97
|
+
<span class="ma-prompt__sr">{sendLabel}</span>
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
{submitOnEnter && <PromptInputSubmitOnEnter />}
|
|
101
|
+
</form>
|
|
102
|
+
|
|
103
|
+
<style is:global>
|
|
104
|
+
@layer components {
|
|
105
|
+
:where(.ma-prompt) {
|
|
106
|
+
display: grid;
|
|
107
|
+
gap: 0.5em;
|
|
108
|
+
padding: 0.75em 0.75em 0.6em;
|
|
109
|
+
border: 1px solid var(--ma-edge);
|
|
110
|
+
border-radius: 1.1em;
|
|
111
|
+
background: var(--ma-panel);
|
|
112
|
+
color: inherit;
|
|
113
|
+
box-shadow: 0 1px 2px var(--ma-glow);
|
|
114
|
+
transition: border-color var(--ma-duration-fast) var(--ma-ease-out);
|
|
115
|
+
}
|
|
116
|
+
:where(.ma-prompt:focus-within) {
|
|
117
|
+
border-color: color-mix(in srgb, var(--ma-ink) 40%, transparent);
|
|
118
|
+
}
|
|
119
|
+
:where(.ma-prompt__area) {
|
|
120
|
+
inline-size: 100%;
|
|
121
|
+
margin: 0;
|
|
122
|
+
padding: 0.25em 0.35em;
|
|
123
|
+
border: 0;
|
|
124
|
+
background: transparent;
|
|
125
|
+
color: inherit;
|
|
126
|
+
font: inherit;
|
|
127
|
+
line-height: 1.5;
|
|
128
|
+
resize: vertical;
|
|
129
|
+
outline: none;
|
|
130
|
+
/* grows with the text to maxRows, then scrolls; the minimum is the rows attribute */
|
|
131
|
+
field-sizing: content;
|
|
132
|
+
min-block-size: calc(var(--ma-prompt-rows) * 1.5em + 0.5em);
|
|
133
|
+
max-block-size: calc(var(--ma-prompt-max) * 1.5em + 0.5em);
|
|
134
|
+
}
|
|
135
|
+
@supports (field-sizing: content) {
|
|
136
|
+
:where(.ma-prompt__area) {
|
|
137
|
+
resize: none;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
:where(.ma-prompt__area)::placeholder {
|
|
141
|
+
color: color-mix(in srgb, var(--ma-ink) 40%, transparent);
|
|
142
|
+
opacity: 1;
|
|
143
|
+
}
|
|
144
|
+
:where(.ma-prompt__area:disabled) {
|
|
145
|
+
opacity: 0.5;
|
|
146
|
+
cursor: not-allowed;
|
|
147
|
+
}
|
|
148
|
+
:where(.ma-prompt__bar) {
|
|
149
|
+
display: flex;
|
|
150
|
+
align-items: center;
|
|
151
|
+
gap: 0.5em;
|
|
152
|
+
}
|
|
153
|
+
:where(.ma-prompt__model) {
|
|
154
|
+
margin-inline-end: auto;
|
|
155
|
+
}
|
|
156
|
+
:where(.ma-prompt__bar > :last-child) {
|
|
157
|
+
margin-inline-start: auto;
|
|
158
|
+
}
|
|
159
|
+
:where(.ma-prompt__attach),
|
|
160
|
+
:where(.ma-prompt__send) {
|
|
161
|
+
position: relative;
|
|
162
|
+
display: grid;
|
|
163
|
+
place-items: center;
|
|
164
|
+
inline-size: 2.25em;
|
|
165
|
+
block-size: 2.25em;
|
|
166
|
+
margin: 0;
|
|
167
|
+
padding: 0;
|
|
168
|
+
border: 1px solid transparent;
|
|
169
|
+
border-radius: 50%;
|
|
170
|
+
background: transparent;
|
|
171
|
+
color: inherit;
|
|
172
|
+
font: inherit;
|
|
173
|
+
cursor: pointer;
|
|
174
|
+
transition:
|
|
175
|
+
background-color var(--ma-duration-fast) var(--ma-ease-out),
|
|
176
|
+
opacity var(--ma-duration-fast) var(--ma-ease-out),
|
|
177
|
+
scale var(--ma-duration-fast) var(--ma-ease-out);
|
|
178
|
+
}
|
|
179
|
+
:where(.ma-prompt__attach > svg),
|
|
180
|
+
:where(.ma-prompt__send > svg) {
|
|
181
|
+
inline-size: 1.15em;
|
|
182
|
+
block-size: 1.15em;
|
|
183
|
+
grid-area: 1 / 1;
|
|
184
|
+
}
|
|
185
|
+
:where(.ma-prompt__attach:hover) {
|
|
186
|
+
background: color-mix(in srgb, var(--ma-ink) 8%, transparent);
|
|
187
|
+
}
|
|
188
|
+
/* the file input: off screen, not display: none — Tab reaches it and Space opens the picker; the label draws the focus */
|
|
189
|
+
:where(.ma-prompt__file) {
|
|
190
|
+
position: absolute;
|
|
191
|
+
inline-size: 1px;
|
|
192
|
+
block-size: 1px;
|
|
193
|
+
margin: -1px;
|
|
194
|
+
padding: 0;
|
|
195
|
+
border: 0;
|
|
196
|
+
overflow: hidden;
|
|
197
|
+
clip-path: inset(50%);
|
|
198
|
+
white-space: nowrap;
|
|
199
|
+
}
|
|
200
|
+
:where(.ma-prompt__attach:has(.ma-prompt__file:focus-visible)),
|
|
201
|
+
:where(.ma-prompt__send:focus-visible) {
|
|
202
|
+
outline: 2px solid var(--ma-ink);
|
|
203
|
+
outline-offset: 2px;
|
|
204
|
+
}
|
|
205
|
+
:where(.ma-prompt__send) {
|
|
206
|
+
background: var(--ma-ink);
|
|
207
|
+
color: var(--ma-panel);
|
|
208
|
+
}
|
|
209
|
+
:where(.ma-prompt__send:hover) {
|
|
210
|
+
scale: 1.06;
|
|
211
|
+
}
|
|
212
|
+
:where(.ma-prompt__send:active) {
|
|
213
|
+
scale: 0.96;
|
|
214
|
+
}
|
|
215
|
+
/* nothing typed: the button reads as inert but stays clickable — required is what refuses the submit */
|
|
216
|
+
:where(.ma-prompt:has(.ma-prompt__area:placeholder-shown) .ma-prompt__send) {
|
|
217
|
+
opacity: 0.35;
|
|
218
|
+
scale: 1;
|
|
219
|
+
}
|
|
220
|
+
:where(.ma-prompt__send:disabled) {
|
|
221
|
+
cursor: not-allowed;
|
|
222
|
+
opacity: 0.35;
|
|
223
|
+
scale: 1;
|
|
224
|
+
transition: none;
|
|
225
|
+
}
|
|
226
|
+
:where(.ma-prompt__busy) {
|
|
227
|
+
grid-area: 1 / 1;
|
|
228
|
+
display: grid;
|
|
229
|
+
opacity: 0;
|
|
230
|
+
transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
|
|
231
|
+
}
|
|
232
|
+
:where(.ma-prompt[data-pending] .ma-prompt__busy) {
|
|
233
|
+
opacity: 1;
|
|
234
|
+
}
|
|
235
|
+
:where(.ma-prompt[data-pending] .ma-prompt__arrow) {
|
|
236
|
+
opacity: 0;
|
|
237
|
+
}
|
|
238
|
+
:where(.ma-prompt[data-pending] .ma-prompt__send) {
|
|
239
|
+
opacity: 1;
|
|
240
|
+
pointer-events: none;
|
|
241
|
+
}
|
|
242
|
+
:where(.ma-prompt__sr) {
|
|
243
|
+
position: absolute;
|
|
244
|
+
inline-size: 1px;
|
|
245
|
+
block-size: 1px;
|
|
246
|
+
margin: -1px;
|
|
247
|
+
padding: 0;
|
|
248
|
+
overflow: hidden;
|
|
249
|
+
clip-path: inset(50%);
|
|
250
|
+
white-space: nowrap;
|
|
251
|
+
}
|
|
252
|
+
@media (forced-colors: active) {
|
|
253
|
+
:where(.ma-prompt) {
|
|
254
|
+
border-color: ButtonText;
|
|
255
|
+
}
|
|
256
|
+
:where(.ma-prompt__send) {
|
|
257
|
+
border-color: ButtonText;
|
|
258
|
+
forced-color-adjust: none;
|
|
259
|
+
background: ButtonText;
|
|
260
|
+
color: Canvas;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
@media (prefers-reduced-motion: reduce) {
|
|
264
|
+
:where(.ma-prompt),
|
|
265
|
+
:where(.ma-prompt__attach),
|
|
266
|
+
:where(.ma-prompt__send),
|
|
267
|
+
:where(.ma-prompt__busy) {
|
|
268
|
+
transition: none;
|
|
269
|
+
scale: 1;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
</style>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'PromptInput',
|
|
5
|
+
slug: 'prompt-input',
|
|
6
|
+
title: 'Prompt Input',
|
|
7
|
+
description: 'The AI prompt box: a form with a textarea that grows with its content through field-sizing, a model select, an attachment button that is a real file input, and a send button that dims until something is typed and spins while the form carries data-pending. Zero JavaScript by default; submitOnEnter adds a measured script: Enter sends, Shift+Enter breaks the line, and an IME composition is never interrupted.',
|
|
8
|
+
summary: 'An AI prompt box: a textarea that grows, a model select, a file attachment, and a send button that shows pending.',
|
|
9
|
+
category: 'ui',
|
|
10
|
+
tier: 'A',
|
|
11
|
+
trigger: ['click'],
|
|
12
|
+
support: { baseline: 'newly', needs: ['field-sizing', 'has'], fallback: 'without field-sizing the textarea keeps its rows and a vertical resize handle; without :has() the send button does not dim' },
|
|
13
|
+
readout: '<PromptInput models={models} attachments submitOnEnter>',
|
|
14
|
+
usage: `---\nconst models = [{ value: 'fable', label: 'Fable 5.1' }, { value: 'sonnet', label: 'Sonnet 5' }];\n---\n<PromptInput action="/api/chat" method="post" models={models} model="fable" attachments submitOnEnter />\n\n<script>\n // your handler: mark the form busy while the request runs\n const form = document.querySelector('.ma-prompt');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n if (form.hasAttribute('data-pending')) return;\n form.dataset.pending = '';\n try {\n await fetch('/api/chat', { method: 'POST', body: new FormData(form) });\n } finally {\n delete form.dataset.pending;\n }\n });\n</script>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'name', type: 'string', default: "'prompt'", description: 'The textarea\'s field name.' },
|
|
17
|
+
{ name: 'placeholder', type: 'string', default: "'Ask anything'", description: 'Placeholder; :placeholder-shown is what dims the send button.' },
|
|
18
|
+
{ name: 'value', type: 'string', description: 'Initial text.' },
|
|
19
|
+
{ name: 'rows', type: 'number', default: '1', description: 'Rows when empty: the minimum height, and the fixed height without field-sizing.' },
|
|
20
|
+
{ name: 'maxRows', type: 'number', default: '8', description: 'Rows the box grows to before it scrolls.' },
|
|
21
|
+
{ name: 'models', type: '{ value, label, disabled?, group? }[]', description: 'Options for the model select (name="model"); none hides it.' },
|
|
22
|
+
{ name: 'model', type: 'string', description: 'The selected model.' },
|
|
23
|
+
{ name: 'attachments', type: 'boolean', default: 'false', description: 'Show the paperclip: a <label> around <input type="file" name="files" multiple>, visually hidden, reachable by Tab.' },
|
|
24
|
+
{ name: 'accept', type: 'string', description: 'Accept list for the file input.' },
|
|
25
|
+
{ name: 'submitOnEnter', type: 'boolean', default: 'false', description: 'Enter submits through form.requestSubmit() (validation and your handler run), Shift+Enter is a newline, nothing during IME composition. Adds the internal script, measured on its own fixture.' },
|
|
26
|
+
{ name: 'label', type: 'string', default: "'Message'", description: 'Accessible name of the textarea.' },
|
|
27
|
+
{ name: 'required', type: 'boolean', default: 'true', description: 'The browser refuses an empty submit.' },
|
|
28
|
+
{ name: 'disabled', type: 'boolean', default: 'false', description: 'Disables every control.' },
|
|
29
|
+
{ name: 'sendLabel', type: 'string', default: "'Send'", description: 'Visually hidden label of the send button.' },
|
|
30
|
+
{ name: 'attachLabel', type: 'string', default: "'Attach files'", description: 'Visually hidden label of the attachment button.' },
|
|
31
|
+
{ name: 'modelLabel', type: 'string', default: "'Model'", description: 'Accessible name of the model select.' },
|
|
32
|
+
],
|
|
33
|
+
reducedMotion: 'The button and the spinner switch without transitions; the textarea still grows (a layout, not an animation).',
|
|
34
|
+
clientRouter: 'A native form; nothing to bind. With submitOnEnter the internal script is bound through the shared runtime and rebinds after every navigation.',
|
|
35
|
+
craft: [
|
|
36
|
+
'field-sizing: content makes the textarea follow its text with no measuring script; min and max heights are rows × the line height, so the cap is a row count. Engines without it keep the rows attribute and a resize handle, which is a plain, working textarea.',
|
|
37
|
+
'The send button never becomes disabled for an empty box: it dims through :has(textarea:placeholder-shown) and stays clickable, so a click on it runs the browser\'s own validation message for required instead of doing nothing.',
|
|
38
|
+
'The attachment control is a label around a real file input, hidden with clip-path rather than display: none: Tab reaches it, Space opens the picker, the files submit under name="files". A list of the chosen names is the host\'s to render; it is not built here.',
|
|
39
|
+
'Busy state is the form\'s data-pending attribute, the NewsletterSection contract: a fetch handler toggles one attribute and the arrow gives way to the Spinner. The handler keeps the form in a variable: after an await, e.currentTarget is null, and a delete through it throws and leaves the form busy for good.',
|
|
40
|
+
'submitOnEnter is a variant on purpose: the default fixture proves the form is 0 B, and the Enter script is measured alone. It calls requestSubmit(), so required, the submit event and your handler all still run; and it checks isComposing (and keyCode 229 for older IMEs) so a Japanese or Chinese reader confirming a candidate does not send the message.',
|
|
41
|
+
],
|
|
42
|
+
related: ['animated-select', 'stateful-button', 'vanish-input'],
|
|
43
|
+
intents: ['ai prompt input', 'chat input box', 'growing textarea', 'textarea autosize css', 'prompt box with model picker', 'chatgpt style input'],
|
|
44
|
+
replaces: ['PromptInput (Kokonut UI)', 'AI Input (Kibo UI)', 'react-textarea-autosize'],
|
|
45
|
+
fixture: { props: { name: 'ma-fixture', placeholder: 'Fixture content for PromptInput', models: [{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }], attachments: true }, expect: 'Fixture content for PromptInput', variants: { submitOnEnter: { props: { submitOnEnter: true }, expect: 'data-ma-prompt-enter' } } },
|
|
46
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ReadingToc — a table of contents that lights up as you read. The list is
|
|
4
|
+
* server-rendered from `items` (Astro's own `headings` shape works), the
|
|
5
|
+
* links are plain anchors, and one passive scroll listener sets
|
|
6
|
+
* aria-current on the link of the last heading above the reading line —
|
|
7
|
+
* the only thing a script does. The indicator is CSS from that attribute.
|
|
8
|
+
* The listener sits on the nearest ancestor of the article that really
|
|
9
|
+
* scrolls, so it works inside a scrolling box as well as on the page, and
|
|
10
|
+
* it is dropped before every ClientRouter swap.
|
|
11
|
+
*/
|
|
12
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
13
|
+
|
|
14
|
+
interface Item {
|
|
15
|
+
slug: string;
|
|
16
|
+
text: string;
|
|
17
|
+
/** 2 for h2, 3 for h3 … */
|
|
18
|
+
depth?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Props extends HTMLAttributes<'nav'> {
|
|
22
|
+
items: Item[];
|
|
23
|
+
/** Selector of the article the headings live in. */
|
|
24
|
+
for: string;
|
|
25
|
+
/** Heading selector inside the article. */
|
|
26
|
+
headings?: string;
|
|
27
|
+
/** Distance from the top of the scrollport where a heading counts as current, in px. */
|
|
28
|
+
offset?: number;
|
|
29
|
+
title?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { items, for: target, headings = 'h2, h3', offset = 96, title = 'On this page', class: className, ...rest } = Astro.props;
|
|
33
|
+
const min = Math.min(...items.map((i) => i.depth ?? 2));
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
<nav class:list={['ma-toc', className]} data-ma-toc data-for={target} data-headings={headings} data-offset={offset} aria-label={title} {...rest}>
|
|
37
|
+
{title && <p class="ma-toc__title">{title}</p>}
|
|
38
|
+
<ul class="ma-toc__list">
|
|
39
|
+
{items.map((i) => (
|
|
40
|
+
<li class="ma-toc__item" style={`--ma-toc-d:${(i.depth ?? 2) - min}`}>
|
|
41
|
+
<a class="ma-toc__link" href={`#${i.slug}`}>{i.text}</a>
|
|
42
|
+
</li>
|
|
43
|
+
))}
|
|
44
|
+
</ul>
|
|
45
|
+
</nav>
|
|
46
|
+
|
|
47
|
+
<style is:global>
|
|
48
|
+
@layer components {
|
|
49
|
+
:where(.ma-toc) {
|
|
50
|
+
font-size: 0.875rem;
|
|
51
|
+
}
|
|
52
|
+
:where(.ma-toc__title) {
|
|
53
|
+
margin: 0 0 0.5rem;
|
|
54
|
+
font-size: 0.75em;
|
|
55
|
+
letter-spacing: 0.08em;
|
|
56
|
+
text-transform: uppercase;
|
|
57
|
+
opacity: var(--ma-dim, 0.7);
|
|
58
|
+
}
|
|
59
|
+
:where(.ma-toc__list) {
|
|
60
|
+
list-style: none;
|
|
61
|
+
margin: 0;
|
|
62
|
+
padding: 0;
|
|
63
|
+
border-left: 1px solid var(--ma-edge);
|
|
64
|
+
}
|
|
65
|
+
:where(.ma-toc__item) {
|
|
66
|
+
margin: 0;
|
|
67
|
+
padding: 0;
|
|
68
|
+
}
|
|
69
|
+
:where(.ma-toc__link) {
|
|
70
|
+
position: relative;
|
|
71
|
+
display: block;
|
|
72
|
+
padding: 0.3em 0 0.3em calc(0.9em + var(--ma-toc-d, 0) * 0.9em);
|
|
73
|
+
color: inherit;
|
|
74
|
+
text-decoration: none;
|
|
75
|
+
opacity: var(--ma-dim, 0.7);
|
|
76
|
+
transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
|
|
77
|
+
}
|
|
78
|
+
/* the indicator: a bar on the list's left edge that grows from its middle */
|
|
79
|
+
:where(.ma-toc__link)::before {
|
|
80
|
+
content: '';
|
|
81
|
+
position: absolute;
|
|
82
|
+
inset: 0.2em auto 0.2em -1px;
|
|
83
|
+
width: 2px;
|
|
84
|
+
border-radius: 999px;
|
|
85
|
+
background: currentColor;
|
|
86
|
+
scale: 1 0;
|
|
87
|
+
transition: scale var(--ma-duration) var(--ma-ease);
|
|
88
|
+
}
|
|
89
|
+
:where(.ma-toc__link:hover) {
|
|
90
|
+
opacity: 0.9;
|
|
91
|
+
}
|
|
92
|
+
:where(.ma-toc__link[aria-current]) {
|
|
93
|
+
opacity: 1;
|
|
94
|
+
}
|
|
95
|
+
:where(.ma-toc__link[aria-current])::before {
|
|
96
|
+
scale: 1 1;
|
|
97
|
+
}
|
|
98
|
+
:where(.ma-toc__link:focus-visible) {
|
|
99
|
+
outline: 2px solid currentColor;
|
|
100
|
+
outline-offset: 2px;
|
|
101
|
+
border-radius: 0.25em;
|
|
102
|
+
}
|
|
103
|
+
@media (prefers-reduced-motion: reduce) {
|
|
104
|
+
:where(.ma-toc__link),
|
|
105
|
+
:where(.ma-toc__link)::before {
|
|
106
|
+
transition: none;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
</style>
|
|
111
|
+
|
|
112
|
+
<script>
|
|
113
|
+
import { onMount } from '../lib/runtime';
|
|
114
|
+
|
|
115
|
+
onMount<HTMLElement>('[data-ma-toc]', (nav, { signal }) => {
|
|
116
|
+
const article = nav.dataset.for ? document.querySelector(nav.dataset.for) : null;
|
|
117
|
+
if (!article) return;
|
|
118
|
+
const heads = Array.from(article.querySelectorAll<HTMLElement>(nav.dataset.headings!)).filter((h) => h.id);
|
|
119
|
+
const links = new Map(Array.from(nav.querySelectorAll<HTMLAnchorElement>('a[href^="#"]')).map((a) => [decodeURIComponent(a.hash.slice(1)), a]));
|
|
120
|
+
if (!heads.length) return;
|
|
121
|
+
// the nearest ancestor that really scrolls decides the line; null is the viewport. overflow-x: hidden alone computes
|
|
122
|
+
// overflow-y to auto as well, so a page wrapper that only stops sideways scroll also matches; its content never
|
|
123
|
+
// overflows it, which is how it is told apart
|
|
124
|
+
let root: Element | null = article.parentElement;
|
|
125
|
+
while (root && root !== document.body) {
|
|
126
|
+
const o = getComputedStyle(root).overflowY;
|
|
127
|
+
if ((o === 'auto' || o === 'scroll') && root.scrollHeight > root.clientHeight) break;
|
|
128
|
+
root = root.parentElement;
|
|
129
|
+
}
|
|
130
|
+
if (root === document.body) root = null;
|
|
131
|
+
const offset = +nav.dataset.offset!;
|
|
132
|
+
let current: HTMLElement | null = null;
|
|
133
|
+
const mark = (h: HTMLElement | null) => {
|
|
134
|
+
if (h === current) return;
|
|
135
|
+
current = h;
|
|
136
|
+
for (const [id, a] of links) {
|
|
137
|
+
if (h && id === h.id) a.setAttribute('aria-current', 'true');
|
|
138
|
+
else a.removeAttribute('aria-current');
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
// the heading nearest the reading line: the last one whose top is above it
|
|
142
|
+
const pick = () => {
|
|
143
|
+
const top = root ? root.getBoundingClientRect().top : 0;
|
|
144
|
+
let hit: HTMLElement | null = null;
|
|
145
|
+
for (const h of heads) if (h.getBoundingClientRect().top - top <= offset + 1) hit = h;
|
|
146
|
+
mark(hit ?? heads[0]!);
|
|
147
|
+
};
|
|
148
|
+
// every scroll re-reads the line (scroll fires at most once a frame): a jump from a link, a key or the scrollbar lands
|
|
149
|
+
// on the right heading too, where an observer on a band below the line saw nothing cross it
|
|
150
|
+
((root ?? window) as EventTarget).addEventListener('scroll', pick, { signal, passive: true });
|
|
151
|
+
pick();
|
|
152
|
+
});
|
|
153
|
+
</script>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'ReadingToc',
|
|
5
|
+
slug: 'reading-toc',
|
|
6
|
+
title: 'Reading TOC',
|
|
7
|
+
description: 'A table of contents that lights up as you read. The list is server-rendered from items (Astro\'s own headings shape works), the links are plain anchors, and one passive scroll listener sets aria-current on the link of the last heading above the reading line, which is the only thing the script does. The indicator is CSS from that attribute. Works inside a scrolling box as well as on the page, follows jumps from its own links, and is dropped before every navigation.',
|
|
8
|
+
summary: 'A table of contents that highlights the section you are reading, on the page or inside a scrolling box.',
|
|
9
|
+
category: 'scroll',
|
|
10
|
+
tier: 'B',
|
|
11
|
+
trigger: ['scroll'],
|
|
12
|
+
support: { baseline: 'widely', needs: [] },
|
|
13
|
+
readout: '<ReadingToc for="article" items={headings}>',
|
|
14
|
+
usage: `---\nconst { Content, headings } = await render(post);\n---\n<aside><ReadingToc for="#post" items={headings} offset={96} /></aside>\n<article id="post"><Content /></article>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'items', type: '{ slug: string; text: string; depth?: number }[]', description: 'The headings; depth indents (2 for h2, 3 for h3).' },
|
|
17
|
+
{ name: 'for', type: 'string', description: 'Selector of the article the headings live in.' },
|
|
18
|
+
{ name: 'headings', type: 'string', default: "'h2, h3'", description: 'Heading selector inside the article; only headings with an id count.' },
|
|
19
|
+
{ name: 'offset', type: 'number', default: '96', description: 'Distance from the top of the scrollport where a heading becomes current, in px; usually your sticky header\'s height.' },
|
|
20
|
+
{ name: 'title', type: 'string', default: "'On this page'", description: 'Heading over the list and the nav\'s label.' },
|
|
21
|
+
],
|
|
22
|
+
reducedMotion: 'The indicator and the link\'s opacity switch at once.',
|
|
23
|
+
clientRouter: 'Bound through the shared runtime: the scroll listener is added after every navigation and removed before the swap (the binding\'s abort signal), so five round trips leave one listener, not six.',
|
|
24
|
+
craft: [
|
|
25
|
+
'The current heading is the last one whose top is above the offset line, recomputed on every scroll (at most once a frame, a rect read per heading): a reader between two headings is in the earlier section, which is the one the indicator should show. An IntersectionObserver on a band under the line looked cheaper and missed every jump, since a heading sent from below the fold to the top by a link never crosses the band.',
|
|
26
|
+
'The listener sits on the nearest ancestor of the article that really scrolls (its content overflows it), so the same component works in a page, a docs shell with its own scroller and the catalogue tile, and a page wrapper with overflow-x: hidden (which computes overflow-y to auto) is passed over.',
|
|
27
|
+
'Only aria-current is written. The indicator (a bar that grows from the middle) and the link\'s full opacity are CSS from that attribute, so the accessible state and the visible state cannot disagree.',
|
|
28
|
+
'Links stay anchors: without the script the TOC still jumps to the section and :target still works.',
|
|
29
|
+
],
|
|
30
|
+
related: ['tracing-beam', 'scroll-progress', 'tabs'],
|
|
31
|
+
intents: ['table of contents highlight on scroll', 'active heading toc', 'scrollspy', 'on this page navigation', 'reading progress toc'],
|
|
32
|
+
replaces: ['TableOfContents (Fumadocs)', 'scrollspy (Bootstrap)', 'toc highlight (Starlight)'],
|
|
33
|
+
fixture: { props: { for: '#ma-fixture-article', items: [{ slug: 'one', text: 'Fixture content for ReadingToc' }, { slug: 'two', text: 'Two' }] }, expect: 'Fixture content for ReadingToc' },
|
|
34
|
+
} satisfies ComponentMeta;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* RetroGrid — a perspective grid floor sliding toward the viewer. Two
|
|
4
|
+
* repeating gradients on one plane rotated in 3D; the motion is a
|
|
5
|
+
* `translate` of exactly one cell on the compositor (not background-position,
|
|
6
|
+
* which repaints every frame), so the loop is seamless and costs nothing
|
|
7
|
+
* on the main thread. A mask fades the horizon. Fills its positioned parent.
|
|
8
|
+
*/
|
|
9
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
10
|
+
|
|
11
|
+
interface Props extends HTMLAttributes<'div'> {
|
|
12
|
+
/** Tilt of the floor in degrees; 65 is a floor, 45 a ramp. */
|
|
13
|
+
angle?: number;
|
|
14
|
+
/** Cell size in px. */
|
|
15
|
+
cellSize?: number;
|
|
16
|
+
/** Line colour. */
|
|
17
|
+
color?: string;
|
|
18
|
+
/** Seconds per cell of travel. */
|
|
19
|
+
speed?: number;
|
|
20
|
+
/** Fade out toward the horizon. */
|
|
21
|
+
fade?: boolean;
|
|
22
|
+
/** Line width in px. */
|
|
23
|
+
lineWidth?: number;
|
|
24
|
+
/** Where the horizon sits, as a percentage of the height. */
|
|
25
|
+
horizon?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { angle = 65, cellSize = 60, color = 'color-mix(in srgb, var(--ma-ink) 22%, transparent)', speed = 1, fade = true, lineWidth = 1, horizon = 40, class: className, style, ...rest } = Astro.props;
|
|
29
|
+
const inline = [`--ma-retro-angle:${angle}deg`, `--ma-retro-cell:${cellSize}px`, `--ma-retro-color:${color}`, `--ma-retro-dur:${speed}s`, `--ma-retro-line:${lineWidth}px`, `--ma-retro-horizon:${horizon}%`, typeof style === 'string' ? style : '']
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.join(';');
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
<div class:list={['ma-retro', className]} aria-hidden="true" data-fade={fade ? '' : undefined} style={inline} {...rest}>
|
|
35
|
+
<div class="ma-retro__floor"><div class="ma-retro__lines"></div></div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<style is:global>
|
|
39
|
+
@layer components {
|
|
40
|
+
:where(.ma-retro) {
|
|
41
|
+
position: absolute;
|
|
42
|
+
inset: 0;
|
|
43
|
+
overflow: hidden;
|
|
44
|
+
pointer-events: none;
|
|
45
|
+
perspective: calc(var(--ma-retro-cell, 60px) * 4);
|
|
46
|
+
contain: strict;
|
|
47
|
+
}
|
|
48
|
+
:where(.ma-retro[data-fade]) {
|
|
49
|
+
mask-image: linear-gradient(to bottom, transparent var(--ma-retro-horizon, 40%), #000 calc(var(--ma-retro-horizon, 40%) + 30%));
|
|
50
|
+
}
|
|
51
|
+
/* the floor: a huge plane hinged at the horizon, rotated back */
|
|
52
|
+
:where(.ma-retro__floor) {
|
|
53
|
+
position: absolute;
|
|
54
|
+
inset: var(--ma-retro-horizon, 40%) -100% -100%;
|
|
55
|
+
transform-origin: 50% 0;
|
|
56
|
+
transform: rotateX(var(--ma-retro-angle, 65deg));
|
|
57
|
+
}
|
|
58
|
+
:where(.ma-retro__lines) {
|
|
59
|
+
position: absolute;
|
|
60
|
+
inset: calc(-1 * var(--ma-retro-cell, 60px)) 0 0;
|
|
61
|
+
background-image:
|
|
62
|
+
linear-gradient(to right, var(--ma-retro-color) var(--ma-retro-line, 1px), transparent var(--ma-retro-line, 1px)),
|
|
63
|
+
linear-gradient(to bottom, var(--ma-retro-color) var(--ma-retro-line, 1px), transparent var(--ma-retro-line, 1px));
|
|
64
|
+
background-size: var(--ma-retro-cell, 60px) var(--ma-retro-cell, 60px);
|
|
65
|
+
background-position: 50% 0;
|
|
66
|
+
will-change: translate;
|
|
67
|
+
animation: ma-retro var(--ma-retro-dur) linear infinite;
|
|
68
|
+
}
|
|
69
|
+
@media (prefers-reduced-motion: reduce) {
|
|
70
|
+
:where(.ma-retro__lines) {
|
|
71
|
+
animation: none;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@keyframes ma-retro {
|
|
77
|
+
from {
|
|
78
|
+
translate: 0 0;
|
|
79
|
+
}
|
|
80
|
+
to {
|
|
81
|
+
translate: 0 var(--ma-retro-cell, 60px);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
</style>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ComponentMeta } from '../lib/meta';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'RetroGrid',
|
|
5
|
+
slug: 'retro-grid',
|
|
6
|
+
title: 'Retro Grid',
|
|
7
|
+
description: 'A perspective grid floor sliding toward the viewer. Two repeating gradients on one plane rotated in 3D; the motion is a translate of exactly one cell on the compositor, so the loop has no visible seam and never repaints. A mask fades the horizon. Zero JS; fills its positioned parent.',
|
|
8
|
+
summary: 'A retro perspective grid floor sliding toward the viewer, with the horizon fading out.',
|
|
9
|
+
category: 'background',
|
|
10
|
+
tier: 'A',
|
|
11
|
+
trigger: ['always'],
|
|
12
|
+
support: { baseline: 'widely', needs: ['mask-image'], fallback: 'the grid without the horizon fade' },
|
|
13
|
+
readout: '<RetroGrid angle={65} cellSize={60}>',
|
|
14
|
+
usage: `<section class="relative overflow-hidden">\n <RetroGrid />\n <h1 class="relative">Above the floor</h1>\n</section>`,
|
|
15
|
+
props: [
|
|
16
|
+
{ name: 'angle', type: 'number', default: '65', description: 'Tilt of the floor in degrees; 65 reads as a floor, 45 as a ramp.' },
|
|
17
|
+
{ name: 'cellSize', type: 'number', default: '60', description: 'Cell size in px; the perspective distance is four cells.' },
|
|
18
|
+
{ name: 'color', type: 'string', default: 'ink at 22 %', description: 'Line colour.' },
|
|
19
|
+
{ name: 'speed', type: 'number', default: '1', description: 'Seconds per cell of travel.' },
|
|
20
|
+
{ name: 'fade', type: 'boolean', default: 'true', description: 'Fade out toward the horizon.' },
|
|
21
|
+
{ name: 'lineWidth', type: 'number', default: '1', description: 'Line width in px.' },
|
|
22
|
+
{ name: 'horizon', type: 'number', default: '40', description: 'Where the horizon sits, as a percentage of the height.' },
|
|
23
|
+
],
|
|
24
|
+
reducedMotion: 'The floor stands still.',
|
|
25
|
+
clientRouter: 'CSS-only; nothing to rebind.',
|
|
26
|
+
craft: [
|
|
27
|
+
'Translate one cell and loop: the plane moves exactly its period, so the wrap is invisible and the animation is compositor-only; background-position would repaint every frame.',
|
|
28
|
+
'The plane is hinged at the horizon (transform-origin at the top) and rotated back, with perspective set on the parent at four cells: near enough to converge, far enough that the top row is not a blur.',
|
|
29
|
+
'Linear timing: an eased floor stutters at every loop.',
|
|
30
|
+
'The lines are the ink token at low alpha, so the grid follows the theme without a second colour.',
|
|
31
|
+
],
|
|
32
|
+
related: ['grid-pattern', 'dot-pattern', 'meteors'],
|
|
33
|
+
intents: ['retro grid background', 'synthwave grid', 'perspective grid floor', 'moving grid background hero', 'tron grid css'],
|
|
34
|
+
replaces: ['RetroGrid (Magic UI)', 'GridBackground (Aceternity)'],
|
|
35
|
+
fixture: { expect: 'class="ma-retro"' },
|
|
36
|
+
} satisfies ComponentMeta;
|