Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions app/src/components/AppMeta/AppMeta.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/* Shared shape for the version / about / credit atoms. Each surface sets its
own colours, since the same link reads differently among header icons than it
does against footer prose. */

.meta-line {
display: inline-flex;
align-items: center;
gap: var(--cc-space-3);
flex-wrap: wrap;
min-width: 0;
}

.meta-version,
.meta-credit {
white-space: nowrap;
}

.meta-link {
text-decoration: none;
}
.meta-link:hover,
.meta-link:focus-visible {
color: var(--cc-text-strong);
text-decoration: underline;
}

/* Pointer-events off so a stray click between items can't land on the dot. */
.meta-sep {
flex: 0 0 auto;
color: var(--cc-text-faint);
pointer-events: none;
}
73 changes: 73 additions & 0 deletions app/src/components/AppMeta/AppMeta.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// components/AppMeta/AppMeta.tsx — What codecity says about itself: which build
// is running, where the source is, and who made it.
//
// Three surfaces show these, in different arrangements: the app header carries
// about, the app footer splits version and credit across its two ends, and the
// project switcher runs all three under the wordmark. One definition each, so
// the wording, the URLs and the link targets cannot drift apart.
//
// Link treatment is the surface's call, via `linkClass`: the app chrome keeps
// its links at icon/prose weight so a status bar doesn't turn into a row of
// accents, while the landing uses the house `.link` (accent + always underlined,
// see styles/text.css) because nothing around it signals a link.

import './AppMeta.css';
import { SERVER_CONFIG } from '@/state/stores/serverConfig';
import { REPO_URL, CREATOR_URL } from '@/constants/ui';

/** The running build's version, as reported by the server. */
export function MetaVersion() {
return <span class="meta-version">v{SERVER_CONFIG.value.version}</span>;
}

export interface MetaLinkProps {
/** House link class from styles/text.css: `link` or `link--chrome`. Omit to
* let the surface's own CSS colour it. */
linkClass?: string;
}

/** Outward link to the repo. */
export function MetaAbout({ linkClass }: MetaLinkProps = {}) {
return (
<a
class={linkClass ? `meta-link ${linkClass}` : 'meta-link'}
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
title="codecity on GitHub"
>
about
</a>
);
}

/** Authorship, linked to the author's site. */
export function MetaCredit({ linkClass }: MetaLinkProps = {}) {
return (
<span class="meta-credit">
made by 🦄{' '}
<a
class={linkClass ? `meta-link ${linkClass}` : 'meta-link'}
href={CREATOR_URL}
target="_blank"
rel="noopener noreferrer"
title="thalida.com"
>
thalida.
</a>
</span>
);
}

/** All three on one line, separated. Used where there's room for the full set. */
export function MetaLine({ linkClass }: MetaLinkProps = {}) {
return (
<span class="meta-line">
<MetaVersion />
<span class="meta-sep">·</span>
<MetaAbout linkClass={linkClass} />
<span class="meta-sep">·</span>
<MetaCredit linkClass={linkClass} />
</span>
);
}
30 changes: 8 additions & 22 deletions app/src/layout/AppFooter/AppFooter.css
Original file line number Diff line number Diff line change
Expand Up @@ -119,35 +119,21 @@

/* ── Version + credit ─────────────────────────────────────────────── */
/* Version sits with the status dot on the left; the credit holds the right.
Both stay a step below the status text so the live state reads first, and
the link brightens like any other. */
.app-footer-version,
.app-footer-credit {
Both stay a step below the status text so the live state reads first. The
name inside the credit takes the house .link--chrome, since nothing around
it in a status bar signals that a word is a link. */
#app-footer .meta-version,
#app-footer .meta-credit {
color: var(--cc-text-muted);
white-space: nowrap;
}
.app-footer-link {
color: var(--cc-text-primary);
text-decoration: none;
}
.app-footer-link:hover,
.app-footer-link:focus-visible {
color: var(--cc-text-strong);
text-decoration: underline;
}

