Compare commits

..

No commits in common. "c65ee029dd3f50142408d2e6727e9ab70c2266df" and "b1dae9d7e8881acd46c5f1f03e3affab6153dcea" have entirely different histories.

64 changed files with 146 additions and 1927 deletions

View file

@ -1,15 +0,0 @@
# Earth Walker Design — site env vars
#
# Both are inlined at build time by Vite. To rotate either, edit
# values here, then rebuild + redeploy.
# The Nostr public key that receives encrypted inquiry submissions.
# Bech32 npub1... form. Generate via `nak key generate` (fiatjaf/nak)
# or any Nostr client.
VITE_OWNER_NPUB=
# Optional. Comma-separated wss:// relay URLs the inquiry form
# publishes to. If unset, defaults to:
# wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
# The submission succeeds if at least one relay accepts the event.
VITE_NOSTR_RELAYS=

148
README.md
View file

@ -1,122 +1,74 @@
# Earth Walker Design
# boilerplate-website
Studio site for an interior designer. Vue 3 + Vite + shadcn-vue +
Tailwind 4. Anonymous-by-default contact: inquiry submissions are
encrypted in the visitor's browser and delivered to the designer's
Nostr inbox.
Opinionated Vue 3 starter — the base every new aiolabs website forks from.
## Routes
## Stack
| Path | Purpose |
|---|---|
| `/` | Hero, studio voice, two project teasers, inquiry CTA |
| `/portfolio` | Two-up project grid (Boulder, Asheville) |
| `/portfolio/boulder` | Boulder project detail — editorial image scroll + lightbox |
| `/portfolio/asheville` | Asheville project detail |
| `/contact` | Inquiry form (Nostr-delivered) |
- **Vue 3.5** + **Vite 8** + **TypeScript 6**
- **shadcn-vue** (via `reka-ui` + `class-variance-authority` + `tailwind-merge`) — components.json pre-wired, run `pnpm dlx shadcn-vue@latest add <name>` to copy components in
- **Tailwind CSS 4** (via `@tailwindcss/vite` — no `tailwind.config.js`)
- **Pinia 3** — sample `useCounterStore` in `src/stores/`
- **Vue Router 5** — file in `src/router/index.ts`, lazy-loaded views in `src/views/`
- **Vue I18n 11**`src/i18n/locales/{en,es}.json`
- **vee-validate 4** + **zod 3** — form validation primitives (zod pinned to ^3 until `@vee-validate/zod` ships a v4-compatible resolver)
- **@lucide/vue** — icon set (`lucide-vue-next` is deprecated upstream)
- **ESLint 10** (flat config) + **Prettier 3**
## Local dev
## Quick start
```sh
pnpm install
cp .env.example .env # fill in VITE_OWNER_NPUB
pnpm dev # http://localhost:5173
pnpm build # type-check + production build to dist/
pnpm preview # serve dist/
pnpm dev # vite dev server
pnpm build # type-check + production build
pnpm preview # serve dist/
pnpm lint
pnpm format
```
The contact form requires `VITE_OWNER_NPUB` to be set — without it,
`submitInquiry` throws so visitors see an explicit error rather
than silently dropping submissions. Generate one with
[`nak key generate`](https://github.com/fiatjaf/nak) or any Nostr client.
## Layout
## Env vars
```
src/
├─ App.vue # router-view shell
├─ main.ts # plugin wiring
├─ style.css # tailwind + shadcn-vue CSS variables
├─ lib/utils.ts # cn() — shadcn-vue's class merger
├─ router/index.ts
├─ stores/counter.ts # example pinia store
├─ i18n/
│ ├─ index.ts
│ └─ locales/{en,es}.json
├─ views/HomeView.vue # proof-of-wiring page (i18n + pinia + tailwind)
└─ features/
├─ nostr/README.md # opt-in: nostr-tools wiring (see file)
└─ lnbits/README.md # opt-in: LNbits payments wiring (see file)
```
| Var | Required | Default | Notes |
|---|---|---|---|
| `VITE_OWNER_NPUB` | yes | — | The designer's Nostr public key (`npub1…`). Inquiries are NIP-17 gift-wrapped to this key. |
| `VITE_NOSTR_RELAYS` | no | `wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band` | Comma-separated wss:// list. Submission succeeds if any one relay accepts. |
## Optional features
Both are inlined at build time by Vite. Rotating either requires a
rebuild + redeploy.
Both nostr and LNbits live as **documentation-only** folders. The deps
aren't installed by default — bundle stays small for sites that don't
need them. Each folder's README walks you through enabling.
## How the contact form delivers inquiries
- **`src/features/nostr/`** — connect to relays, sign/publish events, contact forms that DM the site owner's npub
- **`src/features/lnbits/`** — create invoices, accept Lightning payments via an LNbits instance
1. Visitor fills the form (name optional, contact method + value,
message). Validation runs client-side via `vee-validate` + `zod`.
2. `submitInquiry()` (`src/features/nostr/submitInquiry.ts`):
- generates a fresh ephemeral secp256k1 keypair
- decodes `VITE_OWNER_NPUB` to hex
- calls `nip17.wrapEvent()` to produce a kind:1059 gift-wrap with
NIP-44 v2 encryption inside (the visitor's identity is the
throwaway key, not anything they entered)
- publishes via `SimplePool` to the configured relays in parallel
3. The designer receives the inquiry as a DM in any NIP-17 capable
Nostr client (Damus, Amethyst, 0xchat, etc.) signed in with the
matching nsec.
## Versioning strategy
No server in between stores the message. There is no inbox to leak.
The boilerplate's `main` branch is **the "tools wired, no content" baseline**. New site = clone (or fork on Forgejo) → start adding content immediately.
## Theming
Light + dark stone-warm palettes live in `src/style.css` as OKLCH
CSS variables. Toggle persists to `localStorage` under
`ewd:theme` (see `src/composables/useTheme.ts`). Typography pairs
Fraunces (display, h1h3, brand wordmark) with Inter (UI/body) via
Bunny Fonts, declared in `index.html` and bound through Tailwind 4
`@theme` directives.
To recolor: edit the OKLCH triples in `src/style.css` under `:root`
and `.dark`. Keep all shadcn-vue token names intact.
## Image discipline
All images live under `public/images/<project>/`. The build pipeline
expects:
- Filenames are sequence-numbered (`01.jpg`, `02.jpg`, …) — never
leak addresses, neighborhood names, or owner identities in the
filename.
- EXIF / GPS / timestamp / maker metadata is stripped before commit.
Re-run on new assets:
```sh
nix-shell -p imagemagick --run \
'for f in *.jpg; do magick "$f" -auto-orient -strip -resize "2400x2400>" \
-interlace Plane -sampling-factor 4:2:0 -quality 82 "${f}.tmp" \
&& mv "${f}.tmp" "$f"; done'
```
- Alt text in `src/data/projects.ts` describes the room/feature, not
the location.
## Adding a project
1. Drop sequence-numbered images into `public/images/<slug>/`.
2. Add a new `Project` entry in `src/data/projects.ts` (typed) with
`slug`, `name`, `eyebrow`, `intro`, `cover`, `coverAlt`, and an
`images[]` array. Each image takes a `feature` tag
(`hero` | `wide` | `narrow` | `paired`) that drives the layout
slot in `ProjectDetail.vue`'s editorial scroll.
3. Add a 4-line view at `src/views/projects/<Slug>View.vue`:
```vue
<script setup lang="ts">
import ProjectDetail from '@/components/projects/ProjectDetail.vue'
import { mySlug } from '@/data/projects'
</script>
<template><ProjectDetail :project="mySlug" /></template>
```
4. Register the route in `src/router/index.ts`.
5. The `PortfolioView` already iterates `projects[]`, so the new
project's card appears automatically.
## Stack
Tracking the upstream boilerplate (`aiolabs/boilerplate-website`).
To pull dependency refreshes:
To pull dep refreshes from the boilerplate into an existing site:
```sh
git remote add boilerplate forgejo@git.atitlan.io:aiolabs/boilerplate-website.git
git fetch boilerplate
git merge boilerplate/main
```
When a site diverges enough to no longer benefit from these merges, drop the remote — it becomes its own thing.
## Keeping deps current
- The boilerplate gets dep bumps via PRs (Renovate / Dependabot configurable; otherwise periodic `pnpm update --latest` + manual review).
- The Vue ecosystem (vue, pinia, router, i18n, vee-validate) versions in lockstep — when one majors, the others usually follow within weeks.
- Tailwind 4 + shadcn-vue + reka-ui is the current modern combo. shadcn-vue uses tw-animate-css (not the deprecated tailwindcss-animate plugin).

11
env.d.ts vendored
View file

@ -1,12 +1 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** Hex- or bech32-encoded npub of the inquiry recipient. */
readonly VITE_OWNER_NPUB?: string
/** Comma-separated wss:// relay list; falls back to a default set if unset. */
readonly VITE_NOSTR_RELAYS?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View file

@ -17,7 +17,6 @@
"@vueuse/core": "^14.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"nostr-tools": "^2.23.5",
"pinia": "^3.0.4",
"reka-ui": "^2.9.8",
"tailwind-merge": "^3.6.0",

70
pnpm-lock.yaml generated
View file

@ -23,9 +23,6 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
nostr-tools:
specifier: ^2.23.5
version: 2.23.5(typescript@6.0.3)
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
@ -260,18 +257,6 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@noble/ciphers@2.1.1':
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
engines: {node: '>= 20.19.0'}
'@noble/curves@2.0.1':
resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==}
engines: {node: '>= 20.19.0'}
'@noble/hashes@2.0.1':
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
engines: {node: '>= 20.19.0'}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@ -389,15 +374,6 @@ packages:
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@scure/base@2.0.0':
resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==}
'@scure/bip32@2.0.1':
resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==}
'@scure/bip39@2.0.1':
resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==}
'@swc/helpers@0.5.23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
@ -1150,17 +1126,6 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
nostr-tools@2.23.5:
resolution: {integrity: sha512-Fa7ZlUdjfUW1P4E7H3yBexhOHYi18XNyvd2n7eNHkYR085xADX6Y8V8Vm7nT/XQajaFOBrptXmVIGkJ2E4vfVw==}
peerDependencies:
typescript: '>=5.0.0'
peerDependenciesMeta:
typescript:
optional: true
nostr-wasm@0.1.0:
resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==}
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
@ -1702,14 +1667,6 @@ snapshots:
'@tybys/wasm-util': 0.10.2
optional: true
'@noble/ciphers@2.1.1': {}
'@noble/curves@2.0.1':
dependencies:
'@noble/hashes': 2.0.1
'@noble/hashes@2.0.1': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@ -1777,19 +1734,6 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {}
'@scure/base@2.0.0': {}
'@scure/bip32@2.0.1':
dependencies:
'@noble/curves': 2.0.1
'@noble/hashes': 2.0.1
'@scure/base': 2.0.0
'@scure/bip39@2.0.1':
dependencies:
'@noble/hashes': 2.0.1
'@scure/base': 2.0.0
'@swc/helpers@0.5.23':
dependencies:
tslib: 2.8.1
@ -2533,20 +2477,6 @@ snapshots:
natural-compare@1.4.0: {}
nostr-tools@2.23.5(typescript@6.0.3):
dependencies:
'@noble/ciphers': 2.1.1
'@noble/curves': 2.0.1
'@noble/hashes': 2.0.1
'@scure/base': 2.0.0
'@scure/bip32': 2.0.1
'@scure/bip39': 2.0.1
nostr-wasm: 0.1.0
optionalDependencies:
typescript: 6.0.3
nostr-wasm@0.1.0: {}
nth-check@2.1.1:
dependencies:
boolbase: 1.0.0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 526 KiB

