create-foliokit 0.0.0-stage → 1.0.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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +377 -2
  3. package/bin/index.js +6 -0
  4. package/package.json +50 -4
  5. package/src/cli.js +111 -0
  6. package/src/generator.js +133 -0
  7. package/src/prompts.js +44 -0
  8. package/src/utils/errors.js +7 -0
  9. package/src/utils/paths.js +12 -0
  10. package/src/utils/progress.js +5 -0
  11. package/src/utils/project-name.js +30 -0
  12. package/templates/default/.gitignore +20 -0
  13. package/templates/default/data.ts +227 -0
  14. package/templates/default/index.html +23 -0
  15. package/templates/default/package-lock.json +1280 -0
  16. package/templates/default/package.json +28 -0
  17. package/templates/default/public/avatar-placeholder.svg +10 -0
  18. package/templates/default/public/footer-heart.png +0 -0
  19. package/templates/default/public/love-always-wins.png +0 -0
  20. package/templates/default/public/project-placeholder.svg +10 -0
  21. package/templates/default/public/robots.txt +2 -0
  22. package/templates/default/public/stars-foreground-landscape.svg +202 -0
  23. package/templates/default/public/stars-foreground-portrait.svg +202 -0
  24. package/templates/default/public/stars-landscape.svg +322 -0
  25. package/templates/default/public/stars-portrait.svg +322 -0
  26. package/templates/default/scripts/generate-seo.mjs +34 -0
  27. package/templates/default/src/App.tsx +552 -0
  28. package/templates/default/src/BrandIcon.tsx +36 -0
  29. package/templates/default/src/ProjectMagazineGallery.css +90 -0
  30. package/templates/default/src/ProjectMagazineGallery.tsx +210 -0
  31. package/templates/default/src/main.tsx +10 -0
  32. package/templates/default/src/styles.css +536 -0
  33. package/templates/default/src/utils.ts +2 -0
  34. package/templates/default/src/vite-env.d.ts +1 -0
  35. package/templates/default/tests/portfolio.test.js +96 -0
  36. package/templates/default/tsconfig.app.json +21 -0
  37. package/templates/default/tsconfig.json +7 -0
  38. package/templates/default/tsconfig.node.json +16 -0
  39. package/templates/default/vite.config.ts +38 -0
