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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Ceiling runs on Windows 10 and 11.

**[Download for Windows →](https://ceiling.win/download)**  ·  [ceiling.win](https://ceiling.win)  ·  [all releases](https://github.com/tsouth89/ceiling/releases)

The installer and portable build are code-signed. Everything stays local-first: your credentials and usage data never leave your machine.
The installer and portable build are code-signed. Ceiling is local-first: it reads usage from sources on your PC or from each provider's own usage endpoint, and never sends your credentials or usage data to Ceiling-operated servers. See [How Ceiling gets your data](docs/DATA_SOURCES.md) for the per-provider detail.

## Development

Expand Down
52 changes: 52 additions & 0 deletions apps/desktop-tauri/src/components/FirstRunChecklist.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { FirstRunChecklist } from "./FirstRunChecklist";

const t = (k: string) => k;

function setup(overrides: Partial<Parameters<typeof FirstRunChecklist>[0]> = {}) {
const props = {
enabledCount: 0,
hasWorkingAuth: false,
floatbarEnabled: false,
onOpenProviders: vi.fn(),
onOpenDisplay: vi.fn(),
onDismiss: vi.fn(),
t,
...overrides,
};
render(<FirstRunChecklist {...props} />);
return props;
}

describe("FirstRunChecklist", () => {
it("renders all three steps as actionable when nothing is set up", () => {
setup();
expect(screen.getByText("FirstRunStepEnable")).toBeInTheDocument();
expect(screen.getByText("FirstRunStepAuth")).toBeInTheDocument();
expect(screen.getByText("FirstRunStepFloatbar")).toBeInTheDocument();
// Steps 1 and 2 both link to provider settings.
expect(screen.getAllByText("FirstRunOpenProviders")).toHaveLength(2);
});

it("marks a completed step done and drops its action button", () => {
setup({ enabledCount: 2, floatbarEnabled: true });
const enable = screen.getByText("FirstRunStepEnable").closest("li");
expect(enable?.className).toContain("first-run__step--done");
// Only the auth step's action remains (enable + floatbar are done).
expect(screen.getAllByText("FirstRunOpenProviders")).toHaveLength(1);
expect(screen.queryByText("FirstRunOpenDisplay")).toBeNull();
});

it("dismiss calls onDismiss", () => {
const props = setup();
fireEvent.click(screen.getByText("FirstRunDismiss"));
expect(props.onDismiss).toHaveBeenCalled();
});

it("the floatbar step opens display settings", () => {
const props = setup();
fireEvent.click(screen.getByText("FirstRunOpenDisplay"));
expect(props.onOpenDisplay).toHaveBeenCalled();
});
});
105 changes: 105 additions & 0 deletions apps/desktop-tauri/src/components/FirstRunChecklist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { LocaleKey } from "../i18n/keys";

const DISMISS_KEY = "ceiling.first-run.dismissed.v1";

/** Whether the user has dismissed the first-run checklist on this machine. */
export function firstRunDismissed(): boolean {
try {
return localStorage.getItem(DISMISS_KEY) === "1";
} catch {
return false;
}
}

/** Remember that the first-run checklist was dismissed. */
export function dismissFirstRun(): void {
try {
localStorage.setItem(DISMISS_KEY, "1");
} catch {
// Ignore storage failures (private mode, disabled storage, etc.).
}
}

interface Props {
enabledCount: number;
hasWorkingAuth: boolean;
floatbarEnabled: boolean;
onOpenProviders: () => void;
onOpenDisplay: () => void;
onDismiss: () => void;
t: (key: LocaleKey) => string;
}

/**
* Light, dismissible first-run checklist shown in the empty dashboard when no
* provider has produced usage yet (SOU-157). Each step reflects real state and
* deep-links into the matching Settings tab. Dismissal is remembered in
* localStorage (same pattern as the detected-accounts card); a returning user
* who already has working auth never lands here because the dashboard shows
* their providers instead of the empty state.
*/
export function FirstRunChecklist({
enabledCount,
hasWorkingAuth,
floatbarEnabled,
onOpenProviders,
onOpenDisplay,
onDismiss,
t,
}: Props) {
const steps = [
{
done: enabledCount > 0,
label: t("FirstRunStepEnable"),
action: t("FirstRunOpenProviders"),
onAction: onOpenProviders,
},
{
done: hasWorkingAuth,
label: t("FirstRunStepAuth"),
action: t("FirstRunOpenProviders"),
onAction: onOpenProviders,
},
{
done: floatbarEnabled,
label: t("FirstRunStepFloatbar"),
action: t("FirstRunOpenDisplay"),
onAction: onOpenDisplay,
},
];

return (
<section className="first-run" aria-label={t("FirstRunTitle")}>
<div className="first-run__header">
<h3 className="first-run__title">{t("FirstRunTitle")}</h3>
<button
type="button"
className="first-run__dismiss"
onClick={onDismiss}
>
{t("FirstRunDismiss")}
</button>
</div>
<ol className="first-run__steps">
{steps.map((step, index) => (
<li
key={index}
className={`first-run__step${step.done ? " first-run__step--done" : ""}`}
>
<span className="first-run__check" aria-hidden="true" />
<span className="first-run__label">{step.label}</span>
{!step.done && (
<button
type="button"
className="first-run__action"
onClick={step.onAction}
>
{step.action}
</button>
)}
</li>
))}
</ol>
</section>
);
}
18 changes: 18 additions & 0 deletions apps/desktop-tauri/src/i18n/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,24 @@ export const ALL_LOCALE_KEYS = [
"TrayPaceBadgeBurning",
"TrayResetsInLabel",
"TrayResetsDueNow",
// Provider data-source / privacy explainer (SOU-179)
"DataSourceSectionTitle",
"DataSourcePrivacyNote",
"DataSourceLearnMore",
"DataSourceClaude",
"DataSourceCodex",
"DataSourceCursor",
"DataSourceCopilot",
"DataSourceGemini",
"DataSourceGeneric",
// First-run checklist (SOU-157)
"FirstRunTitle",
"FirstRunStepEnable",
"FirstRunStepAuth",
"FirstRunStepFloatbar",
"FirstRunOpenProviders",
"FirstRunOpenDisplay",
"FirstRunDismiss",
] as const;

export type LocaleKey = (typeof ALL_LOCALE_KEYS)[number];
34 changes: 34 additions & 0 deletions apps/desktop-tauri/src/lib/providerProvenance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { ALL_LOCALE_KEYS } from "../i18n/keys";
import {
DETAILED_PROVENANCE_PROVIDERS,
providerProvenanceKey,
} from "./providerProvenance";

describe("providerProvenance", () => {
it("gives the five first-class providers dedicated (non-generic) copy", () => {
expect([...DETAILED_PROVENANCE_PROVIDERS].sort()).toEqual(
["claude", "codex", "copilot", "cursor", "gemini"].sort(),
);
for (const id of DETAILED_PROVENANCE_PROVIDERS) {
expect(providerProvenanceKey(id)).not.toBe("DataSourceGeneric");
}
});

it("falls back to the generic line for providers without dedicated copy", () => {
expect(providerProvenanceKey("openrouter")).toBe("DataSourceGeneric");
expect(providerProvenanceKey("brand-new-provider")).toBe("DataSourceGeneric");
});

// Guardrail (SOU-179): every provenance key a provider can resolve to must
// exist in the locale set, so a provider can never render a missing string.
it("every provenance key exists in the locale key set", () => {
const keys = [
...DETAILED_PROVENANCE_PROVIDERS.map(providerProvenanceKey),
providerProvenanceKey("unknown"),
];
for (const key of keys) {
expect(ALL_LOCALE_KEYS).toContain(key);
}
});
});
31 changes: 31 additions & 0 deletions apps/desktop-tauri/src/lib/providerProvenance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { LocaleKey } from "../i18n/keys";

/**
* Providers with hand-written, source-verified provenance copy for the
* "How Ceiling gets this data" block (SOU-179). Every other provider falls
* back to `DataSourceGeneric`.
*
* The copy behind these keys is verified against the real fetch paths in
* `rust/src/providers/*` and must stay in sync with `docs/DATA_SOURCES.md`.
* A test (`providerProvenance.test.ts`) asserts these five keep dedicated
* copy so a rename can't silently drop a first-class provider to the generic
* line.
*/
const PROVENANCE_KEYS: Record<string, LocaleKey> = {
claude: "DataSourceClaude",
codex: "DataSourceCodex",
cursor: "DataSourceCursor",
copilot: "DataSourceCopilot",
gemini: "DataSourceGemini",
};

/** Provider ids that ship dedicated (non-generic) provenance copy. */
export const DETAILED_PROVENANCE_PROVIDERS = Object.keys(PROVENANCE_KEYS);

/**
* Locale key describing how Ceiling obtains this provider's data. Providers
* without dedicated copy get the precise generic statement.
*/
export function providerProvenanceKey(providerId: string): LocaleKey {
return PROVENANCE_KEYS[providerId] ?? "DataSourceGeneric";
}
122 changes: 122 additions & 0 deletions apps/desktop-tauri/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -8289,3 +8289,125 @@ body:has(.dashboard-shell) #root {
color: var(--provider-row-text-primary);
border: 1px solid var(--provider-icon-ring);
}

/* ── Provider data-source explainer (SOU-179) ─────────────────────────── */
.provider-detail-datasource__source {
margin: 0 0 8px;
font-size: 0.82rem;
line-height: 1.5;
color: var(--provider-row-text-primary);
}
.provider-detail-datasource__privacy {
margin: 0 0 8px;
font-size: 0.76rem;
line-height: 1.5;
color: var(--provider-row-text-secondary);
}
.provider-detail-datasource__link {
appearance: none;
background: none;
border: none;
padding: 0;
font: inherit;
font-size: 0.78rem;
color: var(--accent);
cursor: pointer;
text-decoration: underline;
}
.provider-detail-datasource__link:hover {
opacity: 0.85;
}

/* ── First-run checklist (SOU-157) ────────────────────────────────────── */
.first-run {
margin: 0 0 12px;
padding: 14px 16px;
border-radius: 10px;
border: 1px solid var(--provider-icon-ring, rgba(255, 255, 255, 0.08));
background: var(--provider-row-bg, rgba(255, 255, 255, 0.03));
}
.first-run__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.first-run__title {
margin: 0;
font-size: 0.92rem;
font-weight: 600;
color: var(--text-primary);
}
.first-run__dismiss {
appearance: none;
background: none;
border: none;
padding: 2px 4px;
font: inherit;
font-size: 0.76rem;
color: var(--provider-row-text-secondary);
cursor: pointer;
}
.first-run__dismiss:hover {
color: var(--text-primary);
}
.first-run__steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.first-run__step {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.82rem;
color: var(--text-primary);
}
.first-run__check {
flex: none;
width: 16px;
height: 16px;
border-radius: 50%;
border: 1.5px solid var(--provider-icon-ring, rgba(255, 255, 255, 0.25));
position: relative;
}
.first-run__step--done .first-run__check {
background: var(--accent);
border-color: var(--accent);
}
.first-run__step--done .first-run__check::after {
content: "";
position: absolute;
left: 5px;
top: 2px;
width: 4px;
height: 8px;
border: solid #fff;
border-width: 0 1.5px 1.5px 0;
transform: rotate(45deg);
}
.first-run__step--done .first-run__label {
color: var(--provider-row-text-secondary);
text-decoration: line-through;
}
.first-run__label {
flex: 1;
}
.first-run__action {
appearance: none;
flex: none;
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 4px 10px;
font: inherit;
font-size: 0.76rem;
cursor: pointer;
}
.first-run__action:hover {
opacity: 0.9;
}
Loading
Loading