/* ── Footer responsive narrowing ───────────────────────────────────────────── */
/* At narrow widths the status timestamp and the credit are both secondary to
knowing the build state at all, so the dot and version keep the bar. */
/* Tighter insets, nothing dropped. The bar holds two short items per side, and
each section already clips with an ellipsis, so hiding anything traded real
information for space that wasn't needed. */
@media (max-width: 580px) {
#app-footer {
gap: var(--cc-space-3);
padding: 0 var(--cc-space-4);
}
.app-footer-status-detail {
display: none;
}
.app-footer-credit {
display: none;
}
}
6 changes: 3 additions & 3 deletions app/src/layout/AppFooter/AppFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
import { openDebug } from '@/state/stores/ui';
import { isDebugMode } from '@/utils/debugMode';
import { FooterSep } from './FooterSep';
import { FooterVersion, FooterCredit } from './FooterMeta';
import { MetaVersion, MetaCredit } from '@/components/AppMeta/AppMeta';

export interface FooterStatus {
/** True when live-poll is active; renders as `live`. False renders as `paused`. */
Expand Down Expand Up @@ -156,7 +156,7 @@ export function AppFooter() {
<div class="app-footer-section app-footer-left">
<FooterStatusSection status={status} />
<FooterSep />
<FooterVersion />
<MetaVersion />
{isDebugMode() && (
<button
type="button"
Expand All @@ -170,7 +170,7 @@ export function AppFooter() {
)}
</div>
<div class="app-footer-section app-footer-right">
<FooterCredit />
<MetaCredit linkClass="link--chrome" />
</div>
</footer>
);
Expand Down
30 changes: 0 additions & 30 deletions app/src/layout/AppFooter/FooterMeta.tsx

This file was deleted.

15 changes: 5 additions & 10 deletions app/src/layout/AppHeader/AppHeader.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,11 @@
flex: 0 0 auto;
}

/* A text link among icon buttons: matched to the resting icon color so the
cluster reads as one weight, and brightened on hover like they are. */
.app-header-link {
color: var(--cc-text-muted);
text-decoration: none;
}
.app-header-link:hover,
.app-header-link:focus-visible {
color: var(--cc-text-strong);
text-decoration: underline;
/* The about link takes the house .link--chrome (accent, underline on hover):
an icon button reads as clickable on its own, a bare word does not, so it
can't borrow the icons' resting colour. */
#app-header-meta .meta-link {
-webkit-app-region: no-drag;
}

/* Header buttons use .btn-icon + .btn-icon--no-drag, or
Expand Down
12 changes: 2 additions & 10 deletions app/src/layout/AppHeader/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { SOURCE_INFO } from '@/state/stores/source';
import { MANIFEST } from '@/state/stores/manifest';
import type { Manifest } from '@/types';
import { openProjectsView, openShortcuts } from '@/state/stores/ui';
import { REPO_URL } from '@/constants/ui';
import { MetaAbout } from '@/components/AppMeta/AppMeta';
import { ProjectSwitcher } from '@/components/ProjectSwitcher/ProjectSwitcher';
import { CopyButton } from '@/components/CopyButton/CopyButton';

Expand Down Expand Up @@ -45,15 +45,7 @@ export function AppHeader({ onSwitchSource }: AppHeaderProps = {}) {
</a>
)}
<div id="app-header-meta">
<a
class="app-header-link btn-icon--no-drag"
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
title="codecity on GitHub"
>
about
</a>
<MetaAbout linkClass="link--chrome" />
<button
type="button"
class="btn-icon btn-icon--sm btn-icon--no-drag"
Expand Down
8 changes: 6 additions & 2 deletions app/src/styles/buttons.css
Original file line number Diff line number Diff line change
Expand Up @@ -130,16 +130,20 @@
padding: 0 var(--cc-space-3); /* symmetric padding each side */
background: none;
border: none;
color: var(--cc-text-muted);
/* The brightest text step, not the icons' resting muted: the repo name is
the header's main control, and a word needs its own signal that it can be
clicked where an icon button carries that by being an icon. */
color: var(--cc-text-strong);
border-radius: var(--cc-radius-sm);
font: inherit;
font-size: var(--cc-font-sm);
white-space: nowrap;
cursor: pointer;
-webkit-app-region: no-drag;
}
/* The label is already at the brightest step, so the background lift is the
whole hover signal. */
.btn-chip:not(:disabled):hover {
color: var(--cc-text-strong);
background: var(--cc-overlay-bg);
}
.btn-chip:disabled {
Expand Down
23 changes: 22 additions & 1 deletion app/src/views/ProjectsView/ProjectsView.css
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,28 @@
.landing-brand {
display: flex;
align-items: center;
gap: var(--cc-space-4);
gap: var(--cc-space-7);
}

/* Brand row and the meta line as one block, tighter than the hero's own gap so
the line reads as attached to the wordmark rather than floating between it
and the tagline. Full-width, so it keeps the hero's left edge: nested beside
the gem the line inherited that indent, and two offset lines read as a
mistake where the wordmark alone reads as the gem's partner. */
.landing-identity {
display: flex;
flex-direction: column;
gap: var(--cc-space-3);
min-width: 0;
}

.landing-identity > .meta-line {
font-family: var(--cc-font-mono);
/* Two steps up from the footer's size: the same 10px that suits a 24px status
bar is out of scale under a 28px wordmark. Still well below the tagline, so
it stays secondary to the pitch. */
font-size: var(--cc-font-sm);
color: var(--cc-text-secondary);
}

.landing-gem {
Expand Down
16 changes: 11 additions & 5 deletions app/src/views/ProjectsView/ProjectsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useEffect, useRef } from 'preact/hooks';
import { X, Waypoints, Building2, TreePine, Sparkles } from 'lucide-preact';
import { useDialogFocus } from '@/hooks/useDialogFocus';
import { GemIcon } from '@/components/GemIcon/GemIcon';
import { MetaLine } from '@/components/AppMeta/AppMeta';
import { LandingBackdrop } from '@/components/LandingBackdrop/LandingBackdrop';
import {
PROJECTS_VIEW,
Expand Down Expand Up @@ -81,11 +82,16 @@ export function ProjectsView({ onSubmit, onCancel, onClose }: ProjectsViewProps)

<div class="landing-inner">
<section class="landing-hero">
<div class="landing-brand">
<span class="landing-gem">
<GemIcon />
</span>
<h1 class="landing-wordmark">codecity</h1>
<div class="landing-identity">
<div class="landing-brand">
<span class="landing-gem">
<GemIcon />
</span>
<h1 class="landing-wordmark">codecity</h1>
</div>
{/* The landing covers the app chrome, so without this nobody sees
the version, the repo link or the credit until a repo loads. */}
<MetaLine linkClass="link" />
</div>
<p class="landing-tagline">Turn any git repo into a 3D city</p>
<ul class="landing-delights">
Expand Down
46 changes: 46 additions & 0 deletions app/tests/views/ProjectsView/ProjectsView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,50 @@ describe('ProjectsView', () => {
it('reflects the PROJECTS_VIEW signal directly', () => {
expect(PROJECTS_VIEW.value.visible).toBe(false);
});

// The landing is fixed over the whole viewport, so it covers the app header
// and footer: without these, a cold boot shows no version, repo link or
// credit until a repo is loaded.
describe('identity line', () => {
const renderLanding = async (opts: { dismissible: boolean }) => {
SERVER_CONFIG.value = { ...DEFAULT_SERVER_CONFIG, allowLocalRepos: true, version: '1.4.0' };
openProjectsView(opts);
render(
<ProjectsView onSubmit={() => {}} onCancel={() => {}} onClose={() => {}} />,
container
);
await flush();
return container.querySelector('.landing-hero')!;
};

it('shows the running version under the wordmark', async () => {
const hero = await renderLanding({ dismissible: false });
expect(hero.querySelector('.landing-wordmark')!.textContent).toBe('codecity');
expect(hero.textContent).toContain('v1.4.0');
});

it('links about to the repo and the credit to thalida.com', async () => {
const hero = await renderLanding({ dismissible: false });
const links = Array.from(hero.querySelectorAll<HTMLAnchorElement>('a'));
expect(links.map((a) => a.getAttribute('href'))).toEqual([
'https://github.com/thalida/codecity',
'https://thalida.com',
]);
for (const a of links) {
expect(a.getAttribute('target')).toBe('_blank');
expect(a.getAttribute('rel')).toBe('noopener noreferrer');
}
});

it('credits the creator with the unicorn', async () => {
const hero = await renderLanding({ dismissible: false });
expect(hero.textContent).toContain('🦄 thalida.');
});

it('shows on the dismissible switcher too, which also covers the chrome', async () => {
const hero = await renderLanding({ dismissible: true });
expect(hero.textContent).toContain('v1.4.0');
expect(hero.querySelectorAll('a')).toHaveLength(2);
});
});
});