@@ -0,0 +1,90 @@
1
+ .project-magazine-gallery {
2
+ --magazine-gold: #D6B77A;
3
+ padding: 2px 0 14px;
4
+ }
5
+
6
+ .magazine-viewport {
7
+ position: relative;
8
+ width: 100%;
9
+ height: 320px;
10
+ overflow: hidden;
11
+ perspective: 1100px;
12
+ perspective-origin: 50% 43%;
13
+ touch-action: pan-y;
14
+ overscroll-behavior-x: contain;
15
+ outline: none;
16
+ user-select: none;
17
+ -webkit-tap-highlight-color: transparent;
18
+ }
19
+ .magazine-viewport:focus-visible { outline: 1px solid color-mix(in srgb, var(--magazine-gold) 55%, transparent); outline-offset: -2px; }
20
+ .magazine-viewport.is-dragging { cursor: grabbing; }
21
+ .magazine-viewport.is-dragging .magazine-cover { cursor: grabbing; transition: none; }
22
+
23
+ .magazine-cover {
24
+ --tilt-x: 0deg;
25
+ --tilt-y: 0deg;
26
+ position: absolute;
27
+ top: 15px;
28
+ left: 50%;
29
+ display: flex;
30
+ width: min(250px, 40%);
31
+ height: 270px;
32
+ flex-direction: column;
33
+ padding: 0;
34
+ overflow: hidden;
35
+ border: 1px solid rgba(214, 183, 122, .25);
36
+ border-radius: 5px;
37
+ color: var(--warm-white);
38
+ background: #070707;
39
+ box-shadow: 0 15px 34px rgba(0, 0, 0, .52), 0 0 0 1px rgba(255, 255, 255, .025) inset, 1px 0 rgba(214, 183, 122, .13) inset;
40
+ cursor: grab;
41
+ opacity: .68;
42
+ transform: translate3d(calc(-50% + var(--translate-x) + var(--drag-x, 0px) + var(--hover-shift)), var(--hover-lift), var(--depth-z)) rotateY(calc(var(--rotate-y) + var(--tilt-y))) rotateX(var(--tilt-x)) scale(var(--card-scale));
43
+ transform-style: preserve-3d;
44
+ transition: transform 460ms cubic-bezier(.22, .8, .22, 1), opacity 360ms ease, box-shadow 400ms ease, border-color 400ms ease;
45
+ animation: magazine-cover-appear 750ms cubic-bezier(.2, .72, .18, 1) var(--card-delay) both;
46
+ -webkit-user-select: none;
47
+ }
48
+ .magazine-cover.is-active { z-index: 4 !important; opacity: 1; cursor: pointer; border-color: rgba(214, 183, 122, .54); box-shadow: 0 20px 44px rgba(0, 0, 0, .62), 0 0 22px rgba(214, 183, 122, .1), 0 0 0 1px rgba(214, 183, 122, .06) inset; }
49
+ .magazine-cover.is-hovered { opacity: 1; border-color: rgba(214, 183, 122, .68); box-shadow: 0 24px 50px rgba(0, 0, 0, .66), 0 0 28px rgba(214, 183, 122, .16), 0 0 0 1px rgba(214, 183, 122, .1) inset; }
50
+ .magazine-cover.is-hidden { pointer-events: none; opacity: 0; }
51
+ .magazine-cover:focus-visible { outline: 1px solid var(--magazine-gold); outline-offset: 3px; }
52
+ .magazine-cover-image-wrap { position: relative; display: block; height: 56%; flex: 0 0 56%; overflow: hidden; background: #101010; transform: translateZ(8px); }
53
+ .magazine-cover-image-wrap::after { position: absolute; inset: 0; border-bottom: 1px solid rgba(214, 183, 122, .22); background: linear-gradient(180deg, rgba(0, 0, 0, .24), transparent 35%, rgba(0, 0, 0, .16)); content: ''; pointer-events: none; }
54
+ .magazine-cover-image { display: block; width: 100%; height: 100%; object-fit: cover; filter: grayscale(1) brightness(.82); transition: filter 480ms ease, transform 700ms cubic-bezier(.2, .72, .18, 1); pointer-events: none; }
55
+ .magazine-cover-image-placeholder { display: grid; width: 100%; height: 100%; place-items: center; background: radial-gradient(circle at 35% 25%, #242424, #050505 72%); color: #e5cf9c; font: 600 46px/1 var(--heading-font); }
56
+ .magazine-cover.is-active .magazine-cover-image, .magazine-cover.is-hovered .magazine-cover-image { filter: grayscale(0) brightness(.98); transform: scale(1.035); }
57
+ .magazine-cover-info { display: flex; min-height: 0; flex: 1; flex-direction: column; align-items: flex-start; padding: 12px 14px 10px; text-align: left; transform: translateZ(14px); }
58
+ .magazine-cover-meta { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 6px; color: var(--muted); font: 8px/1.2 var(--body-font); letter-spacing: .035em; text-transform: uppercase; }
59
+ .magazine-cover-meta span { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
60
+ .magazine-cover-title { margin-top: 7px; color: var(--warm-white); font: 600 17px/1.1 var(--heading-font); letter-spacing: -.035em; }
61
+ .magazine-cover-description { display: -webkit-box; max-height: 0; overflow: hidden; color: var(--muted); font: 10px/1.45 var(--body-font); opacity: 0; -webkit-box-orient: vertical; -webkit-line-clamp: 3; transition: max-height 360ms ease, opacity 280ms ease, margin 280ms ease; }
62
+ .magazine-cover.is-active .magazine-cover-description, .magazine-cover.is-hovered .magazine-cover-description, .magazine-cover:focus-visible .magazine-cover-description { max-height: 48px; margin-top: 6px; opacity: 1; }
63
+ .magazine-cover-hint { display: flex; width: 100%; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 5px; color: var(--magazine-gold); font: 600 8px/1 var(--heading-font); letter-spacing: .1em; opacity: .78; }
64
+ .magazine-cover-hint span { font-size: 13px; line-height: .8; }
65
+
66
+ @keyframes magazine-cover-appear {
67
+ from { opacity: 0; filter: blur(3px); }
68
+ to { filter: blur(0); }
69
+ }
70
+
71
+ @media (min-width: 1024px) {
72
+ .magazine-viewport { height: 345px; perspective: 1250px; }
73
+ .magazine-cover { top: 18px; width: min(275px, 36%); height: 292px; }
74
+ }
75
+ @media (min-width: 768px) and (max-width: 1023px) {
76
+ .magazine-viewport { height: 315px; perspective: 1000px; }
77
+ .magazine-cover { top: 16px; width: min(238px, 42%); height: 267px; }
78
+ }
79
+ @media (max-width: 767px) {
80
+ .project-magazine-gallery { padding: 0 0 12px; }
81
+ .magazine-viewport { height: 300px; perspective: 850px; perspective-origin: 50% 42%; }
82
+ .magazine-cover { top: 14px; width: min(238px, 69%); height: 260px; border-radius: 4px; }
83
+ .magazine-cover-image-wrap { height: 53%; flex-basis: 53%; }
84
+ .magazine-cover-info { padding: 10px 12px 9px; }
85
+ .magazine-cover-description { font-size: 9px; }
86
+ .magazine-cover.is-active .magazine-cover-description { max-height: 52px; }
87
+ }
88
+ @media (prefers-reduced-motion: reduce) {
89
+ .magazine-cover, .magazine-cover-image, .magazine-cover-description { animation: none !important; transition-duration: .01ms !important; }
90
+ }
@@ -0,0 +1,210 @@
1
+ import { useEffect, useRef, useState, type CSSProperties, type PointerEvent, type KeyboardEvent, type WheelEvent } from 'react'
2
+ import { projectCardId } from './utils'
3
+ import type { Project } from '../data'
4
+ import './ProjectMagazineGallery.css'
5
+
6
+ type Props = { projects: Project[] }
7
+
8
+ function getOffset(index: number, activeIndex: number, count: number) {
9
+ let offset = index - activeIndex
10
+ if (offset > count / 2) offset -= count
11
+ if (offset < -count / 2) offset += count
12
+ return offset
13
+ }
14
+
15
+ function ProjectMagazineGallery({ projects }: Props) {
16
+ const [activeIndex, setActiveIndex] = useState(0)
17
+ const [hoverIndex, setHoverIndex] = useState<number | null>(null)
18
+ const [dragX, setDragX] = useState(0)
19
+ const [isDragging, setIsDragging] = useState(false)
20
+ const [reducedMotion, setReducedMotion] = useState(false)
21
+ const dragStart = useRef<number | null>(null)
22
+ const wasDragged = useRef(false)
23
+ const clickResetTimer = useRef<number | undefined>(undefined)
24
+ const wheelDelta = useRef(0)
25
+ const wheelGestureHandled = useRef(false)
26
+ const wheelGestureTimer = useRef<number | undefined>(undefined)
27
+
28
+ useEffect(() => {
29
+ const query = window.matchMedia('(prefers-reduced-motion: reduce)')
30
+ const update = () => setReducedMotion(query.matches)
31
+ update()
32
+ query.addEventListener('change', update)
33
+ return () => {
34
+ query.removeEventListener('change', update)
35
+ window.clearTimeout(clickResetTimer.current)
36
+ window.clearTimeout(wheelGestureTimer.current)
37
+ }
38
+ }, [])
39
+
40
+ if (projects.length === 0) return null
41
+
42
+ const goTo = (nextIndex: number) => {
43
+ setActiveIndex((nextIndex + projects.length) % projects.length)
44
+ setHoverIndex(null)
45
+ }
46
+
47
+ const openProject = (index: number) => {
48
+ setActiveIndex(index)
49
+ document.getElementById(projectCardId(projects[index].name))?.scrollIntoView({
50
+ behavior: reducedMotion ? 'auto' : 'smooth',
51
+ block: 'center',
52
+ })
53
+ }
54
+
55
+ const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
56
+ if (event.pointerType === 'mouse' && event.button !== 0) return
57
+ dragStart.current = event.clientX
58
+ setDragX(0)
59
+ const cover = (event.target as Element | null)?.closest<HTMLButtonElement>('.magazine-cover')
60
+ try {
61
+ (cover ?? event.currentTarget).setPointerCapture(event.pointerId)
62
+ } catch {
63
+ // Pointer capture may be unavailable on synthetic or cancelled pointer events.
64
+ }
65
+ }
66
+
67
+ const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
68
+ if (dragStart.current === null) return
69
+ const delta = event.clientX - dragStart.current
70
+ setDragX(delta)
71
+ if (Math.abs(delta) > 8) setIsDragging(true)
72
+ }
73
+
74
+ const finishDrag = (event: PointerEvent<HTMLDivElement>) => {
75
+ if (dragStart.current === null) return
76
+ if (event.type === 'pointercancel') {
77
+ dragStart.current = null
78
+ setIsDragging(false)
79
+ setDragX(0)
80
+ return
81
+ }
82
+ const delta = event.clientX - dragStart.current
83
+ if (Math.abs(delta) > 48) {
84
+ wasDragged.current = true
85
+ goTo(activeIndex + (delta < 0 ? 1 : -1))
86
+ window.clearTimeout(clickResetTimer.current)
87
+ clickResetTimer.current = window.setTimeout(() => { wasDragged.current = false }, 140)
88
+ }
89
+ dragStart.current = null
90
+ setIsDragging(false)
91
+ setDragX(0)
92
+ }
93
+
94
+ const handleWheel = (event: WheelEvent<HTMLDivElement>) => {
95
+ if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return
96
+ event.preventDefault()
97
+ window.clearTimeout(wheelGestureTimer.current)
98
+ wheelGestureTimer.current = window.setTimeout(() => {
99
+ wheelDelta.current = 0
100
+ wheelGestureHandled.current = false
101
+ }, 280)
102
+ if (wheelGestureHandled.current) return
103
+ wheelDelta.current += event.deltaX
104
+ if (Math.abs(wheelDelta.current) < 36) return
105
+ goTo(activeIndex + (wheelDelta.current > 0 ? 1 : -1))
106
+ wheelDelta.current = 0
107
+ wheelGestureHandled.current = true
108
+ }
109
+
110
+ const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
111
+ if (event.key === 'ArrowRight') { event.preventDefault(); goTo(activeIndex + 1) }
112
+ if (event.key === 'ArrowLeft') { event.preventDefault(); goTo(activeIndex - 1) }
113
+ if (event.key === 'Home') { event.preventDefault(); goTo(0) }
114
+ if (event.key === 'End') { event.preventDefault(); goTo(projects.length - 1) }
115
+ }
116
+
117
+ const viewportStyle = { '--drag-x': `${dragX}px` } as CSSProperties
118
+
119
+ return (
120
+ <section className="project-magazine-gallery" aria-label="Featured project carousel">
121
+ <div
122
+ className={`magazine-viewport${isDragging ? ' is-dragging' : ''}`}
123
+ style={viewportStyle}
124
+ role="region"
125
+ aria-roledescription="project carousel"
126
+ aria-label="Project magazine gallery. Swipe, drag, or use a horizontal trackpad gesture to browse. Use the left and right arrow keys while focused."
127
+ tabIndex={0}
128
+ onKeyDown={handleKeyDown}
129
+ onWheel={handleWheel}
130
+ onPointerDown={handlePointerDown}
131
+ onPointerMove={handlePointerMove}
132
+ onPointerUp={finishDrag}
133
+ onPointerCancel={finishDrag}
134
+ >
135
+ {projects.map((project, index) => {
136
+ const offset = getOffset(index, activeIndex, projects.length)
137
+ const distance = Math.abs(offset)
138
+ const isActive = index === activeIndex
139
+ const isHovered = index === hoverIndex
140
+ const hoverShift = hoverIndex !== null && index !== hoverIndex ? (index < hoverIndex ? '-9px' : '9px') : '0px'
141
+ const cardStyle = {
142
+ '--translate-x': offset === 0 ? '0%' : offset < 0 ? `${Math.max(-96, offset * 76)}%` : `${Math.min(96, offset * 76)}%`,
143
+ '--depth-z': `${-distance * 74}px`,
144
+ '--rotate-y': `${offset * -11}deg`,
145
+ '--card-scale': `${Math.max(.76, 1 - distance * .1)}`,
146
+ '--hover-lift': isHovered && !reducedMotion ? '24px' : '0px',
147
+ '--hover-shift': hoverShift,
148
+ '--card-delay': `${index * 110}ms`,
149
+ zIndex: projects.length - distance,
150
+ } as CSSProperties
151
+
152
+ const pointerTilt = (event: PointerEvent<HTMLButtonElement>) => {
153
+ if (event.pointerType !== 'mouse' || reducedMotion || isDragging) return
154
+ const bounds = event.currentTarget.getBoundingClientRect()
155
+ const relX = (event.clientX - bounds.left) / bounds.width - .5
156
+ const relY = (event.clientY - bounds.top) / bounds.height - .5
157
+ event.currentTarget.style.setProperty('--tilt-y', `${relX * 5}deg`)
158
+ event.currentTarget.style.setProperty('--tilt-x', `${-relY * 4}deg`)
159
+ }
160
+
161
+ const clearTilt = (event: PointerEvent<HTMLButtonElement>) => {
162
+ event.currentTarget.style.removeProperty('--tilt-y')
163
+ event.currentTarget.style.removeProperty('--tilt-x')
164
+ setHoverIndex(null)
165
+ }
166
+
167
+ return (
168
+ <button
169
+ className={`magazine-cover${isActive ? ' is-active' : ''}${isHovered ? ' is-hovered' : ''}${distance > 1 ? ' is-hidden' : ''}`}
170
+ key={project.name}
171
+ data-featured={project.featured || undefined}
172
+ type="button"
173
+ style={cardStyle}
174
+ aria-label={`Open ${project.name} project details`}
175
+ aria-hidden={distance > 1}
176
+ tabIndex={distance > 1 ? -1 : 0}
177
+ onPointerEnter={(event) => { if (event.pointerType === 'mouse') setHoverIndex(index) }}
178
+ onPointerMove={pointerTilt}
179
+ onPointerLeave={clearTilt}
180
+ onFocus={() => { setActiveIndex(index); setHoverIndex(index) }}
181
+ onBlur={() => setHoverIndex(null)}
182
+ onClick={() => {
183
+ if (wasDragged.current) { wasDragged.current = false; return }
184
+ openProject(index)
185
+ }}
186
+ >
187
+ <span className="magazine-cover-image-wrap">
188
+ {project.image ? <>
189
+ <img src={project.image} alt={project.imageAlt ?? `${project.name} project cover`} className="magazine-cover-image" loading="lazy" draggable={false} onError={(event) => { event.currentTarget.hidden = true; const fallback = event.currentTarget.nextElementSibling; if (fallback instanceof HTMLElement) fallback.hidden = false }} />
190
+ <span className="magazine-cover-image-placeholder" aria-hidden="true" hidden>{project.name.slice(0, 2).toUpperCase()}</span>
191
+ </> : <span className="magazine-cover-image-placeholder" aria-hidden="true">{project.name.slice(0, 2).toUpperCase()}</span>}
192
+ </span>
193
+ <span className="magazine-cover-info">
194
+ <span className="magazine-cover-meta">
195
+ <span>{project.category}</span>
196
+ </span>
197
+ <span className="magazine-cover-title">{project.name}</span>
198
+ <span className="magazine-cover-description">{project.description}</span>
199
+ <span className="magazine-cover-hint">VIEW PROJECT <span aria-hidden="true">↗</span></span>
200
+ </span>
201
+ </button>
202
+ )
203
+ })}
204
+ </div>
205
+
206
+ </section>
207
+ )
208
+ }
209
+
210
+ export default ProjectMagazineGallery
@@ -0,0 +1,10 @@
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App'
4
+ import './styles.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ )