Skip to content

Commit bb48233

Browse files
samejrclaude
andcommitted
feat(webapp): add an Underline links preference
Off by default. It targets a marker class on the TextLink component rather than anchors generally, so nav items, buttons-as-links and decorative underlines (dashed tooltip terms, tab underlines) are untouched either way. TextLink itself never underlined - its two variants are colour-only - so this is the first underline it gets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 641dc25 commit bb48233

7 files changed

Lines changed: 107 additions & 4 deletions

File tree

apps/webapp/app/components/primitives/TextLink.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKey
66
import { ShortcutKey } from "./ShortcutKey";
77
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip";
88

9+
// inline-text-link: marker for the "Underline links" preference, which underlines
10+
// these and nothing else (nav items, buttons-as-links and decorative underlines
11+
// all stay put). See tailwind.css.
12+
const base = "inline-text-link inline-flex gap-0.5 items-center group focus-visible:focus-custom";
13+
914
const variations = {
10-
primary:
11-
"text-indigo-500 transition hover:text-indigo-400 inline-flex gap-0.5 items-center group focus-visible:focus-custom",
12-
secondary:
13-
"text-text-dimmed transition hover:text-text-bright inline-flex gap-0.5 items-center group focus-visible:focus-custom",
15+
primary: `${base} text-indigo-500 transition hover:text-indigo-400`,
16+
secondary: `${base} text-text-dimmed transition hover:text-text-bright`,
1417
} as const;
1518

