Skip to content
Open
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
10 changes: 10 additions & 0 deletions .changeset/about-to-footer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@cyberdeck/ascii': patch
---

The `about` trigger leaves the header for a new ultra-thin footer on the empty state. The footer
also carries the `source code →` and `author →` links — attribution that used to hide inside the
About modal. It shows only before a Source loads: once the Control Strip owns the bottom edge, a
footer under it would just invite a mis-tap. The modal keeps the longer narrative (the intro, `ai
scan`, and `made with ai`), so the header sheds a secondary control and the About content splits by
how often you reach for it.
9 changes: 9 additions & 0 deletions .changeset/deck-kit-modal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@cyberdeck/deck-kit': minor
---

The `Modal` primitive crosses the seam into the kit (ADR 0014): it was ASCII//Convert's local
dialog, and GLITCH//Studio's new About modal is the second real caller, so it moves verbatim to
`@cyberdeck/deck-kit/ui` over the already-shared `useDialog` hook. It also gains a mobile fix — the
dialog now caps its height to the viewport and scrolls internally, so a tall modal no longer
overflows and clips its own title on a short screen.
9 changes: 9 additions & 0 deletions .changeset/glitch-footer-about.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@cyberdeck/glitch': patch
---

GLITCH//Studio gains an ultra-thin footer on the empty state, carrying the `source code →` and
`author →` links plus an `about` trigger — the same footer feature ASCII//Convert just grew. It
shows only before a Source loads, so the Control Strip owns the bottom edge once glitching starts.
`about` opens a new About modal: what the program is, and the deck-wide `made with ai` note. No
AI-scan section, the one thing GLITCH doesn't share with ASCII//Convert.
18 changes: 15 additions & 3 deletions apps/ascii/src/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,9 @@ describe('the OUT tab', () => {
})

describe('App header buttons', () => {
it('both buttons have min-h-[44px]', () => {
it('the configure ai button has min-h-[44px]', () => {
render(<App />)
const aboutBtn = screen.getByRole('button', { name: /about/i })
const aiBtn = screen.getByRole('button', { name: /configure ai/i })
expect(aboutBtn.className).toContain('min-h-[44px]')
expect(aiBtn.className).toContain('min-h-[44px]')
})

Expand Down Expand Up @@ -192,3 +190,17 @@ describe('App header buttons', () => {
expect(tokens).not.toContain('border-accent')
})
})

describe('the empty-state footer', () => {
it('carries the About trigger before a Source loads', () => {
render(<App />)
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'about' })).toBeInTheDocument()
})

it('is gone once a Source loads, so it can never sit under the Control Strip', () => {
render(<App />)
fireEvent.click(screen.getByText('hero'))
expect(screen.queryByRole('contentinfo')).not.toBeInTheDocument()
})
})
10 changes: 7 additions & 3 deletions apps/ascii/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import AnalysisModal from './components/analysis-modal'
import ApiKeyModal from './components/api-key-modal'
import AsciiCanvas from './components/ascii-canvas'
import ControlStrip from './components/control-strip'
import Footer from './components/footer'
import HeaderButton from './components/ui/header-button'
import { outputFilename } from './export/output'
import { useWebcamState } from './hooks/use-webcam-state'
Expand Down Expand Up @@ -156,9 +157,6 @@ export default function App() {
<span className="text-fg-faint text-xs hidden sm:block">—</span>
<span className="text-fg-muted text-xs hidden sm:block">image → ascii art</span>
<div className="ml-auto flex items-center gap-xs">
<HeaderButton variant="neutral" onClick={() => setActiveModal({ kind: 'about' })}>
about
</HeaderButton>
<HeaderButton
variant={aiConfig ? 'accent-text' : 'accent-fill'}
onClick={() => setActiveModal({ kind: 'apiKey' })}
Expand Down Expand Up @@ -236,6 +234,12 @@ export default function App() {
/>
)}

{/* Empty state only: with a Source the Control Strip owns the bottom edge, and a footer
directly under it invites a mis-tap on the way to a control. */}
{!(sourceImage || sourceVideo) && (
<Footer onAbout={() => setActiveModal({ kind: 'about' })} />
)}

