first commit
This commit is contained in:
7
src/App.vue
Normal file
7
src/App.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HelloWorld />
|
||||
</template>
|
||||
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/vite.svg
Normal file
1
src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
95
src/components/HelloWorld.vue
Normal file
95
src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import viteLogo from '../assets/vite.svg'
|
||||
import heroImg from '../assets/hero.png'
|
||||
import vueLogo from '../assets/vue.svg'
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="center">
|
||||
<div class="hero">
|
||||
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||
</div>
|
||||
<button type="button" class="counter" @click="count++">
|
||||
Count is {{ count }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img class="logo" :src="viteLogo" alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img class="button-icon" :src="vueLogo" alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</template>
|
||||
87
src/domain/teachingDesign.test.ts
Normal file
87
src/domain/teachingDesign.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import {
|
||||
BOOK_SCHEMA_VERSION,
|
||||
createEmptyBook,
|
||||
createEmptyTeachingDesign,
|
||||
createTeachingStep,
|
||||
type DesignId,
|
||||
type TeachingBook,
|
||||
type TeachingDesign,
|
||||
} from './teachingDesign'
|
||||
|
||||
describe('createTeachingStep', () => {
|
||||
it('creates a named step with editable defaults and an ID', () => {
|
||||
const step = createTeachingStep(3)
|
||||
|
||||
expect(step.id).not.toBe('')
|
||||
expect(step).toMatchObject({
|
||||
name: '3. 教学环节',
|
||||
duration: '',
|
||||
content: '',
|
||||
teacherActivity: '',
|
||||
studentActivity: '',
|
||||
intention: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('createEmptyTeachingDesign', () => {
|
||||
it('creates editable defaults for missing lesson sections', () => {
|
||||
const design = createEmptyTeachingDesign('8.md')
|
||||
|
||||
expect(design.id).not.toBe('')
|
||||
expect(design.originalFilename).toBe('8.md')
|
||||
expect(design.processSteps).toHaveLength(1)
|
||||
expect(design.processSteps[0]?.id).not.toBe('')
|
||||
expect(design.boardDesign).toBe('')
|
||||
expect(design.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('creates independent nested state for each design', () => {
|
||||
const first = createEmptyTeachingDesign('1.md')
|
||||
const second = createEmptyTeachingDesign('2.md')
|
||||
|
||||
first.processSteps[0]!.name = 'Changed'
|
||||
first.warnings.push({ code: 'missing-title', message: 'Missing title' })
|
||||
|
||||
expect(first.id).not.toBe(second.id)
|
||||
expect(first.processSteps).not.toBe(second.processSteps)
|
||||
expect(first.processSteps[0]).not.toBe(second.processSteps[0])
|
||||
expect(second.processSteps[0]?.name).toBe('1. 教学环节')
|
||||
expect(second.warnings).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('createEmptyBook', () => {
|
||||
it('creates the schema defaults with cover selected and an ISO timestamp', () => {
|
||||
const book = createEmptyBook()
|
||||
|
||||
expect(book.schemaVersion).toBe(BOOK_SCHEMA_VERSION)
|
||||
expect(book.selectedId).toBe('cover')
|
||||
expect(new Date(book.updatedAt).toISOString()).toBe(book.updatedAt)
|
||||
})
|
||||
|
||||
it('creates independent cover and design collections', () => {
|
||||
const first = createEmptyBook()
|
||||
const second = createEmptyBook()
|
||||
|
||||
first.cover.courseName = 'Changed'
|
||||
first.designs.push(createEmptyTeachingDesign('1.md'))
|
||||
|
||||
expect(first.cover).not.toBe(second.cover)
|
||||
expect(first.designs).not.toBe(second.designs)
|
||||
expect(second.cover.courseName).toBe('')
|
||||
expect(second.designs).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('domain types', () => {
|
||||
it('uses branded string design IDs and literal schema versions', () => {
|
||||
expectTypeOf<DesignId>().toExtend<string>()
|
||||
expectTypeOf<TeachingDesign['id']>().toEqualTypeOf<DesignId>()
|
||||
expectTypeOf<TeachingBook['selectedId']>().toEqualTypeOf<'cover' | DesignId>()
|
||||
expectTypeOf<TeachingBook['schemaVersion']>().toEqualTypeOf<
|
||||
typeof BOOK_SCHEMA_VERSION
|
||||
>()
|
||||
})
|
||||
})
|
||||
112
src/domain/teachingDesign.ts
Normal file
112
src/domain/teachingDesign.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
export const BOOK_SCHEMA_VERSION = 1
|
||||
|
||||
declare const designIdBrand: unique symbol
|
||||
|
||||
export type DesignId = string & {
|
||||
readonly [designIdBrand]: true
|
||||
}
|
||||
|
||||
export type ParseWarningCode =
|
||||
| 'missing-title'
|
||||
| 'missing-basic-field'
|
||||
| 'missing-process'
|
||||
| 'invalid-process-table'
|
||||
| 'missing-board'
|
||||
| 'missing-reflection'
|
||||
| 'unclassified-content'
|
||||
|
||||
export interface ParseWarning {
|
||||
code: ParseWarningCode
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface TeachingStep {
|
||||
id: string
|
||||
name: string
|
||||
duration: string
|
||||
content: string
|
||||
teacherActivity: string
|
||||
studentActivity: string
|
||||
intention: string
|
||||
}
|
||||
|
||||
export interface TeachingDesign {
|
||||
id: DesignId
|
||||
originalFilename: string
|
||||
title: string
|
||||
topic: string
|
||||
duration: string
|
||||
knowledgeObjective: string
|
||||
skillObjective: string
|
||||
literacyObjective: string
|
||||
keyPoint: string
|
||||
difficultPoint: string
|
||||
resources: string
|
||||
processSteps: TeachingStep[]
|
||||
boardDesign: string
|
||||
effectiveness: string
|
||||
reflection: string
|
||||
additionalContent: string
|
||||
warnings: ParseWarning[]
|
||||
}
|
||||
|
||||
export interface BookCover {
|
||||
courseName: string
|
||||
teacherName: string
|
||||
}
|
||||
|
||||
export interface TeachingBook {
|
||||
schemaVersion: typeof BOOK_SCHEMA_VERSION
|
||||
cover: BookCover
|
||||
designs: TeachingDesign[]
|
||||
selectedId: 'cover' | DesignId
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
function createDesignId(): DesignId {
|
||||
return crypto.randomUUID() as DesignId
|
||||
}
|
||||
|
||||
export function createTeachingStep(index = 1): TeachingStep {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: `${index}. 教学环节`,
|
||||
duration: '',
|
||||
content: '',
|
||||
teacherActivity: '',
|
||||
studentActivity: '',
|
||||
intention: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyTeachingDesign(filename: string): TeachingDesign {
|
||||
return {
|
||||
id: createDesignId(),
|
||||
originalFilename: filename,
|
||||
title: '',
|
||||
topic: '',
|
||||
duration: '',
|
||||
knowledgeObjective: '',
|
||||
skillObjective: '',
|
||||
literacyObjective: '',
|
||||
keyPoint: '',
|
||||
difficultPoint: '',
|
||||
resources: '',
|
||||
processSteps: [createTeachingStep()],
|
||||
boardDesign: '',
|
||||
effectiveness: '',
|
||||
reflection: '',
|
||||
additionalContent: '',
|
||||
warnings: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyBook(): TeachingBook {
|
||||
return {
|
||||
schemaVersion: BOOK_SCHEMA_VERSION,
|
||||
cover: { courseName: '', teacherName: '' },
|
||||
designs: [],
|
||||
selectedId: 'cover',
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
5
src/main.ts
Normal file
5
src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
115
src/services/markdownTable.test.ts
Normal file
115
src/services/markdownTable.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractMarkdownTable, splitMarkdownRow } from './markdownTable'
|
||||
|
||||
describe('splitMarkdownRow', () => {
|
||||
it('keeps pipes inside code spans and escaped pipes inside cells', () => {
|
||||
expect(splitMarkdownRow('| A | `x | y` | left \\| right |')).toEqual([
|
||||
'A',
|
||||
'`x | y`',
|
||||
'left \\| right',
|
||||
])
|
||||
})
|
||||
|
||||
it('matches arbitrary backtick fence lengths and ignores escaped backticks', () => {
|
||||
expect(splitMarkdownRow('| ``x | y`` | escaped \\` tick |')).toEqual([
|
||||
'``x | y``',
|
||||
'escaped \\` tick',
|
||||
])
|
||||
})
|
||||
|
||||
it('treats an unmatched backtick as literal text', () => {
|
||||
expect(splitMarkdownRow('| A | unmatched `code | B |')).toEqual([
|
||||
'A',
|
||||
'unmatched `code',
|
||||
'B',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractMarkdownTable', () => {
|
||||
it('finds a table and gathers its contiguous body rows', () => {
|
||||
const lines = [
|
||||
'intro',
|
||||
'| A | B |',
|
||||
'| --- | :---: |',
|
||||
'| 1 | 2 |',
|
||||
'',
|
||||
]
|
||||
|
||||
expect(extractMarkdownTable(lines)).toEqual({
|
||||
start: 1,
|
||||
end: 3,
|
||||
header: ['A', 'B'],
|
||||
rows: [['1', '2']],
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['backtick', ['```markdown', '| Fake | Table |', '| --- | --- |', '```']],
|
||||
['tilde', ['~~~~', '| Fake | Table |', '| --- | --- |', '~~~~']],
|
||||
])('skips pseudo-tables inside %s fenced code', (_marker, fencedLines) => {
|
||||
const lines = [
|
||||
...fencedLines,
|
||||
'',
|
||||
'| Real | Table |',
|
||||
'| --- | --- |',
|
||||
'| 1 | 2 |',
|
||||
]
|
||||
const realTableStart = fencedLines.length + 1
|
||||
|
||||
expect(extractMarkdownTable(lines)).toEqual({
|
||||
start: realTableStart,
|
||||
end: realTableStart + 2,
|
||||
header: ['Real', 'Table'],
|
||||
rows: [['1', '2']],
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['four spaces', ' '],
|
||||
['a tab', '\t'],
|
||||
])('skips pseudo-tables indented with %s', (_indentation, indent) => {
|
||||
const lines = [
|
||||
`${indent}| Fake | Table |`,
|
||||
`${indent}| --- | --- |`,
|
||||
`${indent}| 0 | 0 |`,
|
||||
'',
|
||||
'| Real | Table |',
|
||||
'| --- | --- |',
|
||||
'| 1 | 2 |',
|
||||
]
|
||||
|
||||
expect(extractMarkdownTable(lines)).toEqual({
|
||||
start: 4,
|
||||
end: 6,
|
||||
header: ['Real', 'Table'],
|
||||
rows: [['1', '2']],
|
||||
})
|
||||
})
|
||||
|
||||
it('starts its table search at fromIndex', () => {
|
||||
const lines = [
|
||||
'| First | Table |',
|
||||
'| --- | --- |',
|
||||
'| 1 | 1 |',
|
||||
'',
|
||||
'| Second | Table |',
|
||||
'| --- | --- |',
|
||||
'| 2 | 2 |',
|
||||
]
|
||||
|
||||
expect(extractMarkdownTable(lines, 4)).toEqual({
|
||||
start: 4,
|
||||
end: 6,
|
||||
header: ['Second', 'Table'],
|
||||
rows: [['2', '2']],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when no valid table exists', () => {
|
||||
expect(extractMarkdownTable(['plain text'])).toBeNull()
|
||||
expect(
|
||||
extractMarkdownTable(['| A | B |', '| -- | not-a-divider |']),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
147
src/services/markdownTable.ts
Normal file
147
src/services/markdownTable.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
function isEscaped(value: string, index: number): boolean {
|
||||
let backslashCount = 0
|
||||
|
||||
for (let cursor = index - 1; cursor >= 0 && value[cursor] === '\\'; cursor--) {
|
||||
backslashCount++
|
||||
}
|
||||
|
||||
return backslashCount % 2 === 1
|
||||
}
|
||||
|
||||
function hasMatchingCodeFence(
|
||||
value: string,
|
||||
start: number,
|
||||
fenceLength: number,
|
||||
): boolean {
|
||||
for (let index = start + fenceLength; index < value.length; ) {
|
||||
if (value[index] !== '`') {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
let runLength = 1
|
||||
|
||||
while (value[index + runLength] === '`') {
|
||||
runLength++
|
||||
}
|
||||
|
||||
if (!isEscaped(value, index) && runLength === fenceLength) {
|
||||
return true
|
||||
}
|
||||
|
||||
index += runLength
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export interface MarkdownTable {
|
||||
start: number
|
||||
end: number
|
||||
header: string[]
|
||||
rows: string[][]
|
||||
}
|
||||
|
||||
export function splitMarkdownRow(row: string): string[] {
|
||||
const cells: string[] = []
|
||||
let cell = ''
|
||||
let codeFenceLength = 0
|
||||
|
||||
for (let index = 0; index < row.length; ) {
|
||||
const character = row[index]!
|
||||
|
||||
if (character === '`' && !isEscaped(row, index)) {
|
||||
let runLength = 1
|
||||
|
||||
while (row[index + runLength] === '`') {
|
||||
runLength++
|
||||
}
|
||||
|
||||
cell += row.slice(index, index + runLength)
|
||||
|
||||
if (
|
||||
codeFenceLength === 0 &&
|
||||
hasMatchingCodeFence(row, index, runLength)
|
||||
) {
|
||||
codeFenceLength = runLength
|
||||
} else if (codeFenceLength === runLength) {
|
||||
codeFenceLength = 0
|
||||
}
|
||||
|
||||
index += runLength
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
character === '|' &&
|
||||
codeFenceLength === 0 &&
|
||||
!isEscaped(row, index)
|
||||
) {
|
||||
cells.push(cell.trim())
|
||||
cell = ''
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
cell += character
|
||||
index++
|
||||
}
|
||||
|
||||
cells.push(cell.trim())
|
||||
|
||||
if (cells[0] === '') {
|
||||
cells.shift()
|
||||
}
|
||||
if (cells.at(-1) === '') {
|
||||
cells.pop()
|
||||
}
|
||||
|
||||
return cells
|
||||
}
|
||||
|
||||
const dividerCellPattern = /^:?-{3,}:?$/
|
||||
|
||||
function startsWithPipe(line: string): boolean {
|
||||
return line.trimStart().startsWith('|')
|
||||
}
|
||||
|
||||
export function extractMarkdownTable(
|
||||
lines: readonly string[],
|
||||
fromIndex = 0,
|
||||
): MarkdownTable | null {
|
||||
for (
|
||||
let start = Math.max(0, fromIndex);
|
||||
start < lines.length - 1;
|
||||
start++
|
||||
) {
|
||||
const headerLine = lines[start]!
|
||||
const dividerLine = lines[start + 1]!
|
||||
|
||||
if (!startsWithPipe(headerLine) || !startsWithPipe(dividerLine)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const header = splitMarkdownRow(headerLine)
|
||||
const divider = splitMarkdownRow(dividerLine)
|
||||
|
||||
if (
|
||||
header.length === 0 ||
|
||||
divider.length !== header.length ||
|
||||
!divider.every((cell) => dividerCellPattern.test(cell))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const rows: string[][] = []
|
||||
let end = start + 1
|
||||
|
||||
while (end + 1 < lines.length && startsWithPipe(lines[end + 1]!)) {
|
||||
end++
|
||||
rows.push(splitMarkdownRow(lines[end]!))
|
||||
}
|
||||
|
||||
return { start, end, header, rows }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
15
src/services/naturalSort.test.ts
Normal file
15
src/services/naturalSort.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sortFilesNaturally } from './naturalSort'
|
||||
|
||||
describe('sortFilesNaturally', () => {
|
||||
it('sorts numbered filenames naturally without mutating the input', () => {
|
||||
const files = [{ name: '10.md' }, { name: '2.md' }, { name: '1.md' }]
|
||||
const original = [...files]
|
||||
|
||||
const sorted = sortFilesNaturally(files)
|
||||
|
||||
expect(sorted.map(({ name }) => name)).toEqual(['1.md', '2.md', '10.md'])
|
||||
expect(files).toEqual(original)
|
||||
expect(sorted).not.toBe(files)
|
||||
})
|
||||
})
|
||||
12
src/services/naturalSort.ts
Normal file
12
src/services/naturalSort.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
const filenameCollator = new Intl.Collator('zh-CN', {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
})
|
||||
|
||||
export function sortFilesNaturally<T extends { name: string }>(
|
||||
files: readonly T[],
|
||||
): T[] {
|
||||
return [...files].sort((left, right) =>
|
||||
filenameCollator.compare(left.name, right.name),
|
||||
)
|
||||
}
|
||||
296
src/style.css
Normal file
296
src/style.css
Normal file
@@ -0,0 +1,296 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
1
src/test/setup.ts
Normal file
1
src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
Reference in New Issue
Block a user