mirror of
https://github.com/abue-ammar/tinycast.git
synced 2026-10-02 08:14:38 +08:00
* Show AI providers as a list beside the selected provider's detail The Providers panel was one long form: every installed tool and API connection stacked as rows, with status, quota and actions squeezed into each. It now follows Mail's Accounts: the on-device model, the installed commands and the API connections sit in a list, each with a one-line state and, for installed tools, its switch. The selected provider shows its status, account, usage, command path and models beside it, with a filter once a list runs long. API connections are added from the list's plus menu, removed with its minus, and still edited in the existing connection editor. * Choose which of a provider's models the model picker lists A route like OpenCode offers hundreds of models, and every one of them landed in the chat's model picker. Each provider's model list is now a set of checkboxes, with Show All and Hide All above it. A provider nobody has trimmed keeps listing everything, including models it adds later; once trimmed, it lists only what was ticked. The default model always stays listed, since the picker has to show what it holds, and a removed connection forgets its choices. The choice is per Mac and stays out of settings backups. The provider switch moves from the list into the selected provider's header, so it no longer sits on the list's blue selection, and model rows drop their effort levels, which the chat's own picker already shows. * Give Grok, Cursor and OpenCode their own marks Grok borrowed X's mark, Cursor a pointer symbol and OpenCode a terminal symbol. Each now has its own, in the Providers list and in the chat's model picker. OpenCode's inner block is drawn with `opacity`: the asset compiler drops `fill-opacity` without a warning, which filled the block solid and turned the mark into a window with a title bar. * Name ChatGPT's newer plans Pro now comes in two sizes, which the account reports as "pro" and "prolite" and ChatGPT sells as Pro 20x and Pro 5x. Business, Enterprise and Edu each gained plan types that read as "Account". * List a provider's models in a table A Form realizes every row it holds, and OpenCode offers over four hundred models, so its list stuttered while scrolling. The checkboxes now sit in an NSTableView inside one Form row, which builds only the rows on screen. * Switch the on-device model or an API connection off in place Only an installed tool could be switched off; an API connection had to be removed, key and all, to leave the model picker. The on-device model and every API connection now have the same switch. A route that is off stays configured but leaves every picker, the default model moves to a route still on, and asking for it anyway is refused with a message. Removing a connection forgets that it was off, and the choice stays out of backups. * Launch an installed tool from a set path, with set variables Tinycast finds each installed tool by asking a login shell, which is right until a Mac has two copies or a shell it cannot read. A tool can now carry a command path that replaces the lookup, and variables set for that tool each time it starts. Nothing sets them yet; the next commit adds the fields. A path with nothing to run fails the check and the turn, never falling back to the lookup, since that would hide the mistake the path was set to fix. The reader's variables lie over the app's own, but never over one Tinycast sets to keep a tool inside the chat. Names are stored in settings and values in the login Keychain; neither travels in a backup, which must not decide what this Mac launches. Only the tool that was edited is checked again. * Add an Advanced section to an installed tool's detail The command path has a field and a Choose… button whose open panel shows hidden folders, since `~/.local/bin` is one. Variables are rows of a name and a hidden value, added and removed one at a time. A name Tinycast sets itself says its value is not used, and one that is not a variable name says why. Each field saves as it is left. * Name a Claude model by its display name when the description has no version Claude Code used to lead a model's description with its version, "Opus 5.5 · Best for everyday…", and Tinycast named the model from that. A newer CLI puts the version in the display name and leaves only the blurb in the description, so every model in the picker read "Claude Best for everyday, complex tasks". The version is now taken from the description only when it is there, and from the display name otherwise. * End an edit in the Providers panel with a click outside the field A field holds the keyboard until something else takes it, and blank form space takes nothing. The panel now releases it the way every other pane does, which is also what saves a field that is saved as it is left. * Show Claude's account and Codex's command, and keep Advanced while a tool is off Claude's detail now names the account it is signed in with and its plan, the address hidden until clicked as Codex's is. Both come from the answer that already lists Claude's models, so nothing more is asked of the CLI. Codex's detail names the command it ran, which the other tools already did, and the empty command path field shows it too. A tool that is switched off keeps its Advanced section, so a wrong path can be fixed before it is switched on. * Give a provider's detail its own pages A provider's status, its models and its advanced settings shared one scrolling form, so OpenCode's four hundred models buried everything under them. The detail now has Overview, Models and Advanced pages behind a segmented control, as Mail's Accounts has. An API connection has the first two, and the on-device model, with one page, shows no control. The chosen page is kept from one provider to the next. * Keep the accent colour on the providers panel's page control An editor panel wraps its content in Liquid Glass, and a segmented control drawn on glass goes grey and bordered. The divider beside the selected page is always hidden, so in grey the control read as missing one. The providers panel now draws its glass behind the content, which leaves the control its accent colour; every other editor panel is unchanged. * Keep the MCP editor's connection control from resizing The system segmented control opens tight around its labels and widens the first time its selection changes, so the HTTP and Command control grew under the pointer and stayed that wide. The editor now uses the AppKit control with each segment pinned to its label plus the inset the control settles at, and reports its own size from those widths, so it opens at the size it would have ended up. * Say Automatic in an empty command path field The empty field showed the path the lookup had found, which read as a path already set, and repeated the Command row on Overview. It now says Automatic; the found path is on Overview alone. * Credit the three new marks, and take two from the sources already credited Cursor's mark now comes from Simple Icons and Grok's from Lobe Icons, the two sources NOTICE already names; both look as they did. OpenCode's is drawn after the one in its own repository, since the libraries carry the frame without the block inside it, and its licence travels with it. * Document the Providers panel, its pages and a tool's overrides The feature doc gains the rules that are now invariants: a route that is off is off everywhere, a picker lists what was ticked, a set command path wins or fails, and a reader's variable never replaces one Tinycast sets. It describes the panel's list and pages, and says why three more AI keys stay out of backups and settings.json. The UI doc records the panel's sizes and the two things learned about controls in an editor panel. The website explains the pages, the switch, and the Advanced page. * Keep a tool's variables when the Keychain cannot be read A failed read loaded the variables' names with blank values, and leaving any field in Advanced then saved the blanks over the stored values. The read now throws, the page says it could not read them and saves only the command path, and a save whose check read fails is refused.
522 lines
31 KiB
Swift
522 lines
31 KiB
Swift
import QuartzCore
|
||
import SwiftUI
|
||
|
||
/// Central design tokens; every dark colour is the literal the forced-dark build shipped.
|
||
enum Theme {
|
||
enum Spacing {
|
||
static let xxs: CGFloat = 2
|
||
static let xs: CGFloat = 4
|
||
static let sm: CGFloat = 6
|
||
static let md: CGFloat = 8
|
||
static let lg: CGFloat = 10
|
||
static let xl: CGFloat = 12
|
||
/// Outer inset of Tinycast's dialog content.
|
||
static let dialogInset: CGFloat = 18
|
||
static let xxl: CGFloat = 20
|
||
/// Calculator answer card's roomier vertical breathing room.
|
||
static let xxxl: CGFloat = 28
|
||
/// Gap under a category header, shared by every palette list's `SectionHeader`.
|
||
static let sectionHeaderBottom: CGFloat = 4
|
||
/// Clearance under the last message, so its actions row belongs to it, not to the footer.
|
||
static let chatTranscriptBottom: CGFloat = 28
|
||
/// A stream grows the transcript as the reader descends, so an exact-bottom test runs away.
|
||
static let chatFollowTailSlack: CGFloat = 44
|
||
/// Extra leading between a transcript's lines, so a long reply reads as paragraphs.
|
||
static let chatLine: CGFloat = 4
|
||
/// Space above every header but the first, reading as the previous section's close.
|
||
static let sectionSpacing: CGFloat = 12
|
||
/// Emoji tiles need a little more separation so adjacent category grids stay distinct.
|
||
static let emojiSectionSpacing: CGFloat = 14
|
||
}
|
||
|
||
enum Radius {
|
||
static let panel: CGFloat = 26
|
||
static let row: CGFloat = 10
|
||
/// Emoji tiles are roomier than list rows, so their corners take one larger step.
|
||
static let emojiCell: CGFloat = 12
|
||
static let menu: CGFloat = 6
|
||
/// Hover highlight behind a popover menu row.
|
||
static let menuRow: CGFloat = 10
|
||
/// A header pop-up button; the footer's action pills stay capsules.
|
||
static let barControl: CGFloat = 8
|
||
static let menuPanel: CGFloat = 16
|
||
/// Subject tile inside the more rounded dialog panel.
|
||
static let dialogSymbol: CGFloat = 16
|
||
/// The dialog and HUD surface, so a dialog reads as a sibling of the palette.
|
||
static let dialog: CGFloat = 20
|
||
static let thumbnail: CGFloat = 6
|
||
/// A shape small enough that `thumbnail` would round it into a circle.
|
||
static let glyph: CGFloat = 2
|
||
/// A pill holding a square thumbnail; a full capsule fights the thumbnail's own corners.
|
||
static let attachmentChip: CGFloat = 8
|
||
static let card: CGFloat = 10
|
||
/// A window-to-be in the room preview, rounded like a real window rather than a row.
|
||
static let roomCard: CGFloat = 16
|
||
static let keyCap: CGFloat = 6
|
||
/// Settings shortcut-recorder keycap — smaller than the palette's `keyCap` chip.
|
||
static let recorderKeyCap: CGFloat = 4
|
||
static let tooltip: CGFloat = 8
|
||
}
|
||
|
||
enum Blur {
|
||
/// Unreadable at full size without smearing the row; the scramble is what hides it.
|
||
static let redaction: CGFloat = 3
|
||
}
|
||
|
||
enum Size {
|
||
static let panelWidth: CGFloat = 750
|
||
static let panelHeight: CGFloat = 475
|
||
/// Opening size on a first run and the floor: below it the title bar's own parts collide.
|
||
static let noteWindow = CGSize(width: 440, height: 180)
|
||
static let noteEditorInset: CGFloat = 16
|
||
/// Shorter than the horizontal inset, so the first line sits close under the title bar.
|
||
static let noteEditorTopInset: CGFloat = 6
|
||
static let noteSearchHeight: CGFloat = 34
|
||
/// The switcher popover, sized independently of a note window that can be 180pt tall.
|
||
static let noteSwitcher = CGSize(width: 300, height: 240)
|
||
static let noteSwitcherEmptyHeight: CGFloat = 96
|
||
static let noteSwitcherDrop: CGFloat = 56
|
||
/// Fixed like every menu's width; the height is exactly four heading rows.
|
||
static let noteHeadingMenu = CGSize(
|
||
width: 220, height: menuRowHeight * 4 + menuRowSpacing * 3 + Spacing.sm * 2)
|
||
static let noteFooterHeight: CGFloat = 28
|
||
/// Holds the launcher's 36-point action capsule with the same margin its own bar gives it.
|
||
static let noteTitlebar: CGFloat = 52
|
||
/// Symmetric, so the title stays centred on the window while clearing lights and capsule.
|
||
static let noteTitleInset: CGFloat = 120
|
||
/// Nine points crowds the palette's 26-point corner, so Notes seats its lights further in.
|
||
static let noteTrafficLightInset: CGFloat = 20
|
||
/// Fraction of visible height above the palette's top edge; it grows downward.
|
||
static let paletteTopMarginFraction: CGFloat = 0.18
|
||
static let headerHeight: CGFloat = 44
|
||
/// Fixed slot for the header glyph, so the field starts at one x in every mode.
|
||
static let headerIconSlot: CGFloat = 22
|
||
/// The narrowest a strip squeezes the search field to: the caret and a few characters.
|
||
static let searchFieldMinWidth: CGFloat = 60
|
||
/// Room above the search row, constant so typing never shifts the bar.
|
||
static let headerPadding: CGFloat = 10
|
||
/// Collapsed compact bar: the search row centered in symmetric `headerPadding` slack.
|
||
static let compactHeight: CGFloat = headerHeight + headerPadding * 2
|
||
/// How close a dragged palette must be to the invisible vertical centre line.
|
||
static let paletteSnapDistance: CGFloat = 8
|
||
/// A restored position needs this much bar on a display to still be grabbable.
|
||
static let paletteMinimumVisible: CGFloat = 44
|
||
static let dropGuideDash: CGFloat = 8
|
||
static let dropGuideGap: CGFloat = 12
|
||
static let dropGuideWidth: CGFloat = 2
|
||
static let dropGuideCombinedFlashTolerance: CGFloat = 6
|
||
static let dropGuideFadeThreshold: CGFloat = 36
|
||
static let dropGuideFadeDistance: CGFloat = 180
|
||
static let bottomBarHeight: CGFloat = 52
|
||
/// A `BarButton`'s hover capsule, shared by the footer group and the header's filter.
|
||
static let barButtonHeight: CGFloat = 28
|
||
static let rowIcon: CGFloat = 24
|
||
/// Colour-codes a secondary label, as Calendar.app marks an event's calendar.
|
||
static let colorDot: CGFloat = 8
|
||
/// The calendar-colour bar between a meeting row's icon and its title.
|
||
static let calendarBarWidth: CGFloat = 3
|
||
static let calendarBarHeight: CGFloat = 18
|
||
/// The same bar in the menu bar and its menu, sized to the system's 13pt menu text.
|
||
static let menuBarCalendarBarHeight: CGFloat = 12
|
||
static let keyCap: CGFloat = 18
|
||
/// Settings shortcut-recorder keycap — smaller than the palette's `keyCap` chip.
|
||
static let recorderKeyCap: CGFloat = 16
|
||
/// Fixed so the recorder can't resize as its binding changes.
|
||
static let shortcutRecorder: CGFloat = 120
|
||
/// One text line in the recorder callout.
|
||
static let shortcutPopoverLine: CGFloat = 14
|
||
/// Summed from the laid-out bands; the width is pinned by `callout-test`.
|
||
static let shortcutPopover = CGSize(
|
||
width: 132,
|
||
height: Spacing.sm * 2 + heroKeyCap + Spacing.sm + shortcutPopoverLine + Spacing.sm
|
||
+ compactKeyCap + calloutCaretHeight)
|
||
/// The callout's pointer: a triangle with a rounded tip.
|
||
static let calloutCaretWidth: CGFloat = 15
|
||
static let calloutCaretHeight: CGFloat = 7
|
||
static let calloutCaretTip: CGFloat = 2.5
|
||
/// Keycaps: `compact` hints, `keyCap` is standard, `hero` where the cap is content.
|
||
static let compactKeyCap: CGFloat = 15
|
||
static let heroKeyCap: CGFloat = 22
|
||
static let menuButton: CGFloat = 36
|
||
static let noteGlyph: CGFloat = 16
|
||
static let noteEmptyGlyph: CGFloat = 28
|
||
/// Hit target for a chat message footer glyph; its caption symbol floats inside it.
|
||
static let chatMessageAction: CGFloat = 16
|
||
/// A one-pixel markdown rule and table header separator.
|
||
static let hairline: CGFloat = 1
|
||
static let markdownListMarker: CGFloat = 20
|
||
static let markdownQuoteBar: CGFloat = 2
|
||
/// The chat composer's context ring, track and fill alike.
|
||
static let contextRingStroke: CGFloat = 2
|
||
/// The chat window's drop outline and the length of its dashes.
|
||
static let dropHintStroke: CGFloat = 2
|
||
static let dropHintDash: CGFloat = 6
|
||
/// The uninstall list's leading checkbox / lock glyph.
|
||
static let checkbox: CGFloat = 16
|
||
static let clipboardListWidth: CGFloat = 290
|
||
/// Symmetric clearance between the emoji grid and both panel edges.
|
||
static let emojiGridInset: CGFloat = 16
|
||
static let emojiCell: CGFloat = 56
|
||
static let menuWidth: CGFloat = 276
|
||
static let actionMenuWidth: CGFloat = 320
|
||
/// The clipboard type filter's menu; `menuWidth` is far too wide for six short rows.
|
||
static let clipboardFilterMenuWidth: CGFloat = 200
|
||
static let fileSearchFilterMenuWidth: CGFloat = 200
|
||
/// Fits "Shapes & Punctuation", the longest category title.
|
||
static let emojiCategoryMenuWidth: CGFloat = 220
|
||
/// Stated, not padded: the cap below counts rows, so a capped menu would land mid-row.
|
||
static let menuRowHeight: CGFloat = menuIcon + Spacing.md * 2
|
||
static let menuRowSpacing: CGFloat = 1
|
||
/// Stated, not measured: `viewportHeight` counts headers, so a capped menu lands on a row.
|
||
static let menuSectionHeader: CGFloat = 16
|
||
/// Five rows and half of the sixth, so a capped menu reads as scrollable, not clipped.
|
||
static let menuVisibleRows: CGFloat = 5.5
|
||
/// Rounded: a half-row of an odd pitch lands the glass edge on a half pixel.
|
||
static var menuRowsMaxHeight: CGFloat {
|
||
(menuVisibleRows * (menuRowHeight + menuRowSpacing)).rounded()
|
||
}
|
||
/// A menu row's glyph slot, sized so symbol and app-icon rows read the same.
|
||
static let menuIcon: CGFloat = 20
|
||
/// A brand mark inside the menu icon slot, sized to the optical weight of a symbol.
|
||
static let menuBrandIcon: CGFloat = 14
|
||
/// The same mark in a header bar button, matched to the callout symbol beside it.
|
||
static let barBrandIcon: CGFloat = 12
|
||
/// A sent image in the transcript; a staged one is a small preview in a composer pill.
|
||
static let chatImageThumb: CGFloat = 96
|
||
static let chatAttachmentGlyph: CGFloat = 16
|
||
/// A staged file's preview in its pill, kept under the pill's height so it reads inside it.
|
||
static let chatAttachmentThumb: CGFloat = 18
|
||
/// The window composer's remove button; small, but what makes a mispaste recoverable.
|
||
static let chatAttachmentRemove: CGFloat = 14
|
||
/// Tighter than the gap inside the pill, so the thumbnail reads as filling it.
|
||
static let chatAttachmentInset: CGFloat = 3
|
||
/// The clipboard preview's player; `VideoPlayer` expands unbounded without a height.
|
||
static let clipboardMediaHeight: CGFloat = 260
|
||
/// The preview pane is ~460pt wide, so 900px stays crisp at 2× without over-decoding.
|
||
static let clipboardPreviewPixel: CGFloat = 900
|
||
/// File search's preview stage: video's own shape, and enough height to read a page in.
|
||
static let previewAspectRatio: CGFloat = 16 / 9
|
||
/// Opening size and resize floor: the Quick Actions row's width, the sidebar's full height.
|
||
static let settingsWindow = CGSize(width: 900, height: 700)
|
||
/// Settings sidebar: a fixed column, wide enough for "Window Management".
|
||
static let settingsSidebar: CGFloat = 215
|
||
/// The narrowest the pane column may get before a grouped row's control starts colliding.
|
||
static let settingsDetailMinimum: CGFloat = 420
|
||
static let settingsRowIcon: CGFloat = 20
|
||
/// A sidebar glyph inside its tinted tile; the tile's inset brings it to the row icon's size.
|
||
static let settingsSidebarGlyph: CGFloat = 14
|
||
/// AI Chat's opening size; the user owns it from there, autosaved.
|
||
static let aiChatWindow = CGSize(width: 960, height: 660)
|
||
static let aiChatWindowMinimum = CGSize(width: 680, height: 440)
|
||
static let aiChatSidebarMinimum: CGFloat = 240
|
||
static let aiChatSidebarMaximum: CGFloat = 340
|
||
/// The sidebar's search capsule, a row's height so it lines up with the list below it.
|
||
static let aiChatSearchField: CGFloat = 28
|
||
static let aiChatDetailMinimum: CGFloat = 440
|
||
/// The transcript and composer column; past this a line of prose stops being readable.
|
||
static let aiChatReadingWidth: CGFloat = 760
|
||
/// The composer grows with its text up to this, then scrolls inside itself.
|
||
static let aiChatComposerMaxHeight: CGFloat = 180
|
||
static let chatContextGauge: CGFloat = 14
|
||
/// A source chip's title before it middle-truncates, so three chips share a row.
|
||
static let chatSourceTitle: CGFloat = 200
|
||
/// The context card's width: a label column and a value one, with room for a model name.
|
||
static let chatContextCard: CGFloat = 300
|
||
/// A grouped `Form` row's control height.
|
||
static let settingsControlHeight: CGFloat = 28
|
||
static let emojiSkinToneGlyph: CGFloat = 13
|
||
/// One density preview; five fit across the Emoji settings detail pane.
|
||
static let emojiSettingsGridPreview: CGFloat = 72
|
||
/// The layout editor. Height is stated so selecting an entry cannot resize the panel.
|
||
static let layoutEditorSheet = CGSize(width: 900, height: 660)
|
||
/// The inspector column; the preview takes the rest, keeping the split two-to-one.
|
||
static let layoutInspectorColumn: CGFloat = 300
|
||
/// The entry dropdown's list, wider than its button so a long app name still reads.
|
||
static let layoutEntryPopover: CGFloat = 260
|
||
/// An app icon inside a preview rect, small enough a narrow window still shows one.
|
||
static let layoutPreviewIcon: CGFloat = 22
|
||
/// A numbered display tab under the preview.
|
||
static let layoutDisplayTab: CGFloat = 24
|
||
/// Every inspector control — field, dropdown, add button — sits on this one height.
|
||
static let layoutControlHeight: CGFloat = 28
|
||
/// The unit slot in a numeric field, stated so "%" and "pt" put their digits on one x.
|
||
static let layoutFieldUnit: CGFloat = 16
|
||
static let layoutPositionGlyph = CGSize(width: 26, height: 19)
|
||
static let layoutPositionStroke: CGFloat = 1.5
|
||
/// A position cell's clickable row; the glyph floats inside it, so the whole cell hits.
|
||
static let layoutPositionCell: CGFloat = 34
|
||
/// AI Providers: Mail's Accounts shape, a provider list beside the selected one's detail.
|
||
static let aiProvidersPanel = CGSize(width: 840, height: 520)
|
||
static let aiProvidersList: CGFloat = 262
|
||
/// What the system leaves either side of a segment's label once the control has settled.
|
||
static let segmentLabelInset: CGFloat = 13
|
||
static let aiVariableName: CGFloat = 170
|
||
/// A Codex usage window's meter, beside its "72% left" readout.
|
||
static let aiUsageBar: CGFloat = 110
|
||
/// Settings editor modals (Custom Commands, Snippets): fixed width, intrinsic height.
|
||
static let editorSheetWidth: CGFloat = 480
|
||
/// The multi-line box inside those modals; it scrolls rather than grows the panel.
|
||
static let editorTextHeight: CGFloat = 120
|
||
/// The confirmation HUD's width ceiling, and its distance above the screen bottom.
|
||
static let hudMaxWidth: CGFloat = 420
|
||
static let hudEdgeOffset: CGFloat = 48
|
||
/// Questions and notices stay compact; controls keep the room their native widgets need.
|
||
static let dialogCompactWidth: CGFloat = 290
|
||
/// Tinycast's own control dialog: fixed width, height measured from its SwiftUI content.
|
||
static let dialogWidth: CGFloat = 420
|
||
static let dialogButtonHeight: CGFloat = menuButton - 2
|
||
/// Subject glyph and its fixed tile at the top of a dialog.
|
||
static let dialogSymbol: CGFloat = 28
|
||
static let dialogSymbolContainer: CGFloat = 52
|
||
/// Shared measurement for dialog accessories and the volume-HUD glyph.
|
||
static let dialogIcon: CGFloat = 32
|
||
/// 16:9 at the dialog's own width, so the two surfaces read as siblings.
|
||
static let cameraPreview = CGSize(width: 420, height: 236)
|
||
/// 16:9 again, wider: the standalone camera is the surface, not a confirmation on one.
|
||
static let cameraStage = CGSize(width: 560, height: 315)
|
||
/// Wider than a dialog: a Quick Action's result is prose to read, not a sentence to answer.
|
||
static let quickActionPanel: CGFloat = 520
|
||
/// Matched to the title's cap height; a row-sized glyph beside it reads as an error.
|
||
static let quickActionHeaderIcon: CGFloat = 14
|
||
/// The dissolve ramp below each bar's clear zone, measured against text behind the title.
|
||
static let quickActionScrollFade: CGFloat = 40
|
||
/// Past this the result scrolls, so a long summary cannot grow the panel off the screen.
|
||
static let quickActionPanelBody: CGFloat = 320
|
||
/// Keeps a two-word grammar fix from collapsing the panel to a slot.
|
||
static let quickActionPanelMinBody: CGFloat = 44
|
||
/// Transient volume HUD shown after any volume or mute command.
|
||
static let hudWidth: CGFloat = 200
|
||
static let hudHeight: CGFloat = 100
|
||
/// Read-only volume bar geometry used by the HUD.
|
||
static let volumeTrackHeight: CGFloat = 6
|
||
/// Fixed slot for the level readout, sized to the widest string it ever holds.
|
||
static let volumeReadout: CGFloat = 38
|
||
static let roomCardTitleBar: CGFloat = 40
|
||
static let roomCardStroke: CGFloat = 2
|
||
static let roomCardShadowRadius: CGFloat = 24
|
||
static let roomCardShadowOffset: CGFloat = 8
|
||
static let roomCardDot: CGFloat = 8
|
||
static let roomCardIcon: CGFloat = 64
|
||
static let roomCardIconLarge: CGFloat = 96
|
||
/// Below this a card is a sliver, too short for its icon.
|
||
static let roomCardIconMinHeight: CGFloat = 160
|
||
static let roomCardLargeIconMinSide: CGFloat = 320
|
||
}
|
||
|
||
enum Duration {
|
||
/// How long each HUD stays up; a sentence needs longer than a level does.
|
||
static let messageHUD: TimeInterval = 2.4
|
||
static let volumeHUD: TimeInterval = 1.6
|
||
/// How a borderless surface arrives and leaves; the exit is shorter, so it feels quick.
|
||
static let enter: TimeInterval = 0.18
|
||
static let exit: TimeInterval = 0.12
|
||
/// A dialog moves with its launcher dimming; its fade-in is a shorter sub-beat.
|
||
static let dialogEnter: TimeInterval = 0.12
|
||
static let dialogExit: TimeInterval = 0.10
|
||
/// Fade-in/out for a hover `Tooltip`, after a wait only a deliberate hover outlasts.
|
||
static let tooltip: TimeInterval = 0.15
|
||
static let dropGuide: TimeInterval = 0.24
|
||
static let tooltipDelay: TimeInterval = 0.4
|
||
/// A control lighting up under the pointer; short enough to feel like a response.
|
||
static let hover: TimeInterval = 0.12
|
||
/// A pop-up chevron turning between its closed and open directions.
|
||
static let menuChevron: TimeInterval = 0.34
|
||
static let copyFeedback: TimeInterval = 1.2
|
||
static let chatFooter: TimeInterval = 0.12
|
||
/// A Settings search result scrolling its section into view, then the pulse that marks it.
|
||
static let settingsReveal: TimeInterval = 0.28
|
||
static let settingsFlash: TimeInterval = 2.0
|
||
static let settingsFlashOut: TimeInterval = 0.6
|
||
/// A room preview card gliding to its next place; arriving and leaving cards fade.
|
||
static let roomGlide: TimeInterval = 0.32
|
||
static let roomCardEnter: TimeInterval = 0.2
|
||
static let roomCardExit: TimeInterval = 0.18
|
||
static let roomSettle: TimeInterval = 0.25
|
||
}
|
||
|
||
enum RoomMotion {
|
||
/// Quick to leave, long to settle, so a card reads as the window it is about to become.
|
||
static let glide = Animation.timingCurve(0.2, 0, 0, 1, duration: Theme.Duration.roomGlide)
|
||
}
|
||
|
||
enum DialogMotion {
|
||
static let offset: CGFloat = 3
|
||
static let initialOpacity: CGFloat = 0.08
|
||
}
|
||
|
||
/// Motion owned by Tinycast's menus; extension-provided panels keep their own behavior.
|
||
@MainActor
|
||
enum MenuMotion {
|
||
static let entryScale: CGFloat = 0.94
|
||
static let maximumScale: CGFloat = 1.003
|
||
static let exitScaleDelta: CGFloat = 0.04
|
||
static let expansionDuration: TimeInterval = 0.10
|
||
static let settleDuration: TimeInterval = 0.05
|
||
static let exitDuration: TimeInterval = 0.18
|
||
static let expansionTiming = CAMediaTimingFunction(controlPoints: 0.2, 0.7, 0.2, 1)
|
||
static let settleTiming = CAMediaTimingFunction(controlPoints: 0.42, 0, 0.58, 1)
|
||
static let exitTiming = CAMediaTimingFunction(controlPoints: 0.4, 0, 1, 1)
|
||
/// Starts promptly and eases gently into the chevron's final direction.
|
||
static let chevronAnimation = Animation.timingCurve(
|
||
0.16, 1, 0.3, 1, duration: Theme.Duration.menuChevron)
|
||
}
|
||
|
||
/// System text styles (not hardcoded sizes) so the UI honors Dynamic Type.
|
||
enum Typography {
|
||
/// One size, two frameworks: `TextTrailingDragHandle` measures what the field renders.
|
||
static let searchFieldSize: CGFloat = 20
|
||
static let searchField = Font.system(size: searchFieldSize, weight: .regular)
|
||
/// `NSFont` is not `Sendable`, hence the isolation; every reader is a view anyway.
|
||
@MainActor static let searchFieldNSFont = NSFont.systemFont(
|
||
ofSize: searchFieldSize, weight: .regular)
|
||
static let headerIcon = Font.system(size: 18, weight: .medium)
|
||
static let rowTitle = Font.body
|
||
static let rowTrailing = Font.callout
|
||
static let sectionHeader = Font.subheadline.weight(.medium)
|
||
/// A borderless panel's own title, which names the surface rather than a section inside it.
|
||
static let panelTitle = Font.headline
|
||
/// The big value line on the calculator answer card (both source and target sides).
|
||
static let calcResult = Font.title
|
||
static let keyCap = Font.caption
|
||
/// Pair with the matching `Size` for `KeyCapChip.Scale`.
|
||
static let compactKeyCap = Font.caption2
|
||
static let heroKeyCap = Font.body
|
||
static let markdownHeading1 = Font.title2.weight(.semibold)
|
||
static let markdownHeading2 = Font.title3.weight(.semibold)
|
||
static let markdownHeading3 = Font.headline
|
||
static let code = Font.system(.callout, design: .monospaced)
|
||
static let inlineCode = Font.body.monospaced()
|
||
static let bar = Font.callout.weight(.medium)
|
||
/// A staged chat attachment's name beside the search text; the NSFont measures the chip.
|
||
static let chip = Font.callout
|
||
@MainActor static let chipNSFont = NSFont.preferredFont(forTextStyle: .callout)
|
||
/// A dropdown control's trailing chevron, deliberately smaller than the label it follows.
|
||
static let disclosure = Font.caption.weight(.semibold)
|
||
static let menuRow = Font.body
|
||
static let menuShortcut = Font.callout
|
||
static let menuIcon = Font.body
|
||
static let menuSymbolSize: CGFloat = 14
|
||
static let menuSymbolWeight = Font.Weight.medium
|
||
static let noteTitle = Font.headline
|
||
}
|
||
|
||
enum Colors {
|
||
/// Resolves against the window's `effectiveAppearance`, so a token repaints on its own.
|
||
static func adaptive(dark: NSColor, light: NSColor) -> Color {
|
||
Color(nsColor: NSColor(name: nil) { $0.isDark ? dark : light })
|
||
}
|
||
|
||
/// The alpha ramp, inverted: white ink over the dark surface, black ink over the light one.
|
||
static func ramp(dark: Double, light: Double) -> Color {
|
||
adaptive(dark: .srgbInk(1, alpha: dark), light: .srgbInk(0, alpha: light))
|
||
}
|
||
|
||
/// The ramp's inverse: the scrim darkens the dark surface and lightens the light one.
|
||
static let panelScrim = adaptive(dark: .srgbInk(0, alpha: 0.40), light: .srgbInk(1, alpha: 0.55))
|
||
/// Modal separation inside Tinycast: the launcher recedes while its dialog is in front.
|
||
static let dialogDimming = adaptive(
|
||
dark: .srgbInk(0, alpha: 0.34), light: .srgbInk(0, alpha: 0.34))
|
||
/// Find's marks, the Mac's own yellow: every match faint, the current one solid.
|
||
static let findMatch = adaptive(
|
||
dark: NSColor.systemYellow.withAlphaComponent(0.32),
|
||
light: NSColor.systemYellow.withAlphaComponent(0.4))
|
||
static let findCurrent = adaptive(
|
||
dark: NSColor.systemYellow.withAlphaComponent(0.9),
|
||
light: NSColor.systemYellow.withAlphaComponent(0.95))
|
||
/// Black on the solid mark in both appearances, as a highlighter's ink stays readable.
|
||
static let findCurrentInk = adaptive(
|
||
dark: .srgbInk(0, alpha: 1), light: .srgbInk(0, alpha: 1))
|
||
static let tooltipShadow = adaptive(
|
||
dark: .srgbInk(0, alpha: 0.18), light: .srgbInk(0, alpha: 0.18))
|
||
|
||
/// Selection fill, shared by every list so they look identical.
|
||
static let selection = ramp(dark: 0.10, light: 0.09)
|
||
/// Mouse hover: a fainter layer, visually distinct from selection.
|
||
static let rowHover = ramp(dark: 0.05, light: 0.045)
|
||
/// Emoji grid chrome: a quiet tile at rest, with two legible rings on interaction.
|
||
static let emojiCell = ramp(dark: 0.045, light: 0.04)
|
||
static let emojiHoverBorder = ramp(dark: 0.42, light: 0.34)
|
||
static let emojiSelectionBorder = adaptive(
|
||
dark: NSColor(srgbRed: 0.96, green: 0.90, blue: 0.72, alpha: 0.92),
|
||
light: .srgbInk(0, alpha: 0.72))
|
||
static let emojiInnerBorder = adaptive(
|
||
dark: .srgbInk(0, alpha: 0.72), light: .srgbInk(1, alpha: 0.72))
|
||
static let menuHover = ramp(dark: 0.10, light: 0.09)
|
||
static let separator = ramp(dark: 0.10, light: 0.12)
|
||
/// Small control surfaces: kbd chips, glyph tiles.
|
||
static let controlSurface = ramp(dark: 0.10, light: 0.08)
|
||
/// A pointer over a control should lift it above its resting surface.
|
||
static let controlHover = ramp(dark: 0.16, light: 0.14)
|
||
/// A held control is stronger than hover, so mouse-down always reads.
|
||
static let controlPressed = ramp(dark: 0.24, light: 0.20)
|
||
/// Control borders: outlined kbd chips.
|
||
static let border = ramp(dark: 0.20, light: 0.18)
|
||
/// Alpha 1, so a call site can dim it with `.opacity` and land on the value it replaced.
|
||
static let textPrimary = ramp(dark: 1.0, light: 1.0)
|
||
static let textSecondary = ramp(dark: 0.60, light: 0.60)
|
||
static let textTertiary = ramp(dark: 0.40, light: 0.42)
|
||
static let menuSymbol = ramp(dark: 0.70, light: 0.70)
|
||
static let noteText = ramp(dark: 0.90, light: 0.85)
|
||
static let iconPlaceholder = ramp(dark: 0.06, light: 0.06)
|
||
/// The faint wash behind the Onboarding header.
|
||
static let sheen = ramp(dark: 0.04, light: 0.04)
|
||
/// The Settings card: a faint surface whose border doubles as the row divider.
|
||
static let cardFill = ramp(dark: 0.05, light: 0.04)
|
||
static let cardStroke = ramp(dark: 0.10, light: 0.10)
|
||
/// White in both: the frost brightens glass, and light glass needs more to read at all.
|
||
/// A window on the preview's plate. White in both, since the plate is always dark.
|
||
static let layoutPreviewWindow = adaptive(
|
||
dark: .srgbInk(1, alpha: 0.22), light: .srgbInk(1, alpha: 0.28))
|
||
/// The selected one, lifted enough to read as chosen before the accent stroke is seen.
|
||
static let layoutPreviewWindowSelected = adaptive(
|
||
dark: .srgbInk(1, alpha: 0.38), light: .srgbInk(1, alpha: 0.44))
|
||
/// The preview's plate: a display is dark in both appearances, so `adaptive`, not `ramp`.
|
||
static let layoutPreviewGround = adaptive(
|
||
dark: .srgbInk(0, alpha: 0.55), light: .srgbInk(0, alpha: 0.50))
|
||
/// The blurred desk behind a room preview, darkened so only the new layout reads.
|
||
static let roomPreviewDim = adaptive(
|
||
dark: .srgbInk(0, alpha: 0.18), light: .srgbInk(0, alpha: 0.18))
|
||
/// A room card is a solid window in both appearances, so the desk never shows through.
|
||
static let roomCardFill = adaptive(
|
||
dark: .srgbInk(0.16, alpha: 0.94), light: .srgbInk(0.98, alpha: 0.94))
|
||
static let roomCardStroke = Color.accentColor
|
||
static let roomCardShadow = adaptive(
|
||
dark: .srgbInk(0, alpha: 0.25), light: .srgbInk(0, alpha: 0.25))
|
||
static let roomCardDot = ramp(dark: 0.25, light: 0.25)
|
||
/// The pill behind the header of the section a Settings search jumped to.
|
||
static let searchFlash = Color.accentColor.opacity(0.35)
|
||
/// The two squares of a checkerboard, behind a colour with alpha to show.
|
||
static let checkerLight = Color(nsColor: .srgbInk(1, alpha: 0.22))
|
||
static let checkerDark = Color(nsColor: .srgbInk(0, alpha: 0.22))
|
||
/// The violet of the app mark, used only to tint the About support callout.
|
||
static let brand = Color(red: 0.525, green: 0.231, blue: 1.0)
|
||
/// The palette's drop guides while dragging, and once a release would snap it home.
|
||
static let dropGuide = ramp(dark: 0.35, light: 0.35)
|
||
static let dropGuideArmed = Color.blue
|
||
/// A dialog's standard default action; destructive defaults keep their semantic red.
|
||
static let primaryAction = Color.blue
|
||
/// Destructive tint: a destructive label, and a `.danger` dialog's glyph.
|
||
static let destructive = Color.red
|
||
/// Success tint: the leading glyph of a `.success` dialog.
|
||
static let success = Color.green
|
||
/// Caution tint, short of destructive: a chat context nearly full.
|
||
static let warning = Color.orange
|
||
/// The window's own page, for a card that must hide the transcript it floats over.
|
||
static let windowSurface = Color(nsColor: .windowBackgroundColor)
|
||
/// Where a dropped file will land: the chat window's dashed outline.
|
||
static let dropTarget = Color.accentColor
|
||
/// Progress tint: the message pill's spinner while the work behind it is still running.
|
||
static let progress = Color.blue
|
||
/// The command output window's page: a flat surface the log sits directly on.
|
||
static let terminalSurface = adaptive(
|
||
dark: .srgbInk(0.07, alpha: 1), light: .srgbInk(0.99, alpha: 1))
|
||
}
|
||
}
|
||
|
||
extension View {
|
||
/// A floating glass control surface: clear, interactive Liquid Glass.
|
||
func frosted(in shape: some Shape) -> some View {
|
||
glassEffect(.clear.interactive(), in: shape)
|
||
}
|
||
}
|