{activeModal?.kind === 'apiKey' && (
<ApiKeyModal
current={aiConfig}
Expand Down
5 changes: 3 additions & 2 deletions apps/ascii/src/components/about-modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import AboutModal from './about-modal'

vi.mock('./ui/modal', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
vi.mock('@cyberdeck/deck-kit/ui', async (importOriginal) => ({
...(await importOriginal<typeof import('@cyberdeck/deck-kit/ui')>()),
Modal: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))

describe('AboutModal section headings', () => {
Expand Down
21 changes: 1 addition & 20 deletions apps/ascii/src/components/about-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Modal from './ui/modal'
import { Modal } from '@cyberdeck/deck-kit/ui'

interface Props {
onClose: () => void
Expand Down Expand Up @@ -36,25 +36,6 @@ export default function AboutModal({ onClose }: Props) {
human + AI workflow looks like in practice.
</p>
</div>

<div className="flex gap-sm flex-wrap">
<a
href="https://github.com/andraderaul/ascii-art-converter"
target="_blank"
rel="noopener noreferrer"
className="text-xs font-mono tracking-wide transition-all text-link no-underline"
>
source code →
</a>
<a
href="https://www.linkedin.com/in/andraderaul/"
target="_blank"
rel="noopener noreferrer"
className="text-xs font-mono tracking-wide transition-all text-link no-underline"
>
author →
</a>
</div>
</Modal>
)
}
3 changes: 1 addition & 2 deletions apps/ascii/src/components/analysis-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Button } from '@cyberdeck/deck-kit/ui'
import { Button, Modal } from '@cyberdeck/deck-kit/ui'
import { cn } from '@cyberdeck/deck-kit/utils'
import type { AnalysisState, ThreatLevel } from '../ai/types'
import Badge from './ui/badge'
import Modal from './ui/modal'

/** A barely-there tint of a role's own colour, for the band behind a threat level. */
function wash(token: string, percent: number): string {
Expand Down
5 changes: 3 additions & 2 deletions apps/ascii/src/components/api-key-modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ApiKeyModal from './api-key-modal'

vi.mock('./ui/modal', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
vi.mock('@cyberdeck/deck-kit/ui', async (importOriginal) => ({
...(await importOriginal<typeof import('@cyberdeck/deck-kit/ui')>()),
Modal: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))

function renderModal(current: Parameters<typeof ApiKeyModal>[0]['current'] = null) {
Expand Down
3 changes: 1 addition & 2 deletions apps/ascii/src/components/api-key-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Button } from '@cyberdeck/deck-kit/ui'
import { Button, Modal } from '@cyberdeck/deck-kit/ui'
import { cn } from '@cyberdeck/deck-kit/utils'
import { useState } from 'react'
import type { AIConfig, AIProviderName } from '../ai/types'
import Modal from './ui/modal'

interface Props {
current: AIConfig | null
Expand Down
39 changes: 39 additions & 0 deletions apps/ascii/src/components/footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
interface Props {
onAbout: () => void
}

const LINK_CLASS = 'font-mono text-xs tracking-wide transition-all text-link no-underline'

/**
* Empty-state bottom chrome (App hides it once a Source loads). Carries the attribution links that
* used to live only inside the About modal; the `about` trigger opens the modal for the narrative.
*/
export default function Footer({ onAbout }: Props) {
return (
<footer className="shrink-0 border-t border-base px-sm sm:px-lg py-2xs flex items-center gap-sm">
<a
href="https://github.com/andraderaul/ascii-art-converter"
target="_blank"
rel="noopener noreferrer"
className={LINK_CLASS}
>
source code →
</a>
<a
href="https://www.linkedin.com/in/andraderaul/"
target="_blank"
rel="noopener noreferrer"
className={LINK_CLASS}
>
author →
</a>
<button
type="button"
onClick={onAbout}
className="ml-auto font-mono text-xs tracking-wide text-fg-subtle hover:text-fg transition-all cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
about
</button>
</footer>
)
}
14 changes: 14 additions & 0 deletions apps/glitch/src/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1108,4 +1108,18 @@ describe('App', () => {
expect(screen.queryByLabelText('live glitched preview')).not.toBeInTheDocument()
})
})

describe('the empty-state footer', () => {
it('carries the About trigger before a Source loads', () => {
render(<App />)
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'about' })).toBeInTheDocument()
})

it('is gone once a Source loads, so it can never sit under the Control Strip', () => {
render(<App />)
fireEvent.click(screen.getByRole('button', { name: 'upload' }))
expect(screen.queryByRole('contentinfo')).not.toBeInTheDocument()
})
})
})
9 changes: 9 additions & 0 deletions apps/glitch/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useRecording } from '@cyberdeck/deck-kit/recording'
import { EmptyStateHero, ErrorBoundary, ThemeControl, useToastError } from '@cyberdeck/deck-kit/ui'
import { useCallback, useEffect, useRef, useState } from 'react'
import AboutModal from './components/about-modal'
import ControlStrip from './components/control-strip'
import Footer from './components/footer'
import GlitchCanvas from './components/glitch-canvas'
import { Errors } from './errors/app-error'
import { outputFilename } from './export/output'
Expand All @@ -21,6 +23,7 @@ export default function App() {
// Beside the Chain, never inside it (ADR 0016): mirror is source-tuning, not part of the
// look, so it rides through Presets, Re-roll and Randomize untouched — like ASCII's isMirrored.
const [isMirrored, setIsMirrored] = useState(false)
const [isAboutOpen, setIsAboutOpen] = useState(false)
const canvasRef = useRef<HTMLCanvasElement>(null)
const showError = useToastError()

Expand Down Expand Up @@ -154,6 +157,12 @@ export default function App() {
onReroll={reroll}
/>
)}

{/* Empty state only: with a Source the Control Strip owns the bottom edge, and a footer
directly under it invites a mis-tap on the way to a control. */}
{!hasSource && <Footer onAbout={() => setIsAboutOpen(true)} />}

{isAboutOpen && <AboutModal onClose={() => setIsAboutOpen(false)} />}
</div>
)
}
32 changes: 32 additions & 0 deletions apps/glitch/src/components/about-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Modal } from '@cyberdeck/deck-kit/ui'

