🐛(global) allow thread viewers to post internal comments (#632)

Writing an internal comment is a personal authoring act that should
not require thread edit rights: support teammates invited as thread
viewers must still be able to comment and mention colleagues, as long
as they have edit rights on the mailbox. ThreadEvent IM writes and
ThreadUser listing are relaxed accordingly, while every other thread
mutation keeps the full edit-rights check.

  The message composer is now gated by the thread edit ability so that
  read-only users cannot bypass the check through reply or forward, and
  the thread-panel selection separator is hidden when no bulk action is
  available. A few unrelated UI polish fixes (disabled link button
  style, combobox placeholder visibility) ship alongside.
This commit is contained in:
Jean-Baptiste PENRATH
2026-04-15 17:07:51 +02:00
committed by GitHub
parent efbae5f517
commit 8f8798cba3
15 changed files with 449 additions and 65 deletions

View File

@@ -545,6 +545,98 @@ class IsGlobalChannelMixin:
raise PermissionDenied()
def _user_can_comment_on_thread(user, thread_id):
"""True if ``user`` can post/read internal comments on ``thread_id``.
Requires any ``ThreadAccess`` (viewer or editor) on a mailbox where the
user has ``MAILBOX_ROLES_CAN_EDIT``. Writing an internal comment is a
personal authoring act that does not mutate the thread's shared state,
so VIEWER ThreadAccess is enough as long as the user is at least editor
on the mailbox.
"""
return models.ThreadAccess.objects.filter(
thread_id=thread_id,
mailbox__accesses__user=user,
mailbox__accesses__role__in=enums.MAILBOX_ROLES_CAN_EDIT,
).exists()
class HasThreadCommentAccess(IsAuthenticated):
"""Allows users who can author internal comments on the thread.
Used for endpoints that serve the comment authoring flow (posting an
``im`` ThreadEvent, listing thread users to pick mention targets): both
must stay accessible to Thread VIEWERs as long as they are at least
editor on one of the mailboxes sharing the thread.
"""
def has_permission(self, request, view):
if not super().has_permission(request, view):
return False
thread_id = view.kwargs.get("thread_id")
if not thread_id:
return False
return _user_can_comment_on_thread(request.user, thread_id)
class HasThreadEventWriteAccess(IsAuthenticated):
"""Gates write actions on ThreadEvent — rules depend on the event type.
Only ``im`` events (internal comments) relax the thread-level role: a
Thread VIEWER with mailbox edit rights can create/update/destroy their
own ``im`` events. Any other event type is considered a shared-state
mutation (system event, status change, …) and keeps the stricter
full-edit-rights policy (``MailboxAccess`` in ``MAILBOX_ROLES_CAN_EDIT``
AND ``ThreadAccess.role == EDITOR``).
Author check for update/destroy is enforced here regardless of type —
no user can edit or delete another user's event.
"""
message = "You do not have permission to perform this action on this thread."
def has_permission(self, request, view):
if not super().has_permission(request, view):
return False
thread_id = view.kwargs.get("thread_id")
if thread_id is None:
return True
if view.action == "create":
event_type = request.data.get("type") if hasattr(request, "data") else None
if event_type == enums.ThreadEventTypeChoices.IM:
return _user_can_comment_on_thread(request.user, thread_id)
# Non-IM (or missing) types require full edit rights. Using the
# stricter path as the default keeps unknown types safe.
return (
models.ThreadAccess.objects.editable_by(request.user)
.filter(thread_id=thread_id)
.exists()
)
# update / partial_update / destroy: defer to object-level check
# (type is resolved from the stored instance, not the payload).
return True
def has_object_permission(self, request, view, obj):
if not isinstance(obj, models.ThreadEvent):
return False
if (
view.action in ("update", "partial_update", "destroy")
and obj.author_id != request.user.id
):
return False
if obj.type == enums.ThreadEventTypeChoices.IM:
return _user_can_comment_on_thread(request.user, obj.thread_id)
return obj.thread.get_abilities(request.user)[enums.ThreadAbilities.CAN_EDIT]
class HasThreadEditAccess(IsAuthenticated):
"""Allows access only to users with full edit rights on the thread.

View File

@@ -36,16 +36,18 @@ class ThreadEventViewSet(
lookup_url_kwarg = "id"
def get_permissions(self):
"""Use HasThreadEditAccess for write actions.
"""Route write actions through the type-aware permission class.
ThreadEvent write operations require editor access, except
``read_mention`` which is a personal acknowledgement and only
requires read access on the thread.
Reads (``list``, ``retrieve``) and the personal ``read_mention``
acknowledgement only need read access on the thread. Writes are
gated by :class:`HasThreadEventWriteAccess`, which relaxes the
ThreadAccess role for ``im`` (comment) events while keeping the
stricter full-edit-rights rule for every other event type.
"""
if self.action in ["list", "retrieve", "read_mention"]:
return [permissions.IsAuthenticated(), permissions.IsAllowedToAccess()]
return [permissions.HasThreadEditAccess()]
return [permissions.HasThreadEventWriteAccess()]
def get_queryset(self):
"""Restrict results to events for the specified thread."""

View File

@@ -19,7 +19,7 @@ class ThreadUserViewSet(
pagination_class = None
permission_classes = [
permissions.IsAuthenticated,
permissions.IsAllowedToManageThreadAccess,
permissions.HasThreadCommentAccess,
]
def get_queryset(self):

View File

@@ -280,22 +280,27 @@ class TestNonAuthorEventManipulation:
assert response.status_code == status.HTTP_204_NO_CONTENT
class TestViewerCannotCreateEvents:
"""Test that VIEWER-role users cannot create events (only EDITOR+)."""
class TestImEventCreationPermissions:
"""Permission matrix for creating ``im`` ThreadEvents (internal comments).
def test_viewer_cannot_create_event(self, api_client):
"""Users with only VIEWER role on a thread should not create events."""
``im`` events are personal authoring acts (not shared-state mutations),
so Thread VIEWERs may post them as long as they are at least editor on
the mailbox. The mailbox role is the hard floor.
"""
def test_thread_viewer_mailbox_editor_can_create_im(self, api_client):
"""Thread VIEWER + mailbox editor: must be allowed to comment."""
user, _mailbox, thread = setup_user_with_thread_access(
role=enums.ThreadAccessRoleChoices.VIEWER
)
api_client.force_authenticate(user=user)
data = {"type": "im", "data": {"content": "from a viewer"}}
data = {"type": "im", "data": {"content": "from a thread viewer"}}
response = api_client.post(get_thread_event_url(thread.id), data, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_201_CREATED
def test_editor_can_create_event(self, api_client):
"""Users with EDITOR role should be able to create events (sanity check)."""
def test_thread_editor_can_create_im(self, api_client):
"""Thread EDITOR + mailbox admin: sanity check."""
user, _mailbox, thread = setup_user_with_thread_access(
role=enums.ThreadAccessRoleChoices.EDITOR
)
@@ -305,20 +310,18 @@ class TestViewerCannotCreateEvents:
response = api_client.post(get_thread_event_url(thread.id), data, format="json")
assert response.status_code == status.HTTP_201_CREATED
def test_viewer_mailbox_with_editor_thread_access_cannot_create_event(
self, api_client
):
"""Previously-missed scenario: a user with only VIEWER MailboxAccess
cannot post events even when the mailbox has EDITOR ThreadAccess.
Creating a ThreadEvent mutates shared state, so both role checks
must pass.
def test_viewer_mailbox_cannot_create_im(self, api_client):
"""Mailbox VIEWER cannot post comments even with EDITOR thread access.
Authoring a comment requires at least editor-level mailbox role,
otherwise a read-only teammate could inject content into threads.
"""
user = factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=enums.MailboxRoleChoices.VIEWER, # VIEWER mailbox role
role=enums.MailboxRoleChoices.VIEWER,
)
thread = factories.ThreadFactory()
factories.ThreadAccessFactory(
@@ -332,6 +335,87 @@ class TestViewerCannotCreateEvents:
response = api_client.post(get_thread_event_url(thread.id), data, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_no_thread_access_cannot_create_im(self, api_client):
"""A user without any ThreadAccess on the thread is denied."""
user = factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=enums.MailboxRoleChoices.ADMIN,
)
thread = factories.ThreadFactory() # no ThreadAccess for user's mailbox
api_client.force_authenticate(user=user)
data = {"type": "im", "data": {"content": "from outsider"}}
response = api_client.post(get_thread_event_url(thread.id), data, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
class TestImEventAuthorEditsOwnComment:
"""Authors of ``im`` events must be able to edit/delete their own
comment even if they are only VIEWER on the thread.
If we allowed them to create the comment, we must let them take it
back — no orphan comments the author can't retract.
"""
def test_thread_viewer_author_can_update_own_im(self, api_client):
"""
A user with VIEWER access to a thread can update their own ``im`` event.
"""
user, _mailbox, thread = setup_user_with_thread_access(
role=enums.ThreadAccessRoleChoices.VIEWER
)
api_client.force_authenticate(user=user)
event = factories.ThreadEventFactory(thread=thread, author=user, type="im")
response = api_client.patch(
get_thread_event_url(thread.id, event.id),
{"data": {"content": "edited from viewer"}},
format="json",
)
assert response.status_code == status.HTTP_200_OK
event.refresh_from_db()
assert event.data["content"] == "edited from viewer"
def test_thread_viewer_author_can_destroy_own_im(self, api_client):
"""
A user with VIEWER access to a thread can destroy their own ``im`` event.
"""
user, _mailbox, thread = setup_user_with_thread_access(
role=enums.ThreadAccessRoleChoices.VIEWER
)
api_client.force_authenticate(user=user)
event = factories.ThreadEventFactory(thread=thread, author=user, type="im")
response = api_client.delete(get_thread_event_url(thread.id, event.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not models.ThreadEvent.objects.filter(id=event.id).exists()
def test_mailbox_viewer_author_cannot_update_own_im(self, api_client):
"""Mailbox role dropped to VIEWER after the event was created:
the author has lost comment rights and can no longer edit.
"""
user = factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox, user=user, role=enums.MailboxRoleChoices.VIEWER
)
thread = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox, thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR
)
event = factories.ThreadEventFactory(thread=thread, author=user, type="im")
api_client.force_authenticate(user=user)
response = api_client.patch(
get_thread_event_url(thread.id, event.id),
{"data": {"content": "nope"}},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
class TestParameterConfusionAttack:
"""Test that conflicting thread_id in URL path vs query params can't bypass permissions."""

View File

@@ -62,9 +62,10 @@ class TestThreadUserListAuthentication:
class TestThreadUserListPermissions:
"""Test permission matrix for GET /threads/{thread_id}/users/.
The endpoint uses IsAllowedToManageThreadAccess which requires:
- ThreadAccess role = EDITOR on the thread
- MailboxAccess role in MAILBOX_ROLES_CAN_EDIT (EDITOR, SENDER, ADMIN)
The endpoint backs the mention picker in the comment composer, so it
must stay reachable to anyone who can post an ``im`` comment: mailbox
role in MAILBOX_ROLES_CAN_EDIT (EDITOR, SENDER, ADMIN) + any
ThreadAccess (VIEWER or EDITOR).
"""
@pytest.mark.parametrize(
@@ -73,12 +74,17 @@ class TestThreadUserListPermissions:
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.ADMIN),
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.EDITOR),
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.SENDER),
# Thread VIEWER with editor-level mailbox role: allowed because
# the user can still author comments and needs to pick mentions.
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.ADMIN),
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.EDITOR),
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.SENDER),
],
)
def test_list_thread_users_allowed(
self, api_client, thread_access_role, mailbox_access_role
):
"""Users with EDITOR thread access + EDITOR/SENDER/ADMIN mailbox role can list."""
"""Any ThreadAccess + editor-level MailboxAccess grants list access."""
user, _mailbox, thread = setup_user_with_thread_access(
thread_role=thread_access_role,
mailbox_role=mailbox_access_role,
@@ -91,12 +97,9 @@ class TestThreadUserListPermissions:
@pytest.mark.parametrize(
"thread_access_role, mailbox_access_role",
[
# VIEWER on thread — regardless of mailbox role
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.ADMIN),
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.EDITOR),
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.SENDER),
# EDITOR on thread but only VIEWER on mailbox
# Mailbox VIEWER: mailbox role too low regardless of thread role.
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.VIEWER),
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.VIEWER),
],
)
def test_list_thread_users_forbidden(

View File

@@ -197,6 +197,7 @@
&::after {
content: attr(data-value) ' ';
white-space: pre-wrap;
visibility: hidden;
pointer-events: none;
}

View File

@@ -69,7 +69,7 @@ export const AttachmentUploader = ({
>
{t("Add attachments")}
</Button>
<DriveAttachmentPicker onPick={onDriveAttachmentPick} />
<DriveAttachmentPicker onPick={onDriveAttachmentPick} disabled={disabled} />
<p className="attachment-uploader__input__helper-text">
{t("or drag and drop some files")}
</p>

View File

@@ -97,7 +97,7 @@ export const MessageForm = ({
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const saveDraftRef = useRef<() => void>(() => {});
const quoteType: QuoteType | undefined = mode !== "new" ? (mode === "forward" ? "forward" : "reply") : undefined;
const { selectedMailbox, mailboxes, invalidateThreadMessages, invalidateThreadsStats, unselectThread } = useMailboxContext();
const { selectedMailbox, selectedThread, mailboxes, invalidateThreadMessages, invalidateThreadsStats, unselectThread } = useMailboxContext();
const hideSubjectField = Boolean(draftMessage?.parent_id ?? parentMessage);
const defaultSenderId = mailboxes?.find((mailbox) => {
if (draft?.sender) return draft.sender.email === mailbox.email;
@@ -221,8 +221,10 @@ export const MessageForm = ({
name: "from",
});
const currentSender = mailboxes?.find((mailbox) => mailbox.id === currentSenderId);
const canSendMessages = useAbility(Abilities.CAN_SEND_MESSAGES, currentSender!);
const canWriteMessages = useAbility(Abilities.CAN_WRITE_MESSAGES, currentSender!);
const canEditCurrentThread = useAbility(Abilities.CAN_EDIT_THREAD, selectedThread);
const canEditThread = mode === "new" ? true : canEditCurrentThread;
const canSendMessages = useAbility(Abilities.CAN_SEND_MESSAGES, currentSender!) && canEditThread;
const canWriteMessages = useAbility(Abilities.CAN_WRITE_MESSAGES, currentSender!) && canEditThread;
const canChangeSender = !draft || canWriteMessages;
const initialAttachments = useMemo((): (Attachment | DriveFile)[] => {

View File

@@ -66,4 +66,3 @@ export const MailboxPanelActions = () => {
</div>
)
}

View File

@@ -17,6 +17,7 @@ import { ThreadPanelFilter } from "./thread-panel-filter";
import { THREAD_PANEL_FILTER_PARAMS, useThreadPanelFilters } from "../hooks/use-thread-panel-filters";
import { SelectionReadStatus, SelectionStarredStatus } from "@/features/providers/thread-selection";
import { LabelsWidget } from "@/features/layouts/components/labels-widget";
import useAbility, { Abilities } from "@/hooks/use-ability";
type ThreadPanelTitleProps = {
selectedThreadIds: Set<string>;
@@ -101,6 +102,14 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
const starLabel = t('Star');
const unstarLabel = t('Unstar');
const canArchive = canEditSelection && !isSpamView && !isTrashedView && !isDraftsView;
const canReportSpam = canEditSelection && !isTrashedView && !isSentView && !isDraftsView;
const canTrash = canEditSelection && !isDraftsView;
const canManageLabels = useAbility(Abilities.CAN_MANAGE_MAILBOX_LABELS, selectedMailbox);
const canAssignLabel = canManageLabels && !isSpamView && !isTrashedView && !isDraftsView;
const hasSelectionActions = canArchive || canReportSpam || canTrash || canAssignLabel;
const countLabel = useMemo(() => {
if (isSearch) {
if (activeFilters.has_mention && activeFilters.has_unread && activeFilters.has_starred) {
@@ -192,8 +201,8 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
</Tooltip>
{isSelectionMode && (
<>
<VerticalSeparator withPadding={false} />
{canEditSelection && !isSpamView && !isTrashedView && !isDraftsView && (
{hasSelectionActions && <VerticalSeparator withPadding={false} />}
{canArchive && (
<Tooltip content={archiveLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
<Button
onClick={() => {
@@ -213,7 +222,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
/>
</Tooltip>
)}
{canEditSelection && !isTrashedView && !isSentView && !isDraftsView && (
{canReportSpam && (
<Tooltip content={spamLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
<Button
onClick={() => {
@@ -233,7 +242,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
/>
</Tooltip>
)}
{canEditSelection && !isDraftsView && (
{canTrash && (
<Tooltip content={trashLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
<Button
onClick={() => {
@@ -253,7 +262,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
/>
</Tooltip>
)}
{!isSpamView && !isTrashedView && !isDraftsView && (
{canAssignLabel && (
<LabelsWidget
threadIds={Array.from(selectedThreadIds)}
/>

View File

@@ -9,7 +9,7 @@ import useRead from "@/features/message/use-read"
import useMentionRead from "@/features/message/use-mention-read"
import { useDebounceCallback } from "@/hooks/use-debounce-callback"
import { useVisibilityObserver } from "@/hooks/use-visibility-observer"
import { Message, Thread, ThreadAccessRoleChoices, ThreadEvent as ThreadEventModel } from "@/features/api/gen/models"
import { MailboxRoleChoices, Message, Thread, ThreadEvent as ThreadEventModel } from "@/features/api/gen/models"
import { Icon, IconType, Spinner } from "@gouvfr-lasuite/ui-kit"
import { Banner } from "@/features/ui/components/banner"
import { SKIP_LINK_TARGET_ID } from "@/features/ui/components/skip-link"
@@ -363,15 +363,18 @@ export const ThreadView = () => {
archived: messagesWithDraftChildren?.filter((m) => m.is_archived).length || 0,
total: messagesWithDraftChildren?.length || 0,
}), [messagesWithDraftChildren]);
// Show IM input for shared mailboxes, or when the current mailbox
// has editor access on a thread shared with other mailboxes.
const hasEditorAccess = selectedThread?.accesses?.some(
access => access.mailbox.id === selectedMailbox?.id
&& access.role === ThreadAccessRoleChoices.editor
// Show IM input when the user has at least edit rights on the mailbox
// (editor/sender/admin), regardless of the thread-level role.
// Still gated to shared contexts: either a shared mailbox or a thread
// shared across multiple mailboxes.
const hasMailboxEditAccess = !!selectedMailbox && (
selectedMailbox.role === MailboxRoleChoices.editor
|| selectedMailbox.role === MailboxRoleChoices.sender
|| selectedMailbox.role === MailboxRoleChoices.admin
);
const hasMultipleAccesses = (selectedThread?.accesses?.length ?? 0) > 1;
const isSharedMailbox = selectedMailbox?.is_identity === false;
const showIMInput = Boolean((isSharedMailbox || hasMultipleAccesses) && hasEditorAccess);
const showIMInput = Boolean((isSharedMailbox || hasMultipleAccesses) && hasMailboxEditAccess);
// Build filtered timeline items: enrich messages with draft children,
// apply trash filtering, and keep all events.

View File

@@ -361,7 +361,7 @@
--c--globals--font--weights--medium: 500;
--c--globals--font--weights--bold: 600;
--c--globals--font--weights--extrabold: 800;
--c--globals--font--weights--black: 900;
--c--globals--font--weights--black: 800;
--c--globals--font--families--base: Hanken Grotesk, Inter, Roboto Flex Variable, sans-serif;
--c--globals--font--families--accent: Hanken Grotesk, Inter, Roboto Flex Variable, sans-serif;
--c--globals--spacings--0: 0;
@@ -556,6 +556,18 @@
--c--components--datagrid--header--weight: 500;
--c--components--datagrid--body--background-color-hover: var(--c--contextuals--background--semantic--neutral--tertiary);
--c--components--forms-checkbox--font-size: var(--c--globals--font--sizes--sm);
--c--components--forms-input--border-radius: 4px;
--c--components--forms-input--border-radius--hover: 4px;
--c--components--forms-input--border-radius--focus: 4px;
--c--components--forms-select--border-radius: 4px;
--c--components--forms-select--border-radius--hover: 4px;
--c--components--forms-select--border-radius--focus: 4px;
--c--components--forms-textarea--border-radius: 4px;
--c--components--forms-textarea--border-radius--hover: 4px;
--c--components--forms-textarea--border-radius--focus: 4px;
--c--components--forms-datepicker--border-radius: 4px;
--c--components--forms-datepicker--border-radius--hover: 4px;
--c--components--forms-datepicker--border-radius--focus: 4px;
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
--c--components--badge--border-radius: 12px;
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);
@@ -936,7 +948,7 @@
--c--globals--font--weights--medium: 500;
--c--globals--font--weights--bold: 600;
--c--globals--font--weights--extrabold: 800;
--c--globals--font--weights--black: 900;
--c--globals--font--weights--black: 800;
--c--globals--font--families--base: Hanken Grotesk, Inter, Roboto Flex Variable, sans-serif;
--c--globals--font--families--accent: Hanken Grotesk, Inter, Roboto Flex Variable, sans-serif;
--c--globals--spacings--0: 0;
@@ -1131,6 +1143,18 @@
--c--components--datagrid--header--weight: 500;
--c--components--datagrid--body--background-color-hover: var(--c--contextuals--background--semantic--neutral--tertiary);
--c--components--forms-checkbox--font-size: var(--c--globals--font--sizes--sm);
--c--components--forms-input--border-radius: 4px;
--c--components--forms-input--border-radius--hover: 4px;
--c--components--forms-input--border-radius--focus: 4px;
--c--components--forms-select--border-radius: 4px;
--c--components--forms-select--border-radius--hover: 4px;
--c--components--forms-select--border-radius--focus: 4px;
--c--components--forms-textarea--border-radius: 4px;
--c--components--forms-textarea--border-radius--hover: 4px;
--c--components--forms-textarea--border-radius--focus: 4px;
--c--components--forms-datepicker--border-radius: 4px;
--c--components--forms-datepicker--border-radius--hover: 4px;
--c--components--forms-datepicker--border-radius--focus: 4px;
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
--c--components--badge--border-radius: 12px;
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);
@@ -1500,7 +1524,6 @@
--c--globals--font--sizes--xs-alt: 3rem;
--c--globals--font--weights--thin: 100;
--c--globals--font--weights--extrabold: 800;
--c--globals--font--weights--black: 900;
--c--globals--font--families--accent: Marianne, Inter, Roboto Flex Variable, sans-serif;
--c--globals--font--families--base: Marianne, Inter, Roboto Flex Variable, sans-serif;
--c--globals--spacings--0: 0;
@@ -1682,6 +1705,18 @@
--c--components--datagrid--header--weight: 500;
--c--components--datagrid--body--background-color-hover: var(--c--contextuals--background--semantic--neutral--tertiary);
--c--components--forms-checkbox--font-size: var(--c--globals--font--sizes--sm);
--c--components--forms-input--border-radius: 4px;
--c--components--forms-input--border-radius--hover: 4px;
--c--components--forms-input--border-radius--focus: 4px;
--c--components--forms-select--border-radius: 4px;
--c--components--forms-select--border-radius--hover: 4px;
--c--components--forms-select--border-radius--focus: 4px;
--c--components--forms-textarea--border-radius: 4px;
--c--components--forms-textarea--border-radius--hover: 4px;
--c--components--forms-textarea--border-radius--focus: 4px;
--c--components--forms-datepicker--border-radius: 4px;
--c--components--forms-datepicker--border-radius--hover: 4px;
--c--components--forms-datepicker--border-radius--focus: 4px;
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
--c--components--badge--border-radius: 12px;
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);
@@ -2051,7 +2086,6 @@
--c--globals--font--sizes--xs-alt: 3rem;
--c--globals--font--weights--thin: 100;
--c--globals--font--weights--extrabold: 800;
--c--globals--font--weights--black: 900;
--c--globals--font--families--accent: Marianne, Inter, Roboto Flex Variable, sans-serif;
--c--globals--font--families--base: Marianne, Inter, Roboto Flex Variable, sans-serif;
--c--globals--spacings--0: 0;
@@ -2233,6 +2267,18 @@
--c--components--datagrid--header--weight: 500;
--c--components--datagrid--body--background-color-hover: var(--c--contextuals--background--semantic--neutral--tertiary);
--c--components--forms-checkbox--font-size: var(--c--globals--font--sizes--sm);
--c--components--forms-input--border-radius: 4px;
--c--components--forms-input--border-radius--hover: 4px;
--c--components--forms-input--border-radius--focus: 4px;
--c--components--forms-select--border-radius: 4px;
--c--components--forms-select--border-radius--hover: 4px;
--c--components--forms-select--border-radius--focus: 4px;
--c--components--forms-textarea--border-radius: 4px;
--c--components--forms-textarea--border-radius--hover: 4px;
--c--components--forms-textarea--border-radius--focus: 4px;
--c--components--forms-datepicker--border-radius: 4px;
--c--components--forms-datepicker--border-radius--hover: 4px;
--c--components--forms-datepicker--border-radius--focus: 4px;
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
--c--components--badge--border-radius: 12px;
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);
@@ -2605,7 +2651,6 @@
--c--globals--font--sizes--xs-alt: 3rem;
--c--globals--font--weights--thin: 100;
--c--globals--font--weights--extrabold: 800;
--c--globals--font--weights--black: 900;
--c--globals--font--families--accent: Marianne, Inter, Roboto Flex Variable, sans-serif;
--c--globals--font--families--base: Marianne, Inter, Roboto Flex Variable, sans-serif;
--c--globals--spacings--0: 0;
@@ -2787,6 +2832,18 @@
--c--components--datagrid--header--weight: 500;
--c--components--datagrid--body--background-color-hover: var(--c--contextuals--background--semantic--neutral--tertiary);
--c--components--forms-checkbox--font-size: var(--c--globals--font--sizes--sm);
--c--components--forms-input--border-radius: 4px;
--c--components--forms-input--border-radius--hover: 4px;
--c--components--forms-input--border-radius--focus: 4px;
--c--components--forms-select--border-radius: 4px;
--c--components--forms-select--border-radius--hover: 4px;
--c--components--forms-select--border-radius--focus: 4px;
--c--components--forms-textarea--border-radius: 4px;
--c--components--forms-textarea--border-radius--hover: 4px;
--c--components--forms-textarea--border-radius--focus: 4px;
--c--components--forms-datepicker--border-radius: 4px;
--c--components--forms-datepicker--border-radius--hover: 4px;
--c--components--forms-datepicker--border-radius--focus: 4px;
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
--c--components--badge--border-radius: 12px;
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);
@@ -3159,7 +3216,6 @@
--c--globals--font--sizes--xs-alt: 3rem;
--c--globals--font--weights--thin: 100;
--c--globals--font--weights--extrabold: 800;
--c--globals--font--weights--black: 900;
--c--globals--font--families--accent: Marianne, Inter, Roboto Flex Variable, sans-serif;
--c--globals--font--families--base: Marianne, Inter, Roboto Flex Variable, sans-serif;
--c--globals--spacings--0: 0;
@@ -3341,6 +3397,18 @@
--c--components--datagrid--header--weight: 500;
--c--components--datagrid--body--background-color-hover: var(--c--contextuals--background--semantic--neutral--tertiary);
--c--components--forms-checkbox--font-size: var(--c--globals--font--sizes--sm);
--c--components--forms-input--border-radius: 4px;
--c--components--forms-input--border-radius--hover: 4px;
--c--components--forms-input--border-radius--focus: 4px;
--c--components--forms-select--border-radius: 4px;
--c--components--forms-select--border-radius--hover: 4px;
--c--components--forms-select--border-radius--focus: 4px;
--c--components--forms-textarea--border-radius: 4px;
--c--components--forms-textarea--border-radius--hover: 4px;
--c--components--forms-textarea--border-radius--focus: 4px;
--c--components--forms-datepicker--border-radius: 4px;
--c--components--forms-datepicker--border-radius--hover: 4px;
--c--components--forms-datepicker--border-radius--focus: 4px;
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
--c--components--badge--border-radius: 12px;
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);

View File

@@ -371,7 +371,7 @@ $themes: (
'medium': 500,
'bold': 600,
'extrabold': 800,
'black': 900
'black': 800
),
'families': (
'base': #{Hanken Grotesk, Inter, Roboto Flex Variable, sans-serif},
@@ -706,6 +706,26 @@ $themes: (
'forms-checkbox': (
'font-size': 0.875rem
),
'forms-input': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'forms-select': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'forms-textarea': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'forms-datepicker': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'badge': (
'font-size': 0.75rem,
'border-radius': 12px,
@@ -1110,7 +1130,7 @@ $themes: (
'medium': 500,
'bold': 600,
'extrabold': 800,
'black': 900
'black': 800
),
'families': (
'base': #{Hanken Grotesk, Inter, Roboto Flex Variable, sans-serif},
@@ -1445,6 +1465,26 @@ $themes: (
'forms-checkbox': (
'font-size': 0.875rem
),
'forms-input': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'forms-select': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'forms-textarea': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'forms-datepicker': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'badge': (
'font-size': 0.75rem,
'border-radius': 12px,
@@ -1835,8 +1875,7 @@ $themes: (
),
'weights': (
'thin': 100,
'extrabold': 800,
'black': 900
'extrabold': 800
),
'families': (
'accent': #{Marianne, Inter, Roboto Flex Variable, sans-serif},
@@ -2158,6 +2197,26 @@ $themes: (
'forms-checkbox': (
'font-size': 0.875rem
),
'forms-input': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'forms-select': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'forms-textarea': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'forms-datepicker': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'badge': (
'font-size': 0.75rem,
'border-radius': 12px,
@@ -2548,8 +2607,7 @@ $themes: (
),
'weights': (
'thin': 100,
'extrabold': 800,
'black': 900
'extrabold': 800
),
'families': (
'accent': #{Marianne, Inter, Roboto Flex Variable, sans-serif},
@@ -2871,6 +2929,26 @@ $themes: (
'forms-checkbox': (
'font-size': 0.875rem
),
'forms-input': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'forms-select': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'forms-textarea': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'forms-datepicker': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'badge': (
'font-size': 0.75rem,
'border-radius': 12px,
@@ -3264,8 +3342,7 @@ $themes: (
),
'weights': (
'thin': 100,
'extrabold': 800,
'black': 900
'extrabold': 800
),
'families': (
'accent': #{Marianne, Inter, Roboto Flex Variable, sans-serif},
@@ -3587,6 +3664,26 @@ $themes: (
'forms-checkbox': (
'font-size': 0.875rem
),
'forms-input': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'forms-select': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'forms-textarea': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'forms-datepicker': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'badge': (
'font-size': 0.75rem,
'border-radius': 12px,
@@ -3980,8 +4077,7 @@ $themes: (
),
'weights': (
'thin': 100,
'extrabold': 800,
'black': 900
'extrabold': 800
),
'families': (
'accent': #{Marianne, Inter, Roboto Flex Variable, sans-serif},
@@ -4303,6 +4399,26 @@ $themes: (
'forms-checkbox': (
'font-size': 0.875rem
),
'forms-input': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'forms-select': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'forms-textarea': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'forms-datepicker': (
'border-radius': 4px,
'border-radius--hover': 4px,
'border-radius--focus': 4px
),
'badge': (
'font-size': 0.75rem,
'border-radius': 12px,

File diff suppressed because one or more lines are too long

View File

@@ -76,6 +76,11 @@ body {
font-size: var(--c--globals--font--sizes--sm);
}
a.c__button.c__button--brand--primary.c__button--disabled {
background-color: var(--c--contextuals--background--semantic--disabled--primary);
color: var(--c--contextuals--content--semantic--disabled--primary);
}
// Cunningham: force label up for datetime-local/time inputs
// The browser always renders native placeholder text for these input types,
// which overlaps with Cunningham's floating label in "placeholder" position.