This commit is contained in:
abue-ammar
2026-07-11 22:16:06 +06:00
parent a4c84ea6f8
commit 853ff9b66a
5 changed files with 199 additions and 10 deletions
+25 -7
View File
@@ -54,9 +54,10 @@ swiftc Tinycast/Core/Calculator/*.swift Tools/calc-test.swift -o /tmp/calc-test
**Single-owner core.** `AppCore.shared` (`Core/AppCore.swift`) is a `@MainActor` singleton that owns
every long-lived manager (`AppIndex`, `ClipboardStore`, `ClipboardManager`, `HotKeyManager`,
`AppSettings`, `FavoritesStore`, `RunningAppsMonitor`, `PaletteViewModel`) plus the window controllers.
`AppDelegate.applicationDidFinishLaunching` calls `AppCore.shared.start()` and nothing else; that's the
one wiring point. All palette/paste/launch actions are methods on `AppCore` that the SwiftUI views call.
`AppSettings`, `FavoritesStore`, `VisibilityStore`, `CalculatorHistoryStore`, `RunningAppsMonitor`,
`PaletteViewModel`) plus the window controllers. `AppDelegate.applicationDidFinishLaunching` calls
`AppCore.shared.start()` and nothing else; that's the one wiring point. All palette/paste/launch actions
are methods on `AppCore` that the SwiftUI views call.
**Two entry points, mostly AppKit windows.** `TinycastApp` (`@main`) declares only a `MenuBarExtra`
scene; everything visible is driven imperatively from AppKit. The command palette is a borderless
@@ -68,8 +69,24 @@ material is tuned for a dark surface only.
**Palette state flow.** `PaletteViewModel` (mode/query/selection/`focusToken`) is the bridge between the
panel and `AppCore`. Showing the palette calls `prepare(mode:)`, which resets state and bumps
`focusToken` (a UUID) so the SwiftUI search field re-focuses. `RootPaletteView` switches between
`LauncherView` and `ClipboardView` on `mode`. The panel auto-dismisses on `windowDidResignKey`.
`focusToken` (a UUID) so the SwiftUI search field re-focuses. `RootPaletteView` switches its content on
`mode` (`.launcher` → `LauncherList`, `.clipboard` → `ClipboardList` + preview, `.calculatorHistory` →
`CalculatorHistoryList`); Clipboard and Calculator History are sub-screens reached from the launcher
(Tab, a command, or a hotkey) and back out to it. The panel auto-dismisses on `windowDidResignKey`. The
flat `selection` index is the single source of truth for highlight/activation and must always match the
visible row order, including the inline calculator card at index 0 when present (see below).
**Inline calculator.** `Core/Calculator/` is a Foundation-only engine (parser → evaluator → formatter,
with unit conversion) fronted by `CalcMemo`, a one-deep memo mirroring `AppIndex`'s. When the launcher
or Calculator History query evaluates to a result, a `CalculatorCard` is pinned at the top of the list
(flat selection index 0, shifting rows by one) and Enter copies the answer + records it to
`CalculatorHistoryStore`. Keep the engine AppKit/SwiftUI-free so the `calc-test.swift` harness can
compile the real sources.
**Visual design.** The palette/settings look — forced-dark, white-alpha ramp, floating transparent
bars, scroll-driven edge dissolve, Liquid Glass only on floating controls — is documented in
`DESIGN.md` at the repo root. `Core/Theme.swift` is the single token source; read `DESIGN.md` before
any restyle or new view.
**Focus restoration is load-bearing.** `PaletteWindowController` records `previousApp` (the frontmost
app) on show. Paste then targets that app: `Paster.paste` activates it and posts a synthetic ⌘V via
@@ -101,10 +118,11 @@ while all Carbon registrations are paused.
## Layout
- `Tinycast/Core/` — managers, stores, windows, AppKit glue (no view bodies beyond hosting).
- `Tinycast/Features/` — SwiftUI views: `RootPaletteView`, `Launcher/`, `Clipboard/`, `Settings/`, `About/`.
- `Tinycast/Core/` — managers, stores, windows, AppKit glue (no view bodies beyond hosting); `Core/Calculator/` is the Foundation-only calc engine, `Core/Theme.swift` the design tokens.
- `Tinycast/Features/` — SwiftUI views: `RootPaletteView`, `Launcher/`, `Clipboard/`, `Calculator/`, `Settings/`, `About/`, plus shared `PopoverMenu`.
- `Tinycast/App/` — `@main` app + delegate.
- `Packaging/` — `make-app.sh` (bundle assembly + signing), `build-dmg.sh`, `dev-cert.sh`.
- `DESIGN.md` (repo root) — the visual design system: tokens, panel chrome, edge dissolve, rules for restyles.
## Concurrency
+171
View File
@@ -0,0 +1,171 @@
# DESIGN.md
The design system for Tinycast's UI, written so an agent restyling or extending it stays consistent
with what's already there. This documents **Tinycast as built** — every rule here maps to code in
`Tinycast/`.
Read this before touching any view body, `Theme` value, or the panel chrome.
---
## The look, in one paragraph
Tinycast is a **Raycast-style dark command palette**: a borderless floating panel whose surface is
just the OS behind-window blur under a 40% black scrim — there is no gray chrome. Everything on that
surface is white at a fixed alpha ramp. The header and bottom bar **float over the list as fully
transparent overlays**; there are no hard-edged bars, strips, or dividers. Rows don't clip under the
bars, they **dissolve**: a scroll-driven gradient mask ghosts them as they pass beneath. Floating
controls (the action pill, the menu circle, popover menus) are **Liquid Glass**. The whole app is
locked to dark mode because the glass material is tuned for a deep dark surface.
Five load-bearing ideas, in priority order:
1. **Surface = 40% black over behind-window blur.** No solid backgrounds. Depth comes from the desktop showing through.
2. **White-alpha ramp, never grays.** Text and surfaces are `Color.white.opacity(…)` at fixed stops.
3. **Floating bars, not chrome.** Header/footer are transparent overlays; the list fills the whole panel.
4. **Edges dissolve, they don't clip.** Scroll-driven mask, no separators between list and bars.
5. **Glass only on floating controls.** The main surface is never glass; pills/menus/circles are.
---
## Non-negotiable invariants
These are the things that quietly break the look if changed. Preserve them unless the task is explicitly to change them.
- **Forced dark.** `AppCore.start()` sets `NSApp.appearance = .darkAqua`. All colors are literal white/black alphas, not adaptive `Color`s. Don't introduce semantic/adaptive colors or a light variant.
- **No grays, no opaque fills on the surface.** Reach for `Theme.Colors.*` (white-alpha) instead of `.gray`, `NSColor.windowBackground`, etc.
- **No hard dividers between the list and the bars.** The header and bottom bar are `safeAreaInset` overlays with no background; separation comes from `edgeDissolve()`, nothing else. (One deliberate exception: the vertical hairline between the clipboard list and its preview pane.)
- **Menus are in-window overlays, not system popovers.** `.contextMenu`/`NSMenu` stall clicks for seconds inside a `LazyVStack` and spill outside the panel. Use `PopoverMenu` anchored to a bottom corner via `.overlay`, inset `menuInset` (8pt) so its own corner isn't clipped by the panel's.
- **The panel corner is clipped once, at the root.** `RootPaletteView.body` ends with `.background(black 40%) → .background(VisualEffectView()) → .clipShape(RoundedRectangle(26, .continuous))`. Keep that order; the scrim goes _over_ the vibrancy, and the clip is last.
- **Don't use the native scroll edge effect.** Inside a transparent panel it renders a hard-bounded rectangle. Use `edgeDissolve()`.
- **Test over a light desktop.** Transparency and corner masking bugs only show over bright wallpaper. Dark wallpaper hides them.
---
## Tokens — `Tinycast/Core/Theme.swift`
`Theme` is the single source of truth. **Never hardcode a spacing/radius/size/color that has a token.**
Add a token rather than a magic number when introducing a new value.
### Spacing (`Theme.Spacing`)
`xs 4` · `sm 6` · `md 8` · `lg 10` · `xl 12` · `xxl 20`
Row content insets are `md`; list horizontal inset is `md`; the search icon aligns with rows via `md * 2`.
### Radius (`Theme.Radius`)
`panel 26` · `row 10` · `card 10` · `menuPanel 12` · `menu 6` · `thumbnail 6` · `keyCap 5`
Always `RoundedRectangle(cornerRadius:, style: .continuous)` — continuous corners everywhere, never `.circular`.
### Size (`Theme.Size`)
`panelWidth 750` · `panelHeight 475` · `headerHeight 44` · `bottomBarHeight 52` · `rowIcon 24` ·
`keyCap 20` · `menuButton 36` · `clipboardListWidth 290` · `menuWidth 240` · `menuIcon 16` ·
`settingsSidebar 184` · `settingsRowIcon 20`
### Typography (`Theme.Typography`)
System fonts only — **no fixed point sizes in views** (honors Dynamic Type). `searchField` is the one
explicit size (20pt regular). Use `rowTitle` (`.body`), `sectionHeader` (`.subheadline.semibold`),
`rowTrailing`/`bar`/`menuRow`/`keyCap` etc. as named.
### Colors (`Theme.Colors`) — the white-alpha ramp
| Token | Value | Use |
| ---------------- | -------------- | ------------------------------------------------ |
| `panelDimming` | black **0.40** | the panel scrim over vibrancy |
| `selection` | white 0.10 | selected row fill (keyboard/active selection) |
| `rowHover` | white 0.05 | mouse-hover fill (always fainter than selection) |
| `menuHover` | white 0.10 | popover-menu row hover |
| `separator` | white 0.10 | the clipboard list↔preview hairline |
| `controlSurface` | white 0.10 | filled keycaps, glyph tiles |
| `border` | white 0.20 | outlined keycap borders |
| `textSecondary` | white 0.60 | secondary labels |
| `textTertiary` | white 0.40 | placeholders, trailing kind labels |
| `cardFill` | white 0.05 | settings/calc card fill |
| `cardStroke` | white 0.10 | settings/calc card border + inset dividers |
Beyond these, `.secondary`/`.tertiary` foreground styles are fine for SF Symbols (they resolve against
the forced-dark environment). **Selection always beats hover** when a row is both.
---
## Panel structure — `Core/PalettePanel.swift`, `Features/RootPaletteView.swift`
- **`PalettePanel`** is a borderless `NSPanel`: `isOpaque = false`, `backgroundColor = .clear`, `.floating` level, `hasShadow`, `animationBehavior = .none`. It hosts SwiftUI via `NSHostingView`. `PaletteWindowController` centers it slightly above screen center (`+8%`) and dismisses it on `windowDidResignKey`.
- **The results layer fills the whole panel.** The header and bottom bar attach via `.safeAreaInset(edge: .top/.bottom)` as transparent overlays that float _over_ the list. The list underlaps them and dissolves at the edges.
- **Header** (`headerHeight 44`): a back-chevron _or_ mode glyph, then the plain `TextField` (no border/background). Sub-screens (Clipboard, Calculator History) show the back chevron; the launcher shows a magnifying glass. The search icon aligns horizontally with row content.
- **Bottom bar** (`bottomBarHeight 52`): a menu circle on the left, the action group on the right — both floating glass, no bar background. The action group is one glass `Capsule` holding the primary-action pill (label + `↵`) and the Actions toggle (`⌘K`).
---
## The edge dissolve — `Core/EdgeDissolve.swift`
The signature effect. A scroll-driven `LinearGradient` mask on each list so rows soften as they approach
a floating bar, ghost beneath it, and vanish only at the window edge. Attach with `.edgeDissolve()` on
the `ScrollView`, **before `.thinScrollbar()`** (so the scrollbar overlay stays unmasked).
- Fade bands: top = `headerHeight + md + 32`, bottom = `bottomBarHeight + 28` — each overshoots its bar into the visible list, so the ramp finishes ~32/28px _past_ the bar rather than cliffing at its edge.
- Alpha floors mid-scroll (not to 0): **top 0.15, bottom 0.25**, eased by how much content is hidden past the edge (`1 − (1 − floor)·clamp(dist/band, 0, 1)`).
- Only masks when the list is scrollable; the edge stop stays transparent so rubber-band bounces still dissolve. A list that fits gets no mask.
- The mask spans the scroll view's **full** frame (`.ignoresSafeArea()`) — otherwise the bars' safe-area insets shift the gradient onto at-rest rows.
---
## Rows, selection, hover — `Launcher/LauncherView.swift`, `Clipboard/ClipboardView.swift`
All lists share one row grammar so launcher and clipboard look identical:
- `HStack(spacing: lg)`: leading 24pt icon/thumbnail, title (`.body`, `lineLimit(1)`), optional trailing keycaps/kind label, `Spacer`. Insets: `.horizontal md`, `.vertical sm`.
- Background is a `RoundedRectangle(row, .continuous)` filled by `fill`: **selection → hover → clear**, in that precedence. This `fill` computed property is copy-identical across `AppRow`, `ClipboardRow`, `CalculatorCard` — keep them in sync.
- **Hover state lives on the row**, not the list, so a mouse sweep repaints only the rows entering/leaving (a list-level hover rebuilds every row per move — don't do that).
- **`SectionHeader`** (`.subheadline.semibold`, secondary) labels groups: launcher uses Favorites/Applications/System Settings/Commands; clipboard/history use date buckets (Today/Yesterday/…).
- **Keycaps** use `KeyCapChip`: `.outline` (white-0.20 border) for hotkey hints on rows, `.filled` (white-0.10 fill) for footer shortcuts.
- **Scroll follows selection only on keyboard nav/reset**, driven by a `scrollToken` UUID — mouse selection targets a visible row and never yanks scroll.
---
## Liquid Glass — `Theme.frosted(in:)`, `Features/PopoverMenu.swift`
Glass is **only** for floating controls, never the main surface.
- `View.frosted(in:)` = `glassEffect(.regular.interactive(), in:)` + `.tint(.clear)` — interactive lensing, untinted. Used on the action-group capsule and the menu circle.
- **`PopoverMenu`** uses `glassEffect(.regular, in: RoundedRectangle(menuPanel 12))` with **no hand-tuned shadow** — Tahoe glass carries its own elevation; adding a drop shadow reads heavy and non-native.
- `PopoverMenuRow`: leading SF Symbol (`hierarchical`, secondary — or **red** when `isDestructive`), label, trailing shortcut glyph, `menuHover` fill on hover, `menu 6` corner. Menus animate in with `.opacity + .scale(0.96)` from the anchored corner, `easeOut 0.14`.
---
## Scrollbar — `Core/ThinScrollbar.swift`
Custom thin overlay scrollbar (the native one flashes and reserves a gutter inside a transparent panel).
`.hideNativeScrollers()` on the scroll _content_ forces the backing `NSScrollView` to a hidden `.overlay`
style; `.thinScrollbar()` on the scroll view draws a hairline thumb (`Color.primary` alpha 0.30 rest →
0.42 hover → 0.5 drag) that fattens on hover, with a faint rail revealed only while hovering/dragging.
Don't reintroduce native scrollers.
---
## Settings — `Features/Settings/SettingsComponents.swift`
Settings runs in its own `NSWindow` (the SwiftUI `Settings` scene is unreliable for accessory apps) but
shares the palette's `Theme` vocabulary. It reads as macOS System Settings, not the palette:
- **`SettingsPane`**: bold `.title2` title + secondary subtitle header, then scrollable content, `xxl` inset all around, the same thin scrollbar.
- **`SettingsCard`**: rounded `card 10` container, `cardFill` (white 0.05) fill, `cardStroke` (white 0.10) hairline border. Rows inside are split by `SettingsDivider` — an inset hairline aligned under the row title (past the icon).
- **`SettingsRow`**: optional 20pt SF Symbol, title + optional caption subtitle, trailing control, fixed `.horizontal xl / .vertical lg` rhythm.
The calculator's inline `CalculatorCard` reuses this card language (`cardFill` + `cardStroke`) rather than the row language, since it's a highlighted answer, not a list item.
---
## Rules for agents working on the UI
- **Restyle from rendered screenshots, not guessed values.** Compare rendered screenshots over a light desktop. There's no screen-recording from the shell here — verify AppKit rendering with a `swiftc` harness that prints layer state, and let the user do visual sign-off.
- **Don't add behavior that wasn't requested.** A restyle changes appearance, not interaction — keep selection/scroll/dismiss/focus flows exactly as they are unless the task is about them.
- **New tokens go in `Theme`**, referenced everywhere. No magic numbers in views.
- **Keep the shared grammar shared.** If you change row insets, the `fill` precedence, section-header style, or keycap style, change it for _all_ lists — divergence is the bug, not the feature.
- **Build & verify** with the real toolchain (see CLAUDE.md → Build & run); a design change that doesn't compile under Swift 6 mode isn't done.
</content>
</invoke>
+1 -1
View File
@@ -53,7 +53,7 @@ Security → Accessibility**.
## Building from source
See **[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)** for the toolchain, build, packaging, release,
and website workflows.
and website workflows, and **[DESIGN.md](DESIGN.md)** for the UI design system.
## License
+1 -1
View File
@@ -1,7 +1,7 @@
import SwiftUI
/// Scroll-driven edge dissolve for a scroll view underlapping the palette's transparent floating
/// bars. The fade band runs
/// bars (see `DESIGN.md` → The edge dissolve). The fade band runs
/// from the window edge to a fixed distance *past* the bar into the visible list, with a pinned
/// midpoint at half the band: rows soften as they approach a bar, ghost beneath it (alpha floors
/// at 15% top / 25% bottom once a full band of content is hidden), and vanish only at the window
+1 -1
View File
@@ -1,7 +1,7 @@
import SwiftUI
/// Central design tokens for the palette UI, so visual tweaks happen in one place.
/// The app forces
/// Color values follow the dark design system in `DESIGN.md`; the app forces
/// `.darkAqua`, so they are literal white/black alphas rather than adaptive colors.
enum Theme {
enum Spacing {