interface Props {
onClose: () => void
}

export default function AboutModal({ onClose }: Props) {
return (
<Modal
onClose={onClose}
title={<span className="text-accent font-bold tracking-wide text-base">GLITCH//STUDIO</span>}
ariaLabel="About"
variant="default"
>
<p className="text-fg-muted text-sm leading-normal">
Run any photo or your webcam through a chain of glitch effects — channel shifts, pixel
sorting, scanlines, noise and more. Pick a preset, randomize it, or build your own chain,
then take the result out as an image. Everything happens in your browser, nothing is
uploaded anywhere.
</p>

<div className="flex flex-col gap-sm">
<span className="text-fg text-xs font-medium">made with ai</span>
<p className="text-fg-muted text-sm leading-normal">
This project was built in collaboration with AI — not just the code, but the design
decisions, documentation, and architecture too. It's an experiment in what a thoughtful
human + AI workflow looks like in practice.
</p>
</div>
</Modal>
)
}
40 changes: 40 additions & 0 deletions apps/glitch/src/components/footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
interface Props {
onAbout: () => void
}

const LINK_CLASS = 'font-mono text-xs tracking-wide transition-all text-link no-underline'

/**
* Empty-state bottom chrome (App hides it once a Source loads). Carries the attribution links plus
* the `about` trigger — mirrors ASCII//Convert's footer (deliberately per-app copy, ADR 0014), the
* source-code link and About content being what differ between the two.
*/
export default function Footer({ onAbout }: Props) {
return (
<footer className="shrink-0 border-t border-base px-sm sm:px-lg py-2xs flex items-center gap-sm">
<a
href="https://github.com/andraderaul/cyberdeck"
target="_blank"
rel="noopener noreferrer"
className={LINK_CLASS}
>
source code →
</a>
<a
href="https://www.linkedin.com/in/andraderaul/"
target="_blank"
rel="noopener noreferrer"
className={LINK_CLASS}
>
author →
</a>
<button
type="button"
onClick={onAbout}
className="ml-auto font-mono text-xs tracking-wide text-fg-subtle hover:text-fg transition-all cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
about
</button>
</footer>
)
}
1 change: 1 addition & 0 deletions packages/deck-kit/src/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { default as Chip } from './chip'
export { default as EmptyStateHero } from './empty-state-hero'
export { default as ErrorBoundary } from './error-boundary'
export { default as Label } from './label'
export { default as Modal } from './modal'
export { default as Slider } from './slider'
export { default as SourceImageDropZone } from './source-image-drop-zone'
export { default as TabStrip, type Tab } from './tab-strip'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,15 @@ describe('Modal', () => {
})
})

describe('Mobile overflow', () => {
it('caps the panel to the viewport and scrolls internally, so a tall dialog cannot clip its title', () => {
renderModal()
const tokens = screen.getByRole('dialog').className.split(/\s+/)
expect(tokens).toContain('max-h-full')
expect(tokens).toContain('overflow-y-auto')
})
})

describe('Body scroll lock (via useDialog)', () => {
it('locks body scroll when modal is open', () => {
renderModal()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useDialog } from '@cyberdeck/deck-kit/hooks'
import { cn } from '@cyberdeck/deck-kit/utils'
import { type ReactNode, useRef } from 'react'
import { useDialog } from '../hooks/use-dialog'
import { cn } from '../utils/cn'

const NOOP = () => {}

Expand Down Expand Up @@ -30,7 +30,7 @@ export default function Modal({
return (
<div
role="presentation"
className="fixed inset-0 z-50 flex items-center justify-center bg-modal-overlay backdrop-blur-sm"
className="fixed inset-0 z-50 flex items-center justify-center p-md bg-modal-overlay backdrop-blur-sm"
>
{closeable && (
<button
Expand All @@ -46,7 +46,7 @@ export default function Modal({
aria-modal="true"
aria-label={ariaLabel}
className={cn(
'relative flex flex-col p-xl',
'relative flex flex-col p-xl max-h-full overflow-y-auto',
variant === 'default'
? 'gap-lg bg-bg-elevated max-w-[480px] w-[90%] rounded-sm border border-base'
: 'gap-md bg-bg-surface border border-base border-t-2 border-t-accent w-full max-w-sm',
Expand Down
Loading