1619
type TextLinkProps = {

apps/webapp/app/root.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { getUser } from "./services/session.server";
2626
import {
2727
normalizeIconContrast,
2828
normalizeThemeContrast,
29+
normalizeUnderlineLinks,
2930
normalizeThemePreference,
3031
type ThemePreference,
3132
} from "~/utils/themePreference";
@@ -100,6 +101,9 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
100101
const iconContrast = showThemeSwitcher
101102
? normalizeIconContrast(user?.dashboardPreferences.iconContrast)
102103
: false;
104+
const underlineLinks = showThemeSwitcher
105+
? normalizeUnderlineLinks(user?.dashboardPreferences.underlineLinks)
106+
: false;
103107
// Display-only: while impersonating, an admin can ask to see the dashboard
104108
// the way the impersonated user sees it. Exposed from root so every route can
105109
// read it.
@@ -130,6 +134,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
130134
timezone,
131135
showThemeSwitcher,
132136
iconContrast,
137+
underlineLinks,
133138
themePreference,
134139
themeContrast,
135140
// Consumed by ResizablePanel: the browser check must match between SSR
@@ -184,6 +189,7 @@ export default function App() {
184189
themePreference,
185190
themeContrast,
186191
iconContrast,
192+
underlineLinks,
187193
} = useTypedLoaderData<typeof loader>();
188194
usePostHog(posthogProjectKey, posthogUiHost);
189195
useSystemThemeSync(themePreference);
@@ -202,6 +208,8 @@ export default function App() {
202208
data-theme-preference={themePreference}
203209
// Accent set for icons and badges; the `system:` variant keys off this
204210
data-icon-contrast={iconContrast ? "true" : "false"}
211+
// Underlines links carrying the inline-text-link marker class
212+
data-underline-links={underlineLinks ? "true" : "false"}
205213
// Contrast overlay input for the System themes; Classic never reads it
206214
style={{ "--theme-contrast": themeContrast / 100 } as CSSProperties}
207215
>

apps/webapp/app/routes/account._index/route.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,12 @@ import {
4848
updateContrastPreference,
4949
updateIconContrastPreference,
5050
updateThemePreference,
51+
updateUnderlineLinksPreference,
5152
} from "~/services/dashboardPreferences.server";
5253
import {
5354
normalizeIconContrast,
5455
normalizeThemeContrast,
56+
normalizeUnderlineLinks,
5557
normalizeThemePreference,
5658
type ThemePreference,
5759
} from "~/utils/themePreference";
@@ -189,6 +191,20 @@ export const action: ActionFunction = async ({ request }) => {
189191
return json({ success: true });
190192
}
191193

194+
if (formData.get("action") === "update-underline-links") {
195+
const user = await requireUser(request);
196+
const showThemeSwitcher =
197+
user.admin || (await cachedFlag({ key: "hasThemeSwitcher", defaultValue: false }));
198+
if (!showThemeSwitcher) {
199+
return json({ error: "Not available" }, { status: 404 });
200+
}
201+
await updateUnderlineLinksPreference({
202+
user,
203+
underlineLinks: formData.get("underlineLinks") === "true",
204+
});
205+
return json({ success: true });
206+
}
207+
192208
const formSchema = createSchema({
193209
isEmailUnique: async (email) => {
194210
const existingUser = await prisma.user.findFirst({
@@ -360,6 +376,12 @@ export default function Page() {
360376
typeof pendingIconContrast === "string"
361377
? pendingIconContrast === "true"
362378
: normalizeIconContrast(user.dashboardPreferences.iconContrast);
379+
const underlineLinksFetcher = useFetcher();
380+
const pendingUnderlineLinks = underlineLinksFetcher.formData?.get("underlineLinks");
381+
const underlineLinks =
382+
typeof pendingUnderlineLinks === "string"
383+
? pendingUnderlineLinks === "true"
384+
: normalizeUnderlineLinks(user.dashboardPreferences.underlineLinks);
363385
const pendingTheme = themeFetcher.formData?.get("theme");
364386
const pendingContrast = contrastFetcher.formData?.get("contrast");
365387
const contrast =
@@ -613,6 +635,30 @@ export default function Page() {
613635
</div>
614636
</div>
615637
</div>
638+
<div className="flex min-h-16 w-full items-center border-b border-grid-dimmed">
639+
<div className="flex w-full items-center justify-between gap-4">
640+
<div className={cn("flex-1", SETTINGS_ROW_TITLE_GAP)}>
641+
<Label>Underline links</Label>
642+
<SettingsRowDescription>Underline links in body text</SettingsRowDescription>
643+
</div>
644+
<div className="flex flex-none items-center">
645+
<Switch
646+
variant="minimal/medium"
647+
aria-label="Underline links"
648+
checked={underlineLinks}
649+
onCheckedChange={(checked) =>
650+
underlineLinksFetcher.submit(
651+
{
652+
action: "update-underline-links",
653+
underlineLinks: checked ? "true" : "false",
654+
},
655+
{ method: "post" }
656+
)
657+
}
658+
/>
659+
</div>
660+
</div>
661+
</div>
616662
</>
617663
)}
618664
</MainHorizontallyCenteredContainer>

apps/webapp/app/services/dashboardPreferences.server.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,36 @@ export async function updateIconContrastPreference({
208208
`;
209209
}
210210

211+
export async function updateUnderlineLinksPreference({
212+
user,
213+
underlineLinks,
214+
}: {
215+
user: UserFromSession;
216+
underlineLinks: boolean;
217+
}) {
218+
if (user.isImpersonating) {
219+
return;
220+
}
221+
222+
if ((user.dashboardPreferences.underlineLinks ?? false) === underlineLinks) {
223+
return;
224+
}
225+
226+
// Narrow jsonb_set write: see updateThemePreference.
227+
return prisma.$executeRaw`
228+
UPDATE "User"
229+
SET "dashboardPreferences" = jsonb_set(
230+
COALESCE(
231+
"dashboardPreferences",
232+
'{"version":"1","projects":{}}'::jsonb
233+
),
234+
'{underlineLinks}',
235+
to_jsonb(${underlineLinks}::boolean)
236+
)
237+
WHERE id = ${user.id}
238+
`;
239+
}
240+
211241
export async function clearCurrentProject({ user }: { user: UserFromSession }) {
212242
if (user.isImpersonating) {
213243
return;

apps/webapp/app/tailwind.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,15 @@
282282
--color-run-timed-out: #ed5f74;
283283
}
284284

285+
/* "Underline links" preference: underlines the inline links that carry the
286+
marker class from the TextLink component, and only those - hand-rolled link
287+
underlines and decorative ones (dashed tooltip terms, tab underlines) are
288+
untouched either way. */
289+
[data-underline-links="true"] .inline-text-link {
290+
text-decoration-line: underline;
291+
text-underline-offset: 2px;
292+
}
293+
285294
/* Icon contrast drops decorative icon accents to monochrome; with it off,
286295
the icons stay colored. side-menu-active-icon is set in SideMenuItem for the
287296
active nav item; system-mono-icon marks section-header icons (e.g. the

apps/webapp/app/utils/dashboardPreferences.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ const DashboardPreferences = z.object({
5454
contrast: z.number().int().min(0).max(100).optional().catch(undefined),
5555
/** Swaps the Classic icon and badge accents for the high-contrast set. */
5656
iconContrast: z.boolean().optional().catch(undefined),
57+
/** Underlines inline links. */
58+
underlineLinks: z.boolean().optional().catch(undefined),
5759
currentProjectId: z.string().optional(),
5860
projects: z.record(
5961
z.string(),

apps/webapp/app/utils/themePreference.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export function normalizeIconContrast(value: unknown): boolean {
2222
return value === true;
2323
}
2424

25+
/** Underlines inline links (the TextLink component). Off is the default. */
26+
export function normalizeUnderlineLinks(value: unknown): boolean {
27+
return value === true;
28+
}
29+
2530
/** Interface contrast for the System themes, 0 to 100. Missing or invalid
2631
* values fall back to the default bump. */
2732
export function normalizeThemeContrast(value: unknown): number {

0 commit comments

Comments
 (0)