After

Width:  |  Height:  |  Size: 892 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 584 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 466 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 547 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 725 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 616 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

View file

@ -1,220 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { submitInquiry, type ContactMethod } from '@/features/nostr/submitInquiry'
const { t } = useI18n({ useScope: 'global' })
const METHODS: ContactMethod[] = ['email', 'whatsapp', 'signal', 'telegram', 'nostr']
const methodItems = computed(() =>
METHODS.map((m) => ({
value: m,
label: t(`methods.${m}`),
placeholder: t(`methodPlaceholders.${m}`),
})),
)
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const PHONE_RE = /^\+?[\d\s().-]{7,}$/
const HANDLE_RE = /^@?[A-Za-z0-9_.]{3,32}$/
const Schema = computed(() =>
z
.object({
name: z
.string()
.trim()
.max(80, t('form.errors.nameTooLong'))
.optional()
.or(z.literal('')),
contactMethod: z.enum(['email', 'whatsapp', 'signal', 'telegram', 'nostr'], {
message: t('form.errors.methodMissing'),
}),
contactValue: z
.string()
.trim()
.min(2, t('form.errors.valueRequired'))
.max(200, t('form.errors.valueTooLong')),
message: z
.string()
.trim()
.min(10, t('form.errors.messageTooShort'))
.max(2000, t('form.errors.messageTooLong')),
})
.superRefine((data, ctx) => {
const v = data.contactValue
const fail = (msg: string) =>
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['contactValue'], message: msg })
switch (data.contactMethod) {
case 'email':
if (!EMAIL_RE.test(v)) fail(t('form.errors.badEmail'))
break
case 'whatsapp':
case 'signal':
if (!PHONE_RE.test(v) && !HANDLE_RE.test(v)) fail(t('form.errors.badPhoneOrHandle'))
break
case 'telegram':
if (!HANDLE_RE.test(v)) fail(t('form.errors.badTelegram'))
break
case 'nostr':
if (!/^npub1[0-9a-z]{30,}$/.test(v)) fail(t('form.errors.badNpub'))
break
}
}),
)
type FormValues = {
name?: string
contactMethod: ContactMethod
contactValue: string
message: string
}
const { handleSubmit, isSubmitting, resetForm, values } = useForm<FormValues>({
validationSchema: computed(() => toTypedSchema(Schema.value)),
initialValues: { name: '', contactMethod: 'email', contactValue: '', message: '' },
})
const submitting = ref(false)
const onSubmit = handleSubmit(async (vals) => {
submitting.value = true
try {
const result = await submitInquiry({
name: vals.name?.trim() || undefined,
contactMethod: vals.contactMethod,
contactValue: vals.contactValue.trim(),
message: vals.message.trim(),
})
if (result.ok) {
toast.success(t('form.toast.successTitle'), {
description:
result.acceptedBy === result.attempted
? t('form.toast.successFull')
: t('form.toast.successPartial', {
accepted: result.acceptedBy,
attempted: result.attempted,
}),
})
resetForm()
} else {
toast.error(t('form.toast.noRelaysTitle'), {
description: t('form.toast.noRelaysBody'),
})
}
} catch (err) {
toast.error(t('form.toast.failedTitle'), {
description: err instanceof Error ? err.message : t('form.toast.unknownError'),
})
} finally {
submitting.value = false
}
})
function placeholderFor(method: string | undefined): string {
return methodItems.value.find((m) => m.value === method)?.placeholder ?? ''
}
</script>
<template>
<form class="space-y-7" novalidate @submit="onSubmit">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>
{{ t('form.name.label') }}
<span class="text-muted-foreground">{{ t('form.name.optional') }}</span>
</FormLabel>
<FormControl>
<Input
type="text"
autocomplete="name"
:placeholder="t('form.name.placeholder')"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<div class="grid gap-7 sm:grid-cols-[180px_1fr]">
<FormField v-slot="{ componentField }" name="contactMethod">
<FormItem>
<FormLabel>{{ t('form.method.label') }}</FormLabel>
<Select v-bind="componentField">
<FormControl>
<SelectTrigger>
<SelectValue :placeholder="t('form.method.placeholder')" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="m in methodItems" :key="m.value" :value="m.value">
{{ m.label }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="contactValue">
<FormItem>
<FormLabel>{{ t('form.value.label') }}</FormLabel>
<FormControl>
<Input
type="text"
:placeholder="placeholderFor(values.contactMethod)"
v-bind="componentField"
/>
</FormControl>
<FormDescription class="text-xs">
{{ t('form.value.description') }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<FormField v-slot="{ componentField }" name="message">
<FormItem>
<FormLabel>{{ t('form.message.label') }}</FormLabel>
<FormControl>
<Textarea
rows="6"
:placeholder="t('form.message.placeholder')"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<div class="flex items-center justify-end pt-2">
<Button type="submit" :disabled="isSubmitting || submitting" class="min-w-36">
{{ isSubmitting || submitting ? t('form.submit.sending') : t('form.submit.idle') }}
</Button>
</div>
</form>
</template>

View file

@ -1,22 +0,0 @@
<script setup lang="ts">
import { Lock } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n({ useScope: 'global' })
</script>
<template>
<aside
class="border-border/60 text-muted-foreground bg-muted/30 rounded-sm border p-5 text-sm leading-relaxed"
>
<div class="text-foreground mb-2 flex items-center gap-2 text-xs uppercase tracking-[0.18em]">
<Lock class="h-3.5 w-3.5" />
<span>{{ t('privacyBlurb.title') }}</span>
</div>
<i18n-t keypath="privacyBlurb.body" tag="p">
<template #nostr>
<span class="text-foreground">{{ t('privacyBlurb.nostrLabel') }}</span>
</template>
</i18n-t>
</aside>
</template>

View file

@ -3,9 +3,6 @@ import { RouterView } from 'vue-router'
import { Toaster } from '@/components/ui/sonner'
import SiteHeader from './SiteHeader.vue'
import SiteFooter from './SiteFooter.vue'
import PaletteSwitcher from './PaletteSwitcher.vue'
const isDev = import.meta.env.DEV
</script>
<template>
@ -16,6 +13,5 @@ const isDev = import.meta.env.DEV
</main>
<SiteFooter />
<Toaster position="bottom-right" />
<PaletteSwitcher v-if="isDev" />
</div>
</template>

View file

@ -1,42 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { SUPPORTED_LOCALES, persistLocale, type LocaleCode } from '@/i18n'
const { locale } = useI18n({ useScope: 'global' })
function set(code: LocaleCode) {
locale.value = code
persistLocale(code)
}
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger
class="text-foreground/70 hover:text-foreground inline-flex h-9 items-center px-2 text-xs uppercase tracking-[0.22em] transition-colors"
:aria-label="`Language: ${locale}`"
>
{{ locale.toUpperCase() }}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="min-w-40">
<DropdownMenuItem
v-for="l in SUPPORTED_LOCALES"
:key="l.code"
class="flex items-center justify-between"
:class="locale === l.code ? 'bg-accent' : ''"
@select="set(l.code as LocaleCode)"
>
<span>{{ l.name }}</span>
<span class="text-muted-foreground text-xs uppercase tracking-[0.18em]">
{{ l.label }}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>

View file

@ -1,41 +0,0 @@
<script setup lang="ts">
import { Palette } from '@lucide/vue'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { PALETTES, usePalette, type PaletteSlug } from '@/composables/usePalette'
const { palette, set } = usePalette()
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger
class="border-border bg-background/85 hover:bg-background fixed right-4 bottom-4 z-50 inline-flex h-10 w-10 items-center justify-center rounded-full border shadow-lg backdrop-blur transition-colors"
aria-label="Switch color palette"
>
<Palette class="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" class="w-72">
<DropdownMenuLabel class="text-xs uppercase tracking-[0.18em]">
Palette (dev only)
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
v-for="p in PALETTES"
:key="p.slug"
class="flex flex-col items-start gap-0.5 py-2.5"
:class="palette === p.slug ? 'bg-accent' : ''"
@select="set(p.slug as PaletteSlug)"
>
<span class="font-serif text-sm">{{ p.label }}</span>
<span class="text-muted-foreground text-xs">{{ p.description }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>

View file

@ -1,8 +1,6 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
const { t } = useI18n({ useScope: 'global' })
const year = new Date().getFullYear()
</script>
@ -12,15 +10,17 @@ const year = new Date().getFullYear()
class="mx-auto flex max-w-[1400px] flex-col items-start justify-between gap-6 px-6 py-12 md:flex-row md:items-center md:px-10"
>
<div class="space-y-1">
<p class="font-serif text-base tracking-[0.18em] uppercase">{{ t('brand') }}</p>
<p class="text-muted-foreground text-xs">{{ t('footer.tagline') }}</p>
<p class="font-serif text-base tracking-[0.18em] uppercase">Earth Walker</p>
<p class="text-muted-foreground text-xs">
Interior design by appointment.
</p>
</div>
<div class="flex items-center gap-8">
<RouterLink
to="/contact"
class="text-foreground/70 hover:text-foreground text-xs uppercase tracking-[0.22em] transition-colors"
>
{{ t('nav.inquire') }}
Inquire
</RouterLink>
<p class="text-muted-foreground text-xs">© {{ year }}</p>
</div>

View file

@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Menu } from '@lucide/vue'
import {
Sheet,
@ -11,14 +10,11 @@ import {
SheetTrigger,
} from '@/components/ui/sheet'
import ThemeToggle from './ThemeToggle.vue'
import LocaleSwitcher from './LocaleSwitcher.vue'
const { t } = useI18n({ useScope: 'global' })
const navItems = computed(() => [
{ to: '/portfolio', label: t('nav.portfolio') },
{ to: '/contact', label: t('nav.contact') },
])
const navItems = [
{ to: '/portfolio', label: 'Portfolio' },
{ to: '/contact', label: 'Contact' },
]
const mobileOpen = ref(false)
</script>
@ -34,10 +30,10 @@ const mobileOpen = ref(false)
to="/"
class="text-foreground font-serif text-lg tracking-[0.18em] uppercase"
>
{{ t('brand') }}
Earth Walker
</RouterLink>
<nav class="hidden items-center gap-8 md:flex">
<nav class="hidden items-center gap-10 md:flex">
<RouterLink
v-for="item in navItems"
:key="item.to"
@ -47,24 +43,22 @@ const mobileOpen = ref(false)
>
{{ item.label }}
</RouterLink>
<LocaleSwitcher />
<ThemeToggle />
</nav>
<div class="flex items-center gap-1 md:hidden">
<LocaleSwitcher />
<ThemeToggle />
<Sheet v-model:open="mobileOpen">
<SheetTrigger
class="text-foreground/70 hover:text-foreground inline-flex h-9 w-9 items-center justify-center"
:aria-label="t('nav.openMenu')"
aria-label="Open menu"
>
<Menu class="h-5 w-5" />
</SheetTrigger>
<SheetContent side="right" class="w-full sm:max-w-sm">
<SheetHeader>
<SheetTitle class="font-serif tracking-[0.18em] uppercase">
{{ t('brand') }}
Earth Walker
</SheetTitle>
</SheetHeader>
<nav class="mt-10 flex flex-col gap-6 px-4">

View file

@ -1,9 +1,7 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Moon, Sun } from '@lucide/vue'
import { useTheme } from '@/composables/useTheme'
const { t } = useI18n({ useScope: 'global' })
const { theme, toggle } = useTheme()
</script>
@ -11,7 +9,7 @@ const { theme, toggle } = useTheme()
<button
type="button"
class="text-foreground/70 hover:text-foreground inline-flex h-9 w-9 items-center justify-center transition-colors"
:aria-label="theme === 'dark' ? t('themeToggle.toLight') : t('themeToggle.toDark')"
:aria-label="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
@click="toggle"
>
<Sun v-if="theme === 'dark'" class="h-4 w-4" />

View file

@ -1,88 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import ProjectImage from './ProjectImage.vue'
import type { Project } from '@/data/projects'
const props = defineProps<{ project: Project }>()
const { t } = useI18n({ useScope: 'global' })
const hero = computed(() => props.project.images.find((img) => img.feature === 'hero'))
const rest = computed(() => props.project.images.filter((img) => img.feature !== 'hero'))
const name = computed(() => t(`projects.${props.project.slug}.name`))
const eyebrow = computed(() => t(`projects.${props.project.slug}.eyebrow`))
const intro = computed(() => t(`projects.${props.project.slug}.intro`))
</script>
<template>
<article class="bg-background">
<!-- Hero -->
<section v-if="hero" class="relative overflow-hidden">
<img
:src="hero.src"
:alt="hero.alt"
class="h-[88vh] w-full object-cover md:h-screen"
/>
<div
class="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/45"
></div>
<div
class="absolute right-0 bottom-10 left-0 mx-auto max-w-[1400px] px-6 text-white md:bottom-16 md:px-10"
>
<p class="eyebrow text-white/80">{{ eyebrow }}</p>
<h1 class="mt-3 font-serif text-5xl font-light tracking-tight md:text-7xl">
{{ name }}
</h1>
</div>
</section>
<!-- Intro -->
<section class="px-6 py-20 md:py-28">
<p
class="mx-auto max-w-[60ch] text-center text-lg leading-relaxed text-foreground/80 md:text-xl"
>
{{ intro }}
</p>
</section>
<!-- Editorial image scroll -->
<section class="space-y-16 px-6 pb-24 md:px-10 md:pb-32">
<div class="mx-auto max-w-[1400px] space-y-16 md:space-y-24">
<template v-for="(img, i) in rest" :key="img.src + i">
<ProjectImage v-if="img.feature === 'wide'" :image="img" />
<div v-else-if="img.feature === 'narrow'" class="mx-auto max-w-3xl">
<ProjectImage :image="img" />
</div>
<div
v-else-if="img.feature === 'paired' && (rest[i - 1]?.feature !== 'paired')"
class="grid gap-6 md:grid-cols-2 md:gap-10"
>
<ProjectImage :image="img" />
<ProjectImage v-if="rest[i + 1]?.feature === 'paired'" :image="rest[i + 1]" />
</div>
<template v-else-if="img.feature === 'paired'"></template>
<ProjectImage v-else :image="img" />
</template>
</div>
</section>
<!-- Closing CTA -->
<section
class="border-border/60 border-t px-6 py-20 text-center md:py-28 md:px-10"
>
<p class="eyebrow">{{ t('projectDetail.cta.eyebrow') }}</p>
<h2 class="mx-auto mt-4 max-w-2xl font-serif text-3xl font-light md:text-4xl">
{{ t('projectDetail.cta.headline') }}
</h2>
<Button as-child class="mt-8">
<RouterLink to="/contact">{{ t('projectDetail.cta.action') }}</RouterLink>
</Button>
</section>
</article>
</template>

View file

@ -1,39 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
import type { ProjectImage } from '@/data/projects'
const props = defineProps<{ image: ProjectImage }>()
const { t } = useI18n({ useScope: 'global' })
const open = ref(false)
</script>
<template>
<Dialog v-model:open="open">
<DialogTrigger as-child>
<button
type="button"
class="group relative block w-full overflow-hidden rounded-sm"
:aria-label="t('projectDetail.viewLarger', { alt: props.image.alt })"
>
<img
:src="image.src"
:alt="image.alt"
loading="lazy"
decoding="async"
class="w-full transition-transform duration-700 ease-out group-hover:scale-[1.015]"
/>
</button>
</DialogTrigger>
<DialogContent
class="border-0 bg-transparent p-0 sm:max-w-[min(95vw,1400px)]"
>
<img
:src="image.src"
:alt="image.alt"
class="h-auto max-h-[88vh] w-full rounded-sm object-contain"
/>
</DialogContent>
</Dialog>
</template>

View file

@ -2,7 +2,7 @@
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "@lucide/vue"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,

View file

@ -2,7 +2,7 @@
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "@lucide/vue"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,

View file

@ -2,7 +2,7 @@
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "@lucide/vue"
import { Check } from "lucide-vue-next"
import {
DropdownMenuCheckboxItem,
DropdownMenuItemIndicator,

View file

@ -2,7 +2,7 @@
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Circle } from "@lucide/vue"
import { Circle } from "lucide-vue-next"
import {
DropdownMenuItemIndicator,
DropdownMenuRadioItem,

View file

@ -2,7 +2,7 @@
import type { DropdownMenuSubTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronRight } from "@lucide/vue"
import { ChevronRight } from "lucide-vue-next"
import {
DropdownMenuSubTrigger,
useForwardProps,

View file

@ -2,7 +2,7 @@
import type { SelectItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "@lucide/vue"
import { Check } from "lucide-vue-next"
import {
SelectItem,
SelectItemIndicator,

View file

@ -2,7 +2,7 @@
import type { SelectScrollDownButtonProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronDown } from "@lucide/vue"
import { ChevronDown } from "lucide-vue-next"
import { SelectScrollDownButton, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"

View file

@ -2,7 +2,7 @@
import type { SelectScrollUpButtonProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronUp } from "@lucide/vue"
import { ChevronUp } from "lucide-vue-next"
import { SelectScrollUpButton, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"

View file

@ -2,7 +2,7 @@
import type { SelectTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronDown } from "@lucide/vue"
import { ChevronDown } from "lucide-vue-next"
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"

View file

@ -3,7 +3,7 @@ import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { SheetVariants } from "."
import { reactiveOmit } from "@vueuse/core"
import { X } from "@lucide/vue"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,

View file

@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { ToasterProps } from "vue-sonner"
import { reactiveOmit } from "@vueuse/core"
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, XIcon } from "@lucide/vue"
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, XIcon } from "lucide-vue-next"
import { Toaster as Sonner } from "vue-sonner"
const props = defineProps<ToasterProps>()

View file

@ -1,54 +0,0 @@
import { ref, watchEffect } from 'vue'
export const PALETTES = [
{
slug: 'stone-warm',
label: 'Stone Warm',
description: 'Bone background, matte-black ink. Gallery-quiet.',
},
{
slug: 'desert-clay',
label: 'Desert Clay',
description: 'Warm cream with terracotta undertone. Evening light.',
},
{
slug: 'forest-ash',
label: 'Forest & Ash',
description: 'Sage-tinted neutral, mossy accent. Nature integrated.',
},
{
slug: 'charcoal-cream',
label: 'Charcoal & Cream',
description: 'High-contrast monochrome. Moody architectural.',
},
] as const
export type PaletteSlug = (typeof PALETTES)[number]['slug']
const STORAGE_KEY = 'ewd:palette'
const DEFAULT: PaletteSlug = 'stone-warm'
function initial(): PaletteSlug {
if (typeof window === 'undefined') return DEFAULT
const url = new URL(window.location.href)
const fromQuery = url.searchParams.get('palette') as PaletteSlug | null
if (fromQuery && PALETTES.some((p) => p.slug === fromQuery)) return fromQuery
const stored = window.localStorage.getItem(STORAGE_KEY) as PaletteSlug | null
if (stored && PALETTES.some((p) => p.slug === stored)) return stored
return DEFAULT
}
const palette = ref<PaletteSlug>(initial())
watchEffect(() => {
if (typeof document === 'undefined') return
document.documentElement.setAttribute('data-palette', palette.value)
window.localStorage.setItem(STORAGE_KEY, palette.value)
})
export function usePalette() {
function set(slug: PaletteSlug) {
palette.value = slug
}
return { palette, set }
}

View file

@ -1,155 +0,0 @@
export type ImageOrientation = 'landscape' | 'portrait'
export interface ProjectImage {
src: string
alt: string
orientation: ImageOrientation
/** Tag controlling layout slot in ProjectDetail's editorial scroll. */
feature?: 'hero' | 'wide' | 'narrow' | 'paired'
}
export interface Project {
slug: 'boulder' | 'asheville'
name: string
eyebrow: string
intro: string
cover: string
coverAlt: string
images: ProjectImage[]
}
export const boulder: Project = {
slug: 'boulder',
name: 'Boulder',
eyebrow: 'Light-filled organic',
intro:
'A mid-century ranch reimagined around warm woods, reclaimed barnwood walls, ' +
'live-edge counters, and emerald glazed tile. The palette stays in conversation ' +
'with the trees outside — sunlight does most of the work.',
cover: '/images/boulder/05.jpg',
coverAlt: 'Walnut kitchen with white embossed tile and warm pendant light',
images: [
{
src: '/images/boulder/03.jpg',
alt: 'Kitchen with reclaimed barnwood walls and live-edge bar top',
orientation: 'landscape',
feature: 'hero',
},
{
src: '/images/boulder/01.jpg',
alt: 'Oil-rubbed bronze faucet over a quartz counter, reclaimed wood backsplash',
orientation: 'portrait',
feature: 'narrow',
},
{
src: '/images/boulder/07.jpg',
alt: 'Kitchen alternate angle showing live-edge counter and Asian carving',
orientation: 'landscape',
feature: 'wide',
},
{
src: '/images/boulder/02.jpg',
alt: 'Dining room with reclaimed barnwood feature wall and rush-seat chairs',
orientation: 'landscape',
feature: 'wide',
},
{
src: '/images/boulder/08.jpg',
alt: 'Living room with mid-century sofa and emerald tile fireplace surround',
orientation: 'landscape',
feature: 'wide',
},
{
src: '/images/boulder/05.jpg',
alt: 'Walnut cabinets with white embossed tile and glass pendant',
orientation: 'portrait',
feature: 'narrow',
},
{
src: '/images/boulder/09.jpg',
alt: 'Bathroom with emerald subway tile shower and walnut vanity',
orientation: 'portrait',
feature: 'paired',
},
{
src: '/images/boulder/06.jpg',
alt: 'Emerald subway tile shower with bronze fittings and white niche',
orientation: 'landscape',
feature: 'paired',
},
{
src: '/images/boulder/12.jpg',
alt: 'Walnut bath vanity with reclaimed wood mirror and white tile backsplash',
orientation: 'portrait',
feature: 'narrow',
},
{
src: '/images/boulder/10.jpg',
alt: 'Rear deck and patio at golden hour with mountain view',
orientation: 'landscape',
feature: 'wide',
},
{
src: '/images/boulder/11.jpg',
alt: 'Rear deck at dusk with warm lit windows',
orientation: 'landscape',
feature: 'wide',
},
{
src: '/images/boulder/04.jpg',
alt: 'Front of the home under a winter moon at dusk',
orientation: 'landscape',
feature: 'wide',
},
],
}
export const asheville: Project = {
slug: 'asheville',
name: 'Asheville',
eyebrow: 'Architectural and moody',
intro:
'A counterpoint to Boulder — black vertical slats, matte black appliances, ' +
'and large picture windows held in balance by warm wood floors and layered ' +
'textiles. A space that is restrained but never cold.',
cover: '/images/asheville/01-living.jpg',
coverAlt: 'Living room with black vertical slat wall and picture window',
images: [
{
src: '/images/asheville/01-living.jpg',
alt: 'Living room with black vertical slat wall and oversized window',
orientation: 'portrait',
feature: 'hero',
},
{
src: '/images/asheville/02-kitchen.jpg',
alt: 'Galley kitchen with matte black appliances and layered Persian rug',
orientation: 'portrait',
feature: 'narrow',
},
{
src: '/images/asheville/04-dining-wide.jpg',
alt: 'Dining nook with vertical slat wall and trailing monstera',
orientation: 'portrait',
feature: 'narrow',
},
{
src: '/images/asheville/05-dining-detail.jpg',
alt: 'Dining table beneath a Nelson Saucer pendant against vertical slat wall',
orientation: 'portrait',
feature: 'narrow',
},
{
src: '/images/asheville/03-bath.jpg',
alt: 'Powder bath with layered glazed tile and matte black accents',
orientation: 'portrait',
feature: 'narrow',
},
],
}
export const projects: Project[] = [boulder, asheville]
export function getProject(slug: string): Project | undefined {
return projects.find((p) => p.slug === slug)
}

View file

@ -1,30 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { SimplePool, type Event } from 'nostr-tools'
const DEFAULT_RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
]
function parseRelays(): string[] {
const env = import.meta.env.VITE_NOSTR_RELAYS
if (!env) return DEFAULT_RELAYS
const list = env
.split(',')
.map((r: string) => r.trim())
.filter((r: string) => r.startsWith('wss://') || r.startsWith('ws://'))
return list.length ? list : DEFAULT_RELAYS
}
export const useNostrStore = defineStore('nostr', () => {
const pool = new SimplePool()
const relays = ref<string[]>(parseRelays())
async function publish(event: Event): Promise<PromiseSettledResult<string>[]> {
return Promise.allSettled(pool.publish(relays.value, event))
}
return { relays, publish }
})

View file

@ -1,69 +0,0 @@
import { nip17, nip19 } from 'nostr-tools'
import { generateSecretKey } from 'nostr-tools/pure'
import { useNostrStore } from './store'
export type ContactMethod = 'email' | 'whatsapp' | 'signal' | 'telegram' | 'nostr'
export interface InquiryPayload {
name?: string
contactMethod: ContactMethod
contactValue: string
message: string
}
export interface SubmitResult {
ok: boolean
acceptedBy: number
attempted: number
}
const METHOD_LABEL: Record<ContactMethod, string> = {
email: 'Email',
whatsapp: 'WhatsApp',
signal: 'Signal',
telegram: 'Telegram',
nostr: 'Nostr',
}
function ownerHex(): string {
const npub = import.meta.env.VITE_OWNER_NPUB
if (!npub) {
throw new Error(
'VITE_OWNER_NPUB is not set — the inquiry form has no destination.',
)
}
const decoded = nip19.decode(npub.trim() as `npub1${string}`)
if (decoded.type !== 'npub') {
throw new Error(
`VITE_OWNER_NPUB must be an npub (got ${(decoded as { type: string }).type}).`,
)
}
return decoded.data
}
function formatMessage(p: InquiryPayload): string {
const lines: string[] = []
lines.push('New inquiry — Earth Walker Design')
lines.push('')
if (p.name) lines.push(`From: ${p.name}`)
lines.push(`Preferred contact: ${METHOD_LABEL[p.contactMethod]}${p.contactValue}`)
lines.push('')
lines.push(p.message)
return lines.join('\n')
}
export async function submitInquiry(payload: InquiryPayload): Promise<SubmitResult> {
const recipient = ownerHex()
const senderSk = generateSecretKey()
const wrapped = nip17.wrapEvent(senderSk, { publicKey: recipient }, formatMessage(payload))
const nostr = useNostrStore()
const results = await nostr.publish(wrapped)
const accepted = results.filter((r) => r.status === 'fulfilled').length
return {
ok: accepted > 0,
acceptedBy: accepted,
attempted: results.length,
}
}

View file

@ -1,36 +1,10 @@
import { createI18n } from 'vue-i18n'
import en from './locales/en.json'
import es from './locales/es.json'
import fr from './locales/fr.json'
export const SUPPORTED_LOCALES = [
{ code: 'en', label: 'EN', name: 'English' },
{ code: 'fr', label: 'FR', name: 'Français' },
{ code: 'es', label: 'ES', name: 'Español' },
] as const
export type LocaleCode = (typeof SUPPORTED_LOCALES)[number]['code']
const STORAGE_KEY = 'ewd:locale'
function initialLocale(): LocaleCode {
if (typeof window === 'undefined') return 'en'
const stored = window.localStorage.getItem(STORAGE_KEY) as LocaleCode | null
if (stored && SUPPORTED_LOCALES.some((l) => l.code === stored)) return stored
const nav = (window.navigator.language || 'en').slice(0, 2).toLowerCase() as LocaleCode
if (SUPPORTED_LOCALES.some((l) => l.code === nav)) return nav
return 'en'
}
export default createI18n({
legacy: false,
locale: initialLocale(),
locale: 'en',
fallbackLocale: 'en',
messages: { en, es, fr },
messages: { en, es },
})
export function persistLocale(code: LocaleCode) {
if (typeof window !== 'undefined') {
window.localStorage.setItem(STORAGE_KEY, code)
}
}

View file

@ -1,134 +1,11 @@
{
"brand": "Earth Walker",
"nav": {
"portfolio": "Portfolio",
"contact": "Contact",
"inquire": "Inquire",
"openMenu": "Open menu",
"viewAll": "View all"
},
"themeToggle": {
"toLight": "Switch to light theme",
"toDark": "Switch to dark theme"
},
"footer": {
"tagline": "Interior design — by appointment."
"app": {
"title": "Boilerplate Website"
},
"home": {
"hero": {
"eyebrow": "Earth Walker Design",
"headline": "Grounded, architectural,",
"headline2": "restorative interiors."
},
"studio": {
"eyebrow": "Studio",
"lead": "A balance of minimalism and soul. Oversized windows and natural light. Warm woods, matte black accents, layered textures. Refined, but deeply human.",
"body": "The studio works closely with a small number of homes each year, slowly and intentionally — letting the architecture, the light, and the people who live there set the brief."
},
"selected": {
"eyebrow": "Selected work",
"headline": "Two homes, one sensibility."
},
"cta": {
"eyebrow": "Inquiries",
"headline": "A small studio, available by appointment.",
"body": "Send a few notes about your home and the feeling you're hoping for.",
"action": "Begin a conversation"
}
},
"portfolio": {
"eyebrow": "Portfolio",
"headline": "Two homes, one sensibility.",
"intro": "Each project responds to its setting — Boulder leans into light and warm woods; Asheville into matte black and architectural shadow. The throughline is restraint and material honesty."
},
"projects": {
"boulder": {
"name": "Boulder",
"eyebrow": "Light-filled organic",
"intro": "A mid-century ranch reimagined around warm woods, reclaimed barnwood walls, live-edge counters, and emerald glazed tile. The palette stays in conversation with the trees outside — sunlight does most of the work.",
"coverAlt": "Walnut kitchen with white embossed tile and warm pendant light"
},
"asheville": {
"name": "Asheville",
"eyebrow": "Architectural and moody",
"intro": "A counterpoint to Boulder — black vertical slats, matte black appliances, and large picture windows held in balance by warm wood floors and layered textiles. A space that is restrained but never cold.",
"coverAlt": "Living room with black vertical slat wall and picture window"
}
},
"projectDetail": {
"cta": {
"eyebrow": "A space of your own",
"headline": "Begin a conversation about your home.",
"action": "Inquire"
},
"viewLarger": "View larger: {alt}"
},
"contact": {
"eyebrow": "Contact",
"headline": "Begin a conversation.",
"intro": "The studio takes on a small number of new homes each year. Send a few notes about your space and we'll be in touch through your preferred channel."
},
"privacyBlurb": {
"title": "How this works",
"body": "Your message is encrypted in your browser and delivered through the {nostr} directly to the studio. No server between us stores or sees it, and the studio replies through the contact method you choose.",
"nostrLabel": "Nostr network"
},
"form": {
"name": {
"label": "Your name",
"optional": "(optional)",
"placeholder": "First name, or however you'd like to be called"
},
"method": {
"label": "Reach me via",
"placeholder": "Choose…"
},
"value": {
"label": "Where to send the reply",
"description": "Whatever is easiest for you — the studio will use exactly this."
},
"message": {
"label": "Your message",
"placeholder": "Tell the studio a little about your space and what you are hoping for."
},
"submit": {
"idle": "Send inquiry",
"sending": "Sending…"
},
"errors": {
"nameTooLong": "Keep it under 80 characters.",
"methodMissing": "Choose how the studio should reach you.",
"valueRequired": "Required.",
"valueTooLong": "Too long.",
"messageTooShort": "A sentence or two helps the studio respond well.",
"messageTooLong": "Keep it under 2000 characters.",
"badEmail": "That does not look like an email address.",
"badPhoneOrHandle": "Use a phone number or a handle.",
"badTelegram": "Use a Telegram handle, like {'@'}yourname.",
"badNpub": "That does not look like an npub."
},
"toast": {
"successTitle": "Inquiry sent",
"successFull": "The studio will be in touch.",
"successPartial": "Delivered to {accepted} of {attempted} relays — the studio will still receive it.",
"noRelaysTitle": "Could not reach any relay",
"noRelaysBody": "Check your network and try again.",
"failedTitle": "Inquiry failed",
"unknownError": "Unknown error."
}
},
"methods": {
"email": "Email",
"whatsapp": "WhatsApp",
"signal": "Signal",
"telegram": "Telegram",
"nostr": "Nostr"
},
"methodPlaceholders": {
"email": "you{'@'}example.com",
"whatsapp": "+1 555 123 4567",
"signal": "+1 555 123 4567 or {'@'}username",
"telegram": "{'@'}yourhandle",
"nostr": "npub1…"
"heading": "Welcome",
"intro": "Edit src/views/HomeView.vue to begin.",
"counter": "Count: {n}",
"increment": "Increment"
}
}

View file

@ -1,134 +1,11 @@
{
"brand": "Earth Walker",
"nav": {
"portfolio": "Proyectos",
"contact": "Contacto",
"inquire": "Consultar",
"openMenu": "Abrir menú",
"viewAll": "Ver todo"
},
"themeToggle": {
"toLight": "Cambiar a tema claro",
"toDark": "Cambiar a tema oscuro"
},
"footer": {
"tagline": "Diseño de interiores — con cita previa."
"app": {
"title": "Plantilla de Sitio Web"
},
"home": {
"hero": {
"eyebrow": "Earth Walker Design",
"headline": "Interiores arraigados,",
"headline2": "arquitectónicos, restaurativos."
},
"studio": {
"eyebrow": "Estudio",
"lead": "Un equilibrio entre minimalismo y alma. Ventanales y luz natural. Maderas cálidas, acentos negros mate, texturas en capas. Refinado, pero profundamente humano.",
"body": "El estudio trabaja de cerca con un pequeño número de hogares cada año, despacio y con intención — dejando que la arquitectura, la luz y las personas que viven allí marquen el rumbo."
},
"selected": {
"eyebrow": "Obra seleccionada",
"headline": "Dos hogares, una sensibilidad."
},
"cta": {
"eyebrow": "Consultas",
"headline": "Un estudio pequeño, disponible con cita previa.",
"body": "Envíe unas notas sobre su hogar y el sentimiento que desea evocar.",
"action": "Iniciar una conversación"
}
},
"portfolio": {
"eyebrow": "Proyectos",
"headline": "Dos hogares, una sensibilidad.",
"intro": "Cada proyecto responde a su entorno — Boulder se inclina hacia la luz y las maderas cálidas; Asheville hacia el negro mate y la sombra arquitectónica. El hilo conductor es la contención y la honestidad de los materiales."
},
"projects": {
"boulder": {
"name": "Boulder",
"eyebrow": "Orgánico lleno de luz",
"intro": "Un rancho de mediados de siglo reinventado en torno a maderas cálidas, paredes de madera de granero recuperada, encimeras con borde vivo y azulejos vidriados color esmeralda. La paleta dialoga con los árboles del exterior — la luz del sol hace casi todo el trabajo.",
"coverAlt": "Cocina de nogal con azulejos blancos en relieve y luz colgante cálida"
},
"asheville": {
"name": "Asheville",
"eyebrow": "Arquitectónico y sombrío",
"intro": "Un contrapunto a Boulder — listones verticales negros, electrodomésticos en negro mate y grandes ventanales equilibrados con suelos de madera cálida y textiles en capas. Un espacio contenido pero nunca frío.",
"coverAlt": "Salón con pared de listones verticales negros y ventanal panorámico"
}
},
"projectDetail": {
"cta": {
"eyebrow": "Un espacio propio",
"headline": "Iniciemos una conversación sobre su hogar.",
"action": "Consultar"
},
"viewLarger": "Ver más grande: {alt}"
},
"contact": {
"eyebrow": "Contacto",
"headline": "Iniciemos una conversación.",
"intro": "El estudio acepta un número reducido de nuevos hogares cada año. Envíe unas notas sobre su espacio y nos pondremos en contacto a través del canal que prefiera."
},
"privacyBlurb": {
"title": "Cómo funciona",
"body": "Su mensaje se cifra en su navegador y se entrega a través de la {nostr} directamente al estudio. Ningún servidor entre nosotros lo guarda ni lo ve, y el estudio responde por el método de contacto que usted elija.",
"nostrLabel": "red Nostr"
},
"form": {
"name": {
"label": "Su nombre",
"optional": "(opcional)",
"placeholder": "Su nombre, o cómo prefiere que le llamen"
},
"method": {
"label": "Contáctame por",
"placeholder": "Elija…"
},
"value": {
"label": "Dónde enviar la respuesta",
"description": "Lo que le resulte más fácil — el estudio usará exactamente esto."
},
"message": {
"label": "Su mensaje",
"placeholder": "Cuéntele al estudio un poco sobre su espacio y lo que espera."
},
"submit": {
"idle": "Enviar consulta",
"sending": "Enviando…"
},
"errors": {
"nameTooLong": "Manténgalo bajo 80 caracteres.",
"methodMissing": "Elija cómo debe contactarle el estudio.",
"valueRequired": "Requerido.",
"valueTooLong": "Demasiado largo.",
"messageTooShort": "Una o dos frases ayudan al estudio a responder mejor.",
"messageTooLong": "Manténgalo bajo 2000 caracteres.",
"badEmail": "Eso no parece una dirección de correo.",
"badPhoneOrHandle": "Use un número de teléfono o un usuario.",
"badTelegram": "Use un usuario de Telegram, como {'@'}sunombre.",
"badNpub": "Eso no parece un npub."
},
"toast": {
"successTitle": "Consulta enviada",
"successFull": "El estudio se pondrá en contacto.",
"successPartial": "Entregada a {accepted} de {attempted} relés — el estudio aún la recibirá.",
"noRelaysTitle": "No se pudo alcanzar ningún relé",
"noRelaysBody": "Compruebe su conexión e inténtelo de nuevo.",
"failedTitle": "Consulta fallida",
"unknownError": "Error desconocido."
}
},
"methods": {
"email": "Correo",
"whatsapp": "WhatsApp",
"signal": "Signal",
"telegram": "Telegram",
"nostr": "Nostr"
},
"methodPlaceholders": {
"email": "tu{'@'}ejemplo.com",
"whatsapp": "+34 600 123 456",
"signal": "+34 600 123 456 o {'@'}usuario",
"telegram": "{'@'}suusuario",
"nostr": "npub1…"
"heading": "Bienvenido",
"intro": "Edita src/views/HomeView.vue para empezar.",
"counter": "Cuenta: {n}",
"increment": "Incrementar"
}
}

View file

@ -1,134 +0,0 @@
{
"brand": "Earth Walker",
"nav": {
"portfolio": "Portfolio",
"contact": "Contact",
"inquire": "Demander",
"openMenu": "Ouvrir le menu",
"viewAll": "Tout voir"
},
"themeToggle": {
"toLight": "Passer au thème clair",
"toDark": "Passer au thème sombre"
},
"footer": {
"tagline": "Design d'intérieur — sur rendez-vous."
},
"home": {
"hero": {
"eyebrow": "Earth Walker Design",
"headline": "Intérieurs ancrés,",
"headline2": "architecturaux, ressourçants."
},
"studio": {
"eyebrow": "Studio",
"lead": "Un équilibre entre minimalisme et âme. De grandes fenêtres, de la lumière naturelle. Bois chauds, accents noir mat, textures superposées. Raffiné, mais profondément humain.",
"body": "Le studio accompagne chaque année quelques maisons, lentement et avec intention — en laissant l'architecture, la lumière, et les personnes qui y vivent guider la démarche."
},
"selected": {
"eyebrow": "Œuvre sélectionnée",
"headline": "Deux maisons, une même sensibilité."
},
"cta": {
"eyebrow": "Demandes",
"headline": "Un petit studio, disponible sur rendez-vous.",
"body": "Écrivez quelques mots sur votre maison et l'émotion que vous recherchez.",
"action": "Entamer la conversation"
}
},
"portfolio": {
"eyebrow": "Portfolio",
"headline": "Deux maisons, une même sensibilité.",
"intro": "Chaque projet répond à son lieu — Boulder s'incline vers la lumière et les bois chauds ; Asheville vers le noir mat et l'ombre architecturale. Le fil conducteur : la retenue et l'honnêteté des matériaux."
},
"projects": {
"boulder": {
"name": "Boulder",
"eyebrow": "Organique, baigné de lumière",
"intro": "Un ranch des années 50 réinventé autour de bois chauds, de murs en planches de grange récupérées, de comptoirs à bord vif et de carreaux émaillés vert émeraude. La palette dialogue avec les arbres du dehors — la lumière du soleil fait presque tout.",
"coverAlt": "Cuisine en noyer avec carrelage blanc en relief et suspension chaude"
},
"asheville": {
"name": "Asheville",
"eyebrow": "Architectural et sombre",
"intro": "Un contrepoint à Boulder — lattes verticales noires, électroménager noir mat et grandes baies vitrées équilibrés par des sols en bois chaud et des textiles superposés. Un espace retenu, mais jamais froid.",
"coverAlt": "Salon avec mur de lattes verticales noires et baie vitrée"
}
},
"projectDetail": {
"cta": {
"eyebrow": "Un espace bien à vous",
"headline": "Entamons une conversation sur votre maison.",
"action": "Demander"
},
"viewLarger": "Voir en grand : {alt}"
},
"contact": {
"eyebrow": "Contact",
"headline": "Entamons la conversation.",
"intro": "Le studio accepte un petit nombre de nouvelles maisons chaque année. Écrivez quelques mots sur votre espace et nous reviendrons vers vous par le canal que vous préférez."
},
"privacyBlurb": {
"title": "Comment ça marche",
"body": "Votre message est chiffré dans votre navigateur et livré via le {nostr} directement au studio. Aucun serveur entre nous ne le stocke ni ne le lit, et le studio répond par le moyen de contact que vous avez choisi.",
"nostrLabel": "réseau Nostr"
},
"form": {
"name": {
"label": "Votre nom",
"optional": "(facultatif)",
"placeholder": "Prénom, ou comme vous préférez être appelé·e"
},
"method": {
"label": "Me joindre par",
"placeholder": "Choisir…"
},
"value": {
"label": "Où envoyer la réponse",
"description": "Ce qui vous arrange — le studio utilisera exactement ceci."
},
"message": {
"label": "Votre message",
"placeholder": "Dites-nous quelques mots sur votre espace et ce que vous espérez."
},
"submit": {
"idle": "Envoyer la demande",
"sending": "Envoi…"
},
"errors": {
"nameTooLong": "Moins de 80 caractères, s'il vous plaît.",
"methodMissing": "Choisissez comment le studio doit vous joindre.",
"valueRequired": "Requis.",
"valueTooLong": "Trop long.",
"messageTooShort": "Une ou deux phrases aident le studio à bien répondre.",
"messageTooLong": "Moins de 2000 caractères, s'il vous plaît.",
"badEmail": "Cela ne ressemble pas à une adresse email.",
"badPhoneOrHandle": "Utilisez un numéro de téléphone ou un pseudo.",
"badTelegram": "Utilisez un pseudo Telegram, comme {'@'}votrenom.",
"badNpub": "Cela ne ressemble pas à un npub."
},
"toast": {
"successTitle": "Demande envoyée",
"successFull": "Le studio reviendra vers vous.",
"successPartial": "Livrée à {accepted} des {attempted} relais — le studio la recevra quand même.",
"noRelaysTitle": "Aucun relais joignable",
"noRelaysBody": "Vérifiez votre connexion et réessayez.",
"failedTitle": "Échec de l'envoi",
"unknownError": "Erreur inconnue."
}
},
"methods": {
"email": "Email",
"whatsapp": "WhatsApp",
"signal": "Signal",
"telegram": "Telegram",
"nostr": "Nostr"
},
"methodPlaceholders": {
"email": "vous{'@'}exemple.com",
"whatsapp": "+33 6 12 34 56 78",
"signal": "+33 6 12 34 56 78 ou {'@'}pseudo",
"telegram": "{'@'}votrepseudo",
"nostr": "npub1…"
}
}

View file

@ -6,39 +6,11 @@ const routes: RouteRecordRaw[] = [
name: 'home',
component: () => import('@/views/HomeView.vue'),
},
{
path: '/portfolio',
name: 'portfolio',
component: () => import('@/views/PortfolioView.vue'),
},
{
path: '/portfolio/boulder',
name: 'portfolio-boulder',
component: () => import('@/views/projects/BoulderView.vue'),
},
{
path: '/portfolio/asheville',
name: 'portfolio-asheville',
component: () => import('@/views/projects/AshevilleView.vue'),
},
{
path: '/contact',
name: 'contact',
component: () => import('@/views/ContactView.vue'),
},
{
path: '/:pathMatch(.*)*',
redirect: '/',
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(_to, _from, saved) {
if (saved) return saved
return { top: 0 }
},
})
export default router

View file

@ -1,9 +1,53 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import './themes/index.css';
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0.985 0.005 80);
--foreground: oklch(0.2 0.012 60);
--card: oklch(0.985 0.005 80);
--card-foreground: oklch(0.2 0.012 60);
--popover: oklch(0.985 0.005 80);
--popover-foreground: oklch(0.2 0.012 60);
--primary: oklch(0.18 0.008 60);
--primary-foreground: oklch(0.97 0.008 80);
--secondary: oklch(0.94 0.008 75);
--secondary-foreground: oklch(0.2 0.012 60);
--muted: oklch(0.94 0.008 75);
--muted-foreground: oklch(0.5 0.015 60);
--accent: oklch(0.92 0.018 65);
--accent-foreground: oklch(0.2 0.012 60);
--destructive: oklch(0.55 0.18 25);
--destructive-foreground: oklch(0.97 0.008 80);
--border: oklch(0.88 0.01 70);
--input: oklch(0.88 0.01 70);
--ring: oklch(0.2 0.012 60);
--radius: 0.25rem;
}
.dark {
--background: oklch(0.16 0.008 60);
--foreground: oklch(0.95 0.01 80);
--card: oklch(0.18 0.008 60);
--card-foreground: oklch(0.95 0.01 80);
--popover: oklch(0.18 0.008 60);
--popover-foreground: oklch(0.95 0.01 80);
--primary: oklch(0.95 0.01 80);
--primary-foreground: oklch(0.18 0.008 60);
--secondary: oklch(0.24 0.012 60);
--secondary-foreground: oklch(0.95 0.01 80);
--muted: oklch(0.24 0.012 60);
--muted-foreground: oklch(0.7 0.012 70);
--accent: oklch(0.3 0.022 65);
--accent-foreground: oklch(0.95 0.01 80);
--destructive: oklch(0.45 0.18 25);
--destructive-foreground: oklch(0.95 0.01 80);
--border: oklch(0.28 0.012 60);
--input: oklch(0.28 0.012 60);
--ring: oklch(0.85 0.012 75);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);

View file

@ -1,50 +0,0 @@
/* Palette: Charcoal & Cream
* High-contrast monochrome. Warm cream background, near-black warm
* charcoal ink, deep-bronze accent for the active states. The
* Asheville mood by default moody, architectural, restrained.
* In dark mode it goes deeper still.
*/
:root[data-palette='charcoal-cream'] {
--background: oklch(0.95 0.013 75);
--foreground: oklch(0.16 0.005 60);
--card: oklch(0.95 0.013 75);
--card-foreground: oklch(0.16 0.005 60);
--popover: oklch(0.95 0.013 75);
--popover-foreground: oklch(0.16 0.005 60);
--primary: oklch(0.16 0.005 60);
--primary-foreground: oklch(0.95 0.013 75);
--secondary: oklch(0.9 0.014 75);
--secondary-foreground: oklch(0.16 0.005 60);
--muted: oklch(0.9 0.014 75);
--muted-foreground: oklch(0.45 0.012 60);
--accent: oklch(0.86 0.025 70);
--accent-foreground: oklch(0.16 0.005 60);
--destructive: oklch(0.55 0.18 25);
--destructive-foreground: oklch(0.95 0.013 75);
--border: oklch(0.82 0.014 70);
--input: oklch(0.82 0.014 70);
--ring: oklch(0.16 0.005 60);
}
:root[data-palette='charcoal-cream'].dark {
--background: oklch(0.12 0.004 60);
--foreground: oklch(0.94 0.013 75);
--card: oklch(0.14 0.004 60);
--card-foreground: oklch(0.94 0.013 75);
--popover: oklch(0.14 0.004 60);
--popover-foreground: oklch(0.94 0.013 75);
--primary: oklch(0.94 0.013 75);
--primary-foreground: oklch(0.14 0.004 60);
--secondary: oklch(0.2 0.006 60);
--secondary-foreground: oklch(0.94 0.013 75);
--muted: oklch(0.2 0.006 60);
--muted-foreground: oklch(0.68 0.012 70);
--accent: oklch(0.26 0.015 65);
--accent-foreground: oklch(0.94 0.013 75);
--destructive: oklch(0.45 0.18 25);
--destructive-foreground: oklch(0.94 0.013 75);
--border: oklch(0.24 0.008 60);
--input: oklch(0.24 0.008 60);
--ring: oklch(0.85 0.014 75);
}

View file

@ -1,49 +0,0 @@
/* Palette: Desert Clay
* Warm creamy background with a terracotta undertone, espresso ink,
* burnt-clay accent. Matches Boulder's reclaimed wood / warm pendant
* mood. Reads as evening light, not gallery white.
*/
:root[data-palette='desert-clay'] {
--background: oklch(0.97 0.012 65);
--foreground: oklch(0.22 0.018 45);
--card: oklch(0.97 0.012 65);
--card-foreground: oklch(0.22 0.018 45);
--popover: oklch(0.97 0.012 65);
--popover-foreground: oklch(0.22 0.018 45);
--primary: oklch(0.4 0.07 35);
--primary-foreground: oklch(0.97 0.012 65);
--secondary: oklch(0.92 0.018 60);
--secondary-foreground: oklch(0.22 0.018 45);
--muted: oklch(0.93 0.014 60);
--muted-foreground: oklch(0.5 0.025 45);
--accent: oklch(0.86 0.04 45);
--accent-foreground: oklch(0.22 0.018 45);
--destructive: oklch(0.55 0.18 25);
--destructive-foreground: oklch(0.97 0.012 65);
--border: oklch(0.86 0.018 55);
--input: oklch(0.86 0.018 55);
--ring: oklch(0.4 0.07 35);
}
:root[data-palette='desert-clay'].dark {
--background: oklch(0.17 0.014 40);
--foreground: oklch(0.94 0.014 70);
--card: oklch(0.2 0.014 40);
--card-foreground: oklch(0.94 0.014 70);
--popover: oklch(0.2 0.014 40);
--popover-foreground: oklch(0.94 0.014 70);
--primary: oklch(0.85 0.05 50);
--primary-foreground: oklch(0.2 0.014 40);
--secondary: oklch(0.27 0.018 45);
--secondary-foreground: oklch(0.94 0.014 70);
--muted: oklch(0.27 0.018 45);
--muted-foreground: oklch(0.7 0.02 55);
--accent: oklch(0.34 0.045 45);
--accent-foreground: oklch(0.94 0.014 70);
--destructive: oklch(0.45 0.18 25);
--destructive-foreground: oklch(0.94 0.014 70);
--border: oklch(0.3 0.018 45);
--input: oklch(0.3 0.018 45);
--ring: oklch(0.8 0.04 50);
}

View file

@ -1,49 +0,0 @@
/* Palette: Forest & Ash
* Cool sage-tinted background, charcoal ink, mossy accent. The
* "nature integrated" word from the brief made literal picks up
* the Boulder emerald tile and the Asheville pine-window framing.
*/
:root[data-palette='forest-ash'] {
--background: oklch(0.97 0.008 150);
--foreground: oklch(0.2 0.012 150);
--card: oklch(0.97 0.008 150);
--card-foreground: oklch(0.2 0.012 150);
--popover: oklch(0.97 0.008 150);
--popover-foreground: oklch(0.2 0.012 150);
--primary: oklch(0.32 0.04 150);
--primary-foreground: oklch(0.97 0.008 150);
--secondary: oklch(0.93 0.014 145);
--secondary-foreground: oklch(0.2 0.012 150);
--muted: oklch(0.93 0.012 145);
--muted-foreground: oklch(0.5 0.018 145);
--accent: oklch(0.86 0.03 150);
--accent-foreground: oklch(0.2 0.012 150);
--destructive: oklch(0.55 0.18 25);
--destructive-foreground: oklch(0.97 0.008 150);
--border: oklch(0.87 0.014 150);
--input: oklch(0.87 0.014 150);
--ring: oklch(0.32 0.04 150);
}
:root[data-palette='forest-ash'].dark {
--background: oklch(0.16 0.012 150);
--foreground: oklch(0.94 0.01 150);
--card: oklch(0.19 0.012 150);
--card-foreground: oklch(0.94 0.01 150);
--popover: oklch(0.19 0.012 150);
--popover-foreground: oklch(0.94 0.01 150);
--primary: oklch(0.86 0.04 150);
--primary-foreground: oklch(0.19 0.012 150);
--secondary: oklch(0.26 0.016 150);
--secondary-foreground: oklch(0.94 0.01 150);
--muted: oklch(0.26 0.016 150);
--muted-foreground: oklch(0.7 0.014 145);
--accent: oklch(0.33 0.03 150);
--accent-foreground: oklch(0.94 0.01 150);
--destructive: oklch(0.45 0.18 25);
--destructive-foreground: oklch(0.94 0.01 150);
--border: oklch(0.29 0.016 150);
--input: oklch(0.29 0.016 150);
--ring: oklch(0.8 0.04 150);
}

View file

@ -1,60 +0,0 @@
/* Palette registry each module declares its tokens scoped to
* :root[data-palette='<slug>']. usePalette() sets the attribute.
* A bare :root fallback below copies the stone-warm tokens so the
* page renders correctly before JS attaches the attribute and for
* users with JS disabled.
*/
@import './stone-warm.css';
@import './desert-clay.css';
@import './forest-ash.css';
@import './charcoal-cream.css';
/* Pre-hydration fallback: same as stone-warm. Kept here (not in
* stone-warm.css under :root) so swapping which palette is the
* default is one edit point this block at the values from a
* different theme module. */
:root {
--background: oklch(0.985 0.005 80);
--foreground: oklch(0.2 0.012 60);
--card: oklch(0.985 0.005 80);
--card-foreground: oklch(0.2 0.012 60);
--popover: oklch(0.985 0.005 80);
--popover-foreground: oklch(0.2 0.012 60);
--primary: oklch(0.18 0.008 60);
--primary-foreground: oklch(0.97 0.008 80);
--secondary: oklch(0.94 0.008 75);
--secondary-foreground: oklch(0.2 0.012 60);
--muted: oklch(0.94 0.008 75);
--muted-foreground: oklch(0.5 0.015 60);
--accent: oklch(0.92 0.018 65);
--accent-foreground: oklch(0.2 0.012 60);
--destructive: oklch(0.55 0.18 25);
--destructive-foreground: oklch(0.97 0.008 80);
--border: oklch(0.88 0.01 70);
--input: oklch(0.88 0.01 70);
--ring: oklch(0.2 0.012 60);
--radius: 0.25rem;
}
.dark {
--background: oklch(0.16 0.008 60);
--foreground: oklch(0.95 0.01 80);
--card: oklch(0.18 0.008 60);
--card-foreground: oklch(0.95 0.01 80);
--popover: oklch(0.18 0.008 60);
--popover-foreground: oklch(0.95 0.01 80);
--primary: oklch(0.95 0.01 80);
--primary-foreground: oklch(0.18 0.008 60);
--secondary: oklch(0.24 0.012 60);
--secondary-foreground: oklch(0.95 0.01 80);
--muted: oklch(0.24 0.012 60);
--muted-foreground: oklch(0.7 0.012 70);
--accent: oklch(0.3 0.022 65);
--accent-foreground: oklch(0.95 0.01 80);
--destructive: oklch(0.45 0.18 25);
--destructive-foreground: oklch(0.95 0.01 80);
--border: oklch(0.28 0.012 60);
--input: oklch(0.28 0.012 60);
--ring: oklch(0.85 0.012 75);
}

View file

@ -1,48 +0,0 @@
/* Palette: Stone Warm
* Bone background, matte-black ink, taupe muted. Gallery-quiet, the
* most conservative of the four. Closest to Studio McGee's chrome.
*/
:root[data-palette='stone-warm'] {
--background: oklch(0.985 0.005 80);
--foreground: oklch(0.2 0.012 60);
--card: oklch(0.985 0.005 80);
--card-foreground: oklch(0.2 0.012 60);
--popover: oklch(0.985 0.005 80);
--popover-foreground: oklch(0.2 0.012 60);
--primary: oklch(0.18 0.008 60);
--primary-foreground: oklch(0.97 0.008 80);
--secondary: oklch(0.94 0.008 75);
--secondary-foreground: oklch(0.2 0.012 60);
--muted: oklch(0.94 0.008 75);
--muted-foreground: oklch(0.5 0.015 60);
--accent: oklch(0.92 0.018 65);
--accent-foreground: oklch(0.2 0.012 60);
--destructive: oklch(0.55 0.18 25);
--destructive-foreground: oklch(0.97 0.008 80);
--border: oklch(0.88 0.01 70);
--input: oklch(0.88 0.01 70);
--ring: oklch(0.2 0.012 60);
}
:root[data-palette='stone-warm'].dark {
--background: oklch(0.16 0.008 60);
--foreground: oklch(0.95 0.01 80);
--card: oklch(0.18 0.008 60);
--card-foreground: oklch(0.95 0.01 80);
--popover: oklch(0.18 0.008 60);
--popover-foreground: oklch(0.95 0.01 80);
--primary: oklch(0.95 0.01 80);
--primary-foreground: oklch(0.18 0.008 60);
--secondary: oklch(0.24 0.012 60);
--secondary-foreground: oklch(0.95 0.01 80);
--muted: oklch(0.24 0.012 60);
--muted-foreground: oklch(0.7 0.012 70);
--accent: oklch(0.3 0.022 65);
--accent-foreground: oklch(0.95 0.01 80);
--destructive: oklch(0.45 0.18 25);
--destructive-foreground: oklch(0.95 0.01 80);
--border: oklch(0.28 0.012 60);
--input: oklch(0.28 0.012 60);
--ring: oklch(0.85 0.012 75);
}

View file

@ -1,27 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import ContactForm from '@/components/contact/ContactForm.vue'
import PrivacyBlurb from '@/components/contact/PrivacyBlurb.vue'
const { t } = useI18n({ useScope: 'global' })
</script>
<template>
<div class="mx-auto max-w-2xl px-6 pt-20 pb-28 md:pt-28 md:pb-32">
<p class="eyebrow">{{ t('contact.eyebrow') }}</p>
<h1 class="mt-3 font-serif text-4xl font-light tracking-tight md:text-5xl">
{{ t('contact.headline') }}
</h1>
<p class="text-foreground/70 mt-6 max-w-prose text-base md:text-lg">
{{ t('contact.intro') }}
</p>
<div class="mt-10">
<PrivacyBlurb />
</div>
<div class="mt-10">
<ContactForm />
</div>
</div>
</template>

View file

@ -1,124 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AspectRatio } from '@/components/ui/aspect-ratio'
import { Button } from '@/components/ui/button'
import { projects } from '@/data/projects'
const { t } = useI18n({ useScope: 'global' })
const projectCards = computed(() =>
projects.map((p) => ({
slug: p.slug,
cover: p.cover,
coverAlt: t(`projects.${p.slug}.coverAlt`),
name: t(`projects.${p.slug}.name`),
eyebrow: t(`projects.${p.slug}.eyebrow`),
})),
)
</script>
<script setup lang="ts"></script>
<template>
<div class="bg-background">
<!-- Hero -->
<section class="relative -mt-16 overflow-hidden">
<img
src="/images/boulder/08.jpg"
:alt="t('projects.boulder.coverAlt')"
class="h-screen min-h-[640px] w-full object-cover"
/>
<div
class="absolute inset-0 bg-gradient-to-b from-black/15 via-transparent to-black/45"
></div>
<div
class="absolute right-0 bottom-14 left-0 mx-auto max-w-[1400px] px-6 text-white md:bottom-20 md:px-10"
>
<p class="text-xs uppercase tracking-[0.22em] text-white/80">
{{ t('home.hero.eyebrow') }}
</p>
<h1
class="mt-4 max-w-3xl font-serif text-4xl font-light leading-[1.05] tracking-tight md:text-6xl lg:text-7xl"
>
{{ t('home.hero.headline') }}<br />{{ t('home.hero.headline2') }}
</h1>
</div>
</section>
<!-- Philosophy -->
<section class="px-6 py-24 md:py-32">
<div class="mx-auto max-w-3xl text-center">
<p class="eyebrow">{{ t('home.studio.eyebrow') }}</p>
<p
class="text-foreground/80 mt-6 font-serif text-2xl font-light leading-relaxed md:text-3xl"
>
{{ t('home.studio.lead') }}
</p>
<p class="text-muted-foreground mt-8 text-base md:text-lg">
{{ t('home.studio.body') }}
</p>
</div>
</section>
<!-- Projects teaser -->
<section class="border-border/60 border-t px-6 py-20 md:px-10 md:py-28">
<div class="mx-auto max-w-[1400px]">
<div class="flex items-baseline justify-between">
<div>
<p class="eyebrow">{{ t('home.selected.eyebrow') }}</p>
<h2
class="mt-3 font-serif text-3xl font-light tracking-tight md:text-5xl"
>
{{ t('home.selected.headline') }}
</h2>
</div>
<RouterLink
to="/portfolio"
class="text-foreground/70 hover:text-foreground hidden text-xs uppercase tracking-[0.22em] transition-colors md:inline-flex"
>
{{ t('nav.viewAll') }}
</RouterLink>
</div>
<div class="mt-12 grid gap-10 md:grid-cols-2 md:gap-14">
<RouterLink
v-for="project in projectCards"
:key="project.slug"
:to="`/portfolio/${project.slug}`"
class="group block"
>
<AspectRatio :ratio="4 / 5" class="overflow-hidden bg-muted">
<img
:src="project.cover"
:alt="project.coverAlt"
loading="lazy"
decoding="async"
class="h-full w-full object-cover transition-transform duration-[900ms] ease-out group-hover:scale-[1.03]"
/>
</AspectRatio>
<div class="mt-5 flex items-baseline justify-between">
<h3 class="font-serif text-2xl font-light tracking-tight md:text-3xl">
{{ project.name }}
</h3>
<p class="eyebrow">{{ project.eyebrow }}</p>
</div>
</RouterLink>
</div>
</div>
</section>
<!-- Closing CTA -->
<section class="border-border/60 border-t px-6 py-24 text-center md:py-32 md:px-10">
<p class="eyebrow">{{ t('home.cta.eyebrow') }}</p>
<h2 class="mx-auto mt-4 max-w-2xl font-serif text-3xl font-light md:text-5xl">
{{ t('home.cta.headline') }}
</h2>
<p class="text-muted-foreground mx-auto mt-6 max-w-prose">
{{ t('home.cta.body') }}
</p>
<Button as-child class="mt-10">
<RouterLink to="/contact">{{ t('home.cta.action') }}</RouterLink>
</Button>
</section>
</div>
<main class="mx-auto max-w-2xl space-y-6 p-8">
<h1 class="text-3xl font-bold">Earth Walker Design</h1>
<p class="text-muted-foreground">Home view content arriving in a later commit.</p>
</main>
</template>

View file

@ -1,60 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AspectRatio } from '@/components/ui/aspect-ratio'
import { projects } from '@/data/projects'
const { t } = useI18n({ useScope: 'global' })
const projectCards = computed(() =>
projects.map((p) => ({
slug: p.slug,
cover: p.cover,
coverAlt: t(`projects.${p.slug}.coverAlt`),
name: t(`projects.${p.slug}.name`),
eyebrow: t(`projects.${p.slug}.eyebrow`),
})),
)
</script>
<template>
<div class="bg-background">
<section class="mx-auto max-w-[1400px] px-6 pt-20 pb-12 md:px-10 md:pt-28 md:pb-16">
<p class="eyebrow">{{ t('portfolio.eyebrow') }}</p>
<h1 class="mt-3 max-w-3xl font-serif text-4xl font-light tracking-tight md:text-6xl">
{{ t('portfolio.headline') }}
</h1>
<p class="text-foreground/70 mt-6 max-w-2xl text-base md:text-lg">
{{ t('portfolio.intro') }}
</p>
</section>
<section class="mx-auto max-w-[1400px] px-6 pb-24 md:px-10 md:pb-32">
<div class="grid gap-10 md:grid-cols-2 md:gap-14">
<RouterLink
v-for="project in projectCards"
:key="project.slug"
:to="`/portfolio/${project.slug}`"
class="group block"
>
<AspectRatio :ratio="4 / 5" class="overflow-hidden bg-muted">
<img
:src="project.cover"
:alt="project.coverAlt"
loading="lazy"
decoding="async"
class="h-full w-full object-cover transition-transform duration-[900ms] ease-out group-hover:scale-[1.03]"
/>
</AspectRatio>
<div class="mt-6 flex items-baseline justify-between">
<h2 class="font-serif text-2xl font-light tracking-tight md:text-3xl">
{{ project.name }}
</h2>
<p class="eyebrow">{{ project.eyebrow }}</p>
</div>
</RouterLink>
</div>
</section>
</div>
</template>

View file

@ -1,8 +0,0 @@
<script setup lang="ts">
import ProjectDetail from '@/components/projects/ProjectDetail.vue'
import { asheville } from '@/data/projects'
</script>
<template>
<ProjectDetail :project="asheville" />
</template>

View file

@ -1,8 +0,0 @@
<script setup lang="ts">
import ProjectDetail from '@/components/projects/ProjectDetail.vue'
import { boulder } from '@/data/projects'
</script>
<template>
<ProjectDetail :project="boulder" />
</template>

View file

@ -4,6 +4,7